import { useFormatters, useT } from "@agent-native/core/client/i18n"; import type { FormField } from "@shared/types"; import { IconArrowLeft, IconDownload, IconRefresh, IconSearch, IconArrowUp, IconArrowDown, IconArrowsSort, } from "@tabler/icons-react"; import { format } from "date-fns"; import { useMemo, useState } from "react"; import { useParams, Link } from "react-router"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { useForm } from "@/hooks/use-forms"; import { useFormResponses } from "@/hooks/use-responses"; import { normalizeFields } from "@/lib/normalize-fields"; import { cn } from "@/lib/utils"; type SortKey = "_submitted" | string; // string = field id type SortDir = "asc" | "desc"; function valueAsString(val: unknown): string { if (val === undefined || val === null) return ""; if (Array.isArray(val)) return val.join(", "); return String(val); } /** Drop the protocol for a cleaner table cell; the full URL stays the link href. */ function formatPageUrl(url: string): string { return url.replace(/^https?:\/\//, ""); } /** * Only http(s) URLs are safe to use as an anchor href. Page URLs arrive from * client `_meta` and could be spoofed by a direct POST, so reject other schemes * (e.g. `javascript:`) to avoid a self-XSS when the owner clicks the cell. */ function safeHttpUrl(value: string): string | null { try { const u = new URL(value); return u.protocol === "http:" || u.protocol === "https:" ? u.href : null; } catch { return null; } } /** Friendly label for the client-surface token forwarded by feedback embeds. */ function formatClientSurface(surface: string): string { switch (surface) { case "electron": return "Desktop (Electron)"; case "tauri": return "Desktop (Tauri)"; case "web": return "Web"; default: return surface; } } function compareValues(a: unknown, b: unknown): number { // Empty values sort last regardless of direction. const aEmpty = a === undefined || a === null || a === ""; const bEmpty = b === undefined || b === null || b === ""; if (aEmpty && bEmpty) return 0; if (aEmpty) return 1; if (bEmpty) return -1; const aNum = typeof a === "number" ? a : Number(a); const bNum = typeof b === "number" ? b : Number(b); if (!Number.isNaN(aNum) && !Number.isNaN(bNum) && a !== "" && b !== "") { return aNum - bNum; } return valueAsString(a).localeCompare(valueAsString(b), undefined, { numeric: true, sensitivity: "base", }); } export function ResponsesPage() { const t = useT(); const { formatDate, formatNumber } = useFormatters(); const { id } = useParams<{ id: string }>(); const { data: form } = useForm(id!); const { data, isLoading, error, refetch } = useFormResponses(id!); const responses = data?.responses || []; const fields: FormField[] = useMemo( () => normalizeFields(data?.fields || form?.fields), [data?.fields, form?.fields], ); const total = data?.total ?? 0; const [search, setSearch] = useState(""); const [sortKey, setSortKey] = useState("_submitted"); const [sortDir, setSortDir] = useState("desc"); const hasSubmitterEmail = useMemo( () => responses.some((r: any) => valueAsString(r.submitterEmail).trim()), [responses], ); const hasPageUrl = useMemo( () => responses.some((r: any) => valueAsString(r.pageUrl).trim()), [responses], ); const hasClientSurface = useMemo( () => responses.some((r: any) => valueAsString(r.clientSurface).trim()), [responses], ); const responseTableMinWidth = 64 + 160 + (hasSubmitterEmail ? 224 : 0) + (hasPageUrl ? 256 : 0) + (hasClientSurface ? 160 : 0) + Math.max(fields.length, 1) * 320; function toggleSort(key: SortKey) { if (sortKey === key) { setSortDir((d) => (d === "asc" ? "desc" : "asc")); } else { setSortKey(key); setSortDir(key === "_submitted" ? "desc" : "asc"); } } const filteredSorted = useMemo(() => { const q = search.trim().toLowerCase(); let rows = responses; if (q) { rows = rows.filter((r: any) => { if (valueAsString(r.submitterEmail).toLowerCase().includes(q)) { return true; } if (valueAsString(r.pageUrl).toLowerCase().includes(q)) { return true; } if ( formatClientSurface(valueAsString(r.clientSurface)) .toLowerCase() .includes(q) ) { return true; } for (const f of fields) { if (valueAsString(r.data[f.id]).toLowerCase().includes(q)) return true; } return false; }); } const sorted = [...rows].sort((a, b) => { let cmp: number; if (sortKey === "_submitted") { cmp = new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime(); } else if (sortKey === "_email") { cmp = compareValues(a.submitterEmail, b.submitterEmail); } else if (sortKey === "_page") { cmp = compareValues(a.pageUrl, b.pageUrl); } else if (sortKey === "_source") { cmp = compareValues(a.clientSurface, b.clientSurface); } else { cmp = compareValues(a.data[sortKey], b.data[sortKey]); } return sortDir === "asc" ? cmp : -cmp; }); return sorted; }, [responses, fields, search, sortKey, sortDir]); function exportCsv() { if (!fields.length || !filteredSorted.length) return; const headers = [ "Submitted At", ...(hasSubmitterEmail ? ["Submitter Email"] : []), ...(hasPageUrl ? ["Page URL"] : []), ...(hasClientSurface ? ["Source"] : []), ...fields.map((f) => f.label), ]; const rows = filteredSorted.map((r) => [ r.submittedAt, ...(hasSubmitterEmail ? [valueAsString(r.submitterEmail)] : []), ...(hasPageUrl ? [valueAsString(r.pageUrl)] : []), ...(hasClientSurface ? [ valueAsString(r.clientSurface) ? formatClientSurface(valueAsString(r.clientSurface)) : "", ] : []), ...fields.map((f) => valueAsString(r.data[f.id])), ]); const csv = [headers, ...rows] .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","), ) .join("\n"); const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${form?.title || "responses"}-${format(new Date(), "yyyy-MM-dd")}.csv`; a.click(); URL.revokeObjectURL(url); } if (isLoading) { return (
{Array.from({ length: 8 }).map((_, i) => (
))}
); } if (error && !responses.length) { return (

{t("responses.failedLoad")}

); } return (
{/* Header */}
{form?.title} {search.trim() && filteredSorted.length !== total ? t("responses.filteredCount", { count: formatNumber(filteredSorted.length), total: formatNumber(total), }) : t("responses.totalCount", { count: total, formattedCount: formatNumber(total), })}
{responses.length > 0 ? (
setSearch(e.target.value)} placeholder={t("responses.filterPlaceholder")} className="h-10 w-48 pl-7 text-xs transition-[background-color,border-color,box-shadow] duration-150 motion-reduce:transition-none" />
) : null}
{/* Mobile filter row */} {responses.length > 0 ? (
setSearch(e.target.value)} placeholder={t("responses.filterPlaceholder")} className="h-10 pl-7 text-xs transition-[background-color,border-color,box-shadow] duration-150 motion-reduce:transition-none" />
) : null} {/* Table */} {responses.length === 0 ? (

{t("responses.emptyTitle")}

{t("responses.emptyDescription")}

) : filteredSorted.length === 0 ? (

{t("responses.noMatchesTitle")}

{t("responses.noMatchesDescription", { search })}

) : (
{hasSubmitterEmail ? : null} {hasPageUrl ? : null} {hasClientSurface ? : null} {fields.map((f, index) => ( ))} toggleSort("_submitted")} /> {hasSubmitterEmail ? ( toggleSort("_email")} /> ) : null} {hasPageUrl ? ( toggleSort("_page")} /> ) : null} {hasClientSurface ? ( toggleSort("_source")} /> ) : null} {fields.map((f) => ( toggleSort(f.id)} /> ))} {filteredSorted.map((response, idx) => ( {hasSubmitterEmail ? ( ) : null} {hasPageUrl ? ( ) : null} {hasClientSurface ? ( ) : null} {fields.map((f) => { const val = response.data[f.id]; const display = val === undefined || val === null || val === "" ? "-" : valueAsString(val); return ( ); })} ))}
#
{filteredSorted.length - idx} {formatDate(response.submittedAt, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", })} {valueAsString(response.submitterEmail) || "-"} {(() => { const raw = valueAsString(response.pageUrl); if (!raw) return ( - ); const safe = safeHttpUrl(raw); return safe ? ( {formatPageUrl(raw)} ) : ( {formatPageUrl(raw)} ); })()} {(() => { const surface = valueAsString(response.clientSurface); if (!surface) return ( - ); const label = formatClientSurface(surface); // Make desktop submissions pop; web stays muted text. return surface === "web" ? ( {label} ) : ( {label} ); })()} {display}
)}
); } function SortableHeader(props: { label: string; active: boolean; dir: SortDir; onClick: () => void; }) { const t = useT(); const { label, active, dir, onClick } = props; const sortState: "neutral" | "asc" | "desc" = !active ? "neutral" : dir === "asc" ? "asc" : "desc"; const iconBase = "absolute inset-0 transition-[opacity,scale,filter] duration-200 ease-[cubic-bezier(0.2,0,0,1)]"; const iconVisible = "scale-100 opacity-60 blur-none"; const iconHidden = "scale-[0.25] opacity-0 blur-[4px]"; return ( ); }