kereru-forms/frontend/src/pages/FillFormPage.tsx

600 lines
20 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useState } from 'react'
import type { FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom'
2026-10-23 19:36:02 +00:00
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<string> {
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: '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 }>()
2026-10-23 19:36:02 +00:00
const { user, config, loading: authLoading } = useAuth()
const submissionLocked = Boolean(config?.requireSubmissionAuth && !user)
const [form, setForm] = useState<Form | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [step, setStep] = useState<Step>('intro')
const [completionMode, setCompletionMode] = useState<CompletionMode>('online')
const [offlineFile, setOfflineFile] = useState<{ name: string; dataUrl: string } | null>(null)
const [offlineError, setOfflineError] = useState<string | null>(null)
const [signature, setSignature] = useState<SignatureDraft | null>(null)
const [signatureName, setSignatureName] = useState('')
const [signatureTitle, setSignatureTitle] = useState('')
const [userIdType, setUserIdType] = useState<UserIdType>('email')
const [userId, setUserId] = useState('')
const [accessCode, setAccessCode] = useState('')
const [externalReference, setExternalReference] = useState('')
const [answers, setAnswers] = useState<Record<string, unknown>>({})
const [formError, setFormError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [submission, setSubmission] = useState<Submission | null>(null)
const [downloadingPdf, setDownloadingPdf] = useState(false)
const [pdfError, setPdfError] = useState<string | null>(null)
useEffect(() => {
2026-10-23 19:36:02 +00:00
if (authLoading) return
if (submissionLocked) {
setForm(null)
setLoading(false)
return
}
let cancelled = false
setLoading(true)
2026-10-23 19:36:02 +00:00
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
}
2026-10-23 19:36:02 +00:00
}, [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)
}
}
2026-10-23 19:36:02 +00:00
if (authLoading) {
return <p className="text-slate-500 dark:text-slate-400">Checking sign-in…</p>
}
if (submissionLocked) {
return (
<SignInPrompt message="Sign in with your organisation account to open and complete this form." />
)
}
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form…</p>
}
if (loadError || !form) {
return (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
{loadError ?? 'Form not found'}
</div>
)
}
if (!form.isActive && step !== 'done') {
return (
<div className="card space-y-2">
<h1 className="text-xl font-bold">This form is closed</h1>
<p className="text-slate-500 dark:text-slate-400">
“{form.title}” is no longer accepting responses.
</p>
<Link to="/" className="btn-secondary mt-2 w-fit">
Back to forms
</Link>
</div>
)
}
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 (
<div className="card mx-auto max-w-xl space-y-3 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-100 text-2xl text-brand-700 dark:bg-brand-900 dark:text-brand-200">
✓
</div>
<h1 className="text-2xl font-bold">Thank you</h1>
<p className="text-slate-500 dark:text-slate-400">
Your response to “{form.title}” has been recorded.
</p>
{submission && (
<p className="text-xs text-slate-400 dark:text-slate-500">
Reference {submission.id}
</p>
)}
{pdfError && <p className="text-sm text-red-600 dark:text-red-400">{pdfError}</p>}
<div className="flex flex-wrap justify-center gap-3 pt-2">
<button
type="button"
className="btn-secondary"
onClick={downloadPrintablePdf}
disabled={downloadingPdf}
>
{downloadingPdf ? 'Preparing PDF…' : 'Download printable PDF'}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => {
setSubmission(null)
setStep('intro')
}}
>
Submit another response
</button>
<Link to="/" className="btn-primary">
Back to forms
</Link>
</div>
</div>
)
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="space-y-2">
{form.logoUrl && (
<img
src={form.logoUrl}
alt={form.companyName ?? form.title}
className="h-14 w-auto"
referrerPolicy="no-referrer"
/>
)}
{form.companyName && (
<p className="text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">
{form.companyName}
</p>
)}
<h1 className="text-3xl font-bold">{form.title}</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">Version {form.version}</p>
{form.description && (
<p className="text-slate-600 dark:text-slate-300">{form.description}</p>
)}
{form.introduction && (
<div className="card bg-slate-100/70 dark:bg-slate-900/60">
<p className="whitespace-pre-line text-sm text-slate-700 dark:text-slate-300">
{form.introduction}
</p>
</div>
)}
{(form.contactEmail || form.contactPhone || form.contactUrl) && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Questions?{' '}
{[form.contactEmail, form.contactPhone, form.contactUrl].filter(Boolean).join(' · ')}
</p>
)}
<div className="pt-1">
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={downloadPrintablePdf}
disabled={downloadingPdf}
>
{downloadingPdf ? 'Preparing PDF…' : 'Download printable PDF'}
</button>
</div>
{pdfError && <p className="text-sm text-red-600 dark:text-red-400">{pdfError}</p>}
</header>
{step === 'intro' && (
<form onSubmit={startQuestions} className="card space-y-4">
<h2 className="text-lg font-semibold">Before you begin</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
We use your identifier to link this response to you. It is not shown again after you
submit.
</p>
<fieldset className="space-y-2">
<legend className="label">How would you like to respond?</legend>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name="completionMode"
checked={completionMode === 'online'}
onChange={() => setCompletionMode('online')}
className="mt-0.5 h-4 w-4 accent-brand-600"
/>
<span>
<span className="font-medium">Complete the form online</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
Answer the questions here and submit your response.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name="completionMode"
checked={completionMode === 'offline'}
onChange={() => setCompletionMode('offline')}
className="mt-0.5 h-4 w-4 accent-brand-600"
/>
<span>
<span className="font-medium">Upload a completed PDF</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
Download the PDF, complete it offline, then upload it with your identity details.
</span>
</span>
</label>
</fieldset>
{completionMode === 'offline' && (
<div>
<label htmlFor="offlinePdf" className="label">
Completed PDF *
</label>
<input
id="offlinePdf"
type="file"
accept="application/pdf,.pdf"
className="input"
onChange={(event) => void chooseOfflineFile(event.target.files?.[0])}
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Maximum 10 MB. {offlineFile ? `Selected: ${offlineFile.name}` : 'No file selected.'}
</p>
{offlineError && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{offlineError}</p>}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="userIdType" className="label">
Identifier type
</label>
<select
id="userIdType"
className="input"
value={userIdType}
onChange={(event) => setUserIdType(event.target.value as UserIdType)}
>
{userIdTypes.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="userId" className="label">
Your identifier
</label>
<input
id="userId"
className="input"
value={userId}
onChange={(event) => setUserId(event.target.value)}
placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'}
required
/>
</div>
</div>
{form.requiresAccessCode && (
<div>
<label htmlFor="accessCode" className="label">
Access code
</label>
<input
id="accessCode"
className="input uppercase"
value={accessCode}
onChange={(event) => setAccessCode(event.target.value.toUpperCase())}
placeholder="XXXX-XXXX"
required
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Each access code can be used once.
</p>
</div>
)}
{form.externalReferenceRequired && (
<div>
<label htmlFor="externalReference" className="label">
{form.externalReferenceLabel ?? 'External reference'}
</label>
<input
id="externalReference"
className="input"
value={externalReference}
onChange={(event) => setExternalReference(event.target.value)}
maxLength={200}
required
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Links this response to a record in another system.
</p>
</div>
)}
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting
? 'Submitting…'
: completionMode === 'offline'
? 'Upload and submit response'
: 'Start form'}
</button>
</form>
)}
{step === 'questions' && (
<form onSubmit={submit} className="space-y-4">
<div className="flex items-center justify-between text-sm text-slate-500 dark:text-slate-400">
<span>
{answeredCount} of {form.questions.length} answered
</span>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={() => setStep('intro')}
>
Back
</button>
</div>
{form.questions.map((question) => (
<div key={question.id} className="card">
<QuestionField
question={question}
value={answers[question.id]}
onChange={(value) =>
setAnswers((current) => ({ ...current, [question.id]: value }))
}
/>
</div>
))}
{form.signatureRequired && (
<section className="card space-y-4">
<div>
<h2 className="text-lg font-semibold">Signature</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Draw your signature or upload an image of it. Your name is required; a title is
optional.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="signatureName" className="label">
Name of person signing *
</label>
<input
id="signatureName"
className="input"
value={signatureName}
onChange={(event) => setSignatureName(event.target.value)}
required
/>
</div>
<div>
<label htmlFor="signatureTitle" className="label">
Title (optional)
</label>
<input
id="signatureTitle"
className="input"
value={signatureTitle}
onChange={(event) => setSignatureTitle(event.target.value)}
/>
</div>
</div>
<SignaturePad value={signature} onChange={setSignature} />
</section>
)}
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit response'}
</button>
</form>
)}
</div>
)
}