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') && ( +