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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 ?? []} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user