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:
@@ -65,7 +65,11 @@ export function FormsClient({ forms, spaceSlug }: { forms: FormRow[]; spaceSlug:
|
||||
</Link>
|
||||
{f.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
|
||||
</div>
|
||||
<p className="font-mono text-xs text-muted-foreground">/f/{spaceSlug}/{f.slug}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{f.visibility === 'members'
|
||||
? 'Members-only (filled from the in-app Forms page)'
|
||||
: `/f/${spaceSlug}/${f.slug}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{VIS_LABEL[f.visibility] ?? f.visibility}</Badge>
|
||||
<Badge variant={f.status === 'published' ? 'default' : 'secondary'}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { requireFormsManagerPage } from '@/lib/forms-guard'
|
||||
import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
||||
import { FormBuilder } from '@/components/forms/form-builder'
|
||||
import { NewFormClient } from '@/components/forms/new-form-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -13,18 +13,7 @@ export default async function NewFormPage() {
|
||||
<PageTitle>New form</PageTitle>
|
||||
</PageHeader>
|
||||
<div className="p-4 md:p-6">
|
||||
<FormBuilder
|
||||
initial={{
|
||||
spaceSlug,
|
||||
slug: '',
|
||||
title: '',
|
||||
description: '',
|
||||
kind: 'form',
|
||||
visibility: 'members',
|
||||
legal_text: '',
|
||||
schema: [],
|
||||
}}
|
||||
/>
|
||||
<NewFormClient spaceSlug={spaceSlug} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
||||
import { parseFormSchema } from '@/lib/forms-schema'
|
||||
import { MemberFillClient } from '@/components/forms/member-fill-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function MemberFillPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
if (!member) redirect('/login')
|
||||
|
||||
const { data: form } = await supabase
|
||||
.from('forms')
|
||||
.select('id, title, description, kind, legal_text, schema, status, space_id')
|
||||
.eq('id', id)
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('status', 'published')
|
||||
.maybeSingle()
|
||||
if (!form) notFound()
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader>
|
||||
<PageTitle>{form.title}</PageTitle>
|
||||
</PageHeader>
|
||||
<div className="mx-auto max-w-2xl p-4 md:p-8">
|
||||
<MemberFillClient
|
||||
formId={form.id}
|
||||
description={form.description}
|
||||
kind={form.kind}
|
||||
legalText={form.legal_text}
|
||||
fields={parseFormSchema(form.schema)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import { ClipboardList } from 'lucide-react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Member-facing list of forms a member can fill. Auth + membership are already
|
||||
// enforced by the (app) layout; RLS lets a member read published forms in
|
||||
// their space.
|
||||
export default async function MyFormsPage() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
if (!member) redirect('/login')
|
||||
|
||||
const { data: forms } = await supabase
|
||||
.from('forms')
|
||||
.select('id, title, description, kind, status')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('status', 'published')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
const list = forms ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader>
|
||||
<PageTitle>Forms</PageTitle>
|
||||
</PageHeader>
|
||||
<div className="p-4 md:p-6">
|
||||
{list.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ClipboardList />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nothing to fill right now</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Forms and waivers your space publishes will show up here.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{list.map(f => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/my-forms/${f.id}`}
|
||||
className="flex items-center gap-3 p-4 hover:bg-muted/40"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{f.title}</span>
|
||||
{f.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
|
||||
</div>
|
||||
{f.description && (
|
||||
<p className="truncate text-sm text-muted-foreground">{f.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-primary">Open →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user