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, + } +}