- 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.
130 lines
9.7 KiB
TypeScript
130 lines
9.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { ListChecks } from 'lucide-react'
|
|
import { createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from '@/lib/actions'
|
|
import { Card } from './card'
|
|
import { useConfirm } from '@/components/ui/confirm'
|
|
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
|
import type { Step, FormOption } from './types'
|
|
|
|
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 ? (
|
|
<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 && (
|
|
<Empty className="border-0 p-0 md:p-6">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon"><ListChecks /></EmptyMedia>
|
|
<EmptyTitle>No steps yet</EmptyTitle>
|
|
<EmptyDescription>Add steps that run, in order, the first time a new member opens the app.</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
)}
|
|
<ul className="space-y-3">
|
|
{[...steps].sort((a, b) => a.sort_order - b.sort_order).map(s => (
|
|
<li key={s.id} className="border border-border rounded p-4">
|
|
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
|
<input type="number" defaultValue={s.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === s.sort_order) return; const res = await updateOnboardingStep(s.id, { sort_order: n }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 min-h-[44px] focus:outline-none focus:border-primary" />
|
|
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase">{s.step_type.replace(/_/g, ' ')}</span>
|
|
<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 === '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" />
|
|
)}
|
|
{isAdmin && (
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" checked={s.is_enabled} onChange={async e => { const res = await updateOnboardingStep(s.id, { is_enabled: e.target.checked }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, is_enabled: e.target.checked } : x)) }} />
|
|
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">Enabled</span>
|
|
</label>
|
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" checked={s.is_required} onChange={async e => { const res = await updateOnboardingStep(s.id, { is_required: e.target.checked }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, is_required: e.target.checked } : x)) }} />
|
|
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">Required</span>
|
|
</label>
|
|
{!s.is_system && (
|
|
<button onClick={async () => { if (!(await confirm({ title: 'Delete step', description: `"${s.title}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteOnboardingStep(s.id); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.filter(x => x.id !== s.id)); toast.success('Deleted') }} className="ml-auto font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)
|
|
}
|