@bygghammar/cms-platform 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,646 @@
1
+ import type React from "react";
2
+ import { Fragment, useMemo, useState } from "react";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { toast } from "sonner";
5
+ import {
6
+ ArrowLeft,
7
+ Download,
8
+ Mail,
9
+ RefreshCw,
10
+ Search,
11
+ Send,
12
+ Sheet as SheetIcon,
13
+ Trash2,
14
+ } from "lucide-react";
15
+ import type { FormFieldDef } from "../forms/field-sets";
16
+
17
+ /** Workflow states a submission can be in. */
18
+ export const SUBMISSION_STATUSES = ["new", "read", "handled", "spam"] as const;
19
+ export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
20
+
21
+ /** Page size used by the inbox; the host must query with the same value. */
22
+ export const SUBMISSIONS_PAGE_SIZE = 50;
23
+
24
+ /** One stored submission. */
25
+ export type SubmissionsViewRow = {
26
+ id: string;
27
+ form_key: string;
28
+ locale: string;
29
+ status: string;
30
+ created_at: string;
31
+ page_path?: string | null;
32
+ payload?: Record<string, string> | null;
33
+ notified_at?: string | null;
34
+ autoreply_at?: string | null;
35
+ sheet_synced_at?: string | null;
36
+ sheet_error?: string | null;
37
+ delivery_error?: string | null;
38
+ consent_at?: string | null;
39
+ ip?: string | null;
40
+ user_agent?: string | null;
41
+ };
42
+
43
+ /** One part-filled form that was never submitted. */
44
+ export type PartialsViewRow = {
45
+ id: string;
46
+ form_key: string;
47
+ updated_at: string;
48
+ payload?: Record<string, string> | null;
49
+ };
50
+
51
+ /** Filter state the host keeps in the URL. */
52
+ export type SubmissionsFilters = {
53
+ form?: string;
54
+ status?: string;
55
+ locale?: string;
56
+ q?: string;
57
+ from?: string;
58
+ to?: string;
59
+ page?: number;
60
+ };
61
+
62
+ /** Host-provided data access and navigation for the submissions inbox. */
63
+ export type FormSubmissionsDeps = {
64
+ filters: SubmissionsFilters;
65
+ setFilters: (patch: SubmissionsFilters) => void;
66
+ clearFilters: () => void;
67
+ goToPage: (page: number) => void;
68
+ data:
69
+ | {
70
+ rows: SubmissionsViewRow[];
71
+ total: number;
72
+ counts?: Record<string, number>;
73
+ }
74
+ | undefined;
75
+ isLoading: boolean;
76
+ isFetching: boolean;
77
+ forms: { key: string; name: string; fields: FormFieldDef[] }[];
78
+ /** Active locales, used for the language filter. */
79
+ locales?: string[];
80
+ refresh: () => Promise<unknown> | void;
81
+ setStatus: (input: { data: { ids: string[]; status: SubmissionStatus } }) => Promise<unknown>;
82
+ pushToSheet: (input: { data: { ids: string[] } }) => Promise<{ error?: string | null }>;
83
+ resendDelivery: (input: {
84
+ data: { id: string; what: "notification" | "autoreply" };
85
+ }) => Promise<unknown>;
86
+ listPartials: (input: {
87
+ data: { formKey?: string; includeCompleted: boolean; limit: number };
88
+ }) => Promise<PartialsViewRow[]>;
89
+ deletePartials: (input: { data: { ids: string[] } }) => Promise<unknown>;
90
+ /** Host router link back to the forms index. */
91
+ BackLink: (props: { className?: string; children: React.ReactNode }) => React.ReactElement;
92
+ };
93
+
94
+ function toCsv(rows: Record<string, string>[], columns: { key: string; label: string }[]) {
95
+ const esc = (v: string) => `"${(v ?? "").replace(/"/g, '""')}"`;
96
+ return [
97
+ columns.map((c) => esc(c.label)).join(","),
98
+ ...rows.map((r) => columns.map((c) => esc(r[c.key] ?? "")).join(",")),
99
+ ].join("\n");
100
+ }
101
+
102
+ /** The submissions inbox: filtering, bulk actions, CSV export and drafts. */
103
+ export function FormSubmissionsView({ deps }: { deps: FormSubmissionsDeps }) {
104
+ const { filters, setFilters, data, isLoading, isFetching, forms } = deps;
105
+ const page = filters.page ?? 0;
106
+ const [term, setTerm] = useState(filters.q ?? "");
107
+ const [selected, setSelected] = useState<Set<string>>(new Set());
108
+ const [open, setOpen] = useState<string | null>(null);
109
+ const [busy, setBusy] = useState(false);
110
+
111
+ const rows = data?.rows ?? [];
112
+ const total = data?.total ?? 0;
113
+ const counts = data?.counts ?? {};
114
+ const locales = deps.locales ?? ["sv", "en"];
115
+
116
+ /** Readable labels per field key, from the form definitions. */
117
+ const labels = useMemo(() => {
118
+ const m = new Map<string, string>();
119
+ for (const f of forms) {
120
+ for (const fl of f.fields ?? []) {
121
+ m.set(fl.key, fl.label?.sv ?? fl.label?.en ?? fl.key);
122
+ }
123
+ }
124
+ return m;
125
+ }, [forms]);
126
+
127
+ const columns = useMemo(() => {
128
+ const keys = new Set<string>();
129
+ for (const r of rows) for (const k of Object.keys(r.payload ?? {})) keys.add(k);
130
+ return Array.from(keys).map((k) => ({ key: k, label: labels.get(k) ?? k }));
131
+ }, [rows, labels]);
132
+
133
+ const download = () => {
134
+ const flat = rows.map((r) => ({
135
+ created_at: new Date(r.created_at).toLocaleString("sv-SE"),
136
+ form: r.form_key,
137
+ locale: r.locale,
138
+ status: r.status,
139
+ page: r.page_path ?? "",
140
+ ...(r.payload ?? {}),
141
+ }));
142
+ const csv = toCsv(flat, [
143
+ { key: "created_at", label: "Received" },
144
+ { key: "form", label: "Form" },
145
+ { key: "locale", label: "Language" },
146
+ { key: "status", label: "Status" },
147
+ { key: "page", label: "Page" },
148
+ ...columns,
149
+ ]);
150
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
151
+ const a = document.createElement("a");
152
+ a.href = url;
153
+ a.download = `submissions-${filters.form ?? "all"}.csv`;
154
+ a.click();
155
+ URL.revokeObjectURL(url);
156
+ };
157
+
158
+ const runBulk = async (fn: () => Promise<unknown>, okMsg: string) => {
159
+ setBusy(true);
160
+ try {
161
+ await fn();
162
+ await deps.refresh();
163
+ setSelected(new Set());
164
+ toast.success(okMsg);
165
+ } catch (e) {
166
+ toast.error(e instanceof Error ? e.message : "Action failed");
167
+ } finally {
168
+ setBusy(false);
169
+ }
170
+ };
171
+
172
+ const ids = Array.from(selected);
173
+ const allOnPage = rows.length > 0 && rows.every((r) => selected.has(r.id));
174
+
175
+ return (
176
+ <div className="p-8 md:p-12 w-full">
177
+ <div className="flex flex-wrap items-end justify-between gap-4">
178
+ <div>
179
+ <deps.BackLink className="inline-flex items-center gap-2 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 hover:text-sea">
180
+ <ArrowLeft className="size-3.5" />
181
+ Back to forms
182
+ </deps.BackLink>
183
+ <h1 className="mt-3 font-serif text-2xl text-ink">Submissions</h1>
184
+ <p className="mt-1 text-xs text-ink/50">
185
+ {total} match{total === 1 ? "" : "es"} · New {counts.new ?? 0} · Read {counts.read ?? 0}{" "}
186
+ · Handled {counts.handled ?? 0} · Spam {counts.spam ?? 0}
187
+ </p>
188
+ </div>
189
+ <div className="flex items-center gap-2">
190
+ <button
191
+ onClick={() => deps.refresh()}
192
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea"
193
+ >
194
+ <RefreshCw className={`size-4 ${isFetching ? "animate-spin" : ""}`} />
195
+ Refresh
196
+ </button>
197
+ <button
198
+ onClick={download}
199
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea"
200
+ >
201
+ <Download className="size-4" />
202
+ Export CSV
203
+ </button>
204
+ </div>
205
+ </div>
206
+
207
+ {/* Filters */}
208
+ <div className="mt-6 flex flex-wrap items-end gap-3 border border-ink/10 bg-white p-4">
209
+ <label className="min-w-[180px]">
210
+ <span className="text-xs text-ink/55">Form</span>
211
+ <select
212
+ value={filters.form ?? ""}
213
+ onChange={(e) => setFilters({ form: e.target.value || undefined })}
214
+ className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
215
+ >
216
+ <option value="">All forms</option>
217
+ {forms.map((f) => (
218
+ <option key={f.key} value={f.key}>
219
+ {f.name}
220
+ </option>
221
+ ))}
222
+ </select>
223
+ </label>
224
+ <label className="min-w-[140px]">
225
+ <span className="text-xs text-ink/55">Status</span>
226
+ <select
227
+ value={filters.status ?? ""}
228
+ onChange={(e) => setFilters({ status: e.target.value || undefined })}
229
+ className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
230
+ >
231
+ <option value="">Any status</option>
232
+ {SUBMISSION_STATUSES.map((s) => (
233
+ <option key={s} value={s}>
234
+ {s[0].toUpperCase() + s.slice(1)}
235
+ </option>
236
+ ))}
237
+ </select>
238
+ </label>
239
+ <label className="min-w-[120px]">
240
+ <span className="text-xs text-ink/55">Language</span>
241
+ <select
242
+ value={filters.locale ?? ""}
243
+ onChange={(e) => setFilters({ locale: e.target.value || undefined })}
244
+ className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
245
+ >
246
+ <option value="">Any</option>
247
+ {locales.map((l) => (
248
+ <option key={l} value={l}>
249
+ {l}
250
+ </option>
251
+ ))}
252
+ </select>
253
+ </label>
254
+ <label>
255
+ <span className="text-xs text-ink/55">From</span>
256
+ <input
257
+ type="date"
258
+ value={filters.from ?? ""}
259
+ onChange={(e) => setFilters({ from: e.target.value || undefined })}
260
+ className="mt-1 border border-ink/20 px-3 py-2 text-sm"
261
+ />
262
+ </label>
263
+ <label>
264
+ <span className="text-xs text-ink/55">To</span>
265
+ <input
266
+ type="date"
267
+ value={filters.to ?? ""}
268
+ onChange={(e) => setFilters({ to: e.target.value || undefined })}
269
+ className="mt-1 border border-ink/20 px-3 py-2 text-sm"
270
+ />
271
+ </label>
272
+ <label className="flex-1 min-w-[200px]">
273
+ <span className="text-xs text-ink/55">Search answers</span>
274
+ <div className="mt-1 flex">
275
+ <input
276
+ value={term}
277
+ onChange={(e) => setTerm(e.target.value)}
278
+ onKeyDown={(e) => {
279
+ if (e.key === "Enter") setFilters({ q: term.trim() || undefined });
280
+ }}
281
+ placeholder="name, email, keyword…"
282
+ className="w-full border border-ink/20 px-3 py-2 text-sm"
283
+ />
284
+ <button
285
+ onClick={() => setFilters({ q: term.trim() || undefined })}
286
+ className="border border-l-0 border-ink/20 px-3 hover:border-sea"
287
+ title="Search"
288
+ >
289
+ <Search className="size-4" />
290
+ </button>
291
+ </div>
292
+ </label>
293
+ <button
294
+ onClick={() => {
295
+ setTerm("");
296
+ deps.clearFilters();
297
+ }}
298
+ className="px-3 py-2 text-xs uppercase tracking-[0.2em] text-ink/50 hover:text-sea"
299
+ >
300
+ Clear
301
+ </button>
302
+ </div>
303
+
304
+ {/* Bulk bar */}
305
+ {ids.length > 0 ? (
306
+ <div className="mt-4 flex flex-wrap items-center gap-3 border border-sea/40 bg-sea/5 px-4 py-3 text-xs">
307
+ <span className="uppercase tracking-[0.2em] text-ink/60">{ids.length} selected</span>
308
+ {SUBMISSION_STATUSES.map((s) => (
309
+ <button
310
+ key={s}
311
+ disabled={busy}
312
+ onClick={() =>
313
+ runBulk(() => deps.setStatus({ data: { ids, status: s } }), `Marked as ${s}`)
314
+ }
315
+ className="border border-ink/15 bg-white px-3 py-1.5 uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
316
+ >
317
+ Mark {s}
318
+ </button>
319
+ ))}
320
+ <button
321
+ disabled={busy}
322
+ onClick={() =>
323
+ runBulk(async () => {
324
+ const res = await deps.pushToSheet({ data: { ids } });
325
+ if (res.error) toast.warning(res.error);
326
+ }, "Pushed to Google Sheets")
327
+ }
328
+ className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-3 py-1.5 uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
329
+ >
330
+ <SheetIcon className="size-3.5" />
331
+ Push to sheet
332
+ </button>
333
+ <button
334
+ onClick={() => setSelected(new Set())}
335
+ className="ml-auto text-ink/50 hover:text-sea"
336
+ >
337
+ Clear selection
338
+ </button>
339
+ </div>
340
+ ) : null}
341
+
342
+ <div className="mt-6 border border-ink/10 bg-white overflow-x-auto">
343
+ <table className="w-full text-sm">
344
+ <thead>
345
+ <tr className="border-b border-ink/10 text-left text-[0.65rem] uppercase tracking-[0.2em] text-ink/50">
346
+ <th className="px-4 py-3">
347
+ <input
348
+ type="checkbox"
349
+ checked={allOnPage}
350
+ onChange={(e) =>
351
+ setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set())
352
+ }
353
+ className="size-4 accent-sea"
354
+ />
355
+ </th>
356
+ <th className="px-4 py-3">Received</th>
357
+ <th className="px-4 py-3">Form</th>
358
+ <th className="px-4 py-3">Summary</th>
359
+ <th className="px-4 py-3">Status</th>
360
+ <th className="px-4 py-3">Delivery</th>
361
+ </tr>
362
+ </thead>
363
+ <tbody>
364
+ {isLoading ? (
365
+ <tr>
366
+ <td className="px-6 py-6 text-ink/50" colSpan={6}>
367
+ Loading…
368
+ </td>
369
+ </tr>
370
+ ) : rows.length === 0 ? (
371
+ <tr>
372
+ <td className="px-6 py-6 text-ink/50" colSpan={6}>
373
+ No submissions match these filters.
374
+ </td>
375
+ </tr>
376
+ ) : (
377
+ rows.map((r) => {
378
+ const p = r.payload ?? {};
379
+ const summary =
380
+ [p.email, p.name, [p.firstName, p.lastName].filter(Boolean).join(" "), p.phone]
381
+ .filter(Boolean)
382
+ .join(" · ") || "—";
383
+ const isOpen = open === r.id;
384
+ return (
385
+ <Fragment key={r.id}>
386
+ <tr
387
+ className="border-b border-ink/5 last:border-0 cursor-pointer hover:bg-ink/[0.02]"
388
+ onClick={() => setOpen(isOpen ? null : r.id)}
389
+ >
390
+ <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
391
+ <input
392
+ type="checkbox"
393
+ checked={selected.has(r.id)}
394
+ onChange={(e) => {
395
+ const next = new Set(selected);
396
+ if (e.target.checked) next.add(r.id);
397
+ else next.delete(r.id);
398
+ setSelected(next);
399
+ }}
400
+ className="size-4 accent-sea"
401
+ />
402
+ </td>
403
+ <td className="px-4 py-3 text-ink/60 whitespace-nowrap">
404
+ {new Date(r.created_at).toLocaleString("sv-SE")}
405
+ </td>
406
+ <td className="px-4 py-3 text-ink/60">{r.form_key}</td>
407
+ <td className="px-4 py-3 text-ink">
408
+ {r.status === "new" ? (
409
+ <span className="mr-2 inline-block size-1.5 rounded-full bg-sea align-middle" />
410
+ ) : null}
411
+ {summary}
412
+ </td>
413
+ <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
414
+ <select
415
+ value={r.status}
416
+ onChange={async (e) => {
417
+ try {
418
+ await deps.setStatus({
419
+ data: {
420
+ ids: [r.id],
421
+ status: e.target.value as SubmissionStatus,
422
+ },
423
+ });
424
+ await deps.refresh();
425
+ } catch (err) {
426
+ toast.error(
427
+ err instanceof Error ? err.message : "Could not update status",
428
+ );
429
+ }
430
+ }}
431
+ className="border border-ink/15 bg-white px-2 py-1 text-xs"
432
+ >
433
+ {SUBMISSION_STATUSES.map((s) => (
434
+ <option key={s} value={s}>
435
+ {s[0].toUpperCase() + s.slice(1)}
436
+ </option>
437
+ ))}
438
+ </select>
439
+ </td>
440
+ <td className="px-4 py-3 text-[0.7rem] text-ink/50 whitespace-nowrap">
441
+ {r.notified_at ? "Notified" : "—"} · {r.autoreply_at ? "Replied" : "—"} ·{" "}
442
+ {r.sheet_synced_at ? "Synced" : r.sheet_error ? "Sheet error" : "—"}
443
+ </td>
444
+ </tr>
445
+ {isOpen ? (
446
+ <tr className="border-b border-ink/5 bg-ink/[0.02]">
447
+ <td colSpan={6} className="px-6 py-4">
448
+ <dl className="grid gap-3 sm:grid-cols-2">
449
+ {Object.entries(p).map(([k, v]) => (
450
+ <div key={k}>
451
+ <dt className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/45">
452
+ {labels.get(k) ?? k}
453
+ </dt>
454
+ <dd className="whitespace-pre-wrap text-ink">{String(v) || "—"}</dd>
455
+ </div>
456
+ ))}
457
+ </dl>
458
+ <p className="mt-4 text-xs text-ink/45">
459
+ {r.locale} · {r.page_path ?? "—"} ·{" "}
460
+ {r.consent_at
461
+ ? `consent ${new Date(r.consent_at).toLocaleString("sv-SE")}`
462
+ : "no consent"}
463
+ {r.ip ? ` · ${r.ip}` : ""}
464
+ </p>
465
+ {r.user_agent ? (
466
+ <p className="mt-1 text-[0.7rem] text-ink/35">{r.user_agent}</p>
467
+ ) : null}
468
+ {r.delivery_error || r.sheet_error ? (
469
+ <p className="mt-2 text-xs text-red-500">
470
+ {[r.delivery_error, r.sheet_error].filter(Boolean).join(" · ")}
471
+ </p>
472
+ ) : null}
473
+ <div className="mt-4 flex flex-wrap gap-2">
474
+ <button
475
+ disabled={busy}
476
+ onClick={() =>
477
+ runBulk(
478
+ () => deps.resendDelivery({ data: { id: r.id, what: "notification" } }),
479
+ "Notification sent",
480
+ )
481
+ }
482
+ className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
483
+ >
484
+ <Send className="size-3.5" />
485
+ Resend notification
486
+ </button>
487
+ <button
488
+ disabled={busy}
489
+ onClick={() =>
490
+ runBulk(
491
+ () => deps.resendDelivery({ data: { id: r.id, what: "autoreply" } }),
492
+ "Autoreply sent",
493
+ )
494
+ }
495
+ className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
496
+ >
497
+ <Mail className="size-3.5" />
498
+ Send autoreply
499
+ </button>
500
+ <button
501
+ disabled={busy}
502
+ onClick={() =>
503
+ runBulk(async () => {
504
+ const res = await deps.pushToSheet({ data: { ids: [r.id] } });
505
+ if (res.error) toast.warning(res.error);
506
+ }, "Pushed to sheet")
507
+ }
508
+ className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
509
+ >
510
+ <SheetIcon className="size-3.5" />
511
+ Retry sheet sync
512
+ </button>
513
+ </div>
514
+ </td>
515
+ </tr>
516
+ ) : null}
517
+ </Fragment>
518
+ );
519
+ })
520
+ )}
521
+ </tbody>
522
+ </table>
523
+ </div>
524
+
525
+ <div className="mt-4 flex items-center justify-between text-xs text-ink/55">
526
+ <span>
527
+ Page {page + 1} of {Math.max(1, Math.ceil(total / SUBMISSIONS_PAGE_SIZE))}
528
+ </span>
529
+ <div className="flex gap-2">
530
+ <button
531
+ disabled={page === 0}
532
+ onClick={() => deps.goToPage(page - 1)}
533
+ className="border border-ink/15 bg-white px-3 py-1.5 uppercase tracking-[0.2em] hover:border-sea disabled:opacity-40"
534
+ >
535
+ Previous
536
+ </button>
537
+ <button
538
+ disabled={(page + 1) * SUBMISSIONS_PAGE_SIZE >= total}
539
+ onClick={() => deps.goToPage(page + 1)}
540
+ className="border border-ink/15 bg-white px-3 py-1.5 uppercase tracking-[0.2em] hover:border-sea disabled:opacity-40"
541
+ >
542
+ Next
543
+ </button>
544
+ </div>
545
+ </div>
546
+
547
+ <PartialsPanel deps={deps} labels={labels} />
548
+ </div>
549
+ );
550
+ }
551
+
552
+ /** Part-filled forms that were never submitted, kept apart from real entries. */
553
+ function PartialsPanel({
554
+ deps,
555
+ labels,
556
+ }: {
557
+ deps: FormSubmissionsDeps;
558
+ labels: Map<string, string>;
559
+ }) {
560
+ const qc = useQueryClient();
561
+ const [open, setOpen] = useState(false);
562
+ const formKey = deps.filters.form;
563
+
564
+ const { data, isFetching } = useQuery({
565
+ queryKey: ["form-partials", formKey],
566
+ queryFn: () => deps.listPartials({ data: { formKey, includeCompleted: false, limit: 100 } }),
567
+ enabled: open,
568
+ });
569
+ const rows = data ?? [];
570
+
571
+ const remove = async (id: string) => {
572
+ try {
573
+ await deps.deletePartials({ data: { ids: [id] } });
574
+ await qc.invalidateQueries({ queryKey: ["form-partials"] });
575
+ toast.success("Draft deleted");
576
+ } catch (e) {
577
+ toast.error(e instanceof Error ? e.message : "Delete failed");
578
+ }
579
+ };
580
+
581
+ return (
582
+ <div className="mt-10 border border-ink/10 bg-white">
583
+ <button
584
+ onClick={() => setOpen((v) => !v)}
585
+ className="flex w-full items-center justify-between px-5 py-4 text-left"
586
+ >
587
+ <span>
588
+ <span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
589
+ Incomplete forms (drafts)
590
+ </span>
591
+ <span className="mt-1 block text-xs text-ink/45">
592
+ What visitors typed but never sent. Never merged into submissions.
593
+ </span>
594
+ </span>
595
+ <span className="text-xs uppercase tracking-[0.2em] text-ink/45">
596
+ {open ? "Hide" : "Show"}
597
+ </span>
598
+ </button>
599
+
600
+ {open ? (
601
+ <div className="border-t border-ink/10">
602
+ {isFetching && !rows.length ? (
603
+ <p className="p-5 text-xs text-ink/45">Loading…</p>
604
+ ) : !rows.length ? (
605
+ <p className="p-5 text-xs text-ink/45">No drafts right now.</p>
606
+ ) : (
607
+ <table className="w-full text-sm">
608
+ <thead className="bg-paper/60 text-[0.65rem] uppercase tracking-[0.18em] text-ink/50">
609
+ <tr>
610
+ <th className="px-4 py-2 text-left">Last typed</th>
611
+ <th className="px-4 py-2 text-left">Form</th>
612
+ <th className="px-4 py-2 text-left">Answers so far</th>
613
+ <th className="px-4 py-2" />
614
+ </tr>
615
+ </thead>
616
+ <tbody>
617
+ {rows.map((r) => (
618
+ <tr key={r.id} className="border-t border-ink/5 align-top">
619
+ <td className="whitespace-nowrap px-4 py-3 text-xs text-ink/60">
620
+ {new Date(r.updated_at).toLocaleString("sv-SE")}
621
+ </td>
622
+ <td className="px-4 py-3 text-xs text-ink/60">{r.form_key}</td>
623
+ <td className="px-4 py-3 text-xs text-ink/70">
624
+ {Object.entries(r.payload ?? {})
625
+ .map(([k, v]) => `${labels.get(k) ?? k}: ${v}`)
626
+ .join(" · ")}
627
+ </td>
628
+ <td className="px-4 py-3 text-right">
629
+ <button
630
+ onClick={() => remove(r.id)}
631
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-2.5 py-1.5 text-[0.6rem] uppercase tracking-[0.2em] hover:border-red-500 hover:text-red-600"
632
+ >
633
+ <Trash2 className="size-3.5" />
634
+ Delete
635
+ </button>
636
+ </td>
637
+ </tr>
638
+ ))}
639
+ </tbody>
640
+ </table>
641
+ )}
642
+ </div>
643
+ ) : null}
644
+ </div>
645
+ );
646
+ }