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
+105 -4
View File
@@ -5,14 +5,25 @@ import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { BrandMark } from '@/components/brand-mark'
import { SafeMarkdown } from '@/components/safe-markdown'
import { FormRenderer } from '@/components/forms/form-renderer'
import type { FormField } from '@/lib/forms-schema'
import {
markOnboardingStepDone,
finishOnboarding,
skipOnboarding,
updateMyProfile,
submitForm,
} from '@/lib/actions'
type StepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
type StepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form'
interface StepFormDef {
id: string
title: string
kind: string
legalText: string | null
fields: FormField[]
}
interface Step {
id: string
@@ -21,6 +32,8 @@ interface Step {
body: string | null
config: Record<string, unknown>
is_required: boolean
formDef?: StepFormDef | null
alreadySubmitted?: boolean
}
interface Props {
@@ -45,6 +58,8 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
// Controlled raw text for the skills field so edits survive navigating
// Back/forward and the user sees exactly what they typed (no normalize).
const [skillsText, setSkillsText] = useState(initialProfile.skills.join(', '))
const [formAnswers, setFormAnswers] = useState<Record<string, Record<string, unknown>>>({})
const [formConsent, setFormConsent] = useState<Record<string, boolean>>({})
const step = steps[index]
const isLast = index === steps.length - 1
@@ -52,6 +67,12 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
const ackLabel = (step.config?.ack_label as string) || 'I have read and agree'
const ackOk = !requireAck || acked[step.id]
const isFormStep = step.step_type === 'form'
const formDef = step.formDef ?? null
const formActionable = isFormStep && formDef && !step.alreadySubmitted
const isWaiver = formDef?.kind === 'waiver'
const consentOk = !formActionable || !isWaiver || formConsent[step.id] === true
async function advance() {
setBusy(true)
await markOnboardingStepDone(step.id)
@@ -95,6 +116,28 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
else setIndex(i => i + 1)
}
async function submitFormStep() {
setBusy(true)
// No form to fill (already done, or misconfigured/unpublished): treat as a
// plain non-blocking step so a member is never trapped.
if (formActionable && formDef) {
const res = await submitForm({
formId: formDef.id,
answers: formAnswers[step.id] ?? {},
consent: isWaiver ? formConsent[step.id] === true : undefined,
})
if ('error' in res && res.error) {
setBusy(false)
toast.error(res.error)
return
}
}
await markOnboardingStepDone(step.id)
setBusy(false)
if (isLast) await finish()
else setIndex(i => i + 1)
}
const progressPct = Math.round(((index) / steps.length) * 100)
return (
@@ -193,6 +236,48 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
</a>
)}
</div>
) : step.step_type === 'form' ? (
<div className="space-y-4">
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
{!formDef ? (
<p className="font-sans text-sm text-muted-foreground">
This form is not available right now. You can continue.
</p>
) : step.alreadySubmitted ? (
<p className="font-sans text-sm text-muted-foreground">
You have already completed this. You can continue.
</p>
) : (
<>
{isWaiver && formDef.legalText && (
<div className="max-h-72 overflow-y-auto whitespace-pre-wrap rounded border border-border bg-muted/30 p-3 font-sans text-sm">
{formDef.legalText}
</div>
)}
<FormRenderer
fields={formDef.fields}
values={formAnswers[step.id] ?? {}}
onChange={(k, v) =>
setFormAnswers(p => ({ ...p, [step.id]: { ...(p[step.id] ?? {}), [k]: v } }))
}
idPrefix={`ob-${step.id}`}
/>
{isWaiver && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={formConsent[step.id] === true}
onChange={e => setFormConsent(p => ({ ...p, [step.id]: e.target.checked }))}
className="mt-0.5"
/>
<span className="font-sans text-sm text-foreground">
I have read and agree to the terms above.
</span>
</label>
)}
</>
)}
</div>
) : (
<div className="space-y-4">
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
@@ -233,11 +318,27 @@ export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProf
</button>
)}
<button
onClick={step.step_type === 'profile' ? saveProfileAndAdvance : advance}
disabled={busy || !ackOk}
onClick={
step.step_type === 'profile'
? saveProfileAndAdvance
: step.step_type === 'form'
? submitFormStep
: advance
}
disabled={busy || !ackOk || !consentOk}
className="bg-primary text-white text-sm font-sans px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{busy ? 'Saving...' : isLast ? 'Finish' : step.step_type === 'payment' ? 'I have set this up' : 'Continue'}
{busy
? 'Saving...'
: isLast
? 'Finish'
: step.step_type === 'payment'
? 'I have set this up'
: formActionable
? isWaiver
? 'Sign & continue'
: 'Submit & continue'
: 'Continue'}
</button>
</div>
</div>
+70 -8
View File
@@ -1,5 +1,7 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { parseFormSchema } from '@/lib/forms-schema'
import { OnboardingFlow } from './onboarding-flow'
export const dynamic = 'force-dynamic'
@@ -40,17 +42,77 @@ export default async function OnboardingPage() {
const canSkip = !steps.some(s => s.is_required)
const spaceName = (member.spaces as { name?: string } | null)?.name ?? 'your space'
// Enrich form steps with the referenced (published) form and whether this
// member has already submitted it. Unpublished/missing forms resolve to no
// formDef; the flow then shows a non-blocking notice (mirrors the
// fail-open in finishOnboarding).
const formIds = Array.from(
new Set(
steps
.filter(s => s.step_type === 'form')
.map(s => (s.config as { form_id?: string } | null)?.form_id)
.filter((x): x is string => typeof x === 'string'),
),
)
const formDefs = new Map<
string,
{ id: string; title: string; kind: string; legal_text: string | null; fields: ReturnType<typeof parseFormSchema> }
>()
const submittedFormIds = new Set<string>()
if (formIds.length > 0) {
const { data: formRows } = await supabase
.from('forms')
.select('id, title, kind, legal_text, schema, status')
.eq('space_id', member.space_id)
.in('id', formIds)
.eq('status', 'published')
for (const f of formRows ?? []) {
formDefs.set(f.id, {
id: f.id,
title: f.title,
kind: f.kind,
legal_text: f.legal_text,
fields: parseFormSchema(f.schema),
})
}
// Submissions are forms.manage-only under RLS; probe with the service client.
const admin = createAdminClient()
const { data: subs } = await admin
.from('form_submissions')
.select('form_id')
.eq('space_id', member.space_id)
.eq('member_id', member.id)
.in('form_id', formIds)
for (const r of subs ?? []) submittedFormIds.add(r.form_id as string)
}
return (
<OnboardingFlow
spaceName={spaceName}
steps={steps.map(s => ({
id: s.id,
step_type: s.step_type as 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content',
title: s.title,
body: s.body,
config: (s.config ?? {}) as Record<string, unknown>,
is_required: s.is_required,
}))}
steps={steps.map(s => {
const formId = (s.config as { form_id?: string } | null)?.form_id
const def = formId ? formDefs.get(formId) : undefined
return {
id: s.id,
step_type: s.step_type as 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content' | 'form',
title: s.title,
body: s.body,
config: (s.config ?? {}) as Record<string, unknown>,
is_required: s.is_required,
formDef: def
? {
id: def.id,
title: def.title,
kind: def.kind,
legalText: def.legal_text,
fields: def.fields,
}
: null,
alreadySubmitted: formId ? submittedFormIds.has(formId) : false,
}
})}
canSkip={canSkip}
profile={{
display_name: member.display_name ?? '',