From 30bba045f8d8f347d5cc412e33e5cbb604a89d33 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Fri, 15 May 2026 15:38:40 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + app/(app)/layout.tsx | 9 + app/(app)/settings/page.tsx | 18 ++ app/(app)/settings/settings-client.tsx | 181 ++++++++++++++++++- app/onboarding/onboarding-flow.tsx | 239 +++++++++++++++++++++++++ app/onboarding/page.tsx | 64 +++++++ components/safe-markdown.tsx | 43 +++++ docs/HANDOFF.md | 34 ++++ docs/UX_AND_PERSONAS.md | 85 +++++++++ lib/actions/index.ts | 3 + lib/actions/onboarding.ts | 212 ++++++++++++++++++++++ lib/auth-actions.ts | 4 +- lib/validations.ts | 24 +++ package.json | 2 + pnpm-lock.yaml | 114 ++++++++++++ scripts/022_onboarding.sql | 137 ++++++++++++++ scripts/schema.sql | 75 ++++++++ 17 files changed, 1243 insertions(+), 5 deletions(-) create mode 100644 app/onboarding/onboarding-flow.tsx create mode 100644 app/onboarding/page.tsx create mode 100644 components/safe-markdown.tsx create mode 100644 docs/UX_AND_PERSONAS.md create mode 100644 lib/actions/onboarding.ts create mode 100644 scripts/022_onboarding.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index c58203a..d85cad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index be6ba2f..db223b6 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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') diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index b3605c7..3e40094 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -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 + is_enabled: boolean + is_required: boolean + is_system: boolean + sort_order: number + }>} /> ) } diff --git a/app/(app)/settings/settings-client.tsx b/app/(app)/settings/settings-client.tsx index e3ebd68..7cf1823 100644 --- a/app/(app)/settings/settings-client.tsx +++ b/app/(app)/settings/settings-client.tsx @@ -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 + 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(initialOnboarding) const [areas, setAreas] = useState(initialAreas) const [tiers, setTiers] = useState(initialTiers) const [invites, setInvites] = useState(initialInvites) @@ -268,7 +283,7 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
{/* Tabs */}
- {(['space', 'roles', 'tiers', 'invites', 'integrations', 'webhooks'] as const).map(tab => ( + {(['space', 'roles', 'tiers', 'invites', 'onboarding', 'integrations', 'webhooks'] as const).map(tab => (
)} + {/* Onboarding Tab */} + {activeTab === 'onboarding' && ( +
+
+
+

New member onboarding

+

+ 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. +

+
+ {isAdmin && ( + + )} +
+ +
    + {[...onboardingSteps].sort((a, b) => a.sort_order - b.sort_order).map(s => ( +
  • +
    + { + 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" + /> + {s.step_type.replace(/_/g, ' ')} + { + 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 && built-in} +
    + + {(s.step_type === 'welcome' || s.step_type === 'code_of_conduct' || s.step_type === 'payment' || s.step_type === 'content') && ( +