feat(forms): builder UI, management pages, and public fill page (Phase 3)

- components/forms/form-renderer.tsx: one controlled renderer for all 8
  field types, shared by the builder preview and the public page
- components/forms/form-builder.tsx: easy builder — label-only field
  creation (keys auto-derived and de-duped at save), one-click type
  buttons, up/down reordering, inline options editor, live preview;
  edit mode adds publish/close/draft/delete and copy-public-link
- /forms directory, /forms/new, /forms/[id]/edit, /forms/[id]/results
  (CSV download). Server-gated by lib/forms-guard.ts (forms.manage via
  the same RPC the RLS uses; RLS remains the real boundary)
- public app/f/[slug] (outside the app group): getPublicForm serves
  only published public forms — members-only forms are never exposed
  there; waiver consent, anon email capture, signed-in gate for
  public_auth, thank-you state
- sidebar "Forms & waivers" link; sonner toasts (codebase convention)
- ARCHITECTURE + HANDOFF updated

285/285 tests, build clean, all four routes compiled. No deploy.
This commit is contained in:
Moheeb Zara
2026-05-16 15:03:48 -07:00
parent 897fa30ec1
commit 03bb3a7466
16 changed files with 1182 additions and 8 deletions
+44
View File
@@ -0,0 +1,44 @@
import { notFound } from 'next/navigation'
import { requireFormsManagerPage } from '@/lib/forms-guard'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { FormBuilder } from '@/components/forms/form-builder'
import { parseFormSchema } from '@/lib/forms-schema'
export const dynamic = 'force-dynamic'
export default async function EditFormPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const { supabase, member } = await requireFormsManagerPage()
const { data: form } = await supabase
.from('forms')
.select('id, slug, title, description, kind, visibility, status, legal_text, schema, space_id')
.eq('id', id)
.eq('space_id', member.space_id)
.single()
if (!form) notFound()
return (
<>
<PageHeader>
<PageTitle>Edit form</PageTitle>
</PageHeader>
<div className="p-4 md:p-6">
<FormBuilder
initial={{
id: form.id,
slug: form.slug,
title: form.title,
description: form.description ?? '',
kind: form.kind as 'form' | 'waiver',
visibility: form.visibility as 'public_anon' | 'public_auth' | 'members',
legal_text: form.legal_text ?? '',
status: form.status as 'draft' | 'published' | 'closed',
schema: parseFormSchema(form.schema),
}}
/>
</div>
</>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { notFound } from 'next/navigation'
import { requireFormsManagerPage } from '@/lib/forms-guard'
import { parseFormSchema } from '@/lib/forms-schema'
import { ResultsClient } from './results-client'
export const dynamic = 'force-dynamic'
export default async function FormResultsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const { supabase, member } = await requireFormsManagerPage()
const { data: form } = await supabase
.from('forms')
.select('id, slug, title, kind, schema')
.eq('id', id)
.eq('space_id', member.space_id)
.single()
if (!form) notFound()
const { data: submissions } = await supabase
.from('form_submissions')
.select('id, member_id, submitter_email, answers, form_version, created_at')
.eq('form_id', id)
.eq('space_id', member.space_id)
.order('created_at', { ascending: false })
.limit(5000)
return (
<ResultsClient
formId={form.id}
title={form.title}
fields={parseFormSchema(form.schema)}
submissions={submissions ?? []}
/>
)
}
@@ -0,0 +1,122 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { ArrowLeft, Download } from 'lucide-react'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { Button } from '@/components/ui/button'
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import { toast } from 'sonner'
import { exportFormResultsCsv } from '@/lib/actions'
import type { FormField } from '@/lib/forms-schema'
type Submission = {
id: string
member_id: string | null
submitter_email: string | null
answers: Record<string, unknown> | null
form_version: number
created_at: string
}
function cell(v: unknown): string {
if (v === null || v === undefined) return ''
return typeof v === 'string' ? v : JSON.stringify(v)
}
export function ResultsClient({
formId,
title,
fields,
submissions,
}: {
formId: string
title: string
fields: FormField[]
submissions: Submission[]
}) {
const [exporting, setExporting] = useState(false)
async function exportCsv() {
setExporting(true)
const res = await exportFormResultsCsv({ formId })
setExporting(false)
if ('error' in res && res.error) {
toast.error(res.error || 'Export failed')
return
}
const { filename, csv } = res.data
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
return (
<>
<PageHeader>
<div className="flex items-center gap-2">
<Button asChild size="icon" variant="ghost" aria-label="Back to forms">
<Link href="/forms">
<ArrowLeft className="size-4" />
</Link>
</Button>
<PageTitle>{title} — responses</PageTitle>
</div>
<Button size="sm" onClick={exportCsv} disabled={exporting || submissions.length === 0}>
<Download className="size-4" /> {exporting ? 'Exporting…' : 'Export CSV'}
</Button>
</PageHeader>
<div className="p-4 md:p-6">
{submissions.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>No responses yet</EmptyTitle>
<EmptyDescription>Submissions will appear here as they come in.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left">
<tr>
<th className="p-2 font-medium">Submitted</th>
<th className="p-2 font-medium">Submitter</th>
{fields.map(f => (
<th key={f.key} className="p-2 font-medium">
{f.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{submissions.map(s => {
const a = s.answers ?? {}
return (
<tr key={s.id}>
<td className="whitespace-nowrap p-2 text-muted-foreground">
{new Date(s.created_at).toLocaleString()}
</td>
<td className="p-2 text-muted-foreground">
{s.member_id ? 'Member' : s.submitter_email || 'Anonymous'}
</td>
{fields.map(f => (
<td key={f.key} className="p-2">
{cell(a[f.key])}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</>
)
}
+89
View File
@@ -0,0 +1,89 @@
'use client'
import Link from 'next/link'
import { FileText, Plus } from 'lucide-react'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
type FormRow = {
id: string
slug: string
title: string
kind: string
visibility: string
status: string
version: number
created_at: string
}
const VIS_LABEL: Record<string, string> = {
members: 'Members only',
public_auth: 'Signed in',
public_anon: 'Public',
}
export function FormsClient({ forms }: { forms: FormRow[] }) {
return (
<>
<PageHeader>
<PageTitle>Forms &amp; waivers</PageTitle>
<Button asChild size="sm">
<Link href="/forms/new">
<Plus className="size-4" /> New form
</Link>
</Button>
</PageHeader>
<div className="p-4 md:p-6">
{forms.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FileText />
</EmptyMedia>
<EmptyTitle>No forms yet</EmptyTitle>
<EmptyDescription>
Build a custom form or a signable waiver. Members-only or public, your choice.
</EmptyDescription>
</EmptyHeader>
<Button asChild>
<Link href="/forms/new">
<Plus className="size-4" /> Create your first form
</Link>
</Button>
</Empty>
) : (
<div className="divide-y rounded-lg border">
{forms.map(f => (
<div key={f.id} className="flex flex-wrap items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link href={`/forms/${f.id}/edit`} className="font-medium hover:underline">
{f.title}
</Link>
{f.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
</div>
<p className="font-mono text-xs text-muted-foreground">/f/{f.slug}</p>
</div>
<Badge variant="outline">{VIS_LABEL[f.visibility] ?? f.visibility}</Badge>
<Badge variant={f.status === 'published' ? 'default' : 'secondary'}>
{f.status}
</Badge>
<div className="flex gap-2">
<Button asChild size="sm" variant="ghost">
<Link href={`/forms/${f.id}/results`}>Results</Link>
</Button>
<Button asChild size="sm" variant="secondary">
<Link href={`/forms/${f.id}/edit`}>Edit</Link>
</Button>
</div>
</div>
))}
</div>
)}
</div>
</>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { requireFormsManagerPage } from '@/lib/forms-guard'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { FormBuilder } from '@/components/forms/form-builder'
export const dynamic = 'force-dynamic'
export default async function NewFormPage() {
await requireFormsManagerPage()
return (
<>
<PageHeader>
<PageTitle>New form</PageTitle>
</PageHeader>
<div className="p-4 md:p-6">
<FormBuilder
initial={{
slug: '',
title: '',
description: '',
kind: 'form',
visibility: 'members',
legal_text: '',
schema: [],
}}
/>
</div>
</>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { requireFormsManagerPage } from '@/lib/forms-guard'
import { FormsClient } from './forms-client'
export const dynamic = 'force-dynamic'
export default async function FormsPage() {
const { supabase, member } = await requireFormsManagerPage()
const { data: forms } = await supabase
.from('forms')
.select('id, slug, title, kind, visibility, status, version, created_at')
.eq('space_id', member.space_id)
.order('created_at', { ascending: false })
return <FormsClient forms={forms ?? []} />
}
+43
View File
@@ -0,0 +1,43 @@
import { createClient } from '@/lib/supabase/server'
import { getPublicForm } from '@/lib/actions'
import { parseFormSchema } from '@/lib/forms-schema'
import { PublicFormClient } from './public-form-client'
export const dynamic = 'force-dynamic'
export default async function PublicFormPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const res = await getPublicForm({ slug })
if ('error' in res && res.error) {
return (
<main className="mx-auto flex min-h-screen max-w-xl flex-col items-center justify-center p-6 text-center">
<h1 className="text-xl font-semibold">This form is not available</h1>
<p className="mt-2 text-sm text-muted-foreground">
It may be unpublished, closed, or the link is wrong.
</p>
</main>
)
}
const form = res.data
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
return (
<main className="mx-auto max-w-2xl p-4 md:p-8">
<PublicFormClient
slug={form.slug}
title={form.title}
description={form.description}
kind={form.kind}
visibility={form.visibility}
legalText={form.legal_text}
fields={parseFormSchema(form.schema)}
authed={Boolean(user)}
/>
</main>
)
}
+127
View File
@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { FormRenderer } from '@/components/forms/form-renderer'
import { submitForm } from '@/lib/actions'
import type { FormField } from '@/lib/forms-schema'
export function PublicFormClient({
slug,
title,
description,
kind,
visibility,
legalText,
fields,
authed,
}: {
slug: string
title: string
description: string | null
kind: string
visibility: string
legalText: string | null
fields: FormField[]
authed: boolean
}) {
const [values, setValues] = useState<Record<string, unknown>>({})
const [email, setEmail] = useState('')
const [consent, setConsent] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
const needsSignIn = visibility === 'public_auth' && !authed
const isWaiver = kind === 'waiver'
const showEmail = visibility === 'public_anon'
async function submit() {
setError(null)
setSubmitting(true)
const res = await submitForm({
slug,
answers: values,
email: showEmail && email ? email : undefined,
consent: isWaiver ? consent : undefined,
})
setSubmitting(false)
if ('error' in res && res.error) {
setError(res.error)
return
}
setDone(true)
}
if (done) {
return (
<div className="rounded-lg border p-8 text-center">
<h1 className="text-xl font-semibold">Thank you</h1>
<p className="mt-2 text-sm text-muted-foreground">Your response has been recorded.</p>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{title}</h1>
{description && <p className="mt-1 text-muted-foreground">{description}</p>}
</div>
{needsSignIn ? (
<div className="rounded-lg border bg-muted/40 p-6 text-center">
<p className="text-sm">You need to be signed in to submit this form.</p>
<Button asChild className="mt-3">
<a href={`/login?next=/f/${slug}`}>Sign in</a>
</Button>
</div>
) : (
<>
{isWaiver && legalText && (
<div className="max-h-72 overflow-y-auto whitespace-pre-wrap rounded-lg border bg-muted/30 p-4 text-sm">
{legalText}
</div>
)}
<FormRenderer fields={fields} values={values} onChange={(k, v) => setValues(p => ({ ...p, [k]: v }))} />
{showEmail && (
<div className="space-y-1.5">
<Label htmlFor="pf-email">Your email (optional)</Label>
<Input
id="pf-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Used to link this to your account if you join later.
</p>
</div>
)}
{isWaiver && (
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={consent}
onChange={e => setConsent(e.target.checked)}
className="mt-1"
/>
I have read and agree to the terms above.
</label>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button onClick={submit} disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</>
)}
</div>
)
}
+2 -1
View File
@@ -14,7 +14,7 @@ import {
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench,
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
MessagesSquare, SlidersHorizontal,
MessagesSquare, SlidersHorizontal, ClipboardList,
} from 'lucide-react'
interface NavLinkProps {
@@ -144,6 +144,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
</div>
<NavLink href="/customize" label="Customize" icon={SlidersHorizontal} active={isActive('/customize')} onClick={onNav} />
<NavLink href="/forms" label="Forms & waivers" icon={ClipboardList} active={isActive('/forms')} onClick={onNav} />
<NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} />
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} />
</>
+443
View File
@@ -0,0 +1,443 @@
'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<string>()
return fields.map((f, i) => {
let 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<Kind>(initial.kind)
const [visibility, setVisibility] = useState<Visibility>(initial.visibility)
const [legalText, setLegalText] = useState(initial.legal_text)
const [fields, setFields] = useState<FormField[]>(initial.schema)
const [preview, setPreview] = useState<Record<string, unknown>>({})
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<FormField>) {
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 (
<div className="grid gap-6 lg:grid-cols-2">
{/* Editor */}
<div className="space-y-6">
{isEdit && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
<Badge variant={initial.status === 'published' ? 'default' : 'secondary'}>
{initial.status}
</Badge>
{initial.status !== 'published' && (
<Button size="sm" disabled={busy} onClick={() => changeStatus('published')}>
Publish
</Button>
)}
{initial.status === 'published' && (
<Button size="sm" variant="secondary" disabled={busy} onClick={() => changeStatus('closed')}>
Close
</Button>
)}
{initial.status !== 'draft' && (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => changeStatus('draft')}>
Back to draft
</Button>
)}
<Button size="sm" variant="ghost" className="text-destructive" disabled={busy} onClick={remove}>
<Trash2 className="size-4" /> Delete
</Button>
</div>
)}
{isEdit && initial.status === 'published' && visibility !== 'members' && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/40 p-3 text-sm">
<span className="truncate font-mono text-xs">{publicUrl}</span>
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard?.writeText(publicUrl)
toast.success('Link copied')
}}
>
Copy link
</Button>
</div>
)}
<div className="space-y-3">
<SectionTitle>Form details</SectionTitle>
<div className="space-y-1.5">
<Label htmlFor="fb-title">Title</Label>
<Input id="fb-title" value={title} onChange={e => onTitle(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="fb-slug">Link slug</Label>
<Input
id="fb-slug"
value={slug}
disabled={isEdit}
onChange={e => {
setSlugTouched(true)
setSlug(e.target.value)
}}
/>
{isEdit ? (
<p className="text-xs text-muted-foreground">The slug cannot change after creation.</p>
) : (
<p className="text-xs text-muted-foreground">Public address: /f/{slug || '…'}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="fb-desc">Description</Label>
<Textarea
id="fb-desc"
rows={2}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="fb-kind">Type</Label>
<select
id="fb-kind"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm disabled:opacity-50"
value={kind}
disabled={isEdit}
onChange={e => setKind(e.target.value as Kind)}
>
<option value="form">Form</option>
<option value="waiver">Waiver</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="fb-vis">Who can submit</Label>
<select
id="fb-vis"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
value={visibility}
onChange={e => setVisibility(e.target.value as Visibility)}
>
<option value="members">Members only</option>
<option value="public_auth">Anyone signed in</option>
<option value="public_anon">Anyone (no account)</option>
</select>
</div>
</div>
{kind === 'waiver' && (
<div className="space-y-1.5">
<Label htmlFor="fb-legal">Waiver / consent text</Label>
<Textarea
id="fb-legal"
rows={5}
value={legalText}
onChange={e => setLegalText(e.target.value)}
placeholder="The legal text the signer must agree to. Snapshotted into every signature."
/>
</div>
)}
</div>
<div className="space-y-3">
<SectionTitle>Fields</SectionTitle>
{fields.map((f, i) => (
<div key={i} className="space-y-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="shrink-0">
{TYPE_LABELS[f.type]}
</Badge>
<Input
aria-label={`Field ${i + 1} label`}
value={f.label}
onChange={e => patch(i, { label: e.target.value })}
className="flex-1"
/>
<Button
size="icon"
variant="ghost"
aria-label="Move up"
disabled={i === 0}
onClick={() => move(i, -1)}
>
<ArrowUp className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
aria-label="Move down"
disabled={i === fields.length - 1}
onClick={() => move(i, 1)}
>
<ArrowDown className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
aria-label="Remove field"
className="text-destructive"
onClick={() => removeField(i)}
>
<Trash2 className="size-4" />
</Button>
</div>
{(f.type === 'select' || f.type === 'radio') && (
<div className="space-y-1.5 pl-1">
{(f.options ?? []).map((opt, oi) => (
<div key={oi} className="flex items-center gap-2">
<Input
aria-label={`Option ${oi + 1}`}
value={opt}
onChange={e =>
patch(i, {
options: (f.options ?? []).map((o, k) => (k === oi ? e.target.value : o)),
})
}
/>
<Button
size="icon"
variant="ghost"
aria-label="Remove option"
onClick={() =>
patch(i, { options: (f.options ?? []).filter((_, k) => k !== oi) })
}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
size="sm"
variant="ghost"
onClick={() => patch(i, { options: [...(f.options ?? []), `Option ${(f.options?.length ?? 0) + 1}`] })}
>
<Plus className="size-4" /> Add option
</Button>
</div>
)}
<div className="flex items-center gap-2 pl-1">
<Checkbox
id={`req-${i}`}
checked={f.required === true}
onCheckedChange={c => patch(i, { required: c === true })}
/>
<Label htmlFor={`req-${i}`} className="font-normal">
Required
</Label>
</div>
</div>
))}
<div className="flex flex-wrap gap-2">
{formFieldTypes.map(t => (
<Button key={t} size="sm" variant="outline" onClick={() => addField(t)}>
<Plus className="size-4" /> {TYPE_LABELS[t]}
</Button>
))}
</div>
</div>
<Button onClick={save} disabled={saving}>
{saving ? 'Saving…' : isEdit ? 'Save changes' : 'Create form'}
</Button>
</div>
{/* Live preview */}
<div className="lg:sticky lg:top-4 h-fit space-y-3 rounded-lg border p-4">
<SectionTitle>Live preview</SectionTitle>
<h2 className="text-lg font-semibold">{title || 'Untitled form'}</h2>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{kind === 'waiver' && legalText && (
<div className="whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-sm">{legalText}</div>
)}
<FormRenderer
fields={deriveKeys(fields)}
values={preview}
onChange={(k, v) => setPreview(p => ({ ...p, [k]: v }))}
idPrefix="pv"
/>
{kind === 'waiver' && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" disabled /> I agree to the waiver above
</label>
)}
</div>
</div>
)
}
+154
View File
@@ -0,0 +1,154 @@
'use client'
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 type { FormField } from '@/lib/forms-schema'
// Renders a form's field schema as a controlled, fillable form. Shared by the
// builder's live preview and the public /f/[slug] page so the two can never
// drift.
export function FormRenderer({
fields,
values,
onChange,
disabled,
idPrefix = 'f',
}: {
fields: FormField[]
values: Record<string, unknown>
onChange: (key: string, value: unknown) => void
disabled?: boolean
idPrefix?: string
}) {
if (fields.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No fields yet. Add one to see it here.
</p>
)
}
return (
<div className="space-y-5">
{fields.map(field => {
const id = `${idPrefix}-${field.key}`
const v = values[field.key]
return (
<div key={field.key} className="space-y-1.5">
{field.type !== 'checkbox' && (
<Label htmlFor={id}>
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
)}
{field.type === 'short_text' && (
<Input
id={id}
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'long_text' && (
<Textarea
id={id}
rows={4}
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'email' && (
<Input
id={id}
type="email"
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'number' && (
<Input
id={id}
type="number"
value={v === undefined || v === null ? '' : String(v)}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'date' && (
<Input
id={id}
type="date"
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'checkbox' && (
<div className="flex items-start gap-2">
<Checkbox
id={id}
checked={v === true}
disabled={disabled}
onCheckedChange={c => onChange(field.key, c === true)}
/>
<Label htmlFor={id} className="font-normal leading-snug">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
</div>
)}
{field.type === 'select' && (
<select
id={id}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm disabled:opacity-50"
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
>
<option value="">Select…</option>
{(field.options ?? []).map(o => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
)}
{field.type === 'radio' && (
<div className="space-y-1.5" role="radiogroup" aria-label={field.label}>
{(field.options ?? []).map(o => (
<label key={o} className="flex items-center gap-2 text-sm">
<input
type="radio"
name={id}
value={o}
checked={v === o}
disabled={disabled}
onChange={() => onChange(field.key, o)}
/>
{o}
</label>
))}
</div>
)}
{field.help && field.type !== 'checkbox' && (
<p className="text-xs text-muted-foreground">{field.help}</p>
)}
</div>
)
})}
</div>
)
}
+4 -3
View File
@@ -422,9 +422,10 @@ forms + waivers).
### Forms & waivers (migration 026 — Phase 1: schema + RLS)
`forms` and `form_submissions` back a custom form/waiver builder. Phase 1 is
schema + RLS only; server actions, the builder UI, the public `/f/[slug]` page,
onboarding integration, and retro-linking are later phases. Security shape:
`forms` and `form_submissions` back a custom form/waiver builder. Phases 1-3
are done (schema + RLS; server actions in `lib/actions/forms.ts`; the builder
UI under `/forms` + the public `/f/[slug]` page). Onboarding integration and
automatic retro-linking are Phases 4-5. Security shape:
all submissions (anonymous, public-authenticated, or members) are written by a
single validated server action using the service client AFTER server-side
schema validation and snapshotting (same pattern as `finishOnboarding`); the
+13 -3
View File
@@ -4,7 +4,7 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-16 (pass 18) — Forms + waivers Phases 1-2 (schema/RLS + server actions), LOCAL, awaiting deploy approval
## 2026-05-16 (pass 18) — Forms + waivers Phases 1-3 (schema/RLS + server actions + builder UI), LOCAL, awaiting deploy approval
Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec vitest run` gate pending in this entry's session.
@@ -37,10 +37,20 @@ Audited the migration before continuing. Three fixes applied: dropped redundant
- `types/database.ts`: typed `user_has_permission` + `user_effective_roles` (were stale since 023). `lib/actions/index.ts` re-exports forms. `__tests__/forms.test.ts` (14 tests). API_REFERENCE updated.
- Gate: `pnpm exec vitest run` 285/285; `pnpm build` exit 0, clean.
### Forms + waivers Phase 3 (this session) — builder UI + public page, LOCAL
- `components/forms/form-renderer.tsx`: one controlled renderer for all 8 field types, shared by the builder preview and the public page so they cannot drift.
- `components/forms/form-builder.tsx`: the easy builder (locked first-class priority). Label-only field creation (keys auto-derived + de-duped at save, never typed), add via one-click type buttons, up/down reorder buttons (no integer order field), inline options editor, live preview pane. Edit mode adds publish/close/draft/delete (via `useConfirm`) and a copy-public-link affordance.
- Pages (server-gated by `requireFormsManagerPage` in `lib/forms-guard.ts` — forms.manage via the same RPC, redirect non-managers; RLS still the real boundary): `/forms` directory (status/visibility badges, Empty state), `/forms/new`, `/forms/[id]/edit`, `/forms/[id]/results` (table + client CSV download via `exportFormResultsCsv`).
- Public `app/f/[slug]/page.tsx` (top-level, outside `(app)`): `getPublicForm` (service client, published + public-visibility only — members-only forms are NOT exposed here; their schema stays in-app), waiver legal text + consent, optional email for anon, signed-in gate for `public_auth`, thank-you state.
- Sidebar: "Forms & waivers" NavLink in the Admin block. Toasts use `sonner` (matched the codebase convention; the custom use-toast has no mounted Toaster). New `getPublicForm` + `formSlug` export; API_REFERENCE updated.
- Gate: `pnpm exec vitest run` 285/285; `pnpm build` exit 0; `/forms`, `/forms/[id]`, `/forms/new`, `/f/[slug]` all compiled.
- NOT browser-verified (no live browser this session): smoke-test the builder (add/reorder/remove fields, live preview, create + edit + publish + close + delete), the results CSV download, and a public submission for each visibility (anon, public_auth signed-out gate, waiver consent).
### Open / next
- **Awaiting user approval to deploy** (push to `main` = prod). Phase 1 commits `3a2d3ca`+`66dd9b7` and Phase 2 are LOCAL only; do NOT push without explicit go. Migration `026` ships with Phase 1.
- Phase 2 not browser-tested (no UI yet — actions only). Phases 3-5 not started: builder UI + `/forms` + public `/f/[slug]`; onboarding form-step + required-waiver enforcement; automatic retro-link hooks (join/signup/addMember/import + email verification) — `linkSubmissionsForMember` primitive already exists.
- **Awaiting user approval to deploy** (push to `main` = prod). Phases 1-3 are all LOCAL (commits `3a2d3ca`, `66dd9b7`, Phase 2, Phase 3); do NOT push without explicit go. Migration `026` ships with the first push.
- Phases 4-5 not started: onboarding form-step + required-waiver enforcement + auto-satisfy when a matching signed waiver exists; automatic retro-link hooks (join/signup/addMember/import + email verification) — `linkSubmissionsForMember` primitive already exists. Phase 3 is code-complete but NOT browser-verified (see Phase 3 smoke-test list above).
- After an approved push: watch the Actions run, then smoke-test that `026` applied (`_migrations_applied` row) and a space's board has `forms.manage`.
---
+24
View File
@@ -19,6 +19,7 @@ import {
formIdSchema,
submitFormSchema,
linkSubmissionsSchema,
getPublicFormSchema,
} from '@/lib/validations'
import { parseFormSchema, validateAnswers } from '@/lib/forms-schema'
@@ -393,6 +394,29 @@ export async function submitForm(input: unknown) {
return { success: true as const }
}
/**
* Public read for the /f/[slug] page. Service client (anon has no grant on
* `forms`). Returns only a published form and only public-safe columns.
*/
export async function getPublicForm(input: unknown) {
const v = parseInput(getPublicFormSchema, input)
if (!v.ok) return { error: v.error }
const admin = createAdminClient()
const { data: form } = await admin
.from('forms')
.select('id, slug, title, description, kind, visibility, schema, legal_text, version, status')
.eq('slug', v.data.slug)
.single()
// Members-only forms are never served on the public page (that would expose
// their schema to anyone). They are filled from inside the app.
if (!form || form.status !== 'published' || form.visibility === 'members') {
return { error: 'This form is not available.' }
}
return { data: form }
}
/**
* Retro-link prior anonymous submissions to a member by matching email.
*
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
// Server-component guard for the /forms management pages. Mirrors the
// /settings server gate: resolve the member, check forms.manage via the
// same RPC the forms RLS uses, and redirect non-managers before any data
// is fetched. The forms RLS independently enforces this; the redirect is
// for UX, not the security boundary.
export async function requireFormsManagerPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members')
.select('id, space_id, role, user_id, display_name')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.single()
if (!member) redirect('/login')
const { data: allowed } = await supabase.rpc('user_has_permission', {
uid: user.id,
sid: member.space_id,
perm: 'forms.manage',
})
if (!allowed) redirect('/dashboard')
return { supabase, member }
}
+3 -1
View File
@@ -569,7 +569,7 @@ export const formKinds = ['form', 'waiver'] as const
export const formVisibilities = ['public_anon', 'public_auth', 'members'] as const
export const formStatuses = ['draft', 'published', 'closed'] as const
const formSlug = z
export const formSlug = z
.string()
.min(1, 'Slug is required')
.max(80, 'Slug must be 80 characters or fewer')
@@ -666,6 +666,8 @@ export const linkSubmissionsSchema = z.object({
email: emailField(),
})
export const getPublicFormSchema = z.object({ slug: formSlug })
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')