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:
@@ -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, ClipboardList,
|
||||
MessagesSquare, SlidersHorizontal, ClipboardList, FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavLinkProps {
|
||||
@@ -133,6 +133,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} onClick={onNav} />
|
||||
<NavLink href="/financials" label="Financials" icon={LineChart} active={isActive('/financials')} onClick={onNav} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
|
||||
<NavLink href="/my-forms" label="Forms" icon={FileText} active={isActive('/my-forms')} onClick={onNav} />
|
||||
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
|
||||
{isAdmin && (
|
||||
<NavLink href="/recruitment" label="Recruitment" icon={UserSearch} active={isActive('/recruitment')} onClick={onNav} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user