import { useEffect, useMemo, useState } from 'react' import type { FormEvent } from 'react' import { Link, useParams } from 'react-router-dom' import { useAuth } from '../auth/AuthContext' import { SignInPrompt } from '../components/SignInPrompt' import { api, ApiError } from '../lib/api' import type { Answer, Form, Submission, UserIdType } from '../lib/types' import { QuestionField } from '../components/QuestionField' import { SignaturePad } from '../components/SignaturePad' import type { SignatureDraft } from '../components/SignaturePad' type Step = 'intro' | 'questions' | 'done' type CompletionMode = 'online' | 'offline' const MAX_OFFLINE_PDF_BYTES = 10 * 1024 * 1024 function readFileAsDataURL(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { if (typeof reader.result === 'string') resolve(reader.result) else reject(new Error('Could not read the file')) } reader.onerror = () => reject(new Error('Could not read the file')) reader.readAsDataURL(file) }) } const userIdTypes: Array<{ value: UserIdType; label: string }> = [ { value: 'email', label: 'Email address' }, { value: 'username', label: 'Username' }, { value: 'phone', label: 'Phone number' }, { value: 'foreignkey', label: 'Provided key' }, { value: 'guid', label: 'GUID' }, ] function isBlank(value: unknown): boolean { if (value === undefined || value === null) return true if (typeof value === 'string') return value.trim() === '' return false } export function FillFormPage() { const { formId = '' } = useParams<{ formId: string }>() const { user, config, loading: authLoading } = useAuth() const submissionLocked = Boolean(config?.requireSubmissionAuth && !user) const [form, setForm] = useState
(null) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [step, setStep] = useState('intro') const [completionMode, setCompletionMode] = useState('online') const [offlineFile, setOfflineFile] = useState<{ name: string; dataUrl: string } | null>(null) const [offlineError, setOfflineError] = useState(null) const [signature, setSignature] = useState(null) const [signatureName, setSignatureName] = useState('') const [signatureTitle, setSignatureTitle] = useState('') const [userIdType, setUserIdType] = useState('email') const [userId, setUserId] = useState('') const [accessCode, setAccessCode] = useState('') const [externalReference, setExternalReference] = useState('') const [answers, setAnswers] = useState>({}) const [formError, setFormError] = useState(null) const [submitting, setSubmitting] = useState(false) const [submission, setSubmission] = useState(null) const [downloadingPdf, setDownloadingPdf] = useState(false) const [pdfError, setPdfError] = useState(null) // Map option values to preferred placeholder texts const placeholders = { email: "Enter your email (e.g., name@example.com)", phone: "Enter your phone number (e.g., +61403555012)", foreignkey: "Use your supplied key", username: "Enter your handle (e.g., @username)", guid: "Enter your identifier" }; useEffect(() => { if (authLoading) return if (submissionLocked) { setForm(null) setLoading(false) return } let cancelled = false setLoading(true) setLoadError(null) api .getForm(formId) .then((result) => { if (!cancelled) setForm(result) }) .catch((err: unknown) => { if (!cancelled) { setLoadError(err instanceof ApiError ? err.message : 'Could not load this form') } }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [formId, authLoading, submissionLocked]) const answeredCount = useMemo( () => Object.values(answers).filter((value) => !isBlank(value)).length, [answers], ) async function downloadPrintablePdf() { if (!form) return setPdfError(null) setDownloadingPdf(true) try { const filename = form.title.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() await api.downloadPdf(form.id, `${filename || 'form'}.pdf`, form.pdfUrl) } catch (err) { setPdfError(err instanceof ApiError ? err.message : 'Could not download the PDF') } finally { setDownloadingPdf(false) } } if (authLoading) { return

Checking sign-in…

} if (submissionLocked) { return ( ) } if (loading) { return

Loading form…

} if (loadError || !form) { return (
{loadError ?? 'Form not found'}
) } if (!form.isActive && step !== 'done') { return (

This form is closed

“{form.title}” is no longer accepting responses.

Back to forms
) } function validateIdentity(): boolean { if (userId.trim() === '') { setFormError('Please enter your identifier before continuing.') return false } if (form!.requiresAccessCode && accessCode.trim() === '') { setFormError('An access code is required for this form.') return false } if (form!.externalReferenceRequired && externalReference.trim() === '') { setFormError(`${form!.externalReferenceLabel ?? 'External reference'} is required.`) return false } return true } function completionTracking() { return { locale: navigator.language, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, referer: document.referrer || undefined, } } async function completeSubmission(extra: { answers: Answer[] signature?: { type: 'drawn' | 'image'; dataUrl: string; name: string; title?: string } offlinePdf?: string offlinePdfName?: string }) { if (!form) return setFormError(null) setSubmitting(true) try { const result = await api.submitForm(form.id, { userId: userId.trim(), userIdType, accessCode: form.requiresAccessCode ? accessCode.trim() : undefined, externalReference: externalReference.trim() || undefined, tracking: completionTracking(), ...extra, }) setSubmission(result) setAnswers({}) setAccessCode('') setUserId('') setExternalReference('') setSignature(null) setSignatureName('') setSignatureTitle('') setOfflineFile(null) setStep('done') } catch (err) { setFormError(err instanceof ApiError ? err.message : 'Could not submit the form') } finally { setSubmitting(false) } } async function chooseOfflineFile(file: File | undefined) { setOfflineError(null) setFormError(null) if (!file) { setOfflineFile(null) return } if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { setOfflineError('Choose a PDF file.') return } if (file.size > MAX_OFFLINE_PDF_BYTES) { setOfflineError('The PDF must be 10 MB or smaller.') return } try { const dataUrl = await readFileAsDataURL(file) setOfflineFile({ name: file.name, dataUrl }) } catch { setOfflineError('Could not read that PDF.') } } async function startQuestions(event: FormEvent) { event.preventDefault() setFormError(null) if (!validateIdentity()) return if (completionMode === 'offline') { if (!offlineFile) { setOfflineError('Upload the completed PDF before submitting.') return } await completeSubmission({ answers: [], offlinePdf: offlineFile.dataUrl, offlinePdfName: offlineFile.name, }) return } setStep('questions') } async function submit(event: FormEvent) { event.preventDefault() setFormError(null) if (!form) return const missing = form.questions.filter((q) => q.required && isBlank(answers[q.id])) if (missing.length > 0) { setFormError(`Please answer: ${missing.map((q) => q.label).join(', ')}`) return } const payloadAnswers: Answer[] = form.questions .filter((q) => !isBlank(answers[q.id])) .map((q) => ({ questionId: q.id, value: answers[q.id] })) let signaturePayload: | { type: 'drawn' | 'image'; dataUrl: string; name: string; title?: string } | undefined if (form.signatureRequired) { if (!signature) { setFormError('Please provide your signature before submitting.') return } if (signatureName.trim() === '') { setFormError('Please enter the name of the person signing.') return } signaturePayload = { type: signature.type, dataUrl: signature.dataUrl, name: signatureName.trim(), title: signatureTitle.trim() || undefined, } } await completeSubmission({ answers: payloadAnswers, signature: signaturePayload }) } if (step === 'done') { return (
✓

Thank you

Your response to “{form.title}” has been recorded.

{submission && (

Reference {submission.id}

)} {pdfError &&

{pdfError}

}
Back to forms
) } return (
{form.logoUrl && ( {form.companyName )} {form.companyName && (

{form.companyName}

)}

{form.title}

Version {form.version}

{form.description && (

{form.description}

)} {form.introduction && (

{form.introduction}

)} {(form.contactEmail || form.contactPhone || form.contactUrl) && (

Questions?{' '} {[form.contactEmail, form.contactPhone, form.contactUrl].filter(Boolean).join(' · ')}

)}
{pdfError &&

{pdfError}

}
{step === 'intro' && (

Before you begin

We use your identifier to link this response to you. It is not shown again after you submit.

How would you like to respond?
{completionMode === 'offline' && (
void chooseOfflineFile(event.target.files?.[0])} />

Maximum 10 MB. {offlineFile ? `Selected: ${offlineFile.name}` : 'No file selected.'}

{offlineError &&

{offlineError}

}
)}
setUserId(event.target.value)} placeholder={placeholders[userIdType] || 'Enter your identifier'} //placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'} required />
{form.requiresAccessCode && (
setAccessCode(event.target.value.toUpperCase())} placeholder="XXXX-XXXX" required />

Each access code can be used once.

)} {form.externalReferenceRequired && (
setExternalReference(event.target.value)} maxLength={200} required />

Links this response to a record in another system.

)} {formError &&

{formError}

} )} {step === 'questions' && (
{answeredCount} of {form.questions.length} answered
{form.questions.map((question) => (
setAnswers((current) => ({ ...current, [question.id]: value })) } />
))} {form.signatureRequired && (

Signature

Draw your signature or upload an image of it. Your name is required; a title is optional.

setSignatureName(event.target.value)} required />
setSignatureTitle(event.target.value)} />
)} {formError &&

{formError}

}
)}
) }