Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #21 from barazo-forum/feat/p2.3-community-onboarding-fields

feat: community onboarding fields UI (PR B)

authored by

Guido X Jansen and committed by
GitHub
aae2ecbe 4ff755ee

+1553 -4
+1 -1
CLAUDE.md
··· 70 70 - Which model to use per milestone (the plan has a per-milestone model map) 71 71 - Review gates (spec compliance + code quality) that must pass before marking tasks complete 72 72 73 - **This repo's milestones (Phase 4):** M1-M3 use `opus` (scaffold, design system, auth -- establishes all patterns). M4-M13 use `sonnet` (follows established component and page patterns). Reviewers always use `sonnet`. 73 + **This repo's milestones (completed during P1):** Implementation milestones Web M1-M14 are internal to this repo and completed during P1 (Core MVP). Web M1-M3 use `opus` (scaffold, design system, auth -- establishes all patterns). Web M4-M14 use `sonnet` (follows established component and page patterns). Reviewers always use `sonnet`. 74 74 75 75 ## Project Context 76 76
+151
src/app/admin/onboarding/page.test.tsx
··· 1 + /** 2 + * Tests for admin onboarding fields page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import AdminOnboardingPage from './page' 10 + 11 + vi.mock('next/navigation', () => ({ 12 + useRouter: () => ({ push: vi.fn() }), 13 + usePathname: () => '/admin/onboarding', 14 + })) 15 + 16 + vi.mock('next/link', () => ({ 17 + default: ({ 18 + children, 19 + href, 20 + ...props 21 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 22 + <a href={href} {...props}> 23 + {children} 24 + </a> 25 + ), 26 + })) 27 + 28 + vi.mock('next/image', () => ({ 29 + default: (props: Record<string, unknown>) => { 30 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 31 + return <img {...props} /> 32 + }, 33 + })) 34 + 35 + describe('AdminOnboardingPage', () => { 36 + it('renders onboarding fields heading', () => { 37 + render(<AdminOnboardingPage />) 38 + expect(screen.getByRole('heading', { name: /onboarding fields/i })).toBeInTheDocument() 39 + }) 40 + 41 + it('renders fields from API', async () => { 42 + render(<AdminOnboardingPage />) 43 + await waitFor(() => { 44 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 45 + }) 46 + expect(screen.getByText('Introduce yourself')).toBeInTheDocument() 47 + }) 48 + 49 + it('renders field type badges', async () => { 50 + render(<AdminOnboardingPage />) 51 + await waitFor(() => { 52 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 53 + }) 54 + expect(screen.getByText('ToS Acceptance')).toBeInTheDocument() 55 + expect(screen.getByText('Text Input')).toBeInTheDocument() 56 + }) 57 + 58 + it('shows required badge for mandatory fields', async () => { 59 + render(<AdminOnboardingPage />) 60 + await waitFor(() => { 61 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 62 + }) 63 + expect(screen.getByText('Required')).toBeInTheDocument() 64 + }) 65 + 66 + it('renders add field button', () => { 67 + render(<AdminOnboardingPage />) 68 + expect(screen.getByRole('button', { name: /add field/i })).toBeInTheDocument() 69 + }) 70 + 71 + it('shows create form when add field button is clicked', async () => { 72 + const user = userEvent.setup() 73 + render(<AdminOnboardingPage />) 74 + await user.click(screen.getByRole('button', { name: /add field/i })) 75 + expect(screen.getByLabelText(/field type/i)).toBeInTheDocument() 76 + expect(screen.getByLabelText(/^label$/i)).toBeInTheDocument() 77 + }) 78 + 79 + it('shows edit form when edit button is clicked', async () => { 80 + const user = userEvent.setup() 81 + render(<AdminOnboardingPage />) 82 + await waitFor(() => { 83 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 84 + }) 85 + const editButtons = screen.getAllByRole('button', { name: /edit/i }) 86 + await user.click(editButtons[0]!) 87 + expect(screen.getByLabelText(/^label$/i)).toBeInTheDocument() 88 + }) 89 + 90 + it('hides field type selector when editing existing field', async () => { 91 + const user = userEvent.setup() 92 + render(<AdminOnboardingPage />) 93 + await waitFor(() => { 94 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 95 + }) 96 + const editButtons = screen.getAllByRole('button', { name: /edit/i }) 97 + await user.click(editButtons[0]!) 98 + expect(screen.queryByLabelText(/field type/i)).not.toBeInTheDocument() 99 + }) 100 + 101 + it('shows validation error when saving with empty label', async () => { 102 + const user = userEvent.setup() 103 + render(<AdminOnboardingPage />) 104 + await user.click(screen.getByRole('button', { name: /add field/i })) 105 + await user.click(screen.getByRole('button', { name: /^save$/i })) 106 + expect(screen.getByRole('alert')).toHaveTextContent('Label is required') 107 + }) 108 + 109 + it('closes form when cancel is clicked', async () => { 110 + const user = userEvent.setup() 111 + render(<AdminOnboardingPage />) 112 + await user.click(screen.getByRole('button', { name: /add field/i })) 113 + expect(screen.getByLabelText(/^label$/i)).toBeInTheDocument() 114 + await user.click(screen.getByRole('button', { name: /cancel/i })) 115 + expect(screen.queryByLabelText(/^label$/i)).not.toBeInTheDocument() 116 + }) 117 + 118 + it('renders reorder buttons for each field', async () => { 119 + render(<AdminOnboardingPage />) 120 + await waitFor(() => { 121 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 122 + }) 123 + expect(screen.getAllByRole('button', { name: /move.*up/i })).toHaveLength(2) 124 + expect(screen.getAllByRole('button', { name: /move.*down/i })).toHaveLength(2) 125 + }) 126 + 127 + it('disables move up on first field and move down on last field', async () => { 128 + render(<AdminOnboardingPage />) 129 + await waitFor(() => { 130 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 131 + }) 132 + const moveUpButtons = screen.getAllByRole('button', { name: /move.*up/i }) 133 + const moveDownButtons = screen.getAllByRole('button', { name: /move.*down/i }) 134 + expect(moveUpButtons[0]).toBeDisabled() 135 + expect(moveDownButtons[moveDownButtons.length - 1]).toBeDisabled() 136 + }) 137 + 138 + it('renders description text', () => { 139 + render(<AdminOnboardingPage />) 140 + expect(screen.getByText(/configure fields that users must complete/i)).toBeInTheDocument() 141 + }) 142 + 143 + it('passes axe accessibility check', async () => { 144 + const { container } = render(<AdminOnboardingPage />) 145 + await waitFor(() => { 146 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 147 + }) 148 + const results = await axe(container) 149 + expect(results).toHaveNoViolations() 150 + }) 151 + })
+367
src/app/admin/onboarding/page.tsx
··· 1 + /** 2 + * Admin onboarding fields configuration page. 3 + * URL: /admin/onboarding 4 + * CRUD for community onboarding fields that users must complete before posting. 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState, useEffect, useCallback } from 'react' 10 + import { Plus, PencilSimple, TrashSimple, ArrowUp, ArrowDown } from '@phosphor-icons/react' 11 + import { AdminLayout } from '@/components/admin/admin-layout' 12 + import { 13 + getOnboardingFields, 14 + createOnboardingField, 15 + updateOnboardingField, 16 + deleteOnboardingField, 17 + reorderOnboardingFields, 18 + } from '@/lib/api/client' 19 + import { cn } from '@/lib/utils' 20 + import type { 21 + OnboardingField, 22 + OnboardingFieldType, 23 + CreateOnboardingFieldInput, 24 + } from '@/lib/api/types' 25 + 26 + // TODO: Replace with actual auth token from session 27 + const MOCK_TOKEN = 'mock-access-token' 28 + 29 + const FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 30 + age_confirmation: 'Age Confirmation', 31 + tos_acceptance: 'ToS Acceptance', 32 + newsletter_email: 'Newsletter Email', 33 + custom_text: 'Text Input', 34 + custom_select: 'Dropdown Select', 35 + custom_checkbox: 'Checkbox', 36 + } 37 + 38 + interface EditingField { 39 + id: string | null 40 + fieldType: OnboardingFieldType 41 + label: string 42 + description: string 43 + isMandatory: boolean 44 + config: Record<string, unknown> | null 45 + } 46 + 47 + const EMPTY_FIELD: EditingField = { 48 + id: null, 49 + fieldType: 'custom_text', 50 + label: '', 51 + description: '', 52 + isMandatory: true, 53 + config: null, 54 + } 55 + 56 + export default function AdminOnboardingPage() { 57 + const [fields, setFields] = useState<OnboardingField[]>([]) 58 + const [loading, setLoading] = useState(true) 59 + const [editing, setEditing] = useState<EditingField | null>(null) 60 + const [saving, setSaving] = useState(false) 61 + const [error, setError] = useState<string | null>(null) 62 + 63 + const fetchFields = useCallback(async () => { 64 + try { 65 + const response = await getOnboardingFields(MOCK_TOKEN) 66 + setFields(response.fields) 67 + } catch { 68 + // Silently handle 69 + } finally { 70 + setLoading(false) 71 + } 72 + }, []) 73 + 74 + useEffect(() => { 75 + void fetchFields() 76 + }, [fetchFields]) 77 + 78 + const handleAdd = () => { 79 + setEditing({ ...EMPTY_FIELD }) 80 + setError(null) 81 + } 82 + 83 + const handleEdit = (field: OnboardingField) => { 84 + setEditing({ 85 + id: field.id, 86 + fieldType: field.fieldType, 87 + label: field.label, 88 + description: field.description ?? '', 89 + isMandatory: field.isMandatory, 90 + config: field.config, 91 + }) 92 + setError(null) 93 + } 94 + 95 + const handleDelete = async (id: string) => { 96 + try { 97 + await deleteOnboardingField(id, MOCK_TOKEN) 98 + void fetchFields() 99 + } catch { 100 + // Silently handle 101 + } 102 + } 103 + 104 + const handleSave = async () => { 105 + if (!editing) return 106 + if (!editing.label.trim()) { 107 + setError('Label is required') 108 + return 109 + } 110 + 111 + setSaving(true) 112 + setError(null) 113 + try { 114 + if (editing.id) { 115 + await updateOnboardingField( 116 + editing.id, 117 + { 118 + label: editing.label, 119 + description: editing.description || null, 120 + isMandatory: editing.isMandatory, 121 + config: editing.config, 122 + }, 123 + MOCK_TOKEN 124 + ) 125 + } else { 126 + const input: CreateOnboardingFieldInput = { 127 + fieldType: editing.fieldType, 128 + label: editing.label, 129 + description: editing.description || undefined, 130 + isMandatory: editing.isMandatory, 131 + sortOrder: fields.length, 132 + config: editing.config ?? undefined, 133 + } 134 + await createOnboardingField(input, MOCK_TOKEN) 135 + } 136 + setEditing(null) 137 + void fetchFields() 138 + } catch { 139 + setError('Failed to save field') 140 + } finally { 141 + setSaving(false) 142 + } 143 + } 144 + 145 + const handleMoveUp = async (index: number) => { 146 + if (index === 0) return 147 + const newFields = [...fields] 148 + const temp = newFields[index - 1]! 149 + newFields[index - 1] = newFields[index]! 150 + newFields[index] = temp 151 + setFields(newFields) 152 + await reorderOnboardingFields( 153 + newFields.map((f, i) => ({ id: f.id, sortOrder: i })), 154 + MOCK_TOKEN 155 + ) 156 + } 157 + 158 + const handleMoveDown = async (index: number) => { 159 + if (index >= fields.length - 1) return 160 + const newFields = [...fields] 161 + const temp = newFields[index + 1]! 162 + newFields[index + 1] = newFields[index]! 163 + newFields[index] = temp 164 + setFields(newFields) 165 + await reorderOnboardingFields( 166 + newFields.map((f, i) => ({ id: f.id, sortOrder: i })), 167 + MOCK_TOKEN 168 + ) 169 + } 170 + 171 + return ( 172 + <AdminLayout> 173 + <div className="space-y-6"> 174 + <div className="flex items-center justify-between"> 175 + <div> 176 + <h1 className="text-2xl font-bold text-foreground">Onboarding Fields</h1> 177 + <p className="mt-1 text-sm text-muted-foreground"> 178 + Configure fields that users must complete before they can post in this community. 179 + </p> 180 + </div> 181 + <button 182 + type="button" 183 + onClick={handleAdd} 184 + className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 185 + > 186 + <Plus size={16} aria-hidden="true" /> 187 + Add Field 188 + </button> 189 + </div> 190 + 191 + {/* Edit/Create form */} 192 + {editing && ( 193 + <div className="rounded-lg border border-border bg-card p-4"> 194 + <h2 className="mb-4 text-lg font-semibold text-foreground"> 195 + {editing.id ? 'Edit Field' : 'New Onboarding Field'} 196 + </h2> 197 + <div className="space-y-4"> 198 + {!editing.id && ( 199 + <div> 200 + <label htmlFor="field-type" className="block text-sm font-medium text-foreground"> 201 + Field Type 202 + </label> 203 + <select 204 + id="field-type" 205 + value={editing.fieldType} 206 + onChange={(e) => 207 + setEditing({ ...editing, fieldType: e.target.value as OnboardingFieldType }) 208 + } 209 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 210 + > 211 + {Object.entries(FIELD_TYPE_LABELS).map(([value, label]) => ( 212 + <option key={value} value={value}> 213 + {label} 214 + </option> 215 + ))} 216 + </select> 217 + </div> 218 + )} 219 + <div> 220 + <label htmlFor="field-label" className="block text-sm font-medium text-foreground"> 221 + Label 222 + </label> 223 + <input 224 + id="field-label" 225 + type="text" 226 + value={editing.label} 227 + onChange={(e) => setEditing({ ...editing, label: e.target.value })} 228 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 229 + placeholder="e.g., Accept our community rules" 230 + /> 231 + </div> 232 + <div> 233 + <label 234 + htmlFor="field-description" 235 + className="block text-sm font-medium text-foreground" 236 + > 237 + Description (optional) 238 + </label> 239 + <textarea 240 + id="field-description" 241 + value={editing.description} 242 + onChange={(e) => setEditing({ ...editing, description: e.target.value })} 243 + rows={2} 244 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 245 + placeholder="Additional context or instructions for users" 246 + /> 247 + </div> 248 + <div className="flex items-center gap-2"> 249 + <input 250 + id="field-mandatory" 251 + type="checkbox" 252 + checked={editing.isMandatory} 253 + onChange={(e) => setEditing({ ...editing, isMandatory: e.target.checked })} 254 + className="h-4 w-4 rounded border-border" 255 + /> 256 + <label htmlFor="field-mandatory" className="text-sm text-foreground"> 257 + Required (users must complete this field before posting) 258 + </label> 259 + </div> 260 + {error && ( 261 + <p role="alert" className="text-sm text-destructive"> 262 + {error} 263 + </p> 264 + )} 265 + <div className="flex gap-2"> 266 + <button 267 + type="button" 268 + onClick={() => void handleSave()} 269 + disabled={saving} 270 + className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 271 + > 272 + {saving ? 'Saving...' : 'Save'} 273 + </button> 274 + <button 275 + type="button" 276 + onClick={() => setEditing(null)} 277 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 278 + > 279 + Cancel 280 + </button> 281 + </div> 282 + </div> 283 + </div> 284 + )} 285 + 286 + {/* Field list */} 287 + {loading && <p className="text-sm text-muted-foreground">Loading onboarding fields...</p>} 288 + 289 + {!loading && fields.length === 0 && !editing && ( 290 + <p className="py-8 text-center text-muted-foreground"> 291 + No onboarding fields configured. Users can post immediately without any onboarding 292 + steps. 293 + </p> 294 + )} 295 + 296 + {!loading && fields.length > 0 && ( 297 + <div className="space-y-2"> 298 + {fields.map((field, index) => ( 299 + <div 300 + key={field.id} 301 + className="flex items-center justify-between rounded-md border border-border bg-card p-3" 302 + > 303 + <div className="min-w-0 flex-1"> 304 + <div className="flex items-center gap-2"> 305 + <p className="text-sm font-medium text-foreground">{field.label}</p> 306 + <span 307 + className={cn( 308 + 'rounded-full px-2 py-0.5 text-xs font-medium', 309 + 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' 310 + )} 311 + > 312 + {FIELD_TYPE_LABELS[field.fieldType]} 313 + </span> 314 + {field.isMandatory && ( 315 + <span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400"> 316 + Required 317 + </span> 318 + )} 319 + </div> 320 + {field.description && ( 321 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 322 + )} 323 + </div> 324 + <div className="flex items-center gap-1"> 325 + <button 326 + type="button" 327 + onClick={() => void handleMoveUp(index)} 328 + disabled={index === 0} 329 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 330 + aria-label={`Move ${field.label} up`} 331 + > 332 + <ArrowUp size={16} aria-hidden="true" /> 333 + </button> 334 + <button 335 + type="button" 336 + onClick={() => void handleMoveDown(index)} 337 + disabled={index === fields.length - 1} 338 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 339 + aria-label={`Move ${field.label} down`} 340 + > 341 + <ArrowDown size={16} aria-hidden="true" /> 342 + </button> 343 + <button 344 + type="button" 345 + onClick={() => handleEdit(field)} 346 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 347 + aria-label={`Edit ${field.label}`} 348 + > 349 + <PencilSimple size={16} aria-hidden="true" /> 350 + </button> 351 + <button 352 + type="button" 353 + onClick={() => void handleDelete(field.id)} 354 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 355 + aria-label={`Delete ${field.label}`} 356 + > 357 + <TrashSimple size={16} aria-hidden="true" /> 358 + </button> 359 + </div> 360 + </div> 361 + ))} 362 + </div> 363 + )} 364 + </div> 365 + </AdminLayout> 366 + ) 367 + }
+18 -1
src/app/new/page.test.tsx
··· 2 2 * Tests for new topic page. 3 3 */ 4 4 5 - import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' 5 + import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest' 6 6 import { render, screen, cleanup } from '@testing-library/react' 7 7 import { setupServer } from 'msw/node' 8 8 import { handlers } from '@/mocks/handlers' ··· 10 10 11 11 const server = setupServer(...handlers) 12 12 13 + const mockStorage: Record<string, string> = {} 14 + 13 15 beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 16 + beforeEach(() => { 17 + vi.stubGlobal('localStorage', { 18 + getItem: vi.fn((key: string) => mockStorage[key] ?? null), 19 + setItem: vi.fn((key: string, value: string) => { 20 + mockStorage[key] = value 21 + }), 22 + removeItem: vi.fn((key: string) => { 23 + delete mockStorage[key] 24 + }), 25 + clear: vi.fn(), 26 + length: 0, 27 + key: vi.fn(), 28 + }) 29 + mockStorage['accessToken'] = 'test-token' 30 + }) 14 31 afterEach(() => { 15 32 cleanup() 16 33 server.resetHandlers()
+36 -2
src/app/new/page.tsx
··· 7 7 8 8 'use client' 9 9 10 - import { useState } from 'react' 10 + import { useState, useRef } from 'react' 11 11 import { useRouter } from 'next/navigation' 12 12 import type { CreateTopicInput } from '@/lib/api/types' 13 13 import { createTopic } from '@/lib/api/client' ··· 15 15 import { ForumLayout } from '@/components/layout/forum-layout' 16 16 import { Breadcrumbs } from '@/components/breadcrumbs' 17 17 import { TopicForm } from '@/components/topic-form' 18 + import { OnboardingModal } from '@/components/onboarding-modal' 19 + import { useOnboarding } from '@/hooks/use-onboarding' 18 20 19 21 export default function NewTopicPage() { 20 22 const router = useRouter() 21 23 const [submitting, setSubmitting] = useState(false) 22 24 const [error, setError] = useState<string | null>(null) 25 + const onboarding = useOnboarding() 26 + const pendingValues = useRef<CreateTopicInput | null>(null) 23 27 24 - const handleSubmit = async (values: CreateTopicInput) => { 28 + const doSubmit = async (values: CreateTopicInput) => { 25 29 setSubmitting(true) 26 30 setError(null) 27 31 ··· 36 40 } 37 41 } 38 42 43 + const handleSubmit = async (values: CreateTopicInput) => { 44 + if (!onboarding.loading && !onboarding.complete) { 45 + pendingValues.current = values 46 + onboarding.openModal() 47 + return 48 + } 49 + await doSubmit(values) 50 + } 51 + 52 + const handleOnboardingComplete = async ( 53 + responses: Array<{ fieldId: string; response: unknown }> 54 + ) => { 55 + const success = await onboarding.submit(responses) 56 + if (success && pendingValues.current) { 57 + await doSubmit(pendingValues.current) 58 + pendingValues.current = null 59 + } 60 + return success 61 + } 62 + 39 63 return ( 40 64 <ForumLayout> 41 65 <div className="space-y-6"> ··· 53 77 )} 54 78 55 79 <TopicForm onSubmit={handleSubmit} submitting={submitting} /> 80 + 81 + <OnboardingModal 82 + open={onboarding.showModal} 83 + fields={onboarding.status?.fields ?? []} 84 + onSubmit={handleOnboardingComplete} 85 + onCancel={() => { 86 + onboarding.closeModal() 87 + pendingValues.current = null 88 + }} 89 + /> 56 90 </div> 57 91 </ForumLayout> 58 92 )
+2
src/components/admin/admin-layout.tsx
··· 16 16 Tag, 17 17 Users, 18 18 PuzzlePiece, 19 + ClipboardText, 19 20 ArrowLeft, 20 21 } from '@phosphor-icons/react' 21 22 import { cn } from '@/lib/utils' ··· 30 31 { href: '/admin/moderation', label: 'Moderation', icon: ShieldCheck }, 31 32 { href: '/admin/settings', label: 'Settings', icon: Gear }, 32 33 { href: '/admin/content-ratings', label: 'Content Ratings', icon: Tag }, 34 + { href: '/admin/onboarding', label: 'Onboarding', icon: ClipboardText }, 33 35 { href: '/admin/users', label: 'Users', icon: Users }, 34 36 { href: '/admin/plugins', label: 'Plugins', icon: PuzzlePiece }, 35 37 ]
+203
src/components/onboarding-modal.test.tsx
··· 1 + /** 2 + * Tests for OnboardingModal component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { OnboardingModal } from './onboarding-modal' 10 + import type { OnboardingField } from '@/lib/api/types' 11 + 12 + const NOW = '2026-02-15T12:00:00.000Z' 13 + 14 + function makeField(overrides: Partial<OnboardingField> = {}): OnboardingField { 15 + return { 16 + id: 'field-1', 17 + communityDid: 'did:plc:test', 18 + fieldType: 'custom_text', 19 + label: 'Tell us about yourself', 20 + description: 'A brief introduction.', 21 + isMandatory: true, 22 + sortOrder: 0, 23 + config: null, 24 + createdAt: NOW, 25 + updatedAt: NOW, 26 + ...overrides, 27 + } 28 + } 29 + 30 + const defaultProps = { 31 + open: true, 32 + fields: [makeField()], 33 + onSubmit: vi.fn().mockResolvedValue(true), 34 + onCancel: vi.fn(), 35 + } 36 + 37 + describe('OnboardingModal', () => { 38 + it('renders nothing when closed', () => { 39 + const { container } = render(<OnboardingModal {...defaultProps} open={false} />) 40 + expect(container.innerHTML).toBe('') 41 + }) 42 + 43 + it('renders dialog with title when open', () => { 44 + render(<OnboardingModal {...defaultProps} />) 45 + expect( 46 + screen.getByRole('heading', { name: /complete community onboarding/i }) 47 + ).toBeInTheDocument() 48 + }) 49 + 50 + it('renders a text input for custom_text fields', () => { 51 + render(<OnboardingModal {...defaultProps} />) 52 + expect(screen.getByLabelText(/tell us about yourself/i)).toBeInTheDocument() 53 + }) 54 + 55 + it('renders a checkbox for tos_acceptance fields', () => { 56 + render( 57 + <OnboardingModal 58 + {...defaultProps} 59 + fields={[makeField({ id: 'tos', fieldType: 'tos_acceptance', label: 'Accept Terms' })]} 60 + /> 61 + ) 62 + expect(screen.getByLabelText(/accept terms/i)).toBeInTheDocument() 63 + expect(screen.getByRole('checkbox')).toBeInTheDocument() 64 + }) 65 + 66 + it('renders age dropdown for age_confirmation fields', () => { 67 + render( 68 + <OnboardingModal 69 + {...defaultProps} 70 + fields={[makeField({ id: 'age', fieldType: 'age_confirmation', label: 'Your age' })]} 71 + /> 72 + ) 73 + expect(screen.getByLabelText(/your age/i)).toBeInTheDocument() 74 + expect(screen.getByText('13+')).toBeInTheDocument() 75 + expect(screen.getByText('18+')).toBeInTheDocument() 76 + }) 77 + 78 + it('renders email input for newsletter_email fields', () => { 79 + render( 80 + <OnboardingModal 81 + {...defaultProps} 82 + fields={[ 83 + makeField({ id: 'email', fieldType: 'newsletter_email', label: 'Newsletter email' }), 84 + ]} 85 + /> 86 + ) 87 + const input = screen.getByLabelText(/newsletter email/i) 88 + expect(input).toHaveAttribute('type', 'email') 89 + }) 90 + 91 + it('renders select for custom_select fields', () => { 92 + render( 93 + <OnboardingModal 94 + {...defaultProps} 95 + fields={[ 96 + makeField({ 97 + id: 'role', 98 + fieldType: 'custom_select', 99 + label: 'Your role', 100 + config: { options: ['Developer', 'Designer', 'Other'] }, 101 + }), 102 + ]} 103 + /> 104 + ) 105 + expect(screen.getByLabelText(/your role/i)).toBeInTheDocument() 106 + expect(screen.getByText('Developer')).toBeInTheDocument() 107 + }) 108 + 109 + it('renders checkbox for custom_checkbox fields', () => { 110 + render( 111 + <OnboardingModal 112 + {...defaultProps} 113 + fields={[ 114 + makeField({ 115 + id: 'agree', 116 + fieldType: 'custom_checkbox', 117 + label: 'I agree to participate', 118 + }), 119 + ]} 120 + /> 121 + ) 122 + expect(screen.getByLabelText(/i agree to participate/i)).toBeInTheDocument() 123 + }) 124 + 125 + it('disables submit when mandatory fields are empty', () => { 126 + render(<OnboardingModal {...defaultProps} />) 127 + expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled() 128 + }) 129 + 130 + it('enables submit when mandatory fields are filled', async () => { 131 + const user = userEvent.setup() 132 + render(<OnboardingModal {...defaultProps} />) 133 + 134 + await user.type(screen.getByLabelText(/tell us about yourself/i), 'Hello!') 135 + expect(screen.getByRole('button', { name: /continue/i })).toBeEnabled() 136 + }) 137 + 138 + it('requires tos_acceptance to be checked for submit to enable', async () => { 139 + const user = userEvent.setup() 140 + render( 141 + <OnboardingModal 142 + {...defaultProps} 143 + fields={[makeField({ id: 'tos', fieldType: 'tos_acceptance', label: 'Accept Terms' })]} 144 + /> 145 + ) 146 + 147 + expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled() 148 + await user.click(screen.getByRole('checkbox')) 149 + expect(screen.getByRole('button', { name: /continue/i })).toBeEnabled() 150 + }) 151 + 152 + it('calls onSubmit with responses when submitted', async () => { 153 + const onSubmit = vi.fn().mockResolvedValue(true) 154 + const user = userEvent.setup() 155 + render(<OnboardingModal {...defaultProps} onSubmit={onSubmit} />) 156 + 157 + await user.type(screen.getByLabelText(/tell us about yourself/i), 'Hello!') 158 + await user.click(screen.getByRole('button', { name: /continue/i })) 159 + 160 + expect(onSubmit).toHaveBeenCalledWith([{ fieldId: 'field-1', response: 'Hello!' }]) 161 + }) 162 + 163 + it('calls onCancel when cancel button is clicked', async () => { 164 + const onCancel = vi.fn() 165 + const user = userEvent.setup() 166 + render(<OnboardingModal {...defaultProps} onCancel={onCancel} />) 167 + 168 + await user.click(screen.getByRole('button', { name: /cancel/i })) 169 + expect(onCancel).toHaveBeenCalled() 170 + }) 171 + 172 + it('shows error when submit fails', async () => { 173 + const onSubmit = vi.fn().mockResolvedValue(false) 174 + const user = userEvent.setup() 175 + render(<OnboardingModal {...defaultProps} onSubmit={onSubmit} />) 176 + 177 + await user.type(screen.getByLabelText(/tell us about yourself/i), 'Hello!') 178 + await user.click(screen.getByRole('button', { name: /continue/i })) 179 + 180 + expect(screen.getByRole('alert')).toHaveTextContent(/failed to submit/i) 181 + }) 182 + 183 + it('shows required indicator for mandatory fields', () => { 184 + render(<OnboardingModal {...defaultProps} />) 185 + expect(screen.getByText('*')).toBeInTheDocument() 186 + }) 187 + 188 + it('shows field descriptions', () => { 189 + render(<OnboardingModal {...defaultProps} />) 190 + expect(screen.getByText('A brief introduction.')).toBeInTheDocument() 191 + }) 192 + 193 + it('does not disable submit when only optional fields are empty', () => { 194 + render(<OnboardingModal {...defaultProps} fields={[makeField({ isMandatory: false })]} />) 195 + expect(screen.getByRole('button', { name: /continue/i })).toBeEnabled() 196 + }) 197 + 198 + it('passes axe accessibility check', async () => { 199 + const { container } = render(<OnboardingModal {...defaultProps} />) 200 + const results = await axe(container) 201 + expect(results).toHaveNoViolations() 202 + }) 203 + })
+281
src/components/onboarding-modal.tsx
··· 1 + /** 2 + * Onboarding modal for community-configured onboarding fields. 3 + * Shown when a user attempts a write action without completing onboarding. 4 + * Renders fields dynamically based on field type configuration. 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState } from 'react' 10 + import { cn } from '@/lib/utils' 11 + import type { OnboardingField } from '@/lib/api/types' 12 + 13 + interface OnboardingModalProps { 14 + open: boolean 15 + fields: OnboardingField[] 16 + onSubmit: (responses: Array<{ fieldId: string; response: unknown }>) => Promise<boolean> 17 + onCancel: () => void 18 + } 19 + 20 + /** Valid age bracket options for age_confirmation fields */ 21 + const AGE_OPTIONS = [ 22 + { value: 0, label: 'Rather not say' }, 23 + { value: 13, label: '13+' }, 24 + { value: 14, label: '14+' }, 25 + { value: 15, label: '15+' }, 26 + { value: 16, label: '16+' }, 27 + { value: 18, label: '18+' }, 28 + ] as const 29 + 30 + export function OnboardingModal({ open, fields, onSubmit, onCancel }: OnboardingModalProps) { 31 + const [responses, setResponses] = useState<Record<string, unknown>>({}) 32 + const [submitting, setSubmitting] = useState(false) 33 + const [error, setError] = useState<string | null>(null) 34 + 35 + if (!open) return null 36 + 37 + const setFieldValue = (fieldId: string, value: unknown) => { 38 + setResponses((prev) => ({ ...prev, [fieldId]: value })) 39 + } 40 + 41 + const mandatoryFields = fields.filter((f) => f.isMandatory) 42 + const allMandatoryFilled = mandatoryFields.every((f) => { 43 + const val = responses[f.id] 44 + if (val === undefined || val === null || val === '') return false 45 + if (f.fieldType === 'tos_acceptance' && val !== true) return false 46 + return true 47 + }) 48 + 49 + const handleSubmit = async () => { 50 + setSubmitting(true) 51 + setError(null) 52 + 53 + const responseArray = fields 54 + .filter((f) => responses[f.id] !== undefined) 55 + .map((f) => ({ fieldId: f.id, response: responses[f.id] })) 56 + 57 + const success = await onSubmit(responseArray) 58 + if (!success) { 59 + setError('Failed to submit onboarding responses') 60 + } 61 + setSubmitting(false) 62 + } 63 + 64 + return ( 65 + <div 66 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 67 + role="dialog" 68 + aria-modal="true" 69 + aria-labelledby="onboarding-title" 70 + > 71 + <div className="mx-4 w-full max-w-lg rounded-lg bg-background p-6 shadow-lg"> 72 + <h2 id="onboarding-title" className="text-lg font-semibold text-foreground"> 73 + Complete Community Onboarding 74 + </h2> 75 + <p className="mt-2 text-sm text-muted-foreground"> 76 + Please complete the following before you can post in this community. 77 + </p> 78 + 79 + <div className="mt-4 space-y-4"> 80 + {fields.map((field) => ( 81 + <OnboardingFieldInput 82 + key={field.id} 83 + field={field} 84 + value={responses[field.id]} 85 + onChange={(val) => setFieldValue(field.id, val)} 86 + /> 87 + ))} 88 + </div> 89 + 90 + {error && ( 91 + <p className="mt-3 text-sm text-destructive" role="alert"> 92 + {error} 93 + </p> 94 + )} 95 + 96 + <div className="mt-6 flex justify-end gap-3"> 97 + <button 98 + type="button" 99 + onClick={onCancel} 100 + className={cn( 101 + 'rounded-md border border-border px-4 py-2 text-sm text-foreground', 102 + 'hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 103 + )} 104 + > 105 + Cancel 106 + </button> 107 + <button 108 + type="button" 109 + onClick={() => void handleSubmit()} 110 + disabled={submitting || !allMandatoryFilled} 111 + className={cn( 112 + 'rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground', 113 + 'hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 114 + 'disabled:cursor-not-allowed disabled:opacity-50' 115 + )} 116 + > 117 + {submitting ? 'Submitting...' : 'Continue'} 118 + </button> 119 + </div> 120 + </div> 121 + </div> 122 + ) 123 + } 124 + 125 + /** Render the appropriate input for a field type */ 126 + function OnboardingFieldInput({ 127 + field, 128 + value, 129 + onChange, 130 + }: { 131 + field: OnboardingField 132 + value: unknown 133 + onChange: (value: unknown) => void 134 + }) { 135 + const labelId = `onboarding-${field.id}` 136 + const required = field.isMandatory 137 + 138 + switch (field.fieldType) { 139 + case 'age_confirmation': 140 + return ( 141 + <div> 142 + <label htmlFor={labelId} className="block text-sm font-medium text-foreground"> 143 + {field.label} 144 + {required && <span className="ml-1 text-destructive">*</span>} 145 + </label> 146 + {field.description && ( 147 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 148 + )} 149 + <select 150 + id={labelId} 151 + value={value !== undefined ? String(value) : ''} 152 + onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} 153 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 154 + > 155 + <option value="">Select age bracket...</option> 156 + {AGE_OPTIONS.map((opt) => ( 157 + <option key={opt.value} value={opt.value}> 158 + {opt.label} 159 + </option> 160 + ))} 161 + </select> 162 + </div> 163 + ) 164 + 165 + case 'tos_acceptance': 166 + return ( 167 + <div className="flex items-start gap-2"> 168 + <input 169 + id={labelId} 170 + type="checkbox" 171 + checked={value === true} 172 + onChange={(e) => onChange(e.target.checked)} 173 + className="mt-1 h-4 w-4 rounded border-border" 174 + /> 175 + <div> 176 + <label htmlFor={labelId} className="text-sm font-medium text-foreground"> 177 + {field.label} 178 + {required && <span className="ml-1 text-destructive">*</span>} 179 + </label> 180 + {field.description && ( 181 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 182 + )} 183 + </div> 184 + </div> 185 + ) 186 + 187 + case 'newsletter_email': 188 + return ( 189 + <div> 190 + <label htmlFor={labelId} className="block text-sm font-medium text-foreground"> 191 + {field.label} 192 + {required && <span className="ml-1 text-destructive">*</span>} 193 + </label> 194 + {field.description && ( 195 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 196 + )} 197 + <input 198 + id={labelId} 199 + type="email" 200 + value={typeof value === 'string' ? value : ''} 201 + onChange={(e) => onChange(e.target.value || undefined)} 202 + placeholder="your@email.com" 203 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 204 + /> 205 + </div> 206 + ) 207 + 208 + case 'custom_text': 209 + return ( 210 + <div> 211 + <label htmlFor={labelId} className="block text-sm font-medium text-foreground"> 212 + {field.label} 213 + {required && <span className="ml-1 text-destructive">*</span>} 214 + </label> 215 + {field.description && ( 216 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 217 + )} 218 + <textarea 219 + id={labelId} 220 + value={typeof value === 'string' ? value : ''} 221 + onChange={(e) => onChange(e.target.value || undefined)} 222 + rows={3} 223 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 224 + /> 225 + </div> 226 + ) 227 + 228 + case 'custom_select': { 229 + const options = (field.config?.options ?? []) as string[] 230 + return ( 231 + <div> 232 + <label htmlFor={labelId} className="block text-sm font-medium text-foreground"> 233 + {field.label} 234 + {required && <span className="ml-1 text-destructive">*</span>} 235 + </label> 236 + {field.description && ( 237 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 238 + )} 239 + <select 240 + id={labelId} 241 + value={typeof value === 'string' ? value : ''} 242 + onChange={(e) => onChange(e.target.value || undefined)} 243 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 244 + > 245 + <option value="">Select...</option> 246 + {options.map((opt) => ( 247 + <option key={opt} value={opt}> 248 + {opt} 249 + </option> 250 + ))} 251 + </select> 252 + </div> 253 + ) 254 + } 255 + 256 + case 'custom_checkbox': 257 + return ( 258 + <div className="flex items-start gap-2"> 259 + <input 260 + id={labelId} 261 + type="checkbox" 262 + checked={value === true} 263 + onChange={(e) => onChange(e.target.checked)} 264 + className="mt-1 h-4 w-4 rounded border-border" 265 + /> 266 + <div> 267 + <label htmlFor={labelId} className="text-sm font-medium text-foreground"> 268 + {field.label} 269 + {required && <span className="ml-1 text-destructive">*</span>} 270 + </label> 271 + {field.description && ( 272 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 273 + )} 274 + </div> 275 + </div> 276 + ) 277 + 278 + default: 279 + return null 280 + } 281 + }
+128
src/hooks/use-onboarding.test.ts
··· 1 + /** 2 + * Tests for useOnboarding hook. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { renderHook, act, waitFor } from '@testing-library/react' 7 + import { http, HttpResponse } from 'msw' 8 + import { server } from '@/mocks/server' 9 + import { useOnboarding } from './use-onboarding' 10 + 11 + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 12 + 13 + const mockStorage: Record<string, string> = {} 14 + 15 + beforeEach(() => { 16 + vi.stubGlobal('localStorage', { 17 + getItem: vi.fn((key: string) => mockStorage[key] ?? null), 18 + setItem: vi.fn((key: string, value: string) => { 19 + mockStorage[key] = value 20 + }), 21 + removeItem: vi.fn((key: string) => { 22 + delete mockStorage[key] 23 + }), 24 + clear: vi.fn(), 25 + length: 0, 26 + key: vi.fn(), 27 + }) 28 + mockStorage['accessToken'] = 'test-token' 29 + }) 30 + 31 + describe('useOnboarding', () => { 32 + it('loads onboarding status on mount', async () => { 33 + const { result } = renderHook(() => useOnboarding()) 34 + 35 + expect(result.current.loading).toBe(true) 36 + 37 + await waitFor(() => { 38 + expect(result.current.loading).toBe(false) 39 + }) 40 + 41 + expect(result.current.complete).toBe(true) 42 + expect(result.current.status).not.toBeNull() 43 + }) 44 + 45 + it('returns complete=false when onboarding is incomplete', async () => { 46 + server.use( 47 + http.get(`${API_URL}/api/onboarding/status`, () => { 48 + return HttpResponse.json({ 49 + complete: false, 50 + fields: [ 51 + { 52 + id: 'f1', 53 + communityDid: 'did:plc:test', 54 + fieldType: 'tos_acceptance', 55 + label: 'ToS', 56 + description: null, 57 + isMandatory: true, 58 + sortOrder: 0, 59 + config: null, 60 + createdAt: '2026-01-01T00:00:00Z', 61 + updatedAt: '2026-01-01T00:00:00Z', 62 + }, 63 + ], 64 + responses: {}, 65 + missingFields: [{ id: 'f1', label: 'ToS', fieldType: 'tos_acceptance' }], 66 + }) 67 + }) 68 + ) 69 + 70 + const { result } = renderHook(() => useOnboarding()) 71 + 72 + await waitFor(() => { 73 + expect(result.current.loading).toBe(false) 74 + }) 75 + 76 + expect(result.current.complete).toBe(false) 77 + expect(result.current.status?.missingFields).toHaveLength(1) 78 + }) 79 + 80 + it('opens and closes modal', async () => { 81 + const { result } = renderHook(() => useOnboarding()) 82 + 83 + await waitFor(() => { 84 + expect(result.current.loading).toBe(false) 85 + }) 86 + 87 + expect(result.current.showModal).toBe(false) 88 + 89 + act(() => { 90 + result.current.openModal() 91 + }) 92 + expect(result.current.showModal).toBe(true) 93 + 94 + act(() => { 95 + result.current.closeModal() 96 + }) 97 + expect(result.current.showModal).toBe(false) 98 + }) 99 + 100 + it('submits responses and refreshes status', async () => { 101 + const { result } = renderHook(() => useOnboarding()) 102 + 103 + await waitFor(() => { 104 + expect(result.current.loading).toBe(false) 105 + }) 106 + 107 + let success = false 108 + await act(async () => { 109 + success = await result.current.submit([{ fieldId: 'f1', response: true }]) 110 + }) 111 + 112 + expect(success).toBe(true) 113 + expect(result.current.showModal).toBe(false) 114 + }) 115 + 116 + it('handles missing auth token gracefully', async () => { 117 + delete mockStorage['accessToken'] 118 + 119 + const { result } = renderHook(() => useOnboarding()) 120 + 121 + await waitFor(() => { 122 + expect(result.current.loading).toBe(false) 123 + }) 124 + 125 + // Without token, complete defaults to false (no status loaded) 126 + expect(result.current.status).toBeNull() 127 + }) 128 + })
+95
src/hooks/use-onboarding.ts
··· 1 + /** 2 + * Hook to check and manage community onboarding status. 3 + * Returns onboarding state and a function to trigger the onboarding modal. 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useEffect, useCallback } from 'react' 9 + import { getOnboardingStatus, submitOnboarding } from '@/lib/api/client' 10 + import type { OnboardingStatus, OnboardingFieldType } from '@/lib/api/types' 11 + 12 + export interface UseOnboardingResult { 13 + /** Whether onboarding status has been loaded */ 14 + loading: boolean 15 + /** Whether onboarding is complete */ 16 + complete: boolean 17 + /** Whether the onboarding modal should be shown */ 18 + showModal: boolean 19 + /** The full onboarding status from the API */ 20 + status: OnboardingStatus | null 21 + /** Open the onboarding modal */ 22 + openModal: () => void 23 + /** Close the onboarding modal */ 24 + closeModal: () => void 25 + /** Submit onboarding responses and refresh status */ 26 + submit: (responses: Array<{ fieldId: string; response: unknown }>) => Promise<boolean> 27 + /** Refresh onboarding status from the API */ 28 + refresh: () => Promise<void> 29 + } 30 + 31 + export function useOnboarding(): UseOnboardingResult { 32 + const [loading, setLoading] = useState(true) 33 + const [status, setStatus] = useState<OnboardingStatus | null>(null) 34 + const [showModal, setShowModal] = useState(false) 35 + 36 + const fetchStatus = useCallback(async () => { 37 + const token = localStorage.getItem('accessToken') 38 + if (!token) { 39 + setLoading(false) 40 + return 41 + } 42 + 43 + try { 44 + const result = await getOnboardingStatus(token) 45 + setStatus(result) 46 + } catch { 47 + // If fetch fails, assume no onboarding required 48 + setStatus({ complete: true, fields: [], responses: {}, missingFields: [] }) 49 + } finally { 50 + setLoading(false) 51 + } 52 + }, []) 53 + 54 + useEffect(() => { 55 + void fetchStatus() 56 + }, [fetchStatus]) 57 + 58 + const submit = useCallback( 59 + async (responses: Array<{ fieldId: string; response: unknown }>): Promise<boolean> => { 60 + const token = localStorage.getItem('accessToken') 61 + if (!token) return false 62 + 63 + try { 64 + await submitOnboarding({ responses }, token) 65 + await fetchStatus() 66 + setShowModal(false) 67 + return true 68 + } catch { 69 + return false 70 + } 71 + }, 72 + [fetchStatus] 73 + ) 74 + 75 + return { 76 + loading, 77 + complete: status?.complete ?? false, 78 + showModal, 79 + status, 80 + openModal: () => setShowModal(true), 81 + closeModal: () => setShowModal(false), 82 + submit, 83 + refresh: fetchStatus, 84 + } 85 + } 86 + 87 + /** Type label map for rendering in the modal */ 88 + export const ONBOARDING_FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 89 + age_confirmation: 'Age Confirmation', 90 + tos_acceptance: 'Terms of Service', 91 + newsletter_email: 'Newsletter Email', 92 + custom_text: 'Text', 93 + custom_select: 'Select', 94 + custom_checkbox: 'Checkbox', 95 + }
+95
src/lib/api/client.ts
··· 29 29 AdminUsersResponse, 30 30 MaturityRating, 31 31 PluginsResponse, 32 + OnboardingField, 33 + OnboardingFieldsResponse, 34 + CreateOnboardingFieldInput, 35 + UpdateOnboardingFieldInput, 36 + OnboardingStatus, 37 + SubmitOnboardingInput, 32 38 } from './types' 33 39 34 40 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' ··· 583 589 ...options, 584 590 method: 'DELETE', 585 591 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 592 + }) 593 + } 594 + 595 + // --- Admin onboarding field endpoints --- 596 + 597 + export function getOnboardingFields( 598 + accessToken: string, 599 + options?: FetchOptions 600 + ): Promise<OnboardingFieldsResponse> { 601 + return apiFetch<OnboardingFieldsResponse>('/api/admin/onboarding-fields', { 602 + ...options, 603 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 604 + }) 605 + } 606 + 607 + export function createOnboardingField( 608 + input: CreateOnboardingFieldInput, 609 + accessToken: string, 610 + options?: FetchOptions 611 + ): Promise<OnboardingField> { 612 + return apiFetch<OnboardingField>('/api/admin/onboarding-fields', { 613 + ...options, 614 + method: 'POST', 615 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 616 + body: input, 617 + }) 618 + } 619 + 620 + export function updateOnboardingField( 621 + id: string, 622 + input: UpdateOnboardingFieldInput, 623 + accessToken: string, 624 + options?: FetchOptions 625 + ): Promise<OnboardingField> { 626 + return apiFetch<OnboardingField>(`/api/admin/onboarding-fields/${encodeURIComponent(id)}`, { 627 + ...options, 628 + method: 'PUT', 629 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 630 + body: input, 631 + }) 632 + } 633 + 634 + export function deleteOnboardingField( 635 + id: string, 636 + accessToken: string, 637 + options?: FetchOptions 638 + ): Promise<void> { 639 + return apiFetch<void>(`/api/admin/onboarding-fields/${encodeURIComponent(id)}`, { 640 + ...options, 641 + method: 'DELETE', 642 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 643 + }) 644 + } 645 + 646 + export function reorderOnboardingFields( 647 + fields: Array<{ id: string; sortOrder: number }>, 648 + accessToken: string, 649 + options?: FetchOptions 650 + ): Promise<{ success: boolean }> { 651 + return apiFetch<{ success: boolean }>('/api/admin/onboarding-fields/reorder', { 652 + ...options, 653 + method: 'PUT', 654 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 655 + body: { fields }, 656 + }) 657 + } 658 + 659 + // --- User onboarding endpoints --- 660 + 661 + export function getOnboardingStatus( 662 + accessToken: string, 663 + options?: FetchOptions 664 + ): Promise<OnboardingStatus> { 665 + return apiFetch<OnboardingStatus>('/api/onboarding/status', { 666 + ...options, 667 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 668 + }) 669 + } 670 + 671 + export function submitOnboarding( 672 + input: SubmitOnboardingInput, 673 + accessToken: string, 674 + options?: FetchOptions 675 + ): Promise<{ success: boolean }> { 676 + return apiFetch<{ success: boolean }>('/api/onboarding/submit', { 677 + ...options, 678 + method: 'POST', 679 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 680 + body: input, 586 681 }) 587 682 } 588 683
+55
src/lib/api/types.ts
··· 412 412 declaredAge: number 413 413 } 414 414 415 + // --- Onboarding Fields --- 416 + 417 + export type OnboardingFieldType = 418 + | 'age_confirmation' 419 + | 'tos_acceptance' 420 + | 'newsletter_email' 421 + | 'custom_text' 422 + | 'custom_select' 423 + | 'custom_checkbox' 424 + 425 + export interface OnboardingField { 426 + id: string 427 + communityDid: string 428 + fieldType: OnboardingFieldType 429 + label: string 430 + description: string | null 431 + isMandatory: boolean 432 + sortOrder: number 433 + config: Record<string, unknown> | null 434 + createdAt: string 435 + updatedAt: string 436 + } 437 + 438 + export interface OnboardingFieldsResponse { 439 + fields: OnboardingField[] 440 + } 441 + 442 + export interface CreateOnboardingFieldInput { 443 + fieldType: OnboardingFieldType 444 + label: string 445 + description?: string 446 + isMandatory?: boolean 447 + sortOrder?: number 448 + config?: Record<string, unknown> 449 + } 450 + 451 + export interface UpdateOnboardingFieldInput { 452 + label?: string 453 + description?: string | null 454 + isMandatory?: boolean 455 + sortOrder?: number 456 + config?: Record<string, unknown> | null 457 + } 458 + 459 + export interface OnboardingStatus { 460 + complete: boolean 461 + fields: OnboardingField[] 462 + responses: Record<string, unknown> 463 + missingFields: Array<{ id: string; label: string; fieldType: OnboardingFieldType }> 464 + } 465 + 466 + export interface SubmitOnboardingInput { 467 + responses: Array<{ fieldId: string; response: unknown }> 468 + } 469 + 415 470 // --- Shared --- 416 471 417 472 export type MaturityRating = 'safe' | 'mature' | 'adult'
+30
src/mocks/data.ts
··· 20 20 CommunityStats, 21 21 Plugin, 22 22 UserPreferences, 23 + OnboardingField, 23 24 } from '@/lib/api/types' 24 25 25 26 const COMMUNITY_DID = 'did:plc:test-community-123' ··· 842 843 crossPostFrontpage: false, 843 844 updatedAt: NOW, 844 845 } 846 + 847 + // --- Onboarding Fields --- 848 + 849 + export const mockOnboardingFields: OnboardingField[] = [ 850 + { 851 + id: 'field-tos', 852 + communityDid: COMMUNITY_DID, 853 + fieldType: 'tos_acceptance', 854 + label: 'Terms of Service', 855 + description: 'You must accept our community rules to participate.', 856 + isMandatory: true, 857 + sortOrder: 0, 858 + config: { tosUrl: 'https://example.com/tos' }, 859 + createdAt: TWO_DAYS_AGO, 860 + updatedAt: TWO_DAYS_AGO, 861 + }, 862 + { 863 + id: 'field-intro', 864 + communityDid: COMMUNITY_DID, 865 + fieldType: 'custom_text', 866 + label: 'Introduce yourself', 867 + description: 'Tell us a bit about yourself and why you joined.', 868 + isMandatory: false, 869 + sortOrder: 1, 870 + config: null, 871 + createdAt: TWO_DAYS_AGO, 872 + updatedAt: TWO_DAYS_AGO, 873 + }, 874 + ]
+91
src/mocks/handlers.ts
··· 22 22 mockAdminUsers, 23 23 mockPlugins, 24 24 mockUserPreferences, 25 + mockOnboardingFields, 25 26 } from './data' 26 27 27 28 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' ··· 503 504 504 505 // DELETE /api/users/me/mute/:did 505 506 http.delete(`${API_URL}/api/users/me/mute/:did`, ({ request }) => { 507 + const auth = request.headers.get('Authorization') 508 + if (!auth?.startsWith('Bearer ')) { 509 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 510 + } 511 + return HttpResponse.json({ success: true }) 512 + }), 513 + 514 + // --- Onboarding field endpoints --- 515 + 516 + // GET /api/admin/onboarding-fields 517 + http.get(`${API_URL}/api/admin/onboarding-fields`, ({ request }) => { 518 + const auth = request.headers.get('Authorization') 519 + if (!auth?.startsWith('Bearer ')) { 520 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 521 + } 522 + return HttpResponse.json({ fields: mockOnboardingFields }) 523 + }), 524 + 525 + // POST /api/admin/onboarding-fields 526 + http.post(`${API_URL}/api/admin/onboarding-fields`, async ({ request }) => { 527 + const auth = request.headers.get('Authorization') 528 + if (!auth?.startsWith('Bearer ')) { 529 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 530 + } 531 + const body = (await request.json()) as Record<string, unknown> 532 + const now = new Date().toISOString() 533 + const newField = { 534 + id: `field-${Date.now()}`, 535 + communityDid: 'did:plc:test-community-123', 536 + fieldType: body.fieldType, 537 + label: body.label, 538 + description: body.description ?? null, 539 + isMandatory: body.isMandatory ?? true, 540 + sortOrder: body.sortOrder ?? 0, 541 + config: body.config ?? null, 542 + createdAt: now, 543 + updatedAt: now, 544 + } 545 + return HttpResponse.json(newField, { status: 201 }) 546 + }), 547 + 548 + // PUT /api/admin/onboarding-fields/:id 549 + http.put(`${API_URL}/api/admin/onboarding-fields/:id`, async ({ request, params }) => { 550 + const auth = request.headers.get('Authorization') 551 + if (!auth?.startsWith('Bearer ')) { 552 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 553 + } 554 + const id = params['id'] as string 555 + const existing = mockOnboardingFields.find((f) => f.id === id) 556 + if (!existing) { 557 + return HttpResponse.json({ error: 'Not found' }, { status: 404 }) 558 + } 559 + const body = (await request.json()) as Record<string, unknown> 560 + return HttpResponse.json({ ...existing, ...body, updatedAt: new Date().toISOString() }) 561 + }), 562 + 563 + // DELETE /api/admin/onboarding-fields/:id 564 + http.delete(`${API_URL}/api/admin/onboarding-fields/:id`, ({ request }) => { 565 + const auth = request.headers.get('Authorization') 566 + if (!auth?.startsWith('Bearer ')) { 567 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 568 + } 569 + return new HttpResponse(null, { status: 204 }) 570 + }), 571 + 572 + // PUT /api/admin/onboarding-fields/reorder 573 + http.put(`${API_URL}/api/admin/onboarding-fields/reorder`, async ({ request }) => { 574 + const auth = request.headers.get('Authorization') 575 + if (!auth?.startsWith('Bearer ')) { 576 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 577 + } 578 + return HttpResponse.json({ success: true }) 579 + }), 580 + 581 + // GET /api/onboarding/status 582 + http.get(`${API_URL}/api/onboarding/status`, ({ request }) => { 583 + const auth = request.headers.get('Authorization') 584 + if (!auth?.startsWith('Bearer ')) { 585 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 586 + } 587 + return HttpResponse.json({ 588 + complete: true, 589 + fields: mockOnboardingFields, 590 + responses: {}, 591 + missingFields: [], 592 + }) 593 + }), 594 + 595 + // POST /api/onboarding/submit 596 + http.post(`${API_URL}/api/onboarding/submit`, async ({ request }) => { 506 597 const auth = request.headers.get('Authorization') 507 598 if (!auth?.startsWith('Bearer ')) { 508 599 return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })