431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState, useEffect } from 'react'
|
||
|
|
import { useParams } from 'next/navigation'
|
||
|
|
import { useForm } from 'react-hook-form'
|
||
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||
|
|
import { z } from 'zod'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import {
|
||
|
|
Card,
|
||
|
|
CardContent,
|
||
|
|
CardDescription,
|
||
|
|
CardHeader,
|
||
|
|
CardTitle,
|
||
|
|
} from '@/components/ui/card'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import { Textarea } from '@/components/ui/textarea'
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select'
|
||
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
||
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||
|
|
|
||
|
|
type FormField = {
|
||
|
|
id: string
|
||
|
|
fieldType: string
|
||
|
|
name: string
|
||
|
|
label: string
|
||
|
|
description?: string | null
|
||
|
|
placeholder?: string | null
|
||
|
|
required: boolean
|
||
|
|
minLength?: number | null
|
||
|
|
maxLength?: number | null
|
||
|
|
minValue?: number | null
|
||
|
|
maxValue?: number | null
|
||
|
|
optionsJson: Array<{ value: string; label: string }> | null
|
||
|
|
conditionJson: { fieldId: string; operator: string; value?: string } | null
|
||
|
|
width: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function PublicFormPage() {
|
||
|
|
const params = useParams()
|
||
|
|
const slug = params.slug as string
|
||
|
|
const [submitted, setSubmitted] = useState(false)
|
||
|
|
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
|
||
|
|
|
||
|
|
const { data: form, isLoading, error } = trpc.applicationForm.getBySlug.useQuery(
|
||
|
|
{ slug },
|
||
|
|
{ retry: false }
|
||
|
|
)
|
||
|
|
|
||
|
|
const submitMutation = trpc.applicationForm.submit.useMutation({
|
||
|
|
onSuccess: (result) => {
|
||
|
|
setSubmitted(true)
|
||
|
|
setConfirmationMessage(result.confirmationMessage || null)
|
||
|
|
},
|
||
|
|
onError: (error) => {
|
||
|
|
toast.error(error.message)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const {
|
||
|
|
register,
|
||
|
|
handleSubmit,
|
||
|
|
watch,
|
||
|
|
formState: { errors, isSubmitting },
|
||
|
|
setValue,
|
||
|
|
} = useForm()
|
||
|
|
|
||
|
|
const watchedValues = watch()
|
||
|
|
|
||
|
|
const onSubmit = async (data: Record<string, unknown>) => {
|
||
|
|
if (!form) return
|
||
|
|
|
||
|
|
// Extract email and name if present
|
||
|
|
const emailField = form.fields.find((f) => f.fieldType === 'EMAIL')
|
||
|
|
const email = emailField ? (data[emailField.name] as string) : undefined
|
||
|
|
|
||
|
|
// Find a name field (common patterns)
|
||
|
|
const nameField = form.fields.find(
|
||
|
|
(f) => f.name.toLowerCase().includes('name') && f.fieldType === 'TEXT'
|
||
|
|
)
|
||
|
|
const name = nameField ? (data[nameField.name] as string) : undefined
|
||
|
|
|
||
|
|
await submitMutation.mutateAsync({
|
||
|
|
formId: form.id,
|
||
|
|
data,
|
||
|
|
email,
|
||
|
|
name,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return (
|
||
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<Skeleton className="h-8 w-64" />
|
||
|
|
<Skeleton className="h-4 w-full" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
{[1, 2, 3, 4].map((i) => (
|
||
|
|
<div key={i} className="space-y-2">
|
||
|
|
<Skeleton className="h-4 w-32" />
|
||
|
|
<Skeleton className="h-10 w-full" />
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
return (
|
||
|
|
<div className="max-w-2xl mx-auto">
|
||
|
|
<Card>
|
||
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||
|
|
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||
|
|
<h2 className="text-xl font-semibold mb-2">Form Not Available</h2>
|
||
|
|
<p className="text-muted-foreground text-center">
|
||
|
|
{error.message}
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (submitted) {
|
||
|
|
return (
|
||
|
|
<div className="max-w-2xl mx-auto">
|
||
|
|
<Card>
|
||
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||
|
|
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||
|
|
<h2 className="text-xl font-semibold mb-2">Thank You!</h2>
|
||
|
|
<p className="text-muted-foreground text-center">
|
||
|
|
{confirmationMessage || 'Your submission has been received.'}
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!form) return null
|
||
|
|
|
||
|
|
// Check if a field should be visible based on conditions
|
||
|
|
const isFieldVisible = (field: FormField): boolean => {
|
||
|
|
if (!field.conditionJson) return true
|
||
|
|
|
||
|
|
const condition = field.conditionJson
|
||
|
|
const dependentValue = watchedValues[form.fields.find((f) => f.id === condition.fieldId)?.name || '']
|
||
|
|
|
||
|
|
switch (condition.operator) {
|
||
|
|
case 'equals':
|
||
|
|
return dependentValue === condition.value
|
||
|
|
case 'not_equals':
|
||
|
|
return dependentValue !== condition.value
|
||
|
|
case 'not_empty':
|
||
|
|
return !!dependentValue && dependentValue !== ''
|
||
|
|
case 'contains':
|
||
|
|
return typeof dependentValue === 'string' && dependentValue.includes(condition.value || '')
|
||
|
|
default:
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const renderField = (field: FormField) => {
|
||
|
|
if (!isFieldVisible(field)) return null
|
||
|
|
|
||
|
|
const fieldError = errors[field.name]
|
||
|
|
const errorMessage = fieldError?.message as string | undefined
|
||
|
|
|
||
|
|
switch (field.fieldType) {
|
||
|
|
case 'SECTION':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className="col-span-full pt-6 pb-2">
|
||
|
|
<h3 className="text-lg font-semibold">{field.label}</h3>
|
||
|
|
{field.description && (
|
||
|
|
<p className="text-sm text-muted-foreground">{field.description}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
case 'INSTRUCTIONS':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className="col-span-full">
|
||
|
|
<div className="bg-muted p-4 rounded-lg">
|
||
|
|
<p className="text-sm">{field.description || field.label}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
case 'TEXT':
|
||
|
|
case 'EMAIL':
|
||
|
|
case 'PHONE':
|
||
|
|
case 'URL':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
|
||
|
|
<Label htmlFor={field.name}>
|
||
|
|
{field.label}
|
||
|
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||
|
|
</Label>
|
||
|
|
{field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||
|
|
)}
|
||
|
|
<Input
|
||
|
|
id={field.name}
|
||
|
|
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
|
||
|
|
placeholder={field.placeholder || undefined}
|
||
|
|
{...register(field.name, {
|
||
|
|
required: field.required ? `${field.label} is required` : false,
|
||
|
|
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
|
||
|
|
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
case 'NUMBER':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
|
||
|
|
<Label htmlFor={field.name}>
|
||
|
|
{field.label}
|
||
|
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||
|
|
</Label>
|
||
|
|
{field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||
|
|
)}
|
||
|
|
<Input
|
||
|
|
id={field.name}
|
||
|
|
type="number"
|
||
|
|
placeholder={field.placeholder || undefined}
|
||
|
|
{...register(field.name, {
|
||
|
|
required: field.required ? `${field.label} is required` : false,
|
||
|
|
valueAsNumber: true,
|
||
|
|
min: field.minValue ? { value: field.minValue, message: `Minimum value is ${field.minValue}` } : undefined,
|
||
|
|
max: field.maxValue ? { value: field.maxValue, message: `Maximum value is ${field.maxValue}` } : undefined,
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
case 'TEXTAREA':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className="col-span-full">
|
||
|
|
<Label htmlFor={field.name}>
|
||
|
|
{field.label}
|
||
|
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||
|
|
</Label>
|
||
|
|
{field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||
|
|
)}
|
||
|
|
<Textarea
|
||
|
|
id={field.name}
|
||
|
|
placeholder={field.placeholder || undefined}
|
||
|
|
rows={4}
|
||
|
|
{...register(field.name, {
|
||
|
|
required: field.required ? `${field.label} is required` : false,
|
||
|
|
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
|
||
|
|
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
case 'DATE':
|
||
|
|
case 'DATETIME':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
|
||
|
|
<Label htmlFor={field.name}>
|
||
|
|
{field.label}
|
||
|
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||
|
|
</Label>
|
||
|
|
{field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||
|
|
)}
|
||
|
|
<Input
|
||
|
|
id={field.name}
|
||
|
|
type={field.fieldType === 'DATETIME' ? 'datetime-local' : 'date'}
|
||
|
|
{...register(field.name, {
|
||
|
|
required: field.required ? `${field.label} is required` : false,
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
case 'SELECT':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
|
||
|
|
<Label htmlFor={field.name}>
|
||
|
|
{field.label}
|
||
|
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||
|
|
</Label>
|
||
|
|
{field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||
|
|
)}
|
||
|
|
<Select
|
||
|
|
onValueChange={(value) => setValue(field.name, value)}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder={field.placeholder || 'Select an option'} />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{(field.optionsJson || []).map((option) => (
|
||
|
|
<SelectItem key={option.value} value={option.value}>
|
||
|
|
{option.label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<input
|
||
|
|
type="hidden"
|
||
|
|
{...register(field.name, {
|
||
|
|
required: field.required ? `${field.label} is required` : false,
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
case 'RADIO':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className="col-span-full">
|
||
|
|
<Label>
|
||
|
|
{field.label}
|
||
|
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||
|
|
</Label>
|
||
|
|
{field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
|
||
|
|
)}
|
||
|
|
<RadioGroup
|
||
|
|
onValueChange={(value) => setValue(field.name, value)}
|
||
|
|
className="mt-2"
|
||
|
|
>
|
||
|
|
{(field.optionsJson || []).map((option) => (
|
||
|
|
<div key={option.value} className="flex items-center space-x-2">
|
||
|
|
<RadioGroupItem value={option.value} id={`${field.name}-${option.value}`} />
|
||
|
|
<Label htmlFor={`${field.name}-${option.value}`} className="font-normal">
|
||
|
|
{option.label}
|
||
|
|
</Label>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</RadioGroup>
|
||
|
|
<input
|
||
|
|
type="hidden"
|
||
|
|
{...register(field.name, {
|
||
|
|
required: field.required ? `${field.label} is required` : false,
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
case 'CHECKBOX':
|
||
|
|
return (
|
||
|
|
<div key={field.id} className="col-span-full">
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Checkbox
|
||
|
|
id={field.name}
|
||
|
|
onCheckedChange={(checked) => setValue(field.name, checked)}
|
||
|
|
/>
|
||
|
|
<Label htmlFor={field.name} className="font-normal">
|
||
|
|
{field.label}
|
||
|
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||
|
|
</Label>
|
||
|
|
</div>
|
||
|
|
{field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground ml-6">{field.description}</p>
|
||
|
|
)}
|
||
|
|
<input
|
||
|
|
type="hidden"
|
||
|
|
{...register(field.name, {
|
||
|
|
validate: field.required ? (value) => value === true || `${field.label} is required` : undefined,
|
||
|
|
})}
|
||
|
|
/>
|
||
|
|
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
|
||
|
|
default:
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="max-w-2xl mx-auto">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>{form.name}</CardTitle>
|
||
|
|
{form.description && (
|
||
|
|
<CardDescription>{form.description}</CardDescription>
|
||
|
|
)}
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
{form.fields.map((field) => renderField(field as FormField))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
type="submit"
|
||
|
|
className="w-full"
|
||
|
|
disabled={isSubmitting || submitMutation.isPending}
|
||
|
|
>
|
||
|
|
{(isSubmitting || submitMutation.isPending) && (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
)}
|
||
|
|
Submit
|
||
|
|
</Button>
|
||
|
|
</form>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|