feat: configurable member onboarding + UX/persona research
A new member who joins via invite code now walks through an admin-defined onboarding sequence before reaching the app. Schema (migration 022, folded into schema.sql Section 14): - space_onboarding_steps: step_key, step_type (welcome|code_of_conduct|profile|payment|content), title, body, config jsonb, is_enabled, is_required, is_system, sort_order, unique per (space, step_key). Full RLS: members read, admin/board write, admin delete non-system. - space_members.onboarding_completed_at + onboarding_progress jsonb. - Trigger seeds four default steps per space (welcome, code of conduct [required], profile, dues). Backfill seeds existing spaces and marks all existing members complete so the new gate never traps them. Flow: - /onboarding (top-level route, outside the (app) group so no redirect loop). Progress bar, step counter, back/continue, per-type rendering. Profile step calls updateMyProfile; payment step is a non-blocking nudge with optional link and "Remind me later". - (app)/layout.tsx redirects to /onboarding while onboarding_completed_at is null. - createSpace marks the founder complete: founders configure the space, they do not run the member flow (matches admin-vs-end-user persona research). - finishOnboarding re-checks required steps server-side so a tampered client cannot skip a required code-of-conduct acknowledgement. skipOnboarding only works when nothing is required. Admin config: - Settings -> Onboarding tab: reorder, edit title/body/payment link, toggle enabled/required, add custom content steps, delete non-system steps. Safety: - components/safe-markdown.tsx renders admin-authored markdown and a sanitized subset of HTML (rehype-raw + rehype-sanitize). Added those two deps. Also: - bio added to updateMyProfileSchema (members can self-set it; not blocked by the migration-015 trigger). - docs/UX_AND_PERSONAS.md: 4 personas and prioritized UX audit findings. Onboarding was finding #1; 2-6 deferred and tracked. 266/266 unit tests pass. Production build clean, /onboarding in the route table. Migration 022 applies idempotently on the next deploy.
This commit is contained in:
@@ -5,6 +5,10 @@ All notable changes to this project are documented here. Format follows [Keep a
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Configurable member onboarding**: a new member who joins via invite code is walked through an admin-defined sequence of steps at `/onboarding` before reaching the app. Built-in step types: welcome, code of conduct (required acknowledgement), profile, dues/payment nudge. Admins can add custom content steps with sanitized markdown/HTML, reorder, enable/disable, and mark steps required from Settings -> Onboarding. Founders skip the member flow (their onboarding is space configuration). Existing members were backfilled as completed so nobody is trapped. Server-side enforcement of required steps. New tables: `space_onboarding_steps`; new columns `space_members.onboarding_completed_at` and `onboarding_progress`.
|
||||
- `SafeMarkdown` component (react-markdown + remark-gfm + rehype-raw + rehype-sanitize) for rendering admin-authored markdown/HTML without XSS.
|
||||
- `docs/UX_AND_PERSONAS.md`: persona model and the UX audit that drove onboarding.
|
||||
- `bio` added to the self-service profile schema.
|
||||
- **Forum** at `/forum`: top-level discussion threads with categories, pinning, locking. Author or admin/board can edit; admin can delete.
|
||||
- **Polymorphic comment threads** on proposals, incidents, and policies. New `comments` table keyed on `(entity_type, entity_id)`. Renders markdown.
|
||||
- **User-creatable chat channels**: inline "+ New channel" form in `/comms` (name, description, type). RLS now allows any member to create; default channels stay protected.
|
||||
|
||||
@@ -25,6 +25,15 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// New members walk through the space's configured onboarding before they
|
||||
// reach the app. Founders (createSpace) and pre-existing members (migration
|
||||
// 022 backfill) have onboarding_completed_at set, so this only gates
|
||||
// genuinely-new joiners. /onboarding is outside this layout group, so there
|
||||
// is no redirect loop.
|
||||
if (!(member as { onboarding_completed_at?: string | null }).onboarding_completed_at) {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
// Count badges
|
||||
const { count: taskCount } = await supabase
|
||||
.from('tasks')
|
||||
|
||||
@@ -37,6 +37,12 @@ export default async function SettingsPage() {
|
||||
.eq('space_id', member.space_id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
const { data: onboardingSteps } = await 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', { ascending: true })
|
||||
|
||||
const isAdmin = member?.role === 'admin'
|
||||
|
||||
return (
|
||||
@@ -74,6 +80,18 @@ export default async function SettingsPage() {
|
||||
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
|
||||
}>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, Check, Copy, Eye, EyeOff, RotateCcw } from 'lucide-react'
|
||||
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret, updateSpaceVisibility, createArea, updateArea, deleteArea, createTier, updateTier, deleteTier, createInvite, updateInvite, deleteInvite } from '@/lib/actions'
|
||||
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret, updateSpaceVisibility, createArea, updateArea, deleteArea, createTier, updateTier, deleteTier, createInvite, updateInvite, deleteInvite, createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from '@/lib/actions'
|
||||
import { toast } from 'sonner'
|
||||
import { financialVisibilities, directoryVisibilities } from '@/lib/validations'
|
||||
|
||||
@@ -27,6 +27,20 @@ interface Props {
|
||||
areas: Area[]
|
||||
tiers: Tier[]
|
||||
invites: Invite[]
|
||||
onboardingSteps: OnboardingStep[]
|
||||
}
|
||||
|
||||
export interface OnboardingStep {
|
||||
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
|
||||
}
|
||||
|
||||
export interface Tier {
|
||||
@@ -124,8 +138,9 @@ const INTEGRATIONS_CONFIG = [
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsClient({ space, isAdmin, integrations, currentRole, areas: initialAreas, tiers: initialTiers, invites: initialInvites }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'tiers' | 'invites' | 'integrations' | 'webhooks'>('space')
|
||||
export default function SettingsClient({ space, isAdmin, integrations, currentRole, areas: initialAreas, tiers: initialTiers, invites: initialInvites, onboardingSteps: initialOnboarding }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'tiers' | 'invites' | 'onboarding' | 'integrations' | 'webhooks'>('space')
|
||||
const [onboardingSteps, setOnboardingSteps] = useState<OnboardingStep[]>(initialOnboarding)
|
||||
const [areas, setAreas] = useState<Area[]>(initialAreas)
|
||||
const [tiers, setTiers] = useState<Tier[]>(initialTiers)
|
||||
const [invites, setInvites] = useState<Invite[]>(initialInvites)
|
||||
@@ -268,7 +283,7 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
<div className="flex-1 p-4 md:p-6">
|
||||
{/* Tabs */}
|
||||
<div className="bg-card border-b border-border flex gap-4 md:gap-6 px-2 mb-6 rounded-t overflow-x-auto">
|
||||
{(['space', 'roles', 'tiers', 'invites', 'integrations', 'webhooks'] as const).map(tab => (
|
||||
{(['space', 'roles', 'tiers', 'invites', 'onboarding', 'integrations', 'webhooks'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -1038,6 +1053,164 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onboarding Tab */}
|
||||
{activeTab === 'onboarding' && (
|
||||
<div className="bg-card rounded border border-border p-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-3">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">New member onboarding</p>
|
||||
<p className="font-sans text-sm text-muted-foreground mt-1">
|
||||
These steps run, in order, the first time a new member opens the app. Reorder with the sort number. Disable a step to hide it. Built-in steps cannot be deleted, only disabled. Body text supports markdown and a safe subset of HTML.
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const result = await createOnboardingStep({
|
||||
step_type: 'content',
|
||||
title: 'New step',
|
||||
body: 'Edit this content in Settings, Onboarding.',
|
||||
sort_order: (onboardingSteps.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. Edit it below.')
|
||||
setOnboardingSteps(prev => [...prev, {
|
||||
id: (result as { id: string }).id,
|
||||
step_key: `custom-${(result as { id: string }).id.slice(0, 12)}`,
|
||||
step_type: 'content',
|
||||
title: 'New step',
|
||||
body: 'Edit this content in Settings, Onboarding.',
|
||||
config: {},
|
||||
is_enabled: true,
|
||||
is_required: false,
|
||||
is_system: false,
|
||||
sort_order: (onboardingSteps.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>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{[...onboardingSteps].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 next = parseInt(e.target.value, 10)
|
||||
if (isNaN(next) || next === s.sort_order) return
|
||||
const result = await updateOnboardingStep(s.id, { sort_order: next })
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: next } : x))
|
||||
}}
|
||||
className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 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 next = e.target.value.trim()
|
||||
if (!next || next === s.title) return
|
||||
const result = await updateOnboardingStep(s.id, { title: next })
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: next } : 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') && (
|
||||
<textarea
|
||||
defaultValue={s.body ?? ''}
|
||||
disabled={!isAdmin}
|
||||
rows={4}
|
||||
maxLength={50000}
|
||||
onBlur={async e => {
|
||||
const next = e.target.value
|
||||
if (next === (s.body ?? '')) return
|
||||
const result = await updateOnboardingStep(s.id, { body: next })
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, body: next } : x))
|
||||
}}
|
||||
placeholder="Markdown / safe HTML. For payment steps, describe how to pay."
|
||||
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 === 'payment' && (
|
||||
<input
|
||||
type="url"
|
||||
defaultValue={(s.config?.payment_url as string) ?? ''}
|
||||
disabled={!isAdmin}
|
||||
placeholder="Payment link (https://...)"
|
||||
onBlur={async e => {
|
||||
const next = e.target.value.trim()
|
||||
if (next === ((s.config?.payment_url as string) ?? '')) return
|
||||
const result = await updateOnboardingStep(s.id, { config: { ...s.config, payment_url: next } })
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
setOnboardingSteps(prev => prev.map(x => x.id === s.id ? { ...x, config: { ...x.config, payment_url: next } } : 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 result = await updateOnboardingStep(s.id, { is_enabled: e.target.checked })
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
setOnboardingSteps(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 result = await updateOnboardingStep(s.id, { is_required: e.target.checked })
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
setOnboardingSteps(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 (!confirm(`Delete step "${s.title}"?`)) return
|
||||
const result = await deleteOnboardingStep(s.id)
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
setOnboardingSteps(prev => prev.filter(x => x.id !== s.id))
|
||||
toast.success('Step 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhooks Tab */}
|
||||
{activeTab === 'webhooks' && (
|
||||
<div className="bg-card rounded border border-border p-6 space-y-6">
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { AtlasLogo } from '@/components/resources/atlas-logo'
|
||||
import { SafeMarkdown } from '@/components/safe-markdown'
|
||||
import {
|
||||
markOnboardingStepDone,
|
||||
finishOnboarding,
|
||||
skipOnboarding,
|
||||
updateMyProfile,
|
||||
} from '@/lib/actions'
|
||||
|
||||
type StepType = 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
|
||||
|
||||
interface Step {
|
||||
id: string
|
||||
step_type: StepType
|
||||
title: string
|
||||
body: string | null
|
||||
config: Record<string, unknown>
|
||||
is_required: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
spaceName: string
|
||||
steps: Step[]
|
||||
canSkip: boolean
|
||||
profile: {
|
||||
display_name: string
|
||||
handle: string
|
||||
bio: string
|
||||
skills: string[]
|
||||
interests: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ spaceName, steps, canSkip, profile: initialProfile }: Props) {
|
||||
const router = useRouter()
|
||||
const [index, setIndex] = useState(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [acked, setAcked] = useState<Record<string, boolean>>({})
|
||||
const [profile, setProfile] = useState(initialProfile)
|
||||
|
||||
const step = steps[index]
|
||||
const isLast = index === steps.length - 1
|
||||
const requireAck = step.config?.require_ack === true
|
||||
const ackLabel = (step.config?.ack_label as string) || 'I have read and agree'
|
||||
const ackOk = !requireAck || acked[step.id]
|
||||
|
||||
async function advance() {
|
||||
setBusy(true)
|
||||
await markOnboardingStepDone(step.id)
|
||||
setBusy(false)
|
||||
if (isLast) {
|
||||
await finish()
|
||||
} else {
|
||||
setIndex(i => i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
setBusy(true)
|
||||
const result = await finishOnboarding()
|
||||
setBusy(false)
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
async function handleSkipAll() {
|
||||
setBusy(true)
|
||||
const result = await skipOnboarding()
|
||||
setBusy(false)
|
||||
if (result.error) { toast.error(result.error); return }
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
async function saveProfileAndAdvance() {
|
||||
setBusy(true)
|
||||
const result = await updateMyProfile({
|
||||
display_name: profile.display_name.trim() || undefined,
|
||||
handle: profile.handle.trim() || null,
|
||||
bio: profile.bio.trim() || null,
|
||||
skills: profile.skills,
|
||||
interests: profile.interests,
|
||||
})
|
||||
if ('error' in result && result.error) { setBusy(false); toast.error(result.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 (
|
||||
<div className="min-h-screen bg-sidebar flex flex-col">
|
||||
<header className="px-4 md:px-6 py-4 flex items-center gap-2 border-b border-sidebar-border">
|
||||
<AtlasLogo className="w-6 h-6 text-primary" />
|
||||
<span className="font-mono text-sm font-bold text-white">{spaceName}</span>
|
||||
{canSkip && (
|
||||
<button
|
||||
onClick={handleSkipAll}
|
||||
disabled={busy}
|
||||
className="ml-auto font-mono text-[11px] text-sidebar-foreground/60 hover:text-white transition"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="h-1 bg-sidebar-border">
|
||||
<div className="h-1 bg-primary transition-all duration-300" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 flex items-start justify-center p-4 md:p-8">
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg mt-6 md:mt-12">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Step {index + 1} of {steps.length}
|
||||
</p>
|
||||
{step.is_required && (
|
||||
<span className="font-mono text-[10px] text-amber-600">required</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<h1 className="font-sans text-xl font-semibold text-foreground mb-4">{step.title}</h1>
|
||||
|
||||
{step.step_type === 'profile' ? (
|
||||
<div className="space-y-4">
|
||||
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Display name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.display_name}
|
||||
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
|
||||
maxLength={100}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.handle}
|
||||
onChange={e => setProfile({ ...profile, handle: e.target.value })}
|
||||
maxLength={50}
|
||||
placeholder="optional"
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Bio</label>
|
||||
<textarea
|
||||
value={profile.bio}
|
||||
onChange={e => setProfile({ ...profile, bio: e.target.value })}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder="optional"
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Skills (comma separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={profile.skills.join(', ')}
|
||||
onChange={e => setProfile({ ...profile, skills: e.target.value.split(',').map(s => s.trim()).filter(Boolean).slice(0, 40) })}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : step.step_type === 'payment' ? (
|
||||
<div className="space-y-4">
|
||||
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
|
||||
{typeof step.config?.payment_url === 'string' && step.config.payment_url && (
|
||||
<a
|
||||
href={step.config.payment_url as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 bg-primary text-white text-sm font-sans px-4 py-2 rounded hover:bg-primary/90 transition"
|
||||
>
|
||||
Set up payment
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{step.body && <SafeMarkdown>{step.body}</SafeMarkdown>}
|
||||
{requireAck && (
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!acked[step.id]}
|
||||
onChange={e => setAcked({ ...acked, [step.id]: e.target.checked })}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="font-sans text-sm text-foreground">{ackLabel}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() => setIndex(i => Math.max(0, i - 1))}
|
||||
disabled={index === 0 || busy}
|
||||
className="font-sans text-sm text-muted-foreground hover:text-foreground transition disabled:opacity-40"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{step.step_type === 'payment' && !isLast && (
|
||||
<button
|
||||
onClick={advance}
|
||||
disabled={busy}
|
||||
className="font-sans text-sm text-muted-foreground hover:text-foreground transition px-3 py-2"
|
||||
>
|
||||
Remind me later
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={step.step_type === 'profile' ? saveProfileAndAdvance : advance}
|
||||
disabled={busy || !ackOk}
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { OnboardingFlow } from './onboarding-flow'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, space_id, display_name, handle, bio, phone, skills, interests, onboarding_completed_at, spaces(name)')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.order('joined_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (!member) redirect(`/signup?email=${encodeURIComponent(user.email || '')}`)
|
||||
if (member.onboarding_completed_at) redirect('/dashboard')
|
||||
|
||||
const { data: steps } = await supabase
|
||||
.from('space_onboarding_steps')
|
||||
.select('id, step_key, step_type, title, body, config, is_required')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('is_enabled', true)
|
||||
.order('sort_order', { ascending: true })
|
||||
|
||||
// No steps configured: nothing to show. Mark complete and move on.
|
||||
if (!steps || steps.length === 0) {
|
||||
await supabase
|
||||
.from('space_members')
|
||||
.update({ onboarding_completed_at: new Date().toISOString() })
|
||||
.eq('id', member.id)
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
const canSkip = !steps.some(s => s.is_required)
|
||||
const spaceName = (member.spaces as { name?: string } | null)?.name ?? 'your space'
|
||||
|
||||
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,
|
||||
}))}
|
||||
canSkip={canSkip}
|
||||
profile={{
|
||||
display_name: member.display_name ?? '',
|
||||
handle: member.handle ?? '',
|
||||
bio: member.bio ?? '',
|
||||
skills: (member.skills as string[] | null) ?? [],
|
||||
interests: (member.interests as string[] | null) ?? [],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
|
||||
// Renders admin-authored markdown that MAY contain a constrained subset of raw
|
||||
// HTML. rehype-raw parses the HTML; rehype-sanitize then strips anything
|
||||
// dangerous (script, event handlers, javascript: URLs, etc). The author is a
|
||||
// space admin/board member, but a stored-XSS payload would still run in other
|
||||
// members' sessions within that tenant, so this is sanitized, not trusted.
|
||||
const schema = {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
'*': [...(defaultSchema.attributes?.['*'] ?? []), 'className', 'style'],
|
||||
a: [...(defaultSchema.attributes?.a ?? []), 'target', 'rel'],
|
||||
},
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames ?? []),
|
||||
'iframe',
|
||||
],
|
||||
// Allow only video embeds from well-known hosts; everything else dropped.
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
src: ['https'],
|
||||
href: ['https', 'http', 'mailto'],
|
||||
},
|
||||
}
|
||||
|
||||
export function SafeMarkdown({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<div className={`prose prose-sm prose-neutral dark:prose-invert max-w-none break-words ${className ?? ''}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,40 @@ Append-only. Newest entries on top. Keep each entry to one screen.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-15 (pass 12) — Configurable member onboarding + UX research
|
||||
|
||||
Branch: `main`.
|
||||
|
||||
### Context carried in
|
||||
|
||||
Pass 11's feature batch (forum, comments, tiers, invites, secrets encryption) deployed to production after one landmine: the `.gitignore` rule `secrets/` also matched `lib/secrets/`, so the first deploy failed on `Module not found: '@/lib/secrets/crypto'`. Fixed by anchoring the rule to `/secrets/` (commit `96a1fb9`); redeploy succeeded. Migration 021 is applied in prod (`_migrations_applied` has 8 rows). `SECRETS_ENCRYPTION_KEY` is set in `/opt/hackerspace-ops/.env.production` on the Droplet.
|
||||
|
||||
### What this pass does
|
||||
|
||||
UX research + persona work, a deep UI/UX audit, and the configurable member-onboarding feature.
|
||||
|
||||
### What shipped
|
||||
|
||||
- Migration `scripts/022_onboarding.sql` (folded into `schema.sql` Section 14): `space_onboarding_steps` (step_key, step_type in welcome|code_of_conduct|profile|payment|content, title, body, config jsonb, is_enabled, is_required, is_system, sort_order; unique per space+key). `space_members.onboarding_completed_at` and `onboarding_progress jsonb`. Trigger seeds 4 default steps per space; backfill seeds existing spaces and marks all existing members complete (so the new gate does not trap them). RLS: members read, admin/board write, admin delete non-system.
|
||||
- `lib/actions/onboarding.ts`: admin CRUD (`createOnboardingStep`/`updateOnboardingStep`/`deleteOnboardingStep`), member progress (`markOnboardingStepDone`, `finishOnboarding` which server-side-enforces required steps, `skipOnboarding` which only works when nothing is required). Validations added; `bio` added to `updateMyProfileSchema`.
|
||||
- `/onboarding` route (top-level, outside the `(app)` group so no redirect loop). `OnboardingFlow` client stepper: progress bar, step counter, back, per-type rendering (welcome/coc/content via `SafeMarkdown`, profile form -> `updateMyProfile`, payment nudge with optional link + "Remind me later"). Gate added in `app/(app)/layout.tsx`: redirect to `/onboarding` when `onboarding_completed_at` is null. `createSpace` now sets `onboarding_completed_at` for the founder.
|
||||
- `components/safe-markdown.tsx`: rehype-raw + rehype-sanitize. Added `rehype-raw` and `rehype-sanitize` deps.
|
||||
- Settings -> Onboarding admin tab: reorder (sort number), edit title/body/payment link, toggle enabled/required, add custom content step, delete non-system steps.
|
||||
- `docs/UX_AND_PERSONAS.md`: 4 personas (founder, new member, operator, casual) and prioritized UX findings. Onboarding was finding #1; findings 2-6 (sidebar icon collision, inconsistent empty states, heading hierarchy, getting-started affordance, role-filtered nav) deferred.
|
||||
|
||||
### Open items / deferred
|
||||
|
||||
- UX findings 2-6 in `docs/UX_AND_PERSONAS.md` (icon collision, empty-state component, page-title primitives, admin checklist, role-filtered sidebar).
|
||||
- Custom role labels / custom roles still have no Settings UI (data layer from pass 11 exists).
|
||||
- `importMembers` / `importPaymentsCsv` still lack Zod schemas (pass-10 audit item).
|
||||
|
||||
### Verification
|
||||
|
||||
- `pnpm test` 266/266. `pnpm build` green, `/onboarding` in the route table.
|
||||
- Migration 022 applies via `deploy.sh`'s idempotent runner on next deploy.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (pass 11) — Forum, comments, secrets encryption, custom tiers/invites, logo
|
||||
|
||||
Branch: `main`. Pushed.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# UX Notes and Personas
|
||||
|
||||
This document captures the persona model and the UX audit that drove the
|
||||
configurable-onboarding feature. It is a working reference, not marketing copy.
|
||||
|
||||
## Personas
|
||||
|
||||
A single hackerspace install serves several distinct people. They share a
|
||||
codebase but have different jobs-to-be-done. Research consistently shows the
|
||||
first real onboarding in a multi-tenant tool belongs to an admin, not an end
|
||||
user, and that role-based segmentation lifts activation 30 to 50 percent over a
|
||||
one-size-fits-all flow. The product reflects that split.
|
||||
|
||||
### 1. The Founder / Admin ("Dana")
|
||||
|
||||
- Starts the space. Technical or technical-adjacent. Wants the space
|
||||
configured: members imported, dues collected, areas defined, channels set up.
|
||||
- Cares about configuration, permissions, and not losing data.
|
||||
- Jobs: stand up the space, get money flowing, delegate.
|
||||
- Onboarding need: an admin setup path, not the member welcome flow. The app
|
||||
now marks the founder's `onboarding_completed_at` at space creation so they
|
||||
land directly on the dashboard.
|
||||
|
||||
### 2. The New Member ("Sam")
|
||||
|
||||
- Joined via an invite code. May be brand new to makerspaces.
|
||||
- Cares about: am I allowed in, what are the rules, how do I pay, who do I
|
||||
talk to.
|
||||
- Jobs: understand the code of conduct, set up dues, complete a profile, feel
|
||||
oriented.
|
||||
- Onboarding need: a short, friendly, mostly non-blocking flow. This is the
|
||||
flow `/onboarding` implements. Real makerspace onboarding (Milwaukee, Dallas,
|
||||
MakerFX, Asmbly) is consistently: orientation, sign a code-of-conduct/waiver,
|
||||
start recurring dues, then tool training. The default steps mirror that.
|
||||
|
||||
### 3. The Operator / Board / Treasurer ("Riley")
|
||||
|
||||
- Recurring power user. Runs payments, proposals, incidents, area leads.
|
||||
- Cares about visibility and throughput. Low onboarding need; high need for
|
||||
scannable lists and good empty states.
|
||||
|
||||
### 4. The Casual / Associate Member ("Jess")
|
||||
|
||||
- Low-frequency. Claims a chore, reads the forum, checks a policy.
|
||||
- Cares about a low-friction path to the one thing they came for. Sidebar
|
||||
breadth is overwhelming for this persona; progressive disclosure matters.
|
||||
|
||||
## Onboarding principles applied
|
||||
|
||||
From current UX research (Appcues, Userpilot, NN/g progressive disclosure,
|
||||
B2B activation studies) and makerspace practice:
|
||||
|
||||
- Short: 3 to 5 steps, not 10. Defaults ship 4 (welcome, code of conduct,
|
||||
profile, payment).
|
||||
- Personalized and configurable: each space edits, reorders, enables, requires,
|
||||
or adds steps. Not one-size-fits-all.
|
||||
- Visible progress: a progress bar and "Step X of N" reduce abandonment.
|
||||
- Value before commitment: the welcome and rules come before the dues nudge;
|
||||
payment is a non-blocking nudge by default ("Remind me later").
|
||||
- Enforce only what matters: code of conduct is `required` by default; the
|
||||
server double-checks required steps so a tampered client cannot skip them.
|
||||
- Escape hatch: if a space marks nothing required, members can "Skip for now".
|
||||
|
||||
## UX audit findings (prioritized)
|
||||
|
||||
The audit reviewed every authenticated page, the auth flow, and the layout.
|
||||
Onboarding was the single highest-leverage gap and is now addressed. The
|
||||
remaining findings are tracked for follow-up passes:
|
||||
|
||||
1. (Done) No post-signup onboarding. Implemented as configurable steps.
|
||||
2. Sidebar icon collision: Ops and Settings both use the `Settings2` icon.
|
||||
Low effort, high clarity win.
|
||||
3. Inconsistent empty states: roughly 17 variants. Forum, Policies, and
|
||||
Recruitment are the good models; the rest are blank or minimal. Recommend a
|
||||
single `Empty` component with icon, message, and a primary CTA.
|
||||
4. Heading hierarchy differs between pages (dashboard large/white vs members
|
||||
small/mono). Recommend `PageTitle` / `SectionTitle` primitives.
|
||||
5. No in-app "getting started" affordance for admins. The onboarding admin tab
|
||||
in Settings partially covers this for member-facing content; an admin
|
||||
checklist is still worth adding.
|
||||
6. Cognitive load for first-time members: 16+ sidebar items with no role
|
||||
filtering. Consider hiding admin-only items for non-privileged roles.
|
||||
|
||||
Items 2 to 6 are intentionally deferred so the onboarding feature ships in a
|
||||
reviewable change rather than a sprawling UI rewrite.
|
||||
@@ -38,3 +38,6 @@ export * from './comms'
|
||||
export * from './tiers'
|
||||
export * from './roles'
|
||||
export * from './invites'
|
||||
|
||||
// Configurable member onboarding
|
||||
export * from './onboarding'
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
||||
import {
|
||||
createOnboardingStepSchema,
|
||||
updateOnboardingStepSchema,
|
||||
uuidSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
const ONBOARDING_ADMIN_ROLES = ['admin', 'board'] as const
|
||||
|
||||
// --- Admin: manage steps -----------------------------------------------------
|
||||
|
||||
export async function createOnboardingStep(input: {
|
||||
step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'
|
||||
title: string
|
||||
body?: string | null
|
||||
config?: Record<string, unknown>
|
||||
is_enabled?: boolean
|
||||
is_required?: boolean
|
||||
sort_order?: number
|
||||
}) {
|
||||
const v = parseInput(createOnboardingStepSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ONBOARDING_ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
// Custom steps get a generated key; built-in keys are reserved for the
|
||||
// seeded rows so they stay addressable.
|
||||
const stepKey = `custom-${crypto.randomUUID().slice(0, 12)}`
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('space_onboarding_steps')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
step_key: stepKey,
|
||||
step_type: v.data.step_type,
|
||||
title: v.data.title,
|
||||
body: v.data.body ?? null,
|
||||
config: v.data.config ?? {},
|
||||
is_enabled: v.data.is_enabled ?? true,
|
||||
is_required: v.data.is_required ?? false,
|
||||
is_system: false,
|
||||
sort_order: v.data.sort_order ?? 100,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { id: data.id }
|
||||
}
|
||||
|
||||
export async function updateOnboardingStep(stepId: string, updates: {
|
||||
title?: string
|
||||
body?: string | null
|
||||
config?: Record<string, unknown>
|
||||
is_enabled?: boolean
|
||||
is_required?: boolean
|
||||
sort_order?: number
|
||||
}) {
|
||||
const idCheck = parseInput(uuidSchema, stepId)
|
||||
if (!idCheck.ok) return { error: 'Invalid step ID' }
|
||||
const v = parseInput(updateOnboardingStepSchema, updates)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ONBOARDING_ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const patch: Record<string, unknown> = {}
|
||||
if (v.data.title !== undefined) patch.title = v.data.title
|
||||
if (v.data.body !== undefined) patch.body = v.data.body
|
||||
if (v.data.config !== undefined) patch.config = v.data.config
|
||||
if (v.data.is_enabled !== undefined) patch.is_enabled = v.data.is_enabled
|
||||
if (v.data.is_required !== undefined) patch.is_required = v.data.is_required
|
||||
if (v.data.sort_order !== undefined) patch.sort_order = v.data.sort_order
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_onboarding_steps')
|
||||
.update(patch)
|
||||
.eq('id', idCheck.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function deleteOnboardingStep(stepId: string) {
|
||||
const v = parseInput(uuidSchema, stepId)
|
||||
if (!v.ok) return { error: 'Invalid step ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
// RLS also blocks deleting is_system rows; surface a clear message.
|
||||
const { error, count } = await supabase
|
||||
.from('space_onboarding_steps')
|
||||
.delete({ count: 'exact' })
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
if (count === 0) return { error: 'Built-in steps cannot be deleted. Disable it instead.' }
|
||||
revalidatePath('/settings')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
// --- Member: progress through the flow ---------------------------------------
|
||||
|
||||
export async function markOnboardingStepDone(stepId: string) {
|
||||
const v = parseInput(uuidSchema, stepId)
|
||||
if (!v.ok) return { error: 'Invalid step ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data: row } = await supabase
|
||||
.from('space_members')
|
||||
.select('onboarding_progress')
|
||||
.eq('id', member.id)
|
||||
.single()
|
||||
|
||||
const progress = (row?.onboarding_progress ?? {}) as { completed_step_ids?: string[] }
|
||||
const done = new Set(progress.completed_step_ids ?? [])
|
||||
done.add(v.data)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_members')
|
||||
.update({ onboarding_progress: { ...progress, completed_step_ids: Array.from(done) } })
|
||||
.eq('id', member.id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
// Completes onboarding. Server-side double-check: every enabled+required step
|
||||
// must be in completed_step_ids, so a tampered client cannot skip a required
|
||||
// code-of-conduct acknowledgement.
|
||||
export async function finishOnboarding() {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data: steps } = await supabase
|
||||
.from('space_onboarding_steps')
|
||||
.select('id, is_required, is_enabled')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('is_enabled', true)
|
||||
|
||||
const { data: row } = await supabase
|
||||
.from('space_members')
|
||||
.select('onboarding_progress')
|
||||
.eq('id', member.id)
|
||||
.single()
|
||||
|
||||
const progress = (row?.onboarding_progress ?? {}) as { completed_step_ids?: string[] }
|
||||
const done = new Set(progress.completed_step_ids ?? [])
|
||||
const requiredMissing = (steps ?? []).filter(s => s.is_required && !done.has(s.id))
|
||||
if (requiredMissing.length > 0) {
|
||||
return { error: 'Please complete the required steps before finishing.' }
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_members')
|
||||
.update({ onboarding_completed_at: new Date().toISOString() })
|
||||
.eq('id', member.id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
// Skip is only honored when no enabled step is required.
|
||||
export async function skipOnboarding() {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { count } = await supabase
|
||||
.from('space_onboarding_steps')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('is_enabled', true)
|
||||
.eq('is_required', true)
|
||||
|
||||
if ((count ?? 0) > 0) {
|
||||
return { error: 'This space requires you to complete onboarding.' }
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_members')
|
||||
.update({ onboarding_completed_at: new Date().toISOString() })
|
||||
.eq('id', member.id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true as const }
|
||||
}
|
||||
+3
-1
@@ -74,7 +74,8 @@ export async function createSpace(formData: {
|
||||
return { error: spaceErr?.message ?? 'Failed to create space' }
|
||||
}
|
||||
|
||||
// Add user as admin
|
||||
// Add user as admin. The founder configures the space; they do not go
|
||||
// through the member onboarding flow, so mark it complete up front.
|
||||
const { error: memberErr } = await admin
|
||||
.from('space_members')
|
||||
.insert({
|
||||
@@ -86,6 +87,7 @@ export async function createSpace(formData: {
|
||||
tier: 'plus',
|
||||
status: 'current',
|
||||
approved: true,
|
||||
onboarding_completed_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (memberErr) {
|
||||
|
||||
@@ -318,11 +318,35 @@ export const updateMyProfileSchema = z.object({
|
||||
display_name: z.string().min(1).max(100).optional(),
|
||||
handle: z.string().max(50).optional().nullable(),
|
||||
phone: z.string().max(20).optional().nullable(),
|
||||
bio: z.string().max(2000).optional().nullable(),
|
||||
skills: z.array(z.string().min(1).max(60)).max(40).optional(),
|
||||
interests: z.array(z.string().min(1).max(60)).max(40).optional(),
|
||||
willing_to: z.array(z.string().min(1).max(60)).max(20).optional(),
|
||||
})
|
||||
|
||||
// ─── Onboarding ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const onboardingStepTypeSchema = z.enum(['welcome', 'code_of_conduct', 'profile', 'payment', 'content'])
|
||||
|
||||
export const createOnboardingStepSchema = z.object({
|
||||
step_type: onboardingStepTypeSchema,
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
body: z.string().max(50000).optional().nullable(),
|
||||
config: z.record(z.unknown()).optional(),
|
||||
is_enabled: z.boolean().optional(),
|
||||
is_required: z.boolean().optional(),
|
||||
sort_order: z.number().int().min(0).max(100000).optional(),
|
||||
})
|
||||
|
||||
export const updateOnboardingStepSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
body: z.string().max(50000).optional().nullable(),
|
||||
config: z.record(z.unknown()).optional(),
|
||||
is_enabled: z.boolean().optional(),
|
||||
is_required: z.boolean().optional(),
|
||||
sort_order: z.number().int().min(0).max(100000).optional(),
|
||||
})
|
||||
|
||||
export const discloseAffiliationsSchema = z.object({
|
||||
affiliations: z.array(z.string().min(1).max(200)).max(50).default([]),
|
||||
})
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
Generated
+114
@@ -149,6 +149,12 @@ importers:
|
||||
recharts:
|
||||
specifier: 2.15.0
|
||||
version: 2.15.0([email protected]([email protected]))([email protected])
|
||||
rehype-raw:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
rehype-sanitize:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -2712,12 +2718,30 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
|
||||
|
||||
@@ -2732,6 +2756,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3742,6 +3769,12 @@ packages:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
@@ -4196,6 +4229,9 @@ packages:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -4270,6 +4306,9 @@ packages:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -6852,6 +6891,43 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
devlop: 1.1.0
|
||||
hastscript: 9.0.1
|
||||
property-information: 7.1.0
|
||||
vfile: 6.0.3
|
||||
vfile-location: 5.0.3
|
||||
web-namespaces: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
hast-util-from-parse5: 8.0.3
|
||||
hast-util-to-parse5: 8.0.1
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
parse5: 7.3.0
|
||||
unist-util-position: 5.0.0
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
web-namespaces: 2.0.1
|
||||
zwitch: 2.0.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
unist-util-position: 5.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -6872,10 +6948,28 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
comma-separated-tokens: 2.0.3
|
||||
devlop: 1.1.0
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
web-namespaces: 2.0.1
|
||||
zwitch: 2.0.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-parse-selector: 4.0.0
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -6886,6 +6980,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -8067,6 +8163,17 @@ snapshots:
|
||||
gopd: 1.2.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-raw: 9.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-sanitize: 5.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -8621,6 +8728,11 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
vfile: 6.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -8716,6 +8828,8 @@ snapshots:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
-- =============================================================================
|
||||
-- 022: Configurable member onboarding
|
||||
-- =============================================================================
|
||||
-- A space defines an ordered list of onboarding steps. A member who joins
|
||||
-- walks through the enabled steps before they land on the dashboard. Founders
|
||||
-- (created via createSpace) skip member onboarding: their onboarding is the
|
||||
-- admin configuration surface, not this flow.
|
||||
--
|
||||
-- Step types:
|
||||
-- welcome info screen (markdown body), never blocking
|
||||
-- code_of_conduct markdown body + required acknowledgement checkbox
|
||||
-- profile prompts the member to fill display_name/handle/bio/skills
|
||||
-- payment dues setup nudge: markdown body + optional link button
|
||||
-- content fully custom sanitized markdown/HTML, optional ack
|
||||
--
|
||||
-- Built-in steps are is_system = true: they can be disabled and reordered but
|
||||
-- not deleted. Custom content steps are is_system = false and deletable.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.space_onboarding_steps (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
step_key text NOT NULL CHECK (char_length(step_key) BETWEEN 1 AND 60),
|
||||
step_type text NOT NULL CHECK (step_type IN ('welcome','code_of_conduct','profile','payment','content')),
|
||||
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
|
||||
body text,
|
||||
config jsonb NOT NULL DEFAULT '{}',
|
||||
is_enabled boolean NOT NULL DEFAULT true,
|
||||
is_required boolean NOT NULL DEFAULT false,
|
||||
is_system boolean NOT NULL DEFAULT false,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, step_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_onboarding_steps_space
|
||||
ON public.space_onboarding_steps (space_id, sort_order);
|
||||
|
||||
ALTER TABLE public.space_members
|
||||
ADD COLUMN IF NOT EXISTS onboarding_completed_at timestamptz;
|
||||
ALTER TABLE public.space_members
|
||||
ADD COLUMN IF NOT EXISTS onboarding_progress jsonb NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Seed the four default steps on space creation.
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_onboarding_steps()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.space_onboarding_steps
|
||||
(space_id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order)
|
||||
VALUES
|
||||
(NEW.id, 'welcome', 'welcome',
|
||||
'Welcome to ' || NEW.name,
|
||||
E'We are glad you are here.\n\nThis short setup gets you ready to use the space. It takes about a minute.',
|
||||
'{}'::jsonb, true, false, true, 0),
|
||||
(NEW.id, 'code_of_conduct', 'code_of_conduct',
|
||||
'Code of Conduct',
|
||||
E'Be excellent to each other.\n\n- Treat people and tools with respect.\n- Clean up after yourself.\n- Ask before using equipment you have not been trained on.\n\nAn admin can edit this text in Settings, Onboarding.',
|
||||
'{"require_ack": true, "ack_label": "I have read and agree to the code of conduct"}'::jsonb,
|
||||
true, true, true, 1),
|
||||
(NEW.id, 'profile', 'profile',
|
||||
'Complete your profile',
|
||||
E'Tell the space who you are. You can change this anytime from your profile.',
|
||||
'{}'::jsonb, true, false, true, 2),
|
||||
(NEW.id, 'payment', 'payment',
|
||||
'Set up your dues',
|
||||
E'Membership dues keep the space running. Set up your recurring payment now so you do not lose access.\n\nAn admin can put the payment link and instructions here in Settings, Onboarding.',
|
||||
'{}'::jsonb, true, false, true, 3)
|
||||
ON CONFLICT (space_id, step_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_seed_default_onboarding_steps ON public.spaces;
|
||||
CREATE TRIGGER trg_seed_default_onboarding_steps
|
||||
AFTER INSERT ON public.spaces
|
||||
FOR EACH ROW EXECUTE FUNCTION public.seed_default_onboarding_steps();
|
||||
|
||||
-- Backfill default steps for existing spaces.
|
||||
INSERT INTO public.space_onboarding_steps
|
||||
(space_id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order)
|
||||
SELECT
|
||||
s.id,
|
||||
d.step_key,
|
||||
d.step_type,
|
||||
CASE WHEN d.step_key = 'welcome' THEN 'Welcome to ' || s.name ELSE d.title END,
|
||||
d.body,
|
||||
d.config::jsonb,
|
||||
true,
|
||||
d.is_required,
|
||||
true,
|
||||
d.sort_order
|
||||
FROM public.spaces s
|
||||
CROSS JOIN LATERAL (
|
||||
VALUES
|
||||
('welcome', 'welcome', 'Welcome',
|
||||
'We are glad you are here. This short setup gets you ready to use the space.',
|
||||
'{}', false, 0),
|
||||
('code_of_conduct', 'code_of_conduct', 'Code of Conduct',
|
||||
'Be excellent to each other. An admin can edit this in Settings, Onboarding.',
|
||||
'{"require_ack": true, "ack_label": "I have read and agree to the code of conduct"}', true, 1),
|
||||
('profile', 'profile', 'Complete your profile',
|
||||
'Tell the space who you are.',
|
||||
'{}', false, 2),
|
||||
('payment', 'payment', 'Set up your dues',
|
||||
'Membership dues keep the space running. An admin can add the payment link in Settings, Onboarding.',
|
||||
'{}', false, 3)
|
||||
) AS d(step_key, step_type, title, body, config, is_required, sort_order)
|
||||
ON CONFLICT (space_id, step_key) DO NOTHING;
|
||||
|
||||
-- Existing members have already been using the app: mark their onboarding done
|
||||
-- so the new gate does not trap them.
|
||||
UPDATE public.space_members
|
||||
SET onboarding_completed_at = now()
|
||||
WHERE onboarding_completed_at IS NULL;
|
||||
|
||||
ALTER TABLE public.space_onboarding_steps ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS onboarding_steps_select ON public.space_onboarding_steps;
|
||||
DROP POLICY IF EXISTS onboarding_steps_insert ON public.space_onboarding_steps;
|
||||
DROP POLICY IF EXISTS onboarding_steps_update ON public.space_onboarding_steps;
|
||||
DROP POLICY IF EXISTS onboarding_steps_delete ON public.space_onboarding_steps;
|
||||
-- Any member of the space can read steps (the joining member needs them).
|
||||
CREATE POLICY onboarding_steps_select ON public.space_onboarding_steps FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY onboarding_steps_insert ON public.space_onboarding_steps FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY onboarding_steps_update ON public.space_onboarding_steps FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY onboarding_steps_delete ON public.space_onboarding_steps FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']) AND NOT is_system);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_steps;
|
||||
CREATE TRIGGER trg_onboarding_steps_touch
|
||||
BEFORE UPDATE ON public.space_onboarding_steps
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
@@ -1617,3 +1617,78 @@ CREATE TRIGGER trg_space_tiers_touch BEFORE UPDATE ON public.space_tier
|
||||
CREATE TRIGGER trg_space_role_labels_touch BEFORE UPDATE ON public.space_role_labels FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
CREATE TRIGGER trg_space_custom_roles_touch BEFORE UPDATE ON public.space_custom_roles FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
CREATE TRIGGER trg_space_invites_touch BEFORE UPDATE ON public.space_invites FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 14. Configurable member onboarding.
|
||||
-- Equivalent to scripts/022_onboarding.sql.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.space_onboarding_steps (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
step_key text NOT NULL CHECK (char_length(step_key) BETWEEN 1 AND 60),
|
||||
step_type text NOT NULL CHECK (step_type IN ('welcome','code_of_conduct','profile','payment','content')),
|
||||
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
|
||||
body text,
|
||||
config jsonb NOT NULL DEFAULT '{}',
|
||||
is_enabled boolean NOT NULL DEFAULT true,
|
||||
is_required boolean NOT NULL DEFAULT false,
|
||||
is_system boolean NOT NULL DEFAULT false,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, step_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_onboarding_steps_space ON public.space_onboarding_steps (space_id, sort_order);
|
||||
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS onboarding_completed_at timestamptz;
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS onboarding_progress jsonb NOT NULL DEFAULT '{}';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_onboarding_steps()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.space_onboarding_steps
|
||||
(space_id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order)
|
||||
VALUES
|
||||
(NEW.id, 'welcome', 'welcome', 'Welcome to ' || NEW.name,
|
||||
E'We are glad you are here.\n\nThis short setup gets you ready to use the space. It takes about a minute.',
|
||||
'{}'::jsonb, true, false, true, 0),
|
||||
(NEW.id, 'code_of_conduct', 'code_of_conduct', 'Code of Conduct',
|
||||
E'Be excellent to each other.\n\n- Treat people and tools with respect.\n- Clean up after yourself.\n- Ask before using equipment you have not been trained on.',
|
||||
'{"require_ack": true, "ack_label": "I have read and agree to the code of conduct"}'::jsonb, true, true, true, 1),
|
||||
(NEW.id, 'profile', 'profile', 'Complete your profile',
|
||||
E'Tell the space who you are. You can change this anytime from your profile.',
|
||||
'{}'::jsonb, true, false, true, 2),
|
||||
(NEW.id, 'payment', 'payment', 'Set up your dues',
|
||||
E'Membership dues keep the space running. Set up your recurring payment now so you do not lose access.',
|
||||
'{}'::jsonb, true, false, true, 3)
|
||||
ON CONFLICT (space_id, step_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_seed_default_onboarding_steps ON public.spaces;
|
||||
CREATE TRIGGER trg_seed_default_onboarding_steps
|
||||
AFTER INSERT ON public.spaces
|
||||
FOR EACH ROW EXECUTE FUNCTION public.seed_default_onboarding_steps();
|
||||
|
||||
ALTER TABLE public.space_onboarding_steps ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS onboarding_steps_select ON public.space_onboarding_steps;
|
||||
DROP POLICY IF EXISTS onboarding_steps_insert ON public.space_onboarding_steps;
|
||||
DROP POLICY IF EXISTS onboarding_steps_update ON public.space_onboarding_steps;
|
||||
DROP POLICY IF EXISTS onboarding_steps_delete ON public.space_onboarding_steps;
|
||||
CREATE POLICY onboarding_steps_select ON public.space_onboarding_steps FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY onboarding_steps_insert ON public.space_onboarding_steps FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY onboarding_steps_update ON public.space_onboarding_steps FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY onboarding_steps_delete ON public.space_onboarding_steps FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']) AND NOT is_system);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_steps;
|
||||
CREATE TRIGGER trg_onboarding_steps_touch
|
||||
BEFORE UPDATE ON public.space_onboarding_steps
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
Reference in New Issue
Block a user