From bb325c6549f10cb5cdc10742fdc745603123593b Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Tue, 3 Mar 2026 14:11:34 +0100 Subject: [PATCH] feat(onboarding): add global OnboardingProvider to gate all write actions (#139) Create a global onboarding context that any write action (likes, replies, new topics) can use to check onboarding completion before proceeding. Previously only the New Topic page had this gate, causing 403 errors when unboarded users tried to like or reply. - Add OnboardingProvider context with ensureOnboarded() fail-open gate - Add shared test utility (mock-onboarding.tsx) - Gate LikeButton.handleToggle with ensureOnboarded() - Gate ReplyComposer.handleSubmit with ensureOnboarded() - Migrate New Topic page from standalone hook to context - Wire OnboardingProvider into layout.tsx provider stack - Add onboarding context mocks to transitive test files --- src/app/layout.tsx | 5 +- src/app/new/page.test.tsx | 5 + src/app/new/page.tsx | 42 +-- src/components/like-button.test.tsx | 46 +++ src/components/like-button.tsx | 5 +- src/components/reply-composer.test.tsx | 62 ++++ src/components/reply-composer.tsx | 5 +- src/components/topic-detail-client.test.tsx | 5 + src/context/onboarding-context.test.tsx | 370 ++++++++++++++++++++ src/context/onboarding-context.tsx | 144 ++++++++ src/test/mock-onboarding.tsx | 63 ++++ 11 files changed, 713 insertions(+), 39 deletions(-) create mode 100644 src/context/onboarding-context.test.tsx create mode 100644 src/context/onboarding-context.tsx create mode 100644 src/test/mock-onboarding.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 617a688..c5c9ea5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import './globals.css' import { ThemeProvider } from '@/components/theme-provider' import { AuthProvider } from '@/context/auth-context' import { AppToastProvider } from '@/context/toast-context' +import { OnboardingProvider } from '@/context/onboarding-context' import { SetupGuard } from '@/components/setup-guard' const sourceCodePro = Source_Code_Pro({ @@ -55,7 +56,9 @@ export default function RootLayout({ > - {children} + + {children} + diff --git a/src/app/new/page.test.tsx b/src/app/new/page.test.tsx index e34656b..e97ee95 100644 --- a/src/app/new/page.test.tsx +++ b/src/app/new/page.test.tsx @@ -6,8 +6,13 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } import { render, screen, cleanup } from '@testing-library/react' import { setupServer } from 'msw/node' import { handlers } from '@/mocks/handlers' +import { createMockOnboardingContext } from '@/test/mock-onboarding' import NewTopicPage from './page' +vi.mock('@/context/onboarding-context', () => ({ + useOnboardingContext: () => createMockOnboardingContext(), +})) + const server = setupServer(...handlers) const mockStorage: Record = {} diff --git a/src/app/new/page.tsx b/src/app/new/page.tsx index 389d820..fd2774f 100644 --- a/src/app/new/page.tsx +++ b/src/app/new/page.tsx @@ -7,7 +7,7 @@ 'use client' -import { useState, useRef, useEffect } from 'react' +import { useState, useEffect } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import type { CreateTopicInput } from '@/lib/api/types' import { createTopic, getPublicSettings } from '@/lib/api/client' @@ -15,8 +15,7 @@ import { getTopicUrl } from '@/lib/format' import { ForumLayout } from '@/components/layout/forum-layout' import { Breadcrumbs } from '@/components/breadcrumbs' import { TopicForm } from '@/components/topic-form' -import { OnboardingModal } from '@/components/onboarding-modal' -import { useOnboarding } from '@/hooks/use-onboarding' +import { useOnboardingContext } from '@/context/onboarding-context' import { useAuth } from '@/hooks/use-auth' export default function NewTopicPage() { @@ -24,11 +23,10 @@ export default function NewTopicPage() { const searchParams = useSearchParams() const initialCategory = searchParams.get('category') ?? '' const { getAccessToken } = useAuth() + const { ensureOnboarded } = useOnboardingContext() const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [communityName, setCommunityName] = useState('') - const onboarding = useOnboarding() - const pendingValues = useRef(null) useEffect(() => { getPublicSettings() @@ -36,7 +34,9 @@ export default function NewTopicPage() { .catch(() => {}) }, []) - const doSubmit = async (values: CreateTopicInput) => { + const handleSubmit = async (values: CreateTopicInput) => { + if (!ensureOnboarded()) return + setSubmitting(true) setError(null) @@ -50,26 +50,6 @@ export default function NewTopicPage() { } } - const handleSubmit = async (values: CreateTopicInput) => { - if (!onboarding.loading && !onboarding.complete) { - pendingValues.current = values - onboarding.openModal() - return - } - await doSubmit(values) - } - - const handleOnboardingComplete = async ( - responses: Array<{ fieldId: string; response: unknown }> - ) => { - const success = await onboarding.submit(responses) - if (success && pendingValues.current) { - await doSubmit(pendingValues.current) - pendingValues.current = null - } - return success - } - return (
@@ -91,16 +71,6 @@ export default function NewTopicPage() { submitting={submitting} initialValues={{ category: initialCategory }} /> - - { - onboarding.closeModal() - pendingValues.current = null - }} - />
) diff --git a/src/components/like-button.test.tsx b/src/components/like-button.test.tsx index caefc12..57218da 100644 --- a/src/components/like-button.test.tsx +++ b/src/components/like-button.test.tsx @@ -8,6 +8,8 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { LikeButton } from './like-button' +import { createMockOnboardingContext } from '@/test/mock-onboarding' +import type { OnboardingContextValue } from '@/context/onboarding-context' // --- Mocks --- @@ -35,6 +37,12 @@ vi.mock('@/hooks/use-auth', () => ({ }), })) +let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext() + +vi.mock('@/context/onboarding-context', () => ({ + useOnboardingContext: () => mockOnboardingContext, +})) + vi.mock('@/lib/api/client', () => ({ getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), createReaction: vi.fn().mockResolvedValue({ @@ -62,6 +70,7 @@ beforeEach(() => { mockToast.mockReset() mockGetAccessToken.mockReturnValue('mock-access-token') mockAuthFetch.mockReset() + mockOnboardingContext = createMockOnboardingContext() vi.mocked(getReactions).mockResolvedValue({ reactions: [], cursor: null }) vi.mocked(createReaction).mockResolvedValue({ uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/abc123', @@ -355,6 +364,43 @@ describe('LikeButton', () => { }) }) + describe('onboarding gate', () => { + it('does not call createReaction when ensureOnboarded returns false', async () => { + mockOnboardingContext = createMockOnboardingContext({ + ensureOnboarded: vi.fn(() => false), + }) + + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(getReactions).toHaveBeenCalled() + }) + + await user.click(screen.getByRole('button')) + + expect(createReaction).not.toHaveBeenCalled() + expect(deleteReaction).not.toHaveBeenCalled() + }) + + it('proceeds normally when ensureOnboarded returns true', async () => { + mockOnboardingContext = createMockOnboardingContext({ + ensureOnboarded: vi.fn(() => true), + }) + + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(getReactions).toHaveBeenCalled() + }) + + await user.click(screen.getByRole('button')) + + expect(createReaction).toHaveBeenCalled() + }) + }) + describe('size variants', () => { it('renders with default size', () => { render() diff --git a/src/components/like-button.tsx b/src/components/like-button.tsx index 56b7b45..93a8ac7 100644 --- a/src/components/like-button.tsx +++ b/src/components/like-button.tsx @@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { Heart } from '@phosphor-icons/react' import { useAuth } from '@/hooks/use-auth' +import { useOnboardingContext } from '@/context/onboarding-context' import { useToast } from '@/hooks/use-toast' import { getReactions, createReaction, deleteReaction } from '@/lib/api/client' import { cn } from '@/lib/utils' @@ -30,6 +31,7 @@ export function LikeButton({ className, }: LikeButtonProps) { const { user, isAuthenticated, getAccessToken } = useAuth() + const { ensureOnboarded } = useOnboardingContext() const { toast } = useToast() const [liked, setLiked] = useState(false) const [count, setCount] = useState(initialCount) @@ -73,6 +75,7 @@ export function LikeButton({ }, [subjectUri, isAuthenticated, user, getAccessToken]) const handleToggle = useCallback(async () => { + if (!ensureOnboarded()) return const token = getAccessToken() if (!token || pending) return @@ -109,7 +112,7 @@ export function LikeButton({ } finally { setPending(false) } - }, [liked, count, pending, subjectUri, subjectCid, getAccessToken, toast]) + }, [liked, count, pending, subjectUri, subjectCid, getAccessToken, ensureOnboarded, toast]) const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4' diff --git a/src/components/reply-composer.test.tsx b/src/components/reply-composer.test.tsx index 6c3bb76..6f479ea 100644 --- a/src/components/reply-composer.test.tsx +++ b/src/components/reply-composer.test.tsx @@ -8,6 +8,8 @@ import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { ReplyComposer } from './reply-composer' import type { ReplyTarget } from './reply-composer' +import { createMockOnboardingContext } from '@/test/mock-onboarding' +import type { OnboardingContextValue } from '@/context/onboarding-context' const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') const mockToast = vi.fn() @@ -43,6 +45,12 @@ vi.mock('@/lib/api/client', () => ({ createReply: (...args: unknown[]) => mockCreateReply(...args), })) +let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext() + +vi.mock('@/context/onboarding-context', () => ({ + useOnboardingContext: () => mockOnboardingContext, +})) + const defaultProps = { topicUri: 'at://did:plc:abc/forum.barazo.topic/123', topicCid: 'bafyreiabc123', @@ -59,6 +67,7 @@ const mockReplyTarget: ReplyTarget = { beforeEach(() => { vi.clearAllMocks() + mockOnboardingContext = createMockOnboardingContext() mockCreateReply.mockResolvedValue({ uri: 'at://did:plc:user-test-001/forum.barazo.reply/789', cid: 'bafyrei789', @@ -328,6 +337,59 @@ describe('ReplyComposer', () => { }) }) + describe('onboarding gate', () => { + it('does not call createReply when ensureOnboarded returns false', async () => { + mockOnboardingContext = createMockOnboardingContext({ + ensureOnboarded: vi.fn(() => false), + }) + + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Write a reply...')) + const textarea = screen.getByRole('textbox', { name: 'Reply' }) + await user.type(textarea, 'My test reply') + await user.click(screen.getByRole('button', { name: 'Reply' })) + + expect(mockCreateReply).not.toHaveBeenCalled() + }) + + it('preserves content when ensureOnboarded returns false', async () => { + mockOnboardingContext = createMockOnboardingContext({ + ensureOnboarded: vi.fn(() => false), + }) + + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Write a reply...')) + const textarea = screen.getByRole('textbox', { name: 'Reply' }) + await user.type(textarea, 'My test reply') + await user.click(screen.getByRole('button', { name: 'Reply' })) + + // Content should still be there + expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue('My test reply') + }) + + it('proceeds normally when ensureOnboarded returns true', async () => { + mockOnboardingContext = createMockOnboardingContext({ + ensureOnboarded: vi.fn(() => true), + }) + + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Write a reply...')) + const textarea = screen.getByRole('textbox', { name: 'Reply' }) + await user.type(textarea, 'My test reply') + await user.click(screen.getByRole('button', { name: 'Reply' })) + + await waitFor(() => { + expect(mockCreateReply).toHaveBeenCalled() + }) + }) + }) + describe('initialContent', () => { it('populates textarea with initialContent and auto-expands', () => { const initialText = '> quoted text\n\n' diff --git a/src/components/reply-composer.tsx b/src/components/reply-composer.tsx index ceda3db..0535e1e 100644 --- a/src/components/reply-composer.tsx +++ b/src/components/reply-composer.tsx @@ -9,6 +9,7 @@ import { useState, useCallback, useRef, useEffect, useImperativeHandle, forwardRef } from 'react' import { PaperPlaneRight, X, Lock } from '@phosphor-icons/react' import { useAuth } from '@/hooks/use-auth' +import { useOnboardingContext } from '@/context/onboarding-context' import { useToast } from '@/hooks/use-toast' import { createReply } from '@/lib/api/client' import { MarkdownEditor } from '@/components/markdown-editor' @@ -53,6 +54,7 @@ export const ReplyComposer = forwardRef ref ) { const { getAccessToken } = useAuth() + const { ensureOnboarded } = useOnboardingContext() const { toast } = useToast() const [isExpanded, setIsExpanded] = useState(false) const [content, setContent] = useState(initialContent) @@ -124,6 +126,7 @@ export const ReplyComposer = forwardRef }, []) const handleSubmit = useCallback(async () => { + if (!ensureOnboarded()) return const trimmed = content.trim() if (!trimmed) return @@ -148,7 +151,7 @@ export const ReplyComposer = forwardRef } finally { setSubmitting(false) } - }, [content, topicUri, replyTarget, getAccessToken, onReplyCreated, toast]) + }, [content, topicUri, replyTarget, getAccessToken, ensureOnboarded, onReplyCreated, toast]) if (isLocked) { return ( diff --git a/src/components/topic-detail-client.test.tsx b/src/components/topic-detail-client.test.tsx index 2766f28..c02d3c5 100644 --- a/src/components/topic-detail-client.test.tsx +++ b/src/components/topic-detail-client.test.tsx @@ -8,6 +8,11 @@ import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { TopicDetailClient } from './topic-detail-client' import { mockTopics, mockReplies } from '@/mocks/data' +import { createMockOnboardingContext } from '@/test/mock-onboarding' + +vi.mock('@/context/onboarding-context', () => ({ + useOnboardingContext: () => createMockOnboardingContext(), +})) const mockRouterRefresh = vi.fn() diff --git a/src/context/onboarding-context.test.tsx b/src/context/onboarding-context.test.tsx new file mode 100644 index 0000000..02dfe87 --- /dev/null +++ b/src/context/onboarding-context.test.tsx @@ -0,0 +1,370 @@ +/** + * Tests for OnboardingProvider context. + * Validates onboarding status fetching, ensureOnboarded gate logic, + * modal rendering, and submission flow. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { OnboardingProvider, useOnboardingContext } from './onboarding-context' + +// --- Mocks --- + +const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') +let mockIsAuthenticated = true +let mockAuthLoading = false + +vi.mock('@/hooks/use-auth', () => ({ + useAuth: () => ({ + user: mockIsAuthenticated ? { did: 'did:plc:user-test-001', handle: 'test.bsky.social' } : null, + isAuthenticated: mockIsAuthenticated, + isLoading: mockAuthLoading, + getAccessToken: mockGetAccessToken, + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + authFetch: vi.fn(), + crossPostScopesGranted: false, + requestCrossPostAuth: vi.fn(), + }), +})) + +const mockGetOnboardingStatus = vi.fn() +const mockSubmitOnboarding = vi.fn() + +vi.mock('@/lib/api/client', () => ({ + getOnboardingStatus: (...args: unknown[]) => mockGetOnboardingStatus(...args), + submitOnboarding: (...args: unknown[]) => mockSubmitOnboarding(...args), +})) + +const completeStatus = { + complete: true, + fields: [], + responses: {}, + missingFields: [], +} + +const incompleteStatus = { + complete: false, + fields: [ + { + id: 'age-field', + communityDid: 'did:plc:community-001', + fieldType: 'age_confirmation', + label: 'Confirm your age', + description: null, + isMandatory: true, + sortOrder: 0, + config: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + responses: {}, + missingFields: [{ id: 'age-field', label: 'Confirm your age', fieldType: 'age_confirmation' }], +} + +beforeEach(() => { + vi.clearAllMocks() + mockIsAuthenticated = true + mockAuthLoading = false + mockGetAccessToken.mockReturnValue('mock-access-token') + mockGetOnboardingStatus.mockResolvedValue(completeStatus) + mockSubmitOnboarding.mockResolvedValue({ success: true }) +}) + +/** Test consumer that exposes context values */ +function TestConsumer() { + const ctx = useOnboardingContext() + return ( +
+ {String(ctx.loading)} + {String(ctx.complete)} + {String(ctx.showModal)} + + +
+ ) +} + +function renderWithProvider() { + return render( + + + + ) +} + +describe('OnboardingProvider', () => { + describe('rendering', () => { + it('renders children', () => { + render( + + child content + + ) + expect(screen.getByText('child content')).toBeInTheDocument() + }) + }) + + describe('fetching status', () => { + it('fetches onboarding status when authenticated', async () => { + renderWithProvider() + + await waitFor(() => { + expect(mockGetOnboardingStatus).toHaveBeenCalledWith('mock-access-token') + }) + }) + + it('does not fetch status when unauthenticated', async () => { + mockIsAuthenticated = false + mockGetAccessToken.mockReturnValue(null) + + renderWithProvider() + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)) + }) + + expect(mockGetOnboardingStatus).not.toHaveBeenCalled() + }) + + it('does not fetch status while auth is loading', async () => { + mockAuthLoading = true + + renderWithProvider() + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)) + }) + + expect(mockGetOnboardingStatus).not.toHaveBeenCalled() + }) + + it('sets complete to true when status.complete is true', async () => { + mockGetOnboardingStatus.mockResolvedValue(completeStatus) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('complete')).toHaveTextContent('true') + }) + }) + + it('sets complete to false when status.complete is false', async () => { + mockGetOnboardingStatus.mockResolvedValue(incompleteStatus) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('complete')).toHaveTextContent('false') + }) + }) + + it('sets loading to false after fetch completes', async () => { + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + }) + }) + }) + + describe('ensureOnboarded', () => { + it('returns true when onboarding is complete', async () => { + mockGetOnboardingStatus.mockResolvedValue(completeStatus) + + let result: boolean | undefined + function Consumer() { + const ctx = useOnboardingContext() + return ( + + ) + } + + render( + + + + ) + + await waitFor(() => { + expect(mockGetOnboardingStatus).toHaveBeenCalled() + }) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Check' })) + + expect(result).toBe(true) + }) + + it('returns true when loading (fail-open)', async () => { + mockGetOnboardingStatus.mockImplementation(() => new Promise(() => {})) + + let result: boolean | undefined + function Consumer() { + const ctx = useOnboardingContext() + return ( + + ) + } + + render( + + + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Check' })) + + expect(result).toBe(true) + }) + + it('returns true when unauthenticated (fail-open)', async () => { + mockIsAuthenticated = false + mockGetAccessToken.mockReturnValue(null) + + let result: boolean | undefined + function Consumer() { + const ctx = useOnboardingContext() + return ( + + ) + } + + render( + + + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Check' })) + + expect(result).toBe(true) + }) + + it('returns false and opens modal when incomplete', async () => { + mockGetOnboardingStatus.mockResolvedValue(incompleteStatus) + + let result: boolean | undefined + function Consumer() { + const ctx = useOnboardingContext() + return ( + <> + {String(ctx.showModal)} + + + ) + } + + render( + + + + ) + + await waitFor(() => { + expect(mockGetOnboardingStatus).toHaveBeenCalled() + }) + + await act(async () => { + await new Promise((r) => setTimeout(r, 10)) + }) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Check' })) + + expect(result).toBe(false) + expect(screen.getByTestId('modal-state')).toHaveTextContent('true') + }) + }) + + describe('submit', () => { + it('calls API, refreshes status, and closes modal on success', async () => { + mockGetOnboardingStatus + .mockResolvedValueOnce(incompleteStatus) + .mockResolvedValueOnce(completeStatus) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('complete')).toHaveTextContent('false') + }) + + const user = userEvent.setup() + await user.click(screen.getByTestId('ensure-btn')) + + expect(screen.getByTestId('showModal')).toHaveTextContent('true') + + await user.click(screen.getByTestId('submit-btn')) + + await waitFor(() => { + expect(mockSubmitOnboarding).toHaveBeenCalledWith( + { responses: [{ fieldId: 'age-field', response: true }] }, + 'mock-access-token' + ) + }) + + await waitFor(() => { + expect(screen.getByTestId('complete')).toHaveTextContent('true') + expect(screen.getByTestId('showModal')).toHaveTextContent('false') + }) + }) + }) + + describe('useOnboardingContext outside provider', () => { + it('throws when used outside OnboardingProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + function BadConsumer() { + useOnboardingContext() + return null + } + + expect(() => render()).toThrow( + 'useOnboardingContext must be used within an OnboardingProvider' + ) + + consoleSpy.mockRestore() + }) + }) +}) diff --git a/src/context/onboarding-context.tsx b/src/context/onboarding-context.tsx new file mode 100644 index 0000000..3eb7ce8 --- /dev/null +++ b/src/context/onboarding-context.tsx @@ -0,0 +1,144 @@ +/** + * Global onboarding context provider. + * Fetches onboarding status on mount (when authenticated) and provides + * ensureOnboarded() for any write action to gate behind onboarding completion. + * Renders the OnboardingModal globally so individual pages don't need to. + * @see specs/prd-web.md Section M5 (Onboarding) + */ + +'use client' + +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import type { OnboardingStatus } from '@/lib/api/types' +import { getOnboardingStatus, submitOnboarding } from '@/lib/api/client' +import { useAuth } from '@/hooks/use-auth' +import { OnboardingModal } from '@/components/onboarding-modal' + +export interface OnboardingContextValue { + /** Whether onboarding status is still loading */ + loading: boolean + /** Whether onboarding is complete (false when status is null/loading) */ + complete: boolean + /** Whether the onboarding modal is currently shown */ + showModal: boolean + /** Full onboarding status from the API */ + status: OnboardingStatus | null + /** + * Gate function for write actions. Returns true if the user may proceed. + * Fail-open: returns true when loading, unauthenticated, or status is null. + * Returns false and opens the modal when onboarding is incomplete. + */ + ensureOnboarded: () => boolean + /** Submit onboarding responses, refresh status, close modal on success */ + submit: (responses: Array<{ fieldId: string; response: unknown }>) => Promise + /** Close the onboarding modal */ + closeModal: () => void +} + +const OnboardingContext = createContext(null) + +interface OnboardingProviderProps { + children: ReactNode +} + +export function OnboardingProvider({ children }: OnboardingProviderProps) { + const { isAuthenticated, isLoading: authLoading, getAccessToken } = useAuth() + const [loading, setLoading] = useState(true) + const [status, setStatus] = useState(null) + const [showModal, setShowModal] = useState(false) + + const fetchStatus = useCallback(async () => { + const token = getAccessToken() + if (!token) { + setLoading(false) + return + } + + try { + const result = await getOnboardingStatus(token) + setStatus(result) + } catch { + setStatus(null) + } finally { + setLoading(false) + } + }, [getAccessToken]) + + useEffect(() => { + if (!isAuthenticated || authLoading) { + setLoading(false) + return + } + void fetchStatus() + }, [isAuthenticated, authLoading, fetchStatus]) + + const ensureOnboarded = useCallback((): boolean => { + // Fail-open: allow action when loading, unauthenticated, or status unknown + if (loading || !isAuthenticated || status === null) { + return true + } + + if (status.complete) { + return true + } + + // Onboarding incomplete -- open modal + setShowModal(true) + return false + }, [loading, isAuthenticated, status]) + + const submit = useCallback( + async (responses: Array<{ fieldId: string; response: unknown }>): Promise => { + const token = getAccessToken() + if (!token) return false + + try { + await submitOnboarding({ responses }, token) + await fetchStatus() + setShowModal(false) + return true + } catch { + return false + } + }, + [fetchStatus, getAccessToken] + ) + + const closeModal = useCallback(() => { + setShowModal(false) + }, []) + + const value = useMemo( + () => ({ + loading, + complete: status?.complete ?? false, + showModal, + status, + ensureOnboarded, + submit, + closeModal, + }), + [loading, showModal, status, ensureOnboarded, submit, closeModal] + ) + + return ( + + {children} + + + ) +} + +export function useOnboardingContext(): OnboardingContextValue { + const context = useContext(OnboardingContext) + if (!context) { + throw new Error('useOnboardingContext must be used within an OnboardingProvider') + } + return context +} diff --git a/src/test/mock-onboarding.tsx b/src/test/mock-onboarding.tsx new file mode 100644 index 0000000..c44bbd7 --- /dev/null +++ b/src/test/mock-onboarding.tsx @@ -0,0 +1,63 @@ +/** + * Shared test utility for mocking onboarding context. + * Use createMockOnboardingContext() to get default overrides for tests + * that render components gated by onboarding. + */ + +import { vi } from 'vitest' +import type { OnboardingContextValue } from '@/context/onboarding-context' + +export function createMockOnboardingContext( + overrides: Partial = {} +): OnboardingContextValue { + return { + loading: false, + complete: true, + showModal: false, + status: { + complete: true, + fields: [], + responses: {}, + missingFields: [], + }, + ensureOnboarded: vi.fn(() => true), + submit: vi.fn().mockResolvedValue(true), + closeModal: vi.fn(), + ...overrides, + } +} + +export function createIncompleteOnboardingContext( + overrides: Partial = {} +): OnboardingContextValue { + return { + loading: false, + complete: false, + showModal: false, + status: { + complete: false, + fields: [ + { + id: 'age-field', + communityDid: 'did:plc:community-001', + fieldType: 'age_confirmation', + label: 'Confirm your age', + description: null, + isMandatory: true, + sortOrder: 0, + config: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + responses: {}, + missingFields: [ + { id: 'age-field', label: 'Confirm your age', fieldType: 'age_confirmation' }, + ], + }, + ensureOnboarded: vi.fn(() => false), + submit: vi.fn().mockResolvedValue(true), + closeModal: vi.fn(), + ...overrides, + } +} -- 2.51.2