feat(forms): onboarding form/waiver step with required enforcement (Phase 4)

- scripts/027 (idempotent CHECK replace) + schema.sql Section 14: allow
  space_onboarding_steps.step_type = 'form'; referenced form id lives in
  the step's existing config.form_id (no new column)
- finishOnboarding: a required form step is satisfied when a submission
  for config.form_id by the member exists (any version — re-sign is
  non-blocking / auto-satisfy); fails open if the form is missing or
  unpublished so a misconfig cannot trap a member. Probe uses the
  service client (submissions are forms.manage-only under RLS)
- onboarding page enriches form steps with the published form def +
  prior-submission flag; onboarding-flow renders FormRenderer + waiver
  legal text/consent and submits via submitForm then marks the step done
- customize: forms fetched and threaded to the onboarding panel; new
  "+ Form step" button + form picker writing config.form_id
- validations/types/casts include 'form'; forms.test.ts +2
- docs: DATABASE_SCHEMA/DB_SCHEMA_MAP/API_REFERENCE/ARCHITECTURE/HANDOFF

287/287 tests, build exit 0. No deploy.
This commit is contained in:
Moheeb Zara
2026-05-16 15:15:17 -07:00
parent 01b924b2f5
commit 295d220849
16 changed files with 355 additions and 47 deletions
+3 -2
View File
@@ -8,7 +8,7 @@ import { AreasPanel } from './panels/areas-panel'
import { AreaLeadsPanel } from './panels/area-leads-panel'
import { InvitesPanel } from './panels/invites-panel'
import { OnboardingPanel } from './panels/onboarding-panel'
import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area } from './panels/types'
import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area, FormOption } from './panels/types'
type Section = 'roles' | 'permissions' | 'tiers' | 'areas' | 'area-leads' | 'invites' | 'onboarding'
@@ -38,6 +38,7 @@ interface Props {
tiers: Tier[]
invites: Invite[]
onboardingSteps: Step[]
forms: FormOption[]
areas: Area[]
rolePerms: Array<{ subject: string; permission: string }>
areaLeads: AreaLead[]
@@ -78,7 +79,7 @@ export function CustomizeClient(props: Props) {
{section === 'areas' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
{section === 'area-leads' && <AreaLeadsPanel isAdmin={props.isAdmin} areaLeads={props.areaLeads} members={props.members} />}
{section === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} />}
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} />}
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} forms={props.forms} />}
</div>
</div>
</div>
+4 -1
View File
@@ -27,6 +27,7 @@ export default async function CustomizePage() {
{ data: tiers },
{ data: invites },
{ data: onboardingSteps },
{ data: forms },
{ data: areas },
{ data: rolePerms },
{ data: areaLeads },
@@ -37,6 +38,7 @@ export default async function CustomizePage() {
supabase.from('space_tiers').select('id, slug, name, description, monthly_price_cents, billing_cadence, is_system, is_archived, sort_order').eq('space_id', member.space_id).order('sort_order'),
supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }),
supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'),
supabase.from('forms').select('id, title, kind, status').eq('space_id', member.space_id).order('created_at', { ascending: false }),
supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'),
supabase.from('space_role_permissions').select('subject, permission').eq('space_id', member.space_id),
supabase.from('area_leads').select('id, area_code, area_name, lead_id, lead_handle, status').eq('space_id', member.space_id).order('area_name'),
@@ -50,7 +52,8 @@ export default async function CustomizePage() {
customRoles={(customRoles ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; color: string | null; sort_order: number }>}
tiers={(tiers ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; monthly_price_cents: number; billing_cadence: string; is_system: boolean; is_archived: boolean; sort_order: number }>}
invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>}
onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'; title: string; body: string | null; config: Record<string, unknown>; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>}
onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form'; title: string; body: string | null; config: Record<string, unknown>; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>}
forms={(forms ?? []) as Array<{ id: string; title: string; kind: string; status: string }>}
areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>}
rolePerms={(rolePerms ?? []) as Array<{ subject: string; permission: string }>}
areaLeads={(areaLeads ?? []) as Array<{ id: string; area_code: string | null; area_name: string; lead_id: string | null; lead_handle: string | null; status: string }>}
+65 -15
View File
@@ -7,29 +7,52 @@ import { createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from
import { Card } from './card'
import { useConfirm } from '@/components/ui/confirm'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import type { Step } from './types'
import type { Step, FormOption } from './types'
export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean; steps: Step[] }) {
export function OnboardingPanel({
isAdmin,
steps: initial,
forms,
}: {
isAdmin: boolean
steps: Step[]
forms: FormOption[]
}) {
const confirm = useConfirm()
const [steps, setSteps] = useState<Step[]>(initial)
const publishedForms = forms.filter(f => f.status === 'published')
return (
<Card
title="New member onboarding"
blurb="These steps run, in order, the first time a new member opens the app. Reorder with the number. Disable to hide. Built-in steps cannot be deleted, only disabled. Body supports markdown and a safe subset of HTML."
action={isAdmin ? (
<button
onClick={async () => {
const result = await createOnboardingStep({ step_type: 'content', title: 'New step', body: 'Edit this content in Customize, Onboarding.', sort_order: steps.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 })
if ('error' in result && result.error) { toast.error(result.error); return }
toast.success('Step added')
const id = (result as { id: string }).id
setSteps(prev => [...prev, { id, step_key: `custom-${id.slice(0, 12)}`, step_type: 'content', title: 'New step', body: 'Edit this content in Customize, Onboarding.', config: {}, is_enabled: true, is_required: false, is_system: false, sort_order: prev.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 }])
}}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
>
+ Custom step
</button>
<div className="flex gap-2">
<button
onClick={async () => {
const result = await createOnboardingStep({ step_type: 'content', title: 'New step', body: 'Edit this content in Customize, Onboarding.', sort_order: steps.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 })
if ('error' in result && result.error) { toast.error(result.error); return }
toast.success('Step added')
const id = (result as { id: string }).id
setSteps(prev => [...prev, { id, step_key: `custom-${id.slice(0, 12)}`, step_type: 'content', title: 'New step', body: 'Edit this content in Customize, Onboarding.', config: {}, is_enabled: true, is_required: false, is_system: false, sort_order: prev.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 }])
}}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
>
+ Custom step
</button>
<button
onClick={async () => {
const result = await createOnboardingStep({ step_type: 'form', title: 'Sign a form or waiver', body: 'Pick which form members fill here.', sort_order: steps.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 })
if ('error' in result && result.error) { toast.error(result.error); return }
toast.success('Form step added — pick a form below')
const id = (result as { id: string }).id
setSteps(prev => [...prev, { id, step_key: `custom-${id.slice(0, 12)}`, step_type: 'form', title: 'Sign a form or waiver', body: 'Pick which form members fill here.', config: {}, is_enabled: true, is_required: false, is_system: false, sort_order: prev.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 }])
}}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
>
+ Form step
</button>
</div>
) : undefined}
>
{steps.length === 0 && (
@@ -50,9 +73,36 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
<input type="text" defaultValue={s.title} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === s.title) return; const res = await updateOnboardingStep(s.id, { title: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: v } : x)) }} className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
{s.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
</div>
{(s.step_type === 'welcome' || s.step_type === 'code_of_conduct' || s.step_type === 'payment' || s.step_type === 'content') && (
{(s.step_type === 'welcome' || s.step_type === 'code_of_conduct' || s.step_type === 'payment' || s.step_type === 'content' || s.step_type === 'form') && (
<textarea defaultValue={s.body ?? ''} disabled={!isAdmin} rows={4} maxLength={50000} onBlur={async e => { const v = e.target.value; if (v === (s.body ?? '')) return; const res = await updateOnboardingStep(s.id, { body: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, body: v } : x)) }} placeholder="Markdown / safe HTML" className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 focus:outline-none focus:border-primary mb-3" />
)}
{s.step_type === 'form' && (
<div className="mb-3">
{publishedForms.length === 0 ? (
<p className="font-sans text-xs text-muted-foreground">No published forms yet. Create and publish one under Forms &amp; waivers, then pick it here.</p>
) : (
<select
defaultValue={(s.config?.form_id as string) ?? ''}
disabled={!isAdmin}
onChange={async e => {
const v = e.target.value
const res = await updateOnboardingStep(s.id, { config: { ...s.config, form_id: v } })
if (res.error) { toast.error(res.error); return }
setSteps(prev => prev.map(x => x.id === s.id ? { ...x, config: { ...x.config, form_id: v } } : x))
toast.success('Form linked')
}}
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-2 focus:outline-none focus:border-primary"
>
<option value="">Select a form…</option>
{publishedForms.map(f => (
<option key={f.id} value={f.id}>
{f.title}{f.kind === 'waiver' ? ' (waiver)' : ''}
</option>
))}
</select>
)}
</div>
)}
{s.step_type === 'payment' && (
<input type="url" defaultValue={(s.config?.payment_url as string) ?? ''} disabled={!isAdmin} placeholder="Payment link (https://...)" onBlur={async e => { const v = e.target.value.trim(); if (v === ((s.config?.payment_url as string) ?? '')) return; const res = await updateOnboardingStep(s.id, { config: { ...s.config, payment_url: v } }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, config: { ...x.config, payment_url: v } } : x)) }} className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 focus:outline-none focus:border-primary mb-3" />
)}
+8 -1
View File
@@ -40,7 +40,14 @@ export interface Invite {
created_at: string
}
export type OnboardingStepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
export type OnboardingStepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form'
export interface FormOption {
id: string
title: string
kind: string
status: string
}
export interface Step {
id: string