feat(forms): obvious publish flow, member Forms surface, templates (P8)

- builder: draft/published/closed status banners; creating a form now
  lands on its edit page so Publish is immediately visible; a
  members-only form shows a "no public link" note instead of a dead URL
- member-facing forms: /my-forms list + /my-forms/[id] fill
  (MemberFillClient), "Forms" sidebar item for all members. Fixes the
  long-standing gap where members-only forms had no fill surface
- starter templates: lib/form-templates.ts + a picker on /forms/new
  (Liability waiver, Membership application, Event RSVP, Photo release,
  Blank) that seeds builder state (no table)
- forms-client relabels members-only forms (was showing a misleading
  /f/<space>/<slug> that always 404s for members-only)
- docs corrected per the audit pass: DATABASE_SCHEMA / DB_SCHEMA_MAP /
  ARCHITECTURE / API_REFERENCE now reflect migrations 014-029, per-space
  slug, /f/[space]/[slug], getPublicForm({space,slug}), invite roles;
  HANDOFF pass-19 added with residual gaps

337 tests; build verified (Compiled successfully, exit 0). No deploy.
This commit is contained in:
Moheeb Zara
2026-05-16 17:41:16 -07:00
parent df6951c323
commit 73eef7293a
14 changed files with 485 additions and 33 deletions
+37 -2
View File
@@ -135,8 +135,15 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) {
toast.error(res.error || 'Could not save')
return
}
toast.success(isEdit ? 'Form saved' : 'Form created')
router.push('/forms')
toast.success(isEdit ? 'Form saved' : 'Form created — now publish it to make it live')
if (isEdit) {
router.push('/forms')
} else {
// Land on the new form's edit page so the Publish step is right there
// (creating a form leaves it as a draft).
const newId = 'data' in res && res.data ? res.data.id : undefined
router.push(newId ? `/forms/${newId}/edit` : '/forms')
}
router.refresh()
}
@@ -180,6 +187,28 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) {
<div className="grid gap-6 lg:grid-cols-2">
{/* Editor */}
<div className="space-y-6">
{isEdit && initial.status === 'draft' && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<strong>Draft.</strong> This form is not visible to anyone yet. Use{' '}
<strong>Publish</strong> below to make it live.
</div>
)}
{isEdit && initial.status === 'published' && (
<div className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<strong>Published.</strong> This form is live and accepting responses.
</div>
)}
{isEdit && initial.status === 'closed' && (
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
<strong>Closed.</strong> No longer accepting responses. Re-publish to reopen.
</div>
)}
{!isEdit && (
<div className="rounded-lg border bg-muted/40 p-3 text-sm text-muted-foreground">
Build your form, then <strong>Create</strong>. It starts as a draft; you publish it on
the next screen.
</div>
)}
{isEdit && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
<Badge variant={initial.status === 'published' ? 'default' : 'secondary'}>
@@ -221,6 +250,12 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) {
</Button>
</div>
)}
{isEdit && initial.status === 'published' && visibility === 'members' && (
<div className="rounded-lg border bg-muted/40 p-3 text-sm text-muted-foreground">
Members-only: there is no public link. Members fill this from their{' '}
<strong>Forms</strong> page in the app.
</div>
)}
<div className="space-y-3">
<SectionTitle>Form details</SectionTitle>
+98
View File
@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { FormRenderer } from '@/components/forms/form-renderer'
import { submitForm } from '@/lib/actions'
import type { FormField } from '@/lib/forms-schema'
// Authenticated member fill surface for members-only / in-app forms. The
// member is already signed in, so submitForm's visibility check passes;
// submission is by formId (slugs are per-space now).
export function MemberFillClient({
formId,
description,
kind,
legalText,
fields,
}: {
formId: string
description: string | null
kind: string
legalText: string | null
fields: FormField[]
}) {
const router = useRouter()
const [values, setValues] = useState<Record<string, unknown>>({})
const [consent, setConsent] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
const isWaiver = kind === 'waiver'
async function submit() {
setError(null)
setSubmitting(true)
const res = await submitForm({
formId,
answers: values,
consent: isWaiver ? consent : undefined,
})
setSubmitting(false)
if ('error' in res && res.error) {
setError(res.error)
return
}
setDone(true)
}
if (done) {
return (
<div className="space-y-4 rounded-lg border p-8 text-center">
<h2 className="text-xl font-semibold">Thank you</h2>
<p className="text-sm text-muted-foreground">Your response has been recorded.</p>
<Button variant="secondary" onClick={() => router.push('/my-forms')}>
Back to forms
</Button>
</div>
)
}
return (
<div className="space-y-6">
{description && <p className="text-muted-foreground">{description}</p>}
{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 }))}
/>
{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>
)
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { FORM_TEMPLATES, type FormTemplate } from '@/lib/form-templates'
import { FormBuilder } from './form-builder'
// Template chooser shown first on /forms/new. Picking a template just seeds
// builder state (no DB row until the builder saves).
export function NewFormClient({ spaceSlug }: { spaceSlug: string }) {
const [tpl, setTpl] = useState<FormTemplate | null>(null)
if (tpl) {
return (
<div className="space-y-4">
<button
onClick={() => setTpl(null)}
className="text-sm text-muted-foreground hover:text-foreground"
>
← Choose a different template
</button>
<FormBuilder
initial={{
spaceSlug,
slug: '',
title: tpl.id === 'blank' ? '' : tpl.name,
description: '',
kind: tpl.kind,
visibility: 'members',
legal_text: tpl.legal_text,
schema: tpl.schema,
}}
/>
</div>
)
}
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Start from a template or a blank form. You can change everything afterwards.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{FORM_TEMPLATES.map(t => (
<button
key={t.id}
onClick={() => setTpl(t)}
className="rounded-lg border p-4 text-left transition hover:border-primary"
>
<div className="flex items-center gap-2">
<span className="font-medium">{t.name}</span>
{t.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">{t.blurb}</p>
</button>
))}
</div>
<Button variant="ghost" onClick={() => setTpl(FORM_TEMPLATES[0])}>
Skip — start blank
</Button>
</div>
)
}