diff --git a/app/(dashboard)/feedback/categories/page.tsx b/app/(dashboard)/feedback/categories/page.tsx new file mode 100644 index 0000000..7b617eb --- /dev/null +++ b/app/(dashboard)/feedback/categories/page.tsx @@ -0,0 +1,366 @@ +"use client"; + +/** + * /feedback/categories — the options on the app's /feedback form. + * + * A category's key (what each submission stores) is fixed when it's created, + * so renaming one relabels every past submission too. Archiving takes it off + * the form but keeps its label on existing feedback. Only a category nothing + * has been filed under can be deleted — production feedback is never left + * without a category. + * + * Anyone who can read feedback can see this page; changing it needs + * feedback:categories. + */ + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { ArrowDown, ArrowUp, Plus } from "lucide-react"; +import { toast } from "sonner"; +import { + AdminError, + feedbackCategories, + type FeedbackCategory, +} from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Modal } from "@/components/ui/modal"; +import { Clamp } from "@/components/ui/clamp"; +import { Can, useStaff } from "@/components/staff-context"; +import { useConfirm } from "@/components/confirm"; + +const errMsg = (err: unknown, fallback: string) => + err instanceof AdminError ? err.message : fallback; + +export default function FeedbackCategoriesPage() { + const [list, setList] = useState(null); + const [editing, setEditing] = useState( + null, + ); + const [busy, setBusy] = useState(false); + const confirm = useConfirm(); + const { can } = useStaff(); + const canEdit = can("feedback:categories"); + + const refresh = useCallback( + () => + feedbackCategories + .list() + .then(setList) + .catch((err) => { + toast.error(errMsg(err, "Could not load categories")); + setList([]); + }), + [], + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const active = (list ?? []).filter((c) => !c.archived_at); + const archived = (list ?? []).filter((c) => c.archived_at); + + const run = async (fn: () => Promise, ok: string) => { + setBusy(true); + try { + await fn(); + toast.success(ok); + await refresh(); + } catch (err) { + toast.error(errMsg(err, "That didn't work")); + } finally { + setBusy(false); + } + }; + + const move = (i: number, by: number) => { + const j = i + by; + if (j < 0 || j >= active.length) return; + const keys = active.map((c) => c.key); + [keys[i], keys[j]] = [keys[j], keys[i]]; + void run(() => feedbackCategories.reorder(keys), "Order saved"); + }; + + const setArchived = (c: FeedbackCategory, archived: boolean) => + run( + () => + feedbackCategories.update(c.key, { + label: c.label, + description: c.description, + archived, + }), + archived ? `“${c.label}” archived` : `“${c.label}” restored`, + ); + + const remove = async (c: FeedbackCategory) => { + if ( + await confirm({ + title: `Delete “${c.label}”?`, + message: "Nothing has been filed under it, so nothing else changes.", + confirmLabel: "Delete category", + destructive: true, + }) + ) { + void run(() => feedbackCategories.remove(c.key), "Category deleted"); + } + }; + + return ( +
+
+
+ + ← Feedback + +

+ Feedback categories +

+

+ The choices on the app's feedback form, in this order. + Renaming a category relabels its past feedback too. Archive one to + take it off the form without touching what's already been sent. +

+
+ + + +
+ + {list === null ? ( +

Loading…

+ ) : ( + <> +
+

+ On the form +

+
    + {active.map((c, i) => ( +
  • + + {canEdit && ( +
    + + + + +
    + )} +
  • + ))} +
+
+ + {archived.length > 0 && ( +
+

Archived

+

+ Not offered on the form. Feedback already filed under them + keeps its category. +

+
    + {archived.map((c) => ( +
  • + + {canEdit && ( +
    + + + {!c.usage_count && ( + + )} +
    + )} +
  • + ))} +
+
+ )} + + )} + + setEditing(null)} + onSaved={() => { + setEditing(null); + void refresh(); + }} + /> +
+ ); +} + +function CategoryText({ c }: { c: FeedbackCategory }) { + const n = c.usage_count ?? 0; + return ( +
+ {c.label} + {c.description && ( + + {c.description} + + )} +

+ {c.key} · {n}{" "} + {n === 1 ? "submission" : "submissions"} +

+
+ ); +} + +function CategoryModal({ + target, + onClose, + onSaved, +}: { + target: FeedbackCategory | "new" | null; + onClose: () => void; + onSaved: () => void; +}) { + const editing = target && target !== "new" ? target : null; + // Re-seed the fields whenever a different category (or "new") opens. + const seed = editing ? editing.key : target; + const [lastSeed, setLastSeed] = useState(null); + const [label, setLabel] = useState(""); + const [description, setDescription] = useState(""); + const [busy, setBusy] = useState(false); + if (seed !== lastSeed) { + setLastSeed(seed); + setLabel(editing?.label ?? ""); + setDescription(editing?.description ?? ""); + } + + const save = async () => { + if (!label.trim()) return; + setBusy(true); + try { + if (editing) { + await feedbackCategories.update(editing.key, { + label: label.trim(), + description: description.trim(), + archived: !!editing.archived_at, + }); + toast.success("Category saved"); + } else { + await feedbackCategories.create(label.trim(), description.trim()); + toast.success("Category added to the form"); + } + onSaved(); + } catch (err) { + toast.error(errMsg(err, "Could not save the category")); + } finally { + setBusy(false); + } + }; + + return ( + +
+ + + {editing && (editing.usage_count ?? 0) > 0 && ( +

+ {editing.usage_count} past{" "} + {editing.usage_count === 1 ? "submission" : "submissions"} will + show the new name. +

+ )} +
+ + +
+
+
+ ); +} diff --git a/app/(dashboard)/feedback/page.tsx b/app/(dashboard)/feedback/page.tsx index bb26ab0..2a37b7f 100644 --- a/app/(dashboard)/feedback/page.tsx +++ b/app/(dashboard)/feedback/page.tsx @@ -1,6 +1,8 @@ "use client"; import { useCallback, useState } from "react"; +import Link from "next/link"; +import { Tags } from "lucide-react"; import { Can } from "@/components/staff-context"; import { toast } from "sonner"; import { api, AdminError, type AdminFeedback } from "@/lib/api"; @@ -70,21 +72,29 @@ export default function FeedbackPage() { total={list.total} list={list} toolbar={ -
- {STATUSES.map((s) => ( - - ))} +
+ + + +
+ {STATUSES.map((s) => ( + + ))} +
} > @@ -122,7 +132,7 @@ export default function FeedbackPage() {
- {f.category} + {f.category_label || f.category}
{f.message}
{f.reply_message ? ( @@ -246,7 +256,7 @@ function ReplyModal({ {feedback ? (
- {feedback.category} + {feedback.category_label || feedback.category}
{feedback.message}
diff --git a/lib/api.ts b/lib/api.ts index 47d0c32..75b44dc 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -36,6 +36,9 @@ async function call( headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, ...init, }); + // 204 No Content (archive, delete, reset…) is success with no body — + // there's no JSON to parse. + if (res.status === 204) return { data: undefined as T }; let body: Envelope; try { body = (await res.json()) as Envelope; @@ -191,6 +194,8 @@ export type AdminCollection = { export type AdminFeedback = { id: string; category: string; + /** The category's current display name. */ + category_label?: string; message: string; contact_email: string | null; created_at: string; @@ -748,3 +753,40 @@ export const quizzes = { resetParticipant: (id: string, userId: string) => call(`/admin/quizzes/${id}/participants/${userId}`, { method: "DELETE" }), }; + +// ─── Feedback categories ──────────────────────────────────────────────────── + +/** One option on the app's /feedback form. `key` is fixed once created. */ +export type FeedbackCategory = { + key: string; + label: string; + description: string; + sort_order: number; + archived_at?: string | null; + /** How much feedback is filed under it. */ + usage_count?: number; +}; + +export const feedbackCategories = { + list: () => + call("/admin/feedback/categories").then((r) => r.data), + create: (label: string, description: string) => + call("/admin/feedback/categories", { + method: "POST", + body: JSON.stringify({ label, description }), + }).then((r) => r.data), + update: (key: string, v: { label: string; description: string; archived: boolean }) => + call(`/admin/feedback/categories/${encodeURIComponent(key)}`, { + method: "PUT", + body: JSON.stringify(v), + }), + remove: (key: string) => + call(`/admin/feedback/categories/${encodeURIComponent(key)}`, { + method: "DELETE", + }), + reorder: (keys: string[]) => + call("/admin/feedback/categories/order", { + method: "PUT", + body: JSON.stringify({ keys }), + }), +};