'use client' import { useState } from 'react' import { useRouter } from 'next/navigation' import { ArrowUp, ArrowDown, Trash2, Plus } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { SectionTitle } from '@/components/ui/page-title' import { useConfirm } from '@/components/ui/confirm' import { toast } from 'sonner' import { createForm, updateForm, setFormStatus, deleteForm, } from '@/lib/actions' import { formFieldTypes } from '@/lib/validations' import type { FormField } from '@/lib/forms-schema' import { FormRenderer } from './form-renderer' type Kind = 'form' | 'waiver' type Visibility = 'public_anon' | 'public_auth' | 'members' type Status = 'draft' | 'published' | 'closed' export type BuilderForm = { id?: string slug: string title: string description: string kind: Kind visibility: Visibility legal_text: string status?: Status schema: FormField[] } const TYPE_LABELS: Record<(typeof formFieldTypes)[number], string> = { short_text: 'Text', long_text: 'Paragraph', email: 'Email', number: 'Number', date: 'Date', checkbox: 'Checkbox', select: 'Dropdown', radio: 'Choice', } function slugify(s: string, sep: '-' | '_') { const cleaned = s .toLowerCase() .replace(/[^a-z0-9]+/g, sep) .replace(new RegExp(`^\\${sep}+|\\${sep}+$`, 'g'), '') return cleaned } // Keys are derived from labels so the builder only ever asks for a label. // Duplicates and empties are made safe here, matching the server schema. function deriveKeys(fields: FormField[]): FormField[] { const seen = new Set() return fields.map((f, i) => { const base = slugify(f.label || '', '_') || `field_${i + 1}` let key = base let n = 2 while (seen.has(key)) key = `${base}_${n++}` seen.add(key) return { ...f, key } }) } export function FormBuilder({ initial }: { initial: BuilderForm }) { const router = useRouter() const confirm = useConfirm() const isEdit = Boolean(initial.id) const [slug, setSlug] = useState(initial.slug) const [slugTouched, setSlugTouched] = useState(isEdit) const [title, setTitle] = useState(initial.title) const [description, setDescription] = useState(initial.description) const [kind, setKind] = useState(initial.kind) const [visibility, setVisibility] = useState(initial.visibility) const [legalText, setLegalText] = useState(initial.legal_text) const [fields, setFields] = useState(initial.schema) const [preview, setPreview] = useState>({}) const [saving, setSaving] = useState(false) const [busy, setBusy] = useState(false) function onTitle(v: string) { setTitle(v) if (!isEdit && !slugTouched) setSlug(slugify(v, '-')) } function addField(type: (typeof formFieldTypes)[number]) { setFields(f => [ ...f, { key: '', type, label: `Untitled ${TYPE_LABELS[type]}`, required: false, options: type === 'select' || type === 'radio' ? ['Option 1'] : undefined, }, ]) } function patch(i: number, p: Partial) { setFields(f => f.map((x, idx) => (idx === i ? { ...x, ...p } : x))) } function move(i: number, dir: -1 | 1) { setFields(f => { const j = i + dir if (j < 0 || j >= f.length) return f const next = [...f] ;[next[i], next[j]] = [next[j], next[i]] return next }) } async function removeField(i: number) { const ok = await confirm({ title: 'Remove this field?', description: 'It will be gone from the form. Save to make it permanent.', destructive: true, confirmText: 'Remove', }) if (ok) setFields(f => f.filter((_, idx) => idx !== i)) } async function save() { if (!title.trim()) { toast.error('Title is required') return } setSaving(true) const schema = deriveKeys(fields) const common = { title: title.trim(), description: description.trim() || null, visibility, schema, legal_text: kind === 'waiver' ? legalText : null, } const res = isEdit ? await updateForm({ formId: initial.id, ...common }) : await createForm({ slug, kind, ...common }) setSaving(false) if ('error' in res && res.error) { toast.error(res.error || 'Could not save') return } toast.success(isEdit ? 'Form saved' : 'Form created') router.push('/forms') router.refresh() } async function changeStatus(status: Status) { setBusy(true) const res = await setFormStatus({ formId: initial.id, status }) setBusy(false) if ('error' in res && res.error) { toast.error(res.error || 'Could not update') return } toast.success(`Form ${status}`) router.refresh() } async function remove() { const ok = await confirm({ title: 'Delete this form?', description: 'This cannot be undone. Forms with submissions cannot be deleted.', destructive: true, confirmText: 'Delete', }) if (!ok) return setBusy(true) const res = await deleteForm({ formId: initial.id }) setBusy(false) if ('error' in res && res.error) { toast.error(res.error || 'Could not delete') return } toast.success('Form deleted') router.push('/forms') router.refresh() } const publicUrl = typeof window !== 'undefined' ? `${window.location.origin}/f/${initial.slug}` : `/f/${initial.slug}` return (
{/* Editor */}
{isEdit && (
{initial.status} {initial.status !== 'published' && ( )} {initial.status === 'published' && ( )} {initial.status !== 'draft' && ( )}
)} {isEdit && initial.status === 'published' && visibility !== 'members' && (
{publicUrl}
)}
Form details
onTitle(e.target.value)} />
{ setSlugTouched(true) setSlug(e.target.value) }} /> {isEdit ? (

The slug cannot change after creation.

) : (

Public address: /f/{slug || '…'}

)}