579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from 'react'
|
||
|
|
import type { FormEvent } from 'react'
|
||
|
|
import { Link, useParams } from 'react-router-dom'
|
||
|
|
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 }>()
|
||
|
|
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(() => {
|
||
|
|
let cancelled = false
|
||
|
|
setLoading(true)
|
||
|
|
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])
|
||
|
|
|
||
|
|
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 (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>
|
||
|
|
)
|
||
|
|
}
|