1"use client";
2
3
31import { useCallback, useEffect, useMemo, useRef, useState } from "react";
32import {
33 ClipboardCopy,
34 Download,
35 ExternalLink,
36 Inbox,
37 Loader2,
38 Search,
39 Trash2
40} from "lucide-react";
41import { toast } from "@/stores/ui-store";
42import { ConfirmDialog } from "@/components/ui/confirm-dialog";
43import { apiFetch } from "@/lib/api";
44import { RecordDto, useDatabaseRecord } from "@/hooks/use-database";
45import { SubmissionDto, useDeleteSubmission, useSubmissions } from "@/hooks/use-forms";
46
47
48
49
50
51
52function formatAnswer(value: unknown, type: string): string {
53 if (value == null) return "—";
54 const t = (type ?? "").toUpperCase();
55 if (t === "CHECKBOX") return value === true ? "Yes" : value === false ? "No" : String(value);
56 if (t === "SINGLE_SELECT" || t === "STATUS" || t === "SELECT") {
57 const s = Array.isArray(value) ? value[0] : value;
58 return s == null ? "—" : String(s);
59 }
60 if (t === "MULTI_SELECT") {
61 const s = Array.isArray(value) ? value : [value];
62 return s.map((x) => String(x)).join(", ");
63 }
64 if (t === "FILE") {
65 const s = Array.isArray(value) ? value : [value];
66 return s.length ? `${s.length} file${s.length > 1 ? "s" : ""}` : "—";
67 }
68 if (t === "DATE" || t === "DATE_RANGE") {
69 const d = new Date(String(value));
70 return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
71 }
72 if (typeof value === "string") return value;
73 const s = JSON.stringify(value);
74 return s.length > 80 ? `${s.slice(0, 77)}…` : s;
75}
76
77
78function answerPairs(s: SubmissionDto): { label: string; type: string; text: string }[] {
79 return Object.entries(s.answers).map(([qid, value]) => {
80 const q = s.questionLabels[qid];
81 const type = q?.type ?? "";
82 return {
83 label: q?.label ?? "Question",
84 type,
85 text: formatAnswer(value, type)
86 };
87 });
88}
89
90function timeAgo(iso: string): string {
91 const d = new Date(iso);
92 if (Number.isNaN(d.getTime())) return "";
93 return d.toLocaleString(undefined, {
94 month: "short",
95 day: "numeric",
96 hour: "2-digit",
97 minute: "2-digit"
98 });
99}
100
101
102function dateBounds(from?: string, to?: string): { from?: string; to?: string } {
103 const out: { from?: string; to?: string } = {};
104 if (from) out.from = `${from}T00:00:00.000Z`;
105 if (to) out.to = `${to}T23:59:59.999Z`;
106 return out;
107}
108
109
110function useDebounced<T>(value: T, ms: number): T {
111 const [v, setV] = useState(value);
112 useEffect(() => {
113 const t = setTimeout(() => setV(value), ms);
114 return () => clearTimeout(t);
115 }, [value, ms]);
116 return v;
117}
118
119function useMediaQuery(query: string): boolean {
120 const [match, setMatch] = useState(false);
121 useEffect(() => {
122 const mq = window.matchMedia(query);
123 setMatch(mq.matches);
124 const onChange = (e: MediaQueryListEvent) => setMatch(e.matches);
125 mq.addEventListener("change", onChange);
126 return () => mq.removeEventListener("change", onChange);
127 }, [query]);
128 return match;
129}
130
131
132
133
134
135const ROW_H_DESKTOP = 48;
136const ROW_H_MOBILE = 180;
137const OVERSCAN = 6;
138const PAGE_SIZE = 50;
139
140export interface FormResponsesViewProps {
141 formId: string;
142
143 slug: string;
144 databaseId: string;
145
146 onOpenRecord?: (record: RecordDto) => void;
147
148 onOpenRecordPage?: (record: RecordDto) => void;
149}
150
151export function FormResponsesView({
152 formId,
153 slug,
154 databaseId,
155 onOpenRecord,
156 onOpenRecordPage
157}: FormResponsesViewProps) {
158 const isMobile = useMediaQuery("(max-width: 767px)");
159 const rowH = isMobile ? ROW_H_MOBILE : ROW_H_DESKTOP;
160
161
162 const [q, setQ] = useState("");
163 const [anonymousOnly, setAnonymousOnly] = useState(false);
164 const [from, setFrom] = useState("");
165 const [to, setTo] = useState("");
166 const [newestFirst, setNewestFirst] = useState(true);
167 const debouncedQ = useDebounced(q, 300);
168
169 const bounds = dateBounds(from, to);
170 const filterActive = !!(debouncedQ || anonymousOnly || from || to);
171
172 const { data, isLoading, isError, error } = useSubmissions(formId, {
173 q: debouncedQ,
174 anonymousOnly,
175 ...bounds,
176 limit: PAGE_SIZE
177 });
178
179
180
181 const [items, setItems] = useState<SubmissionDto[]>([]);
182 const [nextCursor, setNextCursor] = useState<string | null>(null);
183 const [loadingMore, setLoadingMore] = useState(false);
184
185
186 const queryKey = JSON.stringify([debouncedQ, anonymousOnly, from, to]);
187 useEffect(() => {
188 setItems(data?.items ?? []);
189 setNextCursor(data?.nextCursor ?? null);
190
191 }, [queryKey, data]);
192
193 const loadMore = useCallback(async () => {
194 if (!nextCursor || loadingMore) return;
195 setLoadingMore(true);
196 try {
197 const page = await fetchSubmissionsPage(formId, {
198 q: debouncedQ,
199 anonymousOnly,
200 ...bounds,
201 limit: PAGE_SIZE,
202 cursor: nextCursor
203 });
204 setItems((prev) => [...prev, ...page.items]);
205 setNextCursor(page.nextCursor);
206 } catch (e) {
207 toast("error", "Load more failed", e instanceof Error ? e.message : undefined);
208 } finally {
209 setLoadingMore(false);
210 }
211 }, [nextCursor, loadingMore, formId, debouncedQ, anonymousOnly, bounds.from, bounds.to]);
212
213
214 const sorted = useMemo(() => {
215 const list = [...items];
216 list.sort((a, b) =>
217 newestFirst
218 ? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
219 : new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
220 );
221 return list;
222 }, [items, newestFirst]);
223
224
225 const [pendingDelete, setPendingDelete] = useState<SubmissionDto | null>(null);
226 const deleteSubmission = useDeleteSubmission(formId);
227
228 const confirmDelete = () => {
229 if (!pendingDelete) return;
230 const sub = pendingDelete;
231 deleteSubmission.mutate(sub.id, {
232 onSuccess: (res) => {
233 setPendingDelete(null);
234 toast(
235 "success",
236 "Submission deleted",
237 [
238 res.recordDeleted ? "record removed" : "record kept",
239 res.filesPurged > 0 ? `${res.filesPurged} file(s) purged` : ""
240 ]
241 .filter(Boolean)
242 .join(" · ")
243 );
244 },
245 onError: (e) => toast("error", "Delete failed", e instanceof Error ? e.message : undefined)
246 });
247 };
248
249
250 const [linkingRecordId, setLinkingRecordId] = useState<string | null>(null);
251 const { data: linkedRecord } = useDatabaseRecord(
252 linkingRecordId ? databaseId : undefined,
253 linkingRecordId ?? undefined
254 );
255 useEffect(() => {
256 if (!linkedRecord) return;
257 const record = linkedRecord;
258 setLinkingRecordId(null);
259 if (onOpenRecordPage) onOpenRecordPage(record);
260 else if (onOpenRecord) onOpenRecord(record);
261
262 }, [linkedRecord]);
263
264
265 const copyLink = async () => {
266 const url = `${window.location.origin}/f/${slug}`;
267 try {
268 await navigator.clipboard.writeText(url);
269 toast("success", "Link copied", url);
270 } catch {
271 toast("error", "Could not copy the link", url);
272 }
273 };
274
275
276 const scrollRef = useRef<HTMLDivElement>(null);
277 const [scrollTop, setScrollTop] = useState(0);
278 const [viewH, setViewH] = useState(400);
279 useEffect(() => {
280 const el = scrollRef.current;
281 if (!el) return;
282 const ro = new ResizeObserver(() => setViewH(el.clientHeight));
283 ro.observe(el);
284 setViewH(el.clientHeight);
285 return () => ro.disconnect();
286 }, []);
287
288 const total = sorted.length;
289 const start = Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN);
290 const count = Math.ceil(viewH / rowH) + OVERSCAN * 2;
291 const end = Math.min(total, start + count);
292 const slice = sorted.slice(start, end);
293
294
295 if (isError) {
296 return (
297 <div className="flex h-full items-center justify-center p-6 text-center">
298 <div>
299 <p className="text-sm font-medium text-destructive">Could not load responses</p>
300 <p className="mt-1 text-xs text-muted-foreground">
301 {error instanceof Error ? error.message : "Unknown error"}
302 </p>
303 </div>
304 </div>
305 );
306 }
307
308 return (
309 <div className="flex h-full min-h-0 flex-col">
310 {}
311 <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
312 <div className="relative">
313 <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
314 <input
315 type="search"
316 value={q}
317 onChange={(e) => setQ(e.target.value)}
318 placeholder="Search responses…"
319 aria-label="Search responses"
320 className="w-44 rounded-md border border-input bg-background py-1 pl-7 pr-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring sm:w-56"
321 />
322 </div>
323 <input
324 type="date"
325 value={from}
326 onChange={(e) => setFrom(e.target.value)}
327 aria-label="From date"
328 className="rounded-md border border-input bg-background px-2 py-1 text-xs"
329 />
330 <span className="text-xs text-muted-foreground">–</span>
331 <input
332 type="date"
333 value={to}
334 onChange={(e) => setTo(e.target.value)}
335 aria-label="To date"
336 className="rounded-md border border-input bg-background px-2 py-1 text-xs"
337 />
338 <label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
339 <input
340 type="checkbox"
341 checked={anonymousOnly}
342 onChange={(e) => setAnonymousOnly(e.target.checked)}
343 className="size-3.5 rounded border-input"
344 />
345 Anonymous only
346 </label>
347 <div className="ml-auto flex items-center gap-2">
348 <select
349 value={newestFirst ? "newest" : "oldest"}
350 onChange={(e) => setNewestFirst(e.target.value === "newest")}
351 aria-label="Sort order"
352 className="rounded-md border border-input bg-background px-2 py-1 text-xs"
353 >
354 <option value="newest">Newest first</option>
355 <option value="oldest">Oldest first</option>
356 </select>
357 {filterActive && (
358 <button
359 type="button"
360 className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
361 onClick={() => {
362 setQ("");
363 setAnonymousOnly(false);
364 setFrom("");
365 setTo("");
366 }}
367 >
368 Reset
369 </button>
370 )}
371 {}
372 <button
373 type="button"
374 disabled
375 title="CSV export is not implemented yet (Phase 13)"
376 className="inline-flex cursor-not-allowed items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground opacity-50"
377 >
378 <Download className="size-3.5" aria-hidden /> Export
379 </button>
380 </div>
381 </div>
382
383 {}
386 <div
387 data-testid="form-responses-scroll"
388 className="min-h-0 flex-1 overflow-y-auto max-h-[72dvh]"
389 ref={scrollRef}
390 onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
391 >
392 {isLoading ? (
393 <div className="space-y-2 p-3" aria-busy="true" aria-label="Loading responses">
394 {Array.from({ length: 6 }).map((_, i) => (
395 <div key={i} className="h-10 animate-pulse rounded-md bg-muted" />
396 ))}
397 </div>
398 ) : total === 0 && !filterActive ? (
399
400 <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
401 <Inbox className="size-8 text-muted-foreground" aria-hidden />
402 <p className="text-sm text-muted-foreground">No responses yet.</p>
403 <p className="max-w-xs text-xs text-muted-foreground">
404 Share the public link and responses will appear here as they arrive.
405 </p>
406 <button
407 type="button"
408 data-testid="form-responses-copy-link"
409 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90"
410 onClick={copyLink}
411 >
412 <ClipboardCopy className="size-3.5" aria-hidden /> Copy public link
413 </button>
414 </div>
415 ) : total === 0 ? (
416 <div className="flex h-full items-center justify-center p-6 text-center">
417 <p className="text-sm text-muted-foreground">No responses match the current filters.</p>
418 </div>
419 ) : (
420 <>
421 {}
422 {!isMobile && (
423 <div className="sticky top-0 z-10 grid grid-cols-[120px_1fr_1fr_140px_44px] gap-2 border-b border-border bg-background px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
424 <span>Received</span>
425 <span>Submitter</span>
426 <span>Answers</span>
427 <span>IP</span>
428 <span aria-label="Actions" />
429 </div>
430 )}
431 <div style={{ height: total * rowH, position: "relative" }}>
432 <div style={{ transform: `translateY(${start * rowH}px)` }}>
433 {slice.map((s) => {
434 const pairs = answerPairs(s);
435 return isMobile ? (
436 <MobileCard
437 key={s.id}
438 row={s}
439 pairs={pairs}
440 rowH={rowH}
441 onOpenRecord={(rid) => rid && setLinkingRecordId(rid)}
442 onDelete={() => setPendingDelete(s)}
443 />
444 ) : (
445 <div
446 key={s.id}
447 data-testid="form-response-row"
448 className="grid grid-cols-[120px_1fr_1fr_140px_44px] items-center gap-2 border-b border-border/50 px-3 text-xs"
449 style={{ height: rowH }}
450 >
451 <span className="text-muted-foreground" title={new Date(s.createdAt).toUTCString()}>
452 {timeAgo(s.createdAt)}
453 </span>
454 <SubmitterCell name={s.submittedBy?.name ?? "Anonymous"} email={s.submittedBy?.email} />
455 <span className="truncate" title={pairs.map((p) => `${p.label}: ${p.text}`).join("\n")}>
456 {pairs
457 .slice(0, 2)
458 .map((p) => `${p.label}: ${p.text}`)
459 .join(" · ")}
460 {pairs.length > 2 ? ` +${pairs.length - 2}` : ""}
461 </span>
462 <span className="flex items-center gap-1 truncate font-mono text-[11px] text-muted-foreground" title={s.ipMasked ? "IP address masked (owner/admin only)" : s.ip}>
463 {s.ipMasked && <span aria-hidden>🔒</span>}
464 {s.ip}
465 </span>
466 <span className="flex items-center gap-1">
467 {s.recordId && (
468 <button
469 type="button"
470 data-testid="form-response-record-link"
471 className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
472 onClick={() => setLinkingRecordId(s.recordId as string)}
473 title="Open record"
474 aria-label="Open record"
475 >
476 <ExternalLink className="size-3.5" />
477 </button>
478 )}
479 <button
480 type="button"
481 data-testid="form-response-delete"
482 className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
483 onClick={() => setPendingDelete(s)}
484 title="Delete submission"
485 aria-label="Delete submission"
486 >
487 <Trash2 className="size-3.5" />
488 </button>
489 </span>
490 </div>
491 );
492 })}
493 </div>
494 </div>
495 {nextCursor && (
496 <div className="flex items-center justify-center border-t border-border p-3">
497 <button
498 type="button"
499 data-testid="form-responses-load-more"
500 className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted"
501 onClick={loadMore}
502 disabled={loadingMore}
503 >
504 {loadingMore && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
505 Load more ({items.length} loaded)
506 </button>
507 </div>
508 )}
509 </>
510 )}
511 </div>
512
513 {}
514 <ConfirmDialog
515 open={!!pendingDelete}
516 title="Delete this submission?"
517 description={
518 <>
519 This permanently deletes the submission and its database record, and
520 purges any attached files. This cannot be undone.
521 </>
522 }
523 confirmLabel="Delete"
524 destructive
525 busy={deleteSubmission.isPending}
526 onConfirm={confirmDelete}
527 onClose={() => setPendingDelete(null)}
528 />
529 </div>
530 );
531}
532
533
534
535
536
537function SubmitterCell({ name, email }: { name: string; email?: string | null }) {
538 return (
539 <span className="flex min-w-0 items-center gap-1.5">
540 <span className="truncate text-foreground" title={email ?? name}>
541 {name}
542 </span>
543 {email && <span className="truncate text-[11px] text-muted-foreground">{email}</span>}
544 </span>
545 );
546}
547
548function MobileCard({
549 row,
550 pairs,
551 rowH,
552 onOpenRecord,
553 onDelete
554}: {
555 row: SubmissionDto;
556 pairs: { label: string; type: string; text: string }[];
557 rowH: number;
558 onOpenRecord: (recordId: string | null) => void;
559 onDelete: () => void;
560}) {
561 return (
562 <div
563 data-testid="form-response-row"
564 className="mx-2 mb-2 flex flex-col justify-between overflow-hidden rounded-lg border border-border bg-card p-3"
565 style={{ height: rowH - 8 }}
566 >
567 <div className="flex items-center justify-between gap-2 text-xs">
568 <SubmitterCell name={row.submittedBy?.name ?? "Anonymous"} email={row.submittedBy?.email} />
569 <span className="text-[11px] text-muted-foreground">{timeAgo(row.createdAt)}</span>
570 </div>
571 <ul className="my-2 flex-1 space-y-1 overflow-hidden text-xs" aria-label="Answers">
572 {pairs.slice(0, 4).map((p, i) => (
573 <li key={i} className="truncate">
574 <span className="text-muted-foreground">{p.label}: </span>
575 {p.text}
576 </li>
577 ))}
578 {pairs.length > 4 && <li className="text-[11px] text-muted-foreground">+{pairs.length - 4} more</li>}
579 </ul>
580 <div className="flex items-center justify-between">
581 <span className="flex items-center gap-1 truncate font-mono text-[10px] text-muted-foreground">
582 {row.ipMasked && <span aria-hidden>🔒</span>}
583 {row.ip}
584 </span>
585 <span className="flex items-center gap-1">
586 {row.recordId && (
587 <button
588 type="button"
589 data-testid="form-response-record-link"
590 className="rounded p-1 text-muted-foreground hover:bg-muted"
591 onClick={() => onOpenRecord(row.recordId)}
592 title="Open record"
593 aria-label="Open record"
594 >
595 <ExternalLink className="size-3.5" />
596 </button>
597 )}
598 <button
599 type="button"
600 data-testid="form-response-delete"
601 className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
602 onClick={onDelete}
603 title="Delete submission"
604 aria-label="Delete submission"
605 >
606 <Trash2 className="size-3.5" />
607 </button>
608 </span>
609 </div>
610 </div>
611 );
612}
613
614
615
616
617
618
619async function fetchSubmissionsPage(
620 formId: string,
621 f: { q?: string; anonymousOnly?: boolean; from?: string; to?: string; limit?: number; cursor?: string }
622): Promise<{ items: SubmissionDto[]; nextCursor: string | null }> {
623 const params = new URLSearchParams();
624 if (f.q) params.set("q", f.q);
625 if (f.anonymousOnly) params.set("anonymousOnly", "true");
626 if (f.from) params.set("from", f.from);
627 if (f.to) params.set("to", f.to);
628 if (f.limit) params.set("limit", String(f.limit));
629 if (f.cursor) params.set("cursor", f.cursor);
630 return apiFetch(`/forms/${formId}/submissions?${params.toString()}`);
631}
632