Barazo default frontend barazo.forum
2
fork

Configure Feed

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

Merge pull request #20 from barazo-forum/feat/p2.3-age-declaration-revision

feat: P2.3 age declaration revision -- age bracket dropdown + type updates

authored by

Guido X Jansen and committed by
GitHub
4ff755ee 6421aabf

+126 -31
+6 -6
src/app/settings/page.tsx
··· 41 41 const [loading, setLoading] = useState(true) 42 42 const [error, setError] = useState<string | null>(null) 43 43 const [success, setSuccess] = useState(false) 44 - const [ageDeclarationAt, setAgeDeclarationAt] = useState<string | null>(null) 44 + const [declaredAge, setDeclaredAge] = useState<number | null>(null) 45 45 const [showAgeGate, setShowAgeGate] = useState(false) 46 46 47 47 // Load preferences on mount ··· 63 63 notifyMentions: true, 64 64 notifyReactions: false, 65 65 }) 66 - setAgeDeclarationAt(prefs.ageDeclarationAt) 66 + setDeclaredAge(prefs.declaredAge) 67 67 }) 68 68 .catch(() => setError('Failed to load preferences')) 69 69 .finally(() => setLoading(false)) ··· 84 84 } 85 85 86 86 // If switching to mature and no age declaration, show age gate 87 - if (values.maturityLevel === 'sfw-mature' && !ageDeclarationAt) { 87 + if (values.maturityLevel === 'sfw-mature' && !declaredAge) { 88 88 setShowAgeGate(true) 89 89 setSaving(false) 90 90 return ··· 112 112 setSaving(false) 113 113 } 114 114 }, 115 - [values, ageDeclarationAt] 115 + [values, declaredAge] 116 116 ) 117 117 118 118 return ( ··· 282 282 283 283 <AgeGateDialog 284 284 open={showAgeGate} 285 - onConfirm={(ageAt) => { 286 - setAgeDeclarationAt(ageAt) 285 + onConfirm={(age) => { 286 + setDeclaredAge(age) 287 287 setShowAgeGate(false) 288 288 // Re-trigger save now that age is declared 289 289 void handleSave({ preventDefault: () => {} } as React.FormEvent)
+50 -8
src/components/age-gate-dialog.test.tsx
··· 39 39 expect(container.innerHTML).toBe('') 40 40 }) 41 41 42 - it('renders dialog when open', () => { 42 + it('renders dialog with dropdown when open', () => { 43 43 render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 44 - expect(screen.getByText('Age Confirmation Required')).toBeInTheDocument() 45 - expect(screen.getByText('I confirm I am 16 or older')).toBeInTheDocument() 44 + expect(screen.getByText('Age Declaration')).toBeInTheDocument() 45 + expect(screen.getByLabelText('Your age bracket')).toBeInTheDocument() 46 46 expect(screen.getByText('Cancel')).toBeInTheDocument() 47 + expect(screen.getByText('Confirm')).toBeInTheDocument() 47 48 }) 48 49 49 50 it('has correct ARIA attributes', () => { ··· 63 64 expect(onCancel).toHaveBeenCalledOnce() 64 65 }) 65 66 66 - it('calls onConfirm with timestamp when confirmed', async () => { 67 + it('shows all age bracket options in dropdown', () => { 68 + render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 69 + const select = screen.getByLabelText('Your age bracket') as HTMLSelectElement 70 + const options = Array.from(select.options).map((o) => o.text) 71 + 72 + expect(options).toContain('Select age bracket...') 73 + expect(options).toContain('Rather not say') 74 + expect(options).toContain('13+') 75 + expect(options).toContain('14+') 76 + expect(options).toContain('15+') 77 + expect(options).toContain('16+') 78 + expect(options).toContain('18+') 79 + }) 80 + 81 + it('disables Confirm button when no age selected', () => { 82 + render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 83 + const confirmBtn = screen.getByText('Confirm') 84 + expect(confirmBtn).toBeDisabled() 85 + }) 86 + 87 + it('enables Confirm button when age is selected', async () => { 88 + render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 89 + 90 + const user = userEvent.setup() 91 + await user.selectOptions(screen.getByLabelText('Your age bracket'), '16') 92 + 93 + expect(screen.getByText('Confirm')).not.toBeDisabled() 94 + }) 95 + 96 + it('calls onConfirm with declaredAge when confirmed', async () => { 67 97 const onConfirm = vi.fn() 68 98 render(<AgeGateDialog open={true} onConfirm={onConfirm} onCancel={vi.fn()} />) 69 99 70 100 const user = userEvent.setup() 71 - await user.click(screen.getByText('I confirm I am 16 or older')) 101 + await user.selectOptions(screen.getByLabelText('Your age bracket'), '16') 102 + await user.click(screen.getByText('Confirm')) 72 103 73 104 await waitFor(() => { 74 105 expect(onConfirm).toHaveBeenCalledOnce() 75 106 }) 76 - // The mock handler returns an ageDeclarationAt string 77 - expect(onConfirm).toHaveBeenCalledWith(expect.any(String)) 107 + expect(onConfirm).toHaveBeenCalledWith(16) 108 + }) 109 + 110 + it('shows "Rather not say" explanation when 0 is selected', async () => { 111 + render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 112 + 113 + const user = userEvent.setup() 114 + await user.selectOptions(screen.getByLabelText('Your age bracket'), '0') 115 + 116 + expect( 117 + screen.getByText(/Rather not say.*means you will only see Safe content/) 118 + ).toBeInTheDocument() 78 119 }) 79 120 80 121 it('shows error when not authenticated', async () => { ··· 82 123 render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 83 124 84 125 const user = userEvent.setup() 85 - await user.click(screen.getByText('I confirm I am 16 or older')) 126 + await user.selectOptions(screen.getByLabelText('Your age bracket'), '16') 127 + await user.click(screen.getByText('Confirm')) 86 128 87 129 await waitFor(() => { 88 130 expect(screen.getByRole('alert')).toHaveTextContent('Not authenticated')
+55 -11
src/components/age-gate-dialog.tsx
··· 1 1 /** 2 - * Age gate confirmation dialog. 2 + * Age gate dialog with age bracket dropdown. 3 3 * Shown when user tries to enable Mature content without prior age declaration. 4 - * Calls POST /api/users/me/age-declaration on confirm. 4 + * Calls POST /api/users/me/age-declaration on confirm with selected age bracket. 5 5 * @see decisions/features-and-ux.md "Content Maturity & User Safety" 6 6 */ 7 7 ··· 11 11 import { cn } from '@/lib/utils' 12 12 import { declareAge } from '@/lib/api/client' 13 13 14 + /** Valid age bracket options. 0 = "Rather not say". */ 15 + const AGE_OPTIONS = [ 16 + { value: 0, label: 'Rather not say' }, 17 + { value: 13, label: '13+' }, 18 + { value: 14, label: '14+' }, 19 + { value: 15, label: '15+' }, 20 + { value: 16, label: '16+' }, 21 + { value: 18, label: '18+' }, 22 + ] as const 23 + 14 24 interface AgeGateDialogProps { 15 25 open: boolean 16 - onConfirm: (ageDeclarationAt: string) => void 26 + onConfirm: (declaredAge: number) => void 17 27 onCancel: () => void 18 28 } 19 29 20 30 export function AgeGateDialog({ open, onConfirm, onCancel }: AgeGateDialogProps) { 31 + const [selectedAge, setSelectedAge] = useState<number | null>(null) 21 32 const [confirming, setConfirming] = useState(false) 22 33 const [error, setError] = useState<string | null>(null) 23 34 24 35 if (!open) return null 25 36 26 37 const handleConfirm = async () => { 38 + if (selectedAge === null) { 39 + setError('Please select your age bracket') 40 + return 41 + } 42 + 27 43 setConfirming(true) 28 44 setError(null) 29 45 ··· 35 51 } 36 52 37 53 try { 38 - const result = await declareAge(token) 39 - onConfirm(result.ageDeclarationAt) 54 + const result = await declareAge(selectedAge, token) 55 + onConfirm(result.declaredAge) 40 56 } catch { 41 - setError('Failed to confirm age') 57 + setError('Failed to save age declaration') 42 58 } finally { 43 59 setConfirming(false) 44 60 } ··· 53 69 > 54 70 <div className="mx-4 w-full max-w-md rounded-lg bg-background p-6 shadow-lg"> 55 71 <h2 id="age-gate-title" className="text-lg font-semibold text-foreground"> 56 - Age Confirmation Required 72 + Age Declaration 57 73 </h2> 58 74 <p className="mt-2 text-sm text-muted-foreground"> 59 - To view Mature content, you must confirm that you are at least 16 years old. This is a 60 - one-time declaration stored with your account. 75 + To access Mature content, please select your age bracket. This determines which content is 76 + available to you based on this community&apos;s settings. 61 77 </p> 62 78 <p className="mt-2 text-sm text-muted-foreground"> 63 79 Mature content may include strong language, graphic descriptions, and sensitive topics 64 80 (politics, drugs, violence). It does not include explicit sexual content. 65 81 </p> 66 82 83 + <div className="mt-4"> 84 + <label htmlFor="age-select" className="block text-sm font-medium text-foreground"> 85 + Your age bracket 86 + </label> 87 + <select 88 + id="age-select" 89 + value={selectedAge ?? ''} 90 + onChange={(e) => setSelectedAge(e.target.value === '' ? null : Number(e.target.value))} 91 + className={cn( 92 + 'mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 93 + 'focus:outline-none focus:ring-2 focus:ring-ring' 94 + )} 95 + > 96 + <option value="">Select age bracket...</option> 97 + {AGE_OPTIONS.map((opt) => ( 98 + <option key={opt.value} value={opt.value}> 99 + {opt.label} 100 + </option> 101 + ))} 102 + </select> 103 + </div> 104 + 105 + {selectedAge === 0 && ( 106 + <p className="mt-2 text-sm text-muted-foreground"> 107 + Choosing &quot;Rather not say&quot; means you will only see Safe content. 108 + </p> 109 + )} 110 + 67 111 {error && ( 68 112 <p className="mt-2 text-sm text-destructive" role="alert"> 69 113 {error} ··· 84 128 <button 85 129 type="button" 86 130 onClick={handleConfirm} 87 - disabled={confirming} 131 + disabled={confirming || selectedAge === null} 88 132 className={cn( 89 133 'rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground', 90 134 'hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 91 135 'disabled:cursor-not-allowed disabled:opacity-50' 92 136 )} 93 137 > 94 - {confirming ? 'Confirming...' : 'I confirm I am 16 or older'} 138 + {confirming ? 'Saving...' : 'Confirm'} 95 139 </button> 96 140 </div> 97 141 </div>
+2 -1
src/lib/api/client.ts
··· 524 524 } 525 525 526 526 export function declareAge( 527 + declaredAge: number, 527 528 accessToken: string, 528 529 options?: FetchOptions 529 530 ): Promise<AgeDeclarationResponse> { ··· 531 532 ...options, 532 533 method: 'POST', 533 534 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 534 - body: { confirm: true }, 535 + body: { declaredAge }, 535 536 }) 536 537 } 537 538
+5 -2
src/lib/api/types.ts
··· 156 156 communityLogoUrl: string | null 157 157 primaryColor: string | null 158 158 accentColor: string | null 159 + jurisdictionCountry: string | null 160 + ageThreshold: number 161 + requireLoginForMature: boolean 159 162 createdAt: string 160 163 updatedAt: string 161 164 } ··· 386 389 387 390 export interface UserPreferences { 388 391 maturityLevel: 'sfw' | 'mature' 389 - ageDeclarationAt: string | null 392 + declaredAge: number | null 390 393 mutedWords: string[] 391 394 blockedDids: string[] 392 395 mutedDids: string[] ··· 406 409 407 410 export interface AgeDeclarationResponse { 408 411 success: boolean 409 - ageDeclarationAt: string 412 + declaredAge: number 410 413 } 411 414 412 415 // --- Shared ---
+4 -1
src/mocks/data.ts
··· 434 434 communityLogoUrl: null, 435 435 primaryColor: '#31748f', 436 436 accentColor: '#c4a7e7', 437 + jurisdictionCountry: null, 438 + ageThreshold: 16, 439 + requireLoginForMature: true, 437 440 createdAt: TWO_DAYS_AGO, 438 441 updatedAt: NOW, 439 442 } ··· 831 834 832 835 export const mockUserPreferences: UserPreferences = { 833 836 maturityLevel: 'sfw', 834 - ageDeclarationAt: null, 837 + declaredAge: null, 835 838 mutedWords: ['spam', 'offensive'], 836 839 blockedDids: [], 837 840 mutedDids: [],
+4 -2
src/mocks/handlers.ts
··· 461 461 }), 462 462 463 463 // POST /api/users/me/age-declaration 464 - http.post(`${API_URL}/api/users/me/age-declaration`, ({ request }) => { 464 + http.post(`${API_URL}/api/users/me/age-declaration`, async ({ request }) => { 465 465 const auth = request.headers.get('Authorization') 466 466 if (!auth?.startsWith('Bearer ')) { 467 467 return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 468 468 } 469 + const body = (await request.json()) as { declaredAge?: number } 470 + const declaredAge = body.declaredAge ?? 0 469 471 return HttpResponse.json({ 470 472 success: true, 471 - ageDeclarationAt: new Date().toISOString(), 473 + declaredAge, 472 474 }) 473 475 }), 474 476