408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
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<FormInstance | null>(null);
|
|
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
|
const [attachmentRefs, setAttachmentRefs] = useState<EvidenceReference[]>([]);
|
|
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
|
|
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<string, ValidationResult[]>();
|
|
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<string, string> = {};
|
|
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 (
|
|
<main className="forms-runtime-page forms-public-page">
|
|
<div className="form-instance-shell">
|
|
<ActionToolbar className="form-instance-toolbar">
|
|
<strong>{localized.title || "Form"}</strong>
|
|
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={stateLabel(instance.status)} />}
|
|
</ActionToolbar>
|
|
<PageScrollViewport className="form-instance-viewport">
|
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
{loading && <LoadingIndicator label="Loading Form" />}
|
|
{!loading && instance && definition &&
|
|
<div className="form-public-content">
|
|
<header>
|
|
<h1>{localized.title}</h1>
|
|
{localized.description && <p>{localized.description}</p>}
|
|
</header>
|
|
{groups.map((group) =>
|
|
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
|
|
{(groups.length > 1 || definition.pages?.length) &&
|
|
<header>
|
|
<h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>
|
|
{group.description && <p>{group.description}</p>}
|
|
</header>
|
|
}
|
|
<div className="form-fields">
|
|
{group.fields.map((field) =>
|
|
<FormField
|
|
key={field.key}
|
|
field={{
|
|
...field,
|
|
label: localized.fieldLabels[field.key] ?? field.label,
|
|
help_text: localized.fieldHelpTexts[field.key] ?? field.help_text
|
|
}}
|
|
optionLabels={localized.optionLabels[field.key] ?? {}}
|
|
value={values[field.key]}
|
|
disabled={!editable || busy}
|
|
diagnostics={diagnostics.get(field.key) ?? []}
|
|
onChange={(value) => setValues((current) => {
|
|
const next = { ...current };
|
|
if (value === undefined || value === "") delete next[field.key];
|
|
else next[field.key] = value;
|
|
return next;
|
|
})}
|
|
/>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
{definition.max_attachments > 0 && editable &&
|
|
<section className="form-public-attachments">
|
|
<h2>Attachments</h2>
|
|
<FileDropZone
|
|
multiple
|
|
busy={busy}
|
|
disabled={attachmentLimitReached}
|
|
note={`${attachmentRefs.length} of ${definition.max_attachments} attachments`}
|
|
onFiles={upload}
|
|
/>
|
|
<div className="form-public-attachment-list">
|
|
{attachmentRefs.map((reference) =>
|
|
<div key={`${reference.evidence_id}:${reference.version ?? ""}`}>
|
|
<span>{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}</span>
|
|
<Button
|
|
variant="danger"
|
|
aria-label="Remove attachment"
|
|
disabled={busy}
|
|
onClick={() => setAttachmentRefs((current) => current.filter((item) => item !== reference))}>
|
|
<Trash2 size={15} aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
}
|
|
{signatureBlocked &&
|
|
<ActionBlockerHint
|
|
tone="warning"
|
|
reason={{
|
|
summary: "This Form requires an authenticated or external signature.",
|
|
details: "The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.",
|
|
requiredAction: "Use an authenticated service entry or a configured signature provider.",
|
|
actor: "Service owner",
|
|
target: "Form intake and signature policy"
|
|
}}
|
|
/>
|
|
}
|
|
{editable &&
|
|
<div className="form-instance-actions">
|
|
{definition.allow_drafts &&
|
|
<Button disabled={busy || !changed} onClick={() => void save()}>
|
|
<Save size={16} aria-hidden="true" />
|
|
Save draft
|
|
</Button>
|
|
}
|
|
<Button
|
|
variant="primary"
|
|
disabled={busy || signatureBlocked}
|
|
disabledReason={signatureBlocked ? "The required signature profile is unavailable for this public link." : undefined}
|
|
onClick={() => setConfirmingSubmit(true)}>
|
|
<Send size={16} aria-hidden="true" />
|
|
Submit
|
|
</Button>
|
|
</div>
|
|
}
|
|
{!editable && instance.receipt_id &&
|
|
<div className="form-receipt">
|
|
<strong>Submission received</strong>
|
|
<span>Receipt</span>
|
|
<code>{instance.receipt_id}</code>
|
|
</div>
|
|
}
|
|
{!editable && instance.status_access &&
|
|
<section className="form-status-access-receipt">
|
|
<div>
|
|
<strong>Track this application</strong>
|
|
<span>{statusAccessMessage(instance.status_access.mode)}</span>
|
|
</div>
|
|
<code>{instance.status_access.tracking_id}</code>
|
|
<a className="btn" href={instance.status_access.href}>Open status page</a>
|
|
</section>
|
|
}
|
|
</div>
|
|
}
|
|
</PageScrollViewport>
|
|
</div>
|
|
<ConfirmDialog
|
|
open={confirmingSubmit}
|
|
title="Submit Form"
|
|
message="Submit this Form? The values and managed attachment references become an immutable submission revision."
|
|
confirmLabel="Submit"
|
|
busy={busy}
|
|
onCancel={() => setConfirmingSubmit(false)}
|
|
onConfirm={() => {
|
|
setConfirmingSubmit(false);
|
|
void submit();
|
|
}}
|
|
/>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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.";
|
|
}
|