import { Save, Send, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useParams } from "react-router"; import { ActionToolbar, ActionBlockerHint, Button, ConfirmDialog, DismissibleAlert, FileDropZone, LoadingIndicator, PageScrollViewport, StatusBadge, usePlatformLanguage, type PlatformRouteContext } from "@govoplan/core-webui"; import { createPublicFormEvidenceGrant, getPublicFormIntake, savePublicFormDraft, startAnonymousFormIntake, startInvitationFormIntake, submitPublicFormIntake, uploadFormEvidence, type EvidenceReference, type FormDefinition, type FormInstance, type ValidationResult } from "../../api/formsRuntime"; import { FormField, localizeDefinition, visibleGroups } from "./FormInstancePage"; type AnonymousStartAttempt = { idempotencyKey: string; recordedAt: string; }; export default function PublicFormPage({ settings }: PlatformRouteContext) { const { publicId = "", token: invitationToken = "" } = useParams(); const { language } = usePlatformLanguage(); const [token, setToken] = useState(invitationToken); const [instance, setInstance] = useState(null); const [definition, setDefinition] = useState(null); const [values, setValues] = useState>({}); const [attachmentRefs, setAttachmentRefs] = useState([]); const [attachmentNames, setAttachmentNames] = useState>({}); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [confirmingSubmit, setConfirmingSubmit] = useState(false); useEffect(() => { const controller = new AbortController(); let active = true; async function initialize() { setLoading(true); setError(""); try { let activeToken = invitationToken; if (activeToken) { await startInvitationFormIntake(settings, activeToken); } else if (publicId) { activeToken = readSessionToken(publicId); if (!activeToken) { const attempt = anonymousStartAttempt(publicId); const started = await startAnonymousFormIntake( settings, publicId, attempt.idempotencyKey, attempt.recordedAt ); activeToken = started.token ?? ""; if (!activeToken) { throw new Error("This Form was already opened in another browser session. Use the original session or request a new link."); } rememberSessionToken(publicId, activeToken); clearAnonymousStartAttempt(publicId); } } if (!activeToken) throw new Error("This Form link is incomplete."); const loaded = await getPublicFormIntake(settings, activeToken, controller.signal); if (!active) return; setToken(activeToken); applyLoaded(loaded.instance, loaded.definition); } catch (reason) { if (active && (reason as Error).name !== "AbortError") { setError(reason instanceof Error ? reason.message : "The public Form could not be loaded."); } } finally { if (active) setLoading(false); } } function applyLoaded(nextInstance: FormInstance, nextDefinition: FormDefinition) { setInstance(nextInstance); setDefinition(nextDefinition); setValues(nextInstance.values); setAttachmentRefs(nextInstance.attachment_refs); } void initialize(); return () => { active = false; controller.abort(); }; }, [invitationToken, publicId, settings]); const editable = Boolean(instance && ["started", "draft"].includes(instance.status)); const changed = Boolean( instance && ( JSON.stringify(values) !== JSON.stringify(instance.values) || JSON.stringify(attachmentRefs) !== JSON.stringify(instance.attachment_refs) ) ); const localized = useMemo( () => localizeDefinition(definition, language), [definition, language] ); const groups = useMemo( () => definition ? visibleGroups(definition, values) : [], [definition, values] ); const diagnostics = useMemo(() => { const grouped = new Map(); for (const item of instance?.validation_results ?? []) { const key = item.field ?? ""; grouped.set(key, [...(grouped.get(key) ?? []), item]); } return grouped; }, [instance]); const signatureBlocked = Boolean( definition?.signature_requirement === "required" && (instance?.signature_refs.length ?? 0) === 0 ); const attachmentLimitReached = Boolean( definition && attachmentRefs.length >= definition.max_attachments ); async function reload() { if (!token) return; const loaded = await getPublicFormIntake(settings, token); setInstance(loaded.instance); setDefinition(loaded.definition); setValues(loaded.instance.values); setAttachmentRefs(loaded.instance.attachment_refs); } async function save() { if (!instance || !token || !definition?.allow_drafts || !changed) return; setBusy(true); setError(""); try { await savePublicFormDraft( settings, token, instance, values, attachmentRefs, "Public participant saved the draft." ); await reload(); } catch (reason) { setError(reason instanceof Error ? reason.message : "The draft could not be saved."); } finally { setBusy(false); } } async function submit() { if (!instance || !token || !editable || signatureBlocked) return; setBusy(true); setError(""); try { await submitPublicFormIntake(settings, token, instance, values, attachmentRefs); await reload(); } catch (reason) { setError(reason instanceof Error ? reason.message : "The Form could not be submitted."); } finally { setBusy(false); } } async function upload(files: File[]) { if (!instance || !token || !definition) return; const available = Math.max(0, definition.max_attachments - attachmentRefs.length); const selected = files.slice(0, available); if (selected.length === 0) return; setBusy(true); setError(""); try { const uploaded: EvidenceReference[] = []; const names: Record = {}; for (const file of selected) { const grant = await createPublicFormEvidenceGrant( settings, token, instance, crypto.randomUUID(), [...attachmentRefs, ...uploaded] ); const result = await uploadFormEvidence(settings, grant, file); uploaded.push(result.evidence); names[result.evidence.evidence_id] = file.name; } setAttachmentRefs((current) => [...current, ...uploaded]); setAttachmentNames((current) => ({ ...current, ...names })); } catch (reason) { setError(reason instanceof Error ? reason.message : "The attachment could not be uploaded."); } finally { setBusy(false); } } return (
{localized.title || "Form"} {instance && } {error && {error}} {loading && } {!loading && instance && definition &&

{localized.title}

{localized.description &&

{localized.description}

}
{groups.map((group) =>
{(groups.length > 1 || definition.pages?.length) &&

{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}

{group.description &&

{group.description}

}
}
{group.fields.map((field) => setValues((current) => { const next = { ...current }; if (value === undefined || value === "") delete next[field.key]; else next[field.key] = value; return next; })} /> )}
)} {definition.max_attachments > 0 && editable &&

Attachments

{attachmentRefs.map((reference) =>
{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}
)}
} {signatureBlocked && } {editable &&
{definition.allow_drafts && }
} {!editable && instance.receipt_id &&
Submission received Receipt {instance.receipt_id}
} {!editable && instance.status_access &&
Track this application {statusAccessMessage(instance.status_access.mode)}
{instance.status_access.tracking_id} Open status page
}
}
setConfirmingSubmit(false)} onConfirm={() => { setConfirmingSubmit(false); void submit(); }} />
); } function anonymousStartAttempt(publicId: string): AnonymousStartAttempt { const key = `govoplan.forms-runtime.start.${publicId}`; try { const stored = sessionStorage.getItem(key); if (stored) return JSON.parse(stored) as AnonymousStartAttempt; const attempt = { idempotencyKey: crypto.randomUUID(), recordedAt: new Date().toISOString() }; sessionStorage.setItem(key, JSON.stringify(attempt)); return attempt; } catch { return { idempotencyKey: crypto.randomUUID(), recordedAt: new Date().toISOString() }; } } function clearAnonymousStartAttempt(publicId: string) { try { sessionStorage.removeItem(`govoplan.forms-runtime.start.${publicId}`); } catch { // Private browsing may deny session storage; the active token still works. } } function readSessionToken(publicId: string): string { try { return sessionStorage.getItem(`govoplan.forms-runtime.token.${publicId}`) ?? ""; } catch { return ""; } } function rememberSessionToken(publicId: string, token: string) { try { sessionStorage.setItem(`govoplan.forms-runtime.token.${publicId}`, token); } catch { // Keep the token in component memory when session storage is unavailable. } } function stateLabel(value: string): string { return `i18n:govoplan-forms-runtime.state_${value}`; } function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string { if (mode === "authenticated") return "Sign in with the linked applicant account to view status."; if (mode === "email_link") return "Use this tracking ID and the linked email address to request a short-lived status link."; return "This permanent bearer link does not require sign-in. Store and share it carefully."; }