Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
430
src/app/(public)/apply/[slug]/page.tsx
Normal file
430
src/app/(public)/apply/[slug]/page.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user