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:
@@ -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>
|
||||
|
||||
@@ -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 }>}
|
||||
|
||||
@@ -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 & 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" />
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ?? '',
|
||||
|
||||
Reference in New Issue
Block a user