diff --git a/src/app/onboarding/tags/page.tsx b/src/app/onboarding/tags/page.tsx new file mode 100644 index 0000000000..d0b80d856a --- /dev/null +++ b/src/app/onboarding/tags/page.tsx @@ -0,0 +1,11 @@ +import { Metadata } from '@/molecules/Metadata/Metadata'; +import { TagsOfInterest } from '@/templates/Onboarding/TagsOfInterest/TagsOfInterest'; + +export const metadata = Metadata({ + title: 'Tags of Interest - Onboarding', + description: 'Onboarding tags of interest page on pubky app.', +}); + +export default function TagsPage() { + return ; +} diff --git a/src/app/routes.ts b/src/app/routes.ts index 02f426609b..ca0e01df7f 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -9,6 +9,7 @@ export enum ONBOARDING_ROUTES { PUBKY = '/onboarding/pubky', SCAN = '/onboarding/scan', HUMAN = '/onboarding/human', + TAGS = '/onboarding/tags', } export enum AUTH_ROUTES { @@ -89,6 +90,7 @@ export const PUBLIC_ROUTES: string[] = [ export const ALLOWED_ROUTES = [ ONBOARDING_ROUTES.PROFILE, + ONBOARDING_ROUTES.TAGS, APP_ROUTES.HOME, APP_ROUTES.FEED, APP_ROUTES.SEARCH, diff --git a/src/components/molecules/Fab/Fab.test.tsx b/src/components/molecules/Fab/Fab.test.tsx index e7594cf4b7..6c0d2eb0f2 100644 --- a/src/components/molecules/Fab/Fab.test.tsx +++ b/src/components/molecules/Fab/Fab.test.tsx @@ -15,6 +15,11 @@ const mockUseAuthStatus = vi.fn(() => ({ const mockIsPublicExploreRoute = vi.fn(() => false); const mockRequireAuth = vi.fn((action: () => void) => action()); const mockUseFabAction = vi.fn<() => FabAction>(() => ({ kind: 'createPost', ariaLabel: 'New post' })); +const mockUsePathname = vi.fn(() => '/home'); + +vi.mock('next/navigation', () => ({ + usePathname: () => mockUsePathname(), +})); vi.mock('@/hooks/useAuthStatus/useAuthStatus', () => ({ useAuthStatus: () => mockUseAuthStatus(), @@ -109,6 +114,7 @@ describe('Fab', () => { mockIsPublicExploreRoute.mockReturnValue(false); mockRequireAuth.mockImplementation((action: () => void) => action()); mockUseFabAction.mockReturnValue({ kind: 'createPost', ariaLabel: 'New post' }); + mockUsePathname.mockReturnValue('/home'); useCollectionReorderStore.setState({ activeCollectionId: null }); }); @@ -160,6 +166,12 @@ describe('Fab', () => { expect(container.firstChild).toBeNull(); }); + it('returns null on onboarding routes even when fully authenticated', () => { + mockUsePathname.mockReturnValue('/onboarding/tags'); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + describe('createPost action', () => { it('renders the new post dialog and opens it on click', () => { render(); diff --git a/src/components/molecules/Fab/Fab.tsx b/src/components/molecules/Fab/Fab.tsx index 317a358496..5f60c17c98 100644 --- a/src/components/molecules/Fab/Fab.tsx +++ b/src/components/molecules/Fab/Fab.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { usePathname } from 'next/navigation'; import { Plus } from 'lucide-react'; import { Button } from '@/atoms/Button/Button'; import { useAuthStatus } from '@/hooks/useAuthStatus/useAuthStatus'; @@ -26,6 +27,10 @@ import { useCollectionReorderStore } from '@/stores/collectionReorder/collection * - Shows for authenticated users (opens the context dialog) * - Shows for unauthenticated users on public explore routes (opens sign-in) * - Hidden on landing page and other non-public routes for unauthenticated users + * - Hidden on onboarding routes: the flow has its own primary actions + * (Back/Continue) that the FAB would overlap, and creating posts + * mid-onboarding is out of flow (reachable once fully authenticated, + * e.g. the tags step) * - Hidden while a collection is in reorder mode (reorder mode is for * reordering, not adding posts; the flag bridges from the page via the * `collectionReorder` store since the FAB lives outside the page tree) @@ -37,15 +42,17 @@ import { useCollectionReorderStore } from '@/stores/collectionReorder/collection */ export function Fab() { const [open, setOpen] = useState(false); + const pathname = usePathname(); const { isFullyAuthenticated, isLoading } = useAuthStatus(); const { isPublicExploreRoute } = usePublicRoute(); const { requireAuth } = useRequireAuth(); const action = useFabAction(); const isReorderActive = useCollectionReorderStore((state) => state.activeCollectionId !== null); + const isOnboardingRoute = pathname?.startsWith('/onboarding') ?? false; // Show FAB for authenticated users OR unauthenticated users on public explore routes const shouldShow = isFullyAuthenticated || isPublicExploreRoute; - if (isLoading || !shouldShow || isReorderActive) { + if (isLoading || !shouldShow || isReorderActive || isOnboardingRoute) { return null; } const buttonClasses = cn( diff --git a/src/components/molecules/Header/Header.test.tsx b/src/components/molecules/Header/Header.test.tsx index 03788a59c4..0bde54f990 100644 --- a/src/components/molecules/Header/Header.test.tsx +++ b/src/components/molecules/Header/Header.test.tsx @@ -283,7 +283,7 @@ describe('Header Components', () => { const progressSteps = screen.getByTestId('progress-steps'); expect(progressSteps).toHaveAttribute('data-current', '3'); - expect(progressSteps).toHaveAttribute('data-total', '5'); + expect(progressSteps).toHaveAttribute('data-total', '4'); }); }); diff --git a/src/components/molecules/Header/Header.test.tsx.snap b/src/components/molecules/Header/Header.test.tsx.snap index 76df213254..b070d989ac 100644 --- a/src/components/molecules/Header/Header.test.tsx.snap +++ b/src/components/molecules/Header/Header.test.tsx.snap @@ -523,7 +523,7 @@ exports[`Header Components - Snapshots > matches snapshot for HeaderOnboarding 1
Progress Steps
diff --git a/src/components/molecules/Header/Header.tsx b/src/components/molecules/Header/Header.tsx index 0d6f3bc244..ccc900fc51 100644 --- a/src/components/molecules/Header/Header.tsx +++ b/src/components/molecules/Header/Header.tsx @@ -60,7 +60,7 @@ export const HeaderTitle = ({ currentTitle }: { currentTitle: string }) => { ); }; export const HeaderOnboarding = ({ currentStep }: { currentStep: number }) => { - return ; + return ; }; export function HeaderSocialLinks({ ...props }: React.HTMLAttributes) { return ( diff --git a/src/components/molecules/PostTag/PostTag.types.ts b/src/components/molecules/PostTag/PostTag.types.ts index 18665b992a..09779a690d 100644 --- a/src/components/molecules/PostTag/PostTag.types.ts +++ b/src/components/molecules/PostTag/PostTag.types.ts @@ -1,6 +1,6 @@ -import type { HTMLAttributes, MouseEvent } from 'react'; +import type { ButtonHTMLAttributes, MouseEvent } from 'react'; -export interface PostTagProps extends Omit, 'onClick' | 'color'> { +export interface PostTagProps extends Omit, 'onClick' | 'color'> { /** Tag label text */ label: string; /** Number of posts with this tag (optional) */ diff --git a/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx b/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx index 2c9b6aa870..200c8bd428 100644 --- a/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx +++ b/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx @@ -1,16 +1,18 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { HOME_ROUTES } from '@/app/routes'; +import { ONBOARDING_ROUTES } from '@/app/routes'; import { AuthController } from '@/controllers/auth/auth'; import { FileController } from '@/controllers/file/file'; import { ProfileController } from '@/controllers/profile/profile'; +import { useCurrentUserProfile } from '@/hooks/useCurrentUserProfile/useCurrentUserProfile'; import { ServerErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { ErrorService } from '@/libs/error/error.types'; import { UserValidator } from '@/pipes/user/user.validator'; import { useAuthStore } from '@/stores/auth/auth.store'; import { useOnboardingStore } from '@/stores/onboarding/onboarding.store'; +import { asOpaque } from '@/test-utils/type-assertions'; import { CreateProfileForm } from './CreateProfileForm'; vi.mock('@/atoms/Dialog/Dialog', () => { @@ -64,14 +66,21 @@ vi.mock('facehash', () => ({ vi.mock('@/stores/onboarding/onboarding.store', () => ({ useOnboardingStore: vi.fn(), })); -vi.mock('@/stores/auth/auth.store', () => ({ - useAuthStore: vi.fn(), +vi.mock('@/stores/auth/auth.store', () => { + const useAuthStore = Object.assign(vi.fn(), { + getState: vi.fn(() => ({ hasProfile: false })), + }); + return { useAuthStore }; +}); +vi.mock('@/hooks/useCurrentUserProfile/useCurrentUserProfile', () => ({ + useCurrentUserProfile: vi.fn(() => ({ userDetails: null, currentUserPubky: 'test-public-key' })), })); vi.mock('@/controllers/profile/profile', () => ({ ProfileController: { upload: vi.fn(), create: vi.fn(), commitCreate: vi.fn(), + commitUpdate: vi.fn(), }, })); vi.mock('@/controllers/file/file', () => ({ @@ -186,8 +195,14 @@ vi.mock('@/atoms/Card/Card', () => { vi.mock('@/atoms/Container/Container', () => { return { - Container: ({ children, className }: { children: React.ReactNode; className?: string }) => ( -
+ // Forwards rest props so components that set their own data-testid (e.g. the + // profile form skeleton) keep it instead of the mock's fallback. + Container: ({ + children, + className, + ...props + }: { children?: React.ReactNode; className?: string } & Record) => ( +
{children}
), @@ -460,12 +475,16 @@ describe('CreateProfileForm', () => { vi.mocked(useAuthStore).mockReturnValue({ selectCurrentUserPubky: vi.fn(() => mockPubky), }); + vi.mocked(useAuthStore.getState).mockReturnValue( + asOpaque>({ hasProfile: false }), + ); // Reset all mock functions mockPush.mockReset(); mockToast.mockReset(); vi.mocked(FileController.commitCreate).mockReset(); vi.mocked(ProfileController.commitCreate).mockReset(); + vi.mocked(ProfileController.commitUpdate).mockReset(); vi.mocked(UserValidator.check).mockReset(); vi.mocked(AuthController.bootstrapWithDelay).mockReset(); }); @@ -865,8 +884,8 @@ describe('CreateProfileForm', () => { // Button text should change to "Try again!" expect(continueButton).toHaveTextContent('Try again!'); - // Should not navigate to feed page - expect(mockPush).not.toHaveBeenCalledWith(HOME_ROUTES.HOME); + // Should not navigate to the tags step + expect(mockPush).not.toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS); }); // Verify the mocks were called in the correct order @@ -915,8 +934,8 @@ describe('CreateProfileForm', () => { // Wait for the success handling to complete await waitFor(() => { - // Should navigate to feed page - expect(mockPush).toHaveBeenCalledWith(HOME_ROUTES.HOME); + // Should navigate to the onboarding tags step + expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS); }); // Verify that setShowWelcomeDialog(true) was called @@ -1047,4 +1066,96 @@ describe('CreateProfileForm', () => { expect(screen.getByTestId('card')).toBeInTheDocument(); }); }); + + describe('Profile revisit (Back from tags step, hasProfile=true)', () => { + const revisitUserDetails = { + id: mockPubky, + name: 'Existing Name', + bio: 'Existing bio', + links: [], + status: null, + image: null, + indexed_at: 1, + }; + + beforeEach(() => { + vi.mocked(useAuthStore.getState).mockReturnValue( + asOpaque>({ hasProfile: true }), + ); + vi.mocked(useCurrentUserProfile).mockReturnValue({ + userDetails: revisitUserDetails, + currentUserPubky: mockPubky, + }); + }); + + it('renders in edit mode prefilled from the current user details', async () => { + render(); + + await waitFor(() => { + const nameInput = screen.getAllByTestId('molecules-input')[0]; + expect(nameInput).toHaveValue('Existing Name'); + }); + + const continueButton = screen.getByTestId('continue-button'); + expect(continueButton).toHaveTextContent('Save Profile'); + }); + + it('renders the loading skeleton instead of the interactive form until the profile arrives', () => { + // Local miss + pending/failed fetch: userDetails has not arrived. An interactive + // form here would let edits (text, avatar file) be overwritten or resurface once + // hydration lands, so nothing editable may render. + vi.mocked(useCurrentUserProfile).mockReturnValue({ + userDetails: null, + currentUserPubky: mockPubky, + }); + + render(); + + expect(screen.getByTestId('profile-form-skeleton')).toBeInTheDocument(); + expect(screen.queryAllByTestId('molecules-input')).toHaveLength(0); + expect(screen.queryByTestId('continue-button')).not.toBeInTheDocument(); + expect(ProfileController.commitUpdate).not.toHaveBeenCalled(); + }); + + it('enables the back button and navigates to the tags step without saving', async () => { + render(); + + const backButton = screen.getByTestId('back-button'); + expect(backButton).not.toBeDisabled(); + + fireEvent.click(backButton); + + expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS); + expect(ProfileController.commitUpdate).not.toHaveBeenCalled(); + expect(ProfileController.commitCreate).not.toHaveBeenCalled(); + }); + + it('submits via commitUpdate (not commitCreate) and returns to the tags step', async () => { + vi.mocked(UserValidator.check).mockReturnValue({ + data: { + name: 'Existing Name', + bio: 'Existing bio', + links: [], + }, + error: [], + }); + vi.mocked(ProfileController.commitUpdate).mockResolvedValue(undefined); + + render(); + + await waitFor(() => { + expect(screen.getAllByTestId('molecules-input')[0]).toHaveValue('Existing Name'); + }); + + fireEvent.click(screen.getByTestId('continue-button')); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS); + }); + + expect(ProfileController.commitUpdate).toHaveBeenCalled(); + expect(ProfileController.commitCreate).not.toHaveBeenCalled(); + expect(AuthController.bootstrapWithDelay).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx b/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx index 40a2a81b72..887e38c0a4 100644 --- a/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx +++ b/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx @@ -1,6 +1,9 @@ 'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { File, Trash2 } from 'lucide-react'; +import { ONBOARDING_ROUTES } from '@/app/routes'; import { Avatar, AvatarFallback, AvatarImage } from '@/atoms/Avatar/Avatar'; import { Button } from '@/atoms/Button/Button'; import { Card } from '@/atoms/Card/Card'; @@ -9,26 +12,34 @@ import { Heading } from '@/atoms/Heading/Heading'; import { Label } from '@/atoms/Label/Label'; import { Typography } from '@/atoms/Typography/Typography'; import { USER_MAX_LINKS } from '@/config/user'; +import { useCurrentUserProfile } from '@/hooks/useCurrentUserProfile/useCurrentUserProfile'; import { useProfileForm } from '@/hooks/useProfileForm/useProfileForm'; import { extractInitials } from '@/libs/utils/utils'; import { FacehashAvatar } from '@/molecules/FacehashAvatar/FacehashAvatar'; import { InputField } from '@/molecules/InputField/InputField'; import { ProfileNavigation } from '@/molecules/ProfileNavigation/ProfileNavigation'; import { TextareaField } from '@/molecules/TextareaField/TextareaField'; +import { ProfileFormSkeleton } from '@/organisms/ProfileFormSkeleton/ProfileFormSkeleton'; import { useAuthStore } from '@/stores/auth/auth.store'; import { useOnboardingStore } from '@/stores/onboarding/onboarding.store'; import { DialogAddLink } from '../DialogAddLink/DialogAddLink'; import { DialogCropImage } from '../DialogCropImage/DialogCropImage'; export const CreateProfileForm = () => { + const router = useRouter(); const { setShowWelcomeDialog } = useOnboardingStore(); const authStore = useAuthStore(); const pubky = authStore.selectCurrentUserPubky(); - const { state, errors, handlers, cropDialog, fileInputRef, isSubmitDisabled } = useProfileForm({ - mode: 'create', - pubky, - setShowWelcomeDialog, - }); + // Frozen at mount: a user with a profile is revisiting this step (Back from the tags step), + // so the form must edit, not re-create. hasProfile flips true mid-create-submit + // (bootstrapWithDelay), and freezing prevents the form from remounting as an editor then. + const [isRevisit] = useState(() => useAuthStore.getState().hasProfile === true); + const { userDetails } = useCurrentUserProfile({ enabled: isRevisit }); + const { state, errors, handlers, cropDialog, fileInputRef, isSubmitDisabled } = useProfileForm( + isRevisit + ? { mode: 'edit', pubky, userDetails, redirectTo: ONBOARDING_ROUTES.TAGS } + : { mode: 'create', pubky, setShowWelcomeDialog }, + ); const avatarFallbackSeed = pubky || state.name || 'user'; const avatarFallbackInitial = extractInitials({ @@ -37,6 +48,12 @@ export const CreateProfileForm = () => { }) || avatarFallbackSeed.charAt(0).toUpperCase() || 'U'; + // Revisit mode only: isLoading stays true until the current profile hydrates the form. + // Rendering the interactive form before then would let edits (text, avatar file) be + // silently overwritten or resurface once hydration lands, so show the skeleton instead. + if (state.isLoading) { + return ; + } return ( <> @@ -206,7 +223,8 @@ export const CreateProfileForm = () => { router.push(ONBOARDING_ROUTES.TAGS) : undefined} continueButtonDisabled={isSubmitDisabled} continueButtonLoading={state.isSaving} continueText={state.submitText} diff --git a/src/components/organisms/Header/Header.constants.ts b/src/components/organisms/Header/Header.constants.ts index 4875ea37d7..fa35233439 100644 --- a/src/components/organisms/Header/Header.constants.ts +++ b/src/components/organisms/Header/Header.constants.ts @@ -1,10 +1,12 @@ -// Map paths to step numbers and header titles +// Map paths to step numbers and header titles. +// 4-step model per the onboarding design: account (1), keys (2), profile (3), experience (4). export const pathToStepConfig: Record = { '/onboarding/human': { step: 1, title: 'Create account' }, '/onboarding/install': { step: 2, title: 'Identity keys' }, - '/onboarding/scan': { step: 3, title: 'Use Pubky Ring' }, - '/onboarding/pubky': { step: 3, title: 'Your pubky' }, - '/onboarding/backup': { step: 4, title: 'Backup' }, - '/onboarding/profile': { step: 5, title: 'Profile' }, + '/onboarding/scan': { step: 2, title: 'Use Pubky Ring' }, + '/onboarding/pubky': { step: 2, title: 'Your pubky' }, + '/onboarding/backup': { step: 2, title: 'Backup' }, + '/onboarding/profile': { step: 3, title: 'Profile' }, + '/onboarding/tags': { step: 4, title: 'Experience' }, '/logout': { step: 1, title: 'Signed out' }, }; diff --git a/src/components/organisms/Header/Header.test.tsx b/src/components/organisms/Header/Header.test.tsx index 56d95cd7ec..828dcef3e1 100644 --- a/src/components/organisms/Header/Header.test.tsx +++ b/src/components/organisms/Header/Header.test.tsx @@ -85,6 +85,7 @@ vi.mock('@/app/routes', async (importOriginal) => { PUBKY: '/onboarding/pubky', BACKUP: '/onboarding/backup', PROFILE: '/onboarding/profile', + TAGS: '/onboarding/tags', }, }; }); @@ -282,7 +283,7 @@ describe('Header', () => { render(
); const onboardingHeader = screen.getByTestId('onboarding-header'); - expect(onboardingHeader).toHaveAttribute('data-step', '3'); + expect(onboardingHeader).toHaveAttribute('data-step', '2'); expect(screen.getByTestId('logo')).toBeInTheDocument(); }); @@ -292,7 +293,7 @@ describe('Header', () => { render(
); const onboardingHeader = screen.getByTestId('onboarding-header'); - expect(onboardingHeader).toHaveAttribute('data-step', '3'); + expect(onboardingHeader).toHaveAttribute('data-step', '2'); expect(screen.getByTestId('logo')).toBeInTheDocument(); }); @@ -302,7 +303,7 @@ describe('Header', () => { render(
); const onboardingHeader = screen.getByTestId('onboarding-header'); - expect(onboardingHeader).toHaveAttribute('data-step', '4'); + expect(onboardingHeader).toHaveAttribute('data-step', '2'); expect(screen.getByTestId('logo')).toBeInTheDocument(); }); @@ -424,7 +425,7 @@ describe('Header', () => { }); describe('Logo Configuration', () => { - it('renders logo with noLink=true when on profile step (step 5)', () => { + it('renders logo with noLink=true when on profile step', () => { mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE); render(
); @@ -433,7 +434,16 @@ describe('Header', () => { expect(logo).toHaveAttribute('data-no-link', 'true'); }); - it('renders logo with noLink=false when not on profile step', () => { + it('renders logo with noLink=true when on tags step', () => { + mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.TAGS); + + render(
); + + const logo = screen.getByTestId('logo'); + expect(logo).toHaveAttribute('data-no-link', 'true'); + }); + + it('renders logo with noLink=false when not on a post-auth onboarding step', () => { mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.INSTALL); render(
); @@ -482,7 +492,17 @@ describe('Header', () => { render(
); const onboardingHeader = screen.getByTestId('onboarding-header'); - expect(onboardingHeader).toHaveAttribute('data-step', '5'); + expect(onboardingHeader).toHaveAttribute('data-step', '3'); + expect(screen.getByTestId('logo')).toBeInTheDocument(); + }); + + it('displays correct step for tags path', () => { + mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.TAGS); + + render(
); + + const onboardingHeader = screen.getByTestId('onboarding-header'); + expect(onboardingHeader).toHaveAttribute('data-step', '4'); expect(screen.getByTestId('logo')).toBeInTheDocument(); }); }); @@ -509,7 +529,7 @@ describe('Header', () => { expect(screen.getByTestId('header-title')).toHaveTextContent('Signed out'); }); - it('renders HeaderTitle when on step 5 (profile) even if signed in', () => { + it('renders HeaderTitle when on the profile step even if signed in', () => { mockCurrentUserPubky = 'test-pubky-123'; mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE); @@ -519,7 +539,17 @@ describe('Header', () => { expect(screen.getByTestId('header-title')).toHaveTextContent('Profile'); }); - it('does not render HeaderTitle when signed in and not on step 5', () => { + it('renders HeaderTitle when on the tags step even if signed in', () => { + mockCurrentUserPubky = 'test-pubky-123'; + mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.TAGS); + + render(
); + + expect(screen.getByTestId('header-title')).toBeInTheDocument(); + expect(screen.getByTestId('header-title')).toHaveTextContent('Experience'); + }); + + it('does not render HeaderTitle when signed in and not on a post-auth onboarding step', () => { mockCurrentUserPubky = 'test-pubky-123'; mockUsePathname.mockReturnValue(ROOT_ROUTES); @@ -536,6 +566,7 @@ describe('Header', () => { { path: ONBOARDING_ROUTES.PUBKY, expectedTitle: 'Your pubky' }, { path: ONBOARDING_ROUTES.BACKUP, expectedTitle: 'Backup' }, { path: ONBOARDING_ROUTES.PROFILE, expectedTitle: 'Profile' }, + { path: ONBOARDING_ROUTES.TAGS, expectedTitle: 'Experience' }, { path: AUTH_ROUTES.LOGOUT, expectedTitle: 'Signed out' }, ]; @@ -552,18 +583,24 @@ describe('Header', () => { }); }); - describe('Step 5 (Profile) Specific Logic', () => { - it('renders logo with noLink=true only on step 5 (profile)', () => { - mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE); + describe('Post-auth Onboarding Steps (Profile, Tags) Specific Logic', () => { + it('renders logo with noLink=true on the profile and tags steps', () => { + const postAuthPaths = [ONBOARDING_ROUTES.PROFILE, ONBOARDING_ROUTES.TAGS]; - render(
); + postAuthPaths.forEach((path) => { + mockUsePathname.mockReturnValue(path); - const logo = screen.getByTestId('logo'); - expect(logo).toHaveAttribute('data-no-link', 'true'); + const { rerender } = render(
); + + const logo = screen.getByTestId('logo'); + expect(logo).toHaveAttribute('data-no-link', 'true'); + + rerender(<>); // Clear for next iteration + }); }); it('renders logo with noLink=false on all other steps', () => { - const nonProfilePaths = [ + const nonPostAuthPaths = [ ONBOARDING_ROUTES.INSTALL, ONBOARDING_ROUTES.SCAN, ONBOARDING_ROUTES.PUBKY, @@ -573,7 +610,7 @@ describe('Header', () => { AUTH_ROUTES.LOGOUT, ]; - nonProfilePaths.forEach((path) => { + nonPostAuthPaths.forEach((path) => { mockUsePathname.mockReturnValue(path); const { rerender } = render(
); @@ -585,7 +622,7 @@ describe('Header', () => { }); }); - it('shows HeaderTitle on step 5 regardless of authentication state', () => { + it('shows HeaderTitle on the profile step regardless of authentication state', () => { // Test with authenticated user mockCurrentUserPubky = 'test-pubky-123'; mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE); @@ -777,11 +814,11 @@ describe('Header', () => { expect(onboardingHeader).toHaveAttribute('data-step', '2'); // Change pathname - mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.BACKUP); + mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE); rerender(
); onboardingHeader = screen.getByTestId('onboarding-header'); - expect(onboardingHeader).toHaveAttribute('data-step', '4'); + expect(onboardingHeader).toHaveAttribute('data-step', '3'); }); it('updates HeaderTitle visibility when authentication state changes on configured routes', () => { diff --git a/src/components/organisms/Header/Header.tsx b/src/components/organisms/Header/Header.tsx index 26cac5418f..efc1437700 100644 --- a/src/components/organisms/Header/Header.tsx +++ b/src/components/organisms/Header/Header.tsx @@ -2,6 +2,7 @@ import { usePathname } from 'next/navigation'; import type { MouseEvent } from 'react'; +import { ONBOARDING_ROUTES } from '@/app/routes'; import { usePublicRoute } from '@/hooks/usePublicRoute/usePublicRoute'; import { cn } from '@/libs/utils/utils'; import { @@ -27,6 +28,9 @@ export function Header() { const stepConfig = pathname ? pathToStepConfig[pathname] : undefined; const currentStep = stepConfig?.step ?? 1; const currentTitle = stepConfig?.title; + // Onboarding steps reached after authentication (profile setup and tags of interest). + // Step numbers are not unique in the 4-step model, so match on the path instead. + const isPostAuthOnboardingStep = pathname === ONBOARDING_ROUTES.PROFILE || pathname === ONBOARDING_ROUTES.TAGS; // Hide header on mobile when: // - User is on a core explore route (/home, /hot, /search, /collections) — MobileHeader + MobileFooter @@ -35,8 +39,8 @@ export function Header() { const shouldHideHeaderOnMobile = isCoreExploreRoute || isDynamicPublicRoute || (isAuthenticated && !isOnboarding && !isDynamicPublicRoute); // Show title only for onboarding/logout pages (when stepConfig exists) and user is not authenticated, - // or during profile setup (step 5) - const shouldShowTitle = currentTitle && (!isAuthenticated || currentStep === 5); + // or during the post-auth onboarding steps (profile setup, tags of interest) + const shouldShowTitle = currentTitle && (!isAuthenticated || isPostAuthOnboardingStep); // App-shell layout: authenticated app pages and Explore mode (unauthenticated on a // public route, e.g. feed/post/profile) both render the feed + sidebars, so the header @@ -85,7 +89,7 @@ export function Header() { classNameNav={classNameNav} className={cn(isLandingPage && 'p-0 sm:py-6', shouldHideHeaderOnMobile && 'hidden lg:block')} > - + {shouldShowTitle && } {renderHeaderContent()} diff --git a/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx new file mode 100644 index 0000000000..c98d1ce122 --- /dev/null +++ b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ProfileFormSkeleton } from './ProfileFormSkeleton'; + +describe('ProfileFormSkeleton', () => { + it('renders the skeleton container with its own test id', () => { + render(); + + expect(screen.getByTestId('profile-form-skeleton')).toBeInTheDocument(); + }); + + it('renders no interactive controls', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); +}); + +describe('ProfileFormSkeleton - Snapshots', () => { + it('matches snapshot', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx.snap b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx.snap new file mode 100644 index 0000000000..27e85cc1ca --- /dev/null +++ b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx.snap @@ -0,0 +1,112 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ProfileFormSkeleton - Snapshots > matches snapshot 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/components/organisms/Settings/EditProfileForm/EditProfileForm.skeleton.tsx b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.tsx similarity index 87% rename from src/components/organisms/Settings/EditProfileForm/EditProfileForm.skeleton.tsx rename to src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.tsx index 2b4c2ff993..1f4de3dbca 100644 --- a/src/components/organisms/Settings/EditProfileForm/EditProfileForm.skeleton.tsx +++ b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.tsx @@ -2,9 +2,13 @@ import { Card } from '@/atoms/Card/Card'; import { Container } from '@/atoms/Container/Container'; import { Skeleton } from '@/atoms/Skeleton/Skeleton'; -export function EditProfileFormSkeleton() { +/** + * Loading placeholder for the profile form card (name/bio, links, avatar, bottom buttons). + * Shared by the Settings EditProfileForm and the onboarding CreateProfileForm revisit mode. + */ +export function ProfileFormSkeleton() { return ( - + {/* Profile fields */} diff --git a/src/components/organisms/Settings/EditProfileForm/EditProfileForm.tsx b/src/components/organisms/Settings/EditProfileForm/EditProfileForm.tsx index b9e60a289e..766458ae21 100644 --- a/src/components/organisms/Settings/EditProfileForm/EditProfileForm.tsx +++ b/src/components/organisms/Settings/EditProfileForm/EditProfileForm.tsx @@ -16,9 +16,9 @@ import { extractInitials } from '@/libs/utils/utils'; import { FacehashAvatar } from '@/molecules/FacehashAvatar/FacehashAvatar'; import { InputField } from '@/molecules/InputField/InputField'; import { TextareaField } from '@/molecules/TextareaField/TextareaField'; +import { ProfileFormSkeleton } from '@/organisms/ProfileFormSkeleton/ProfileFormSkeleton'; import { DialogAddLink } from '../../DialogAddLink/DialogAddLink'; import { DialogCropImage } from '../../DialogCropImage/DialogCropImage'; -import { EditProfileFormSkeleton } from './EditProfileForm.skeleton'; export const EditProfileForm = () => { const { userDetails, currentUserPubky } = useCurrentUserProfile(); @@ -36,7 +36,7 @@ export const EditProfileForm = () => { avatarFallbackSeed.charAt(0).toUpperCase() || 'U'; if (state.isLoading) { - return ; + return ; } return ( <> diff --git a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx new file mode 100644 index 0000000000..bb841cf131 --- /dev/null +++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { APP_ROUTES, ONBOARDING_ROUTES } from '@/app/routes'; +import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; +import { useOnboardingStore } from '@/stores/onboarding/onboarding.store'; +import { TagsOfInterestForm } from './TagsOfInterestForm'; + +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +const ACTIVE_PUBKY = 'form-test-pubky'; +vi.mock('@/stores/auth/auth.store', () => ({ + useAuthStore: (selector: (state: { currentUserPubky: string | null }) => unknown) => + selector({ currentUserPubky: ACTIVE_PUBKY }), +})); + +const POPULAR_TAGS = ['bitcoin', 'art', 'music']; +vi.mock('@/hooks/useHotTags/useHotTags', () => ({ + useHotTags: vi.fn(() => ({ + tags: POPULAR_TAGS.map((name) => ({ name, count: 10 })), + rawTags: [], + isLoading: false, + error: null, + refetch: vi.fn(), + })), +})); + +vi.mock('@/molecules/TagInput/TagInput', () => ({ + TagInput: ({ + onTagAdd, + currentTagsCount, + maxTags, + }: { + onTagAdd: (tag: string) => void; + currentTagsCount?: number; + maxTags?: number; + }) => ( + { + if (e.key === 'Enter') { + onTagAdd((e.target as HTMLInputElement).value); + } + }} + /> + ), +})); + +describe('TagsOfInterestForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + useOnboardingStore.setState({ + hasHydrated: true, + interestTags: [], + experienceCompletedByPubky: {}, + }); + }); + + it('renders the illustration, both sections, the tag input, and navigation', () => { + render(); + + expect(screen.getByAltText('Tags of interest')).toBeInTheDocument(); + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (0 selected)'); + expect(screen.getByText('Select which topics you find interesting.')).toBeInTheDocument(); + expect(screen.getByText('Your interests')).toBeInTheDocument(); + expect(screen.getByText('Add other topics you like.')).toBeInTheDocument(); + const tagInput = screen.getByTestId('tag-input'); + expect(tagInput).toHaveAttribute('data-max-tags', String(STARTER_PACK_MAX_TAGS)); + expect(screen.getByRole('button', { name: /back/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /continue/i })).not.toBeDisabled(); + }); + + it('seeds the selection from the persisted store tags', () => { + useOnboardingStore.setState({ interestTags: ['bitcoin', 'satoshi'] }); + + render(); + + expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('interest-tag-satoshi')).toBeInTheDocument(); + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)'); + }); + + it('sanitizes an invalid persisted seed instead of trusting it', () => { + useOnboardingStore.setState({ interestTags: [' Bitcoin ', 'bitcoin', 'bad tag', 'a'.repeat(21)] }); + + render(); + + expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true'); + expect(useOnboardingStore.getState().interestTags).toEqual(['bitcoin']); + }); + + it('syncs every selection change to the store without navigating', () => { + render(); + + fireEvent.click(screen.getByTestId('popular-tag-art')); + + expect(useOnboardingStore.getState().interestTags).toEqual(['art']); + expect(mockPush).not.toHaveBeenCalled(); + }); + + it('preserves the selection when navigating Back to the profile step', () => { + render(); + + fireEvent.click(screen.getByTestId('popular-tag-bitcoin')); + fireEvent.click(screen.getByRole('button', { name: /back/i })); + + expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.PROFILE); + const state = useOnboardingStore.getState(); + expect(state.interestTags).toEqual(['bitcoin']); + expect(state.experienceCompletedByPubky[ACTIVE_PUBKY]).toBeUndefined(); + }); + + it('marks completion and navigates home on Continue', () => { + render(); + + fireEvent.click(screen.getByTestId('popular-tag-bitcoin')); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + const state = useOnboardingStore.getState(); + expect(state.interestTags).toEqual(['bitcoin']); + expect(state.experienceCompletedByPubky[ACTIVE_PUBKY]).toBe(true); + expect(mockPush).toHaveBeenCalledWith(APP_ROUTES.HOME); + }); +}); diff --git a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx new file mode 100644 index 0000000000..e50b59daf8 --- /dev/null +++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { APP_ROUTES, ONBOARDING_ROUTES } from '@/app/routes'; +import { Card } from '@/atoms/Card/Card'; +import { Container } from '@/atoms/Container/Container'; +import { Heading } from '@/atoms/Heading/Heading'; +import { Typography } from '@/atoms/Typography/Typography'; +import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; +import { ONBOARDING_INTERESTS_SUGGESTED_COUNT } from '@/config/tags'; +import { useHotTags } from '@/hooks/useHotTags/useHotTags'; +import { canonicalizeInterestTag, useInterestTags } from '@/hooks/useInterestTags/useInterestTags'; +import { PostTag } from '@/molecules/PostTag/PostTag'; +import { ProfileNavigation } from '@/molecules/ProfileNavigation/ProfileNavigation'; +import { TagInput } from '@/molecules/TagInput/TagInput'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { useOnboardingStore } from '@/stores/onboarding/onboarding.store'; + +export const TagsOfInterestForm = () => { + const router = useRouter(); + const pubky = useAuthStore((state) => state.currentUserPubky); + const setInterestTags = useOnboardingStore((state) => state.setInterestTags); + const markExperienceCompleted = useOnboardingStore((state) => state.markExperienceCompleted); + + const { tags: popularTags } = useHotTags({ limit: ONBOARDING_INTERESTS_SUGGESTED_COUNT }); + // Seed from the persisted selection (frozen at mount) so a round trip to the profile + // step — Back button or browser back — restores the tags and their order. + const [initialTags] = useState(() => useOnboardingStore.getState().interestTags); + const { selectedTags, addTag, removeTag, toggleTag, isSelected, isAtLimit } = useInterestTags(initialTags); + + // Persist every change rather than only on Continue: Back, browser back, and guard + // redirects all bypass the Continue handler and must not lose the selection. + useEffect(() => { + setInterestTags(selectedTags); + }, [selectedTags, setInterestTags]); + + const popularLabels = new Set(popularTags.map((tag) => canonicalizeInterestTag(tag.name))); + const selectedPopularCount = selectedTags.filter((tag) => popularLabels.has(tag)).length; + const customTags = selectedTags.filter((tag) => !popularLabels.has(tag)); + + const handleContinue = () => { + // Selection is already persisted by the sync effect above. + // TEMPORARY(#2388): Tags is currently the last Experience screen, so completion is + // written here. #2388 relocates this write to the Follow screen's Finish action and + // retargets Continue to the follow route. + if (pubky) { + markExperienceCompleted(pubky); + } + router.push(APP_ROUTES.HOME); + }; + + const handleBack = () => { + router.push(ONBOARDING_ROUTES.PROFILE); + }; + + return ( + + + {/* Illustration Section */} + + Tags of interest + + + {/* Popular Interests Section */} + + + + {'Popular interests'} + {` (${selectedPopularCount} selected)`} + + + {'Select which topics you find interesting.'} + + + + {popularTags.map((tag) => { + const selected = isSelected(tag.name); + return ( + toggleTag(tag.name)} + data-testid={`popular-tag-${canonicalizeInterestTag(tag.name)}`} + /> + ); + })} + + + + {/* Your Interests Section */} + + + + {'Your interests'} + + + {'Add other topics you like.'} + + + + ({ label }))} + maxTags={STARTER_PACK_MAX_TAGS} + currentTagsCount={selectedTags.length} + limitReachedPlaceholder={`${STARTER_PACK_MAX_TAGS} tags max`} + showEmojiButton={!isAtLimit} + enableApiSuggestions + excludeFromApiSuggestions={selectedTags} + addOnSuggestionClick + containerVariant="dashed" + /> + {customTags.length > 0 && ( + + {customTags.map((tag) => ( + removeTag(tag)} + onClick={() => removeTag(tag)} + data-testid={`interest-tag-${tag}`} + /> + ))} + + )} + + + + + + + ); +}; diff --git a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx new file mode 100644 index 0000000000..da8a6f6baa --- /dev/null +++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { TagsOfInterestHeader } from './TagsOfInterestHeader'; + +describe('TagsOfInterestHeader', () => { + it('renders the title with the brand-highlighted word', () => { + render(); + + // toHaveTextContent (not a role name query): the space sits inside the brand span, + // whose edge whitespace the accessible-name computation trims away. + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Tags of interest.'); + expect(screen.getByText('interest.')).toHaveClass('text-brand'); + }); + + it('renders the subtitle', () => { + render(); + + expect(screen.getByText('Select topics to get suggestions on who to follow.')).toBeInTheDocument(); + }); +}); + +describe('TagsOfInterestHeader - Snapshots', () => { + it('matches snapshot', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap new file mode 100644 index 0000000000..5bee0fc70a --- /dev/null +++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap @@ -0,0 +1,25 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TagsOfInterestHeader - Snapshots > matches snapshot 1`] = ` +
+

+ Tags of + + interest. + +

+

+ Select topics to get suggestions on who to follow. +

+
+`; diff --git a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx new file mode 100644 index 0000000000..4b1b5ea899 --- /dev/null +++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx @@ -0,0 +1,17 @@ +import { PageHeader } from '@/atoms/PageHeader/PageHeader'; +import { PageSubtitle } from '@/atoms/PageSubtitle/PageSubtitle'; +import { PageTitle } from '@/molecules/Page/Page'; + +export const TagsOfInterestHeader = () => { + return ( + + + {/* Space lives inside the span: a trailing space on the text node would end up + as trailing whitespace in snapshot files and trip `git diff --check`. */} + {'Tags of'} + {' interest.'} + + {'Select topics to get suggestions on who to follow.'} + + ); +}; diff --git a/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx new file mode 100644 index 0000000000..352f00903c --- /dev/null +++ b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { APP_ROUTES, ONBOARDING_ROUTES } from '@/app/routes'; +import { ONBOARDING_INTERESTS_SUGGESTED_COUNT } from '@/config/tags'; +import { useHotTags } from '@/hooks/useHotTags/useHotTags'; +import { useOnboardingStore } from '@/stores/onboarding/onboarding.store'; +import { TagsOfInterest } from './TagsOfInterest'; + +const mockPush = vi.fn(); +const mockReplace = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, replace: mockReplace }), +})); + +const ACTIVE_PUBKY = 'onboarding-test-pubky'; +let mockCurrentUserPubky: string | null = ACTIVE_PUBKY; +vi.mock('@/stores/auth/auth.store', () => ({ + useAuthStore: (selector: (state: { currentUserPubky: string | null }) => unknown) => + selector({ currentUserPubky: mockCurrentUserPubky }), +})); + +const POPULAR_TAGS = ['bitcoin', 'art', 'music', 'photography', 'travel', 'food']; +vi.mock('@/hooks/useHotTags/useHotTags', () => ({ + useHotTags: vi.fn(() => ({ + tags: POPULAR_TAGS.map((name) => ({ name, count: 10 })), + rawTags: [], + isLoading: false, + error: null, + refetch: vi.fn(), + })), +})); + +vi.mock('@/molecules/TagInput/TagInput', () => ({ + TagInput: ({ + onTagAdd, + currentTagsCount, + maxTags, + }: { + onTagAdd: (tag: string) => void; + currentTagsCount?: number; + maxTags?: number; + }) => ( + { + if (e.key === 'Enter') { + onTagAdd((e.target as HTMLInputElement).value); + } + }} + /> + ), +})); + +function addCustomTag(label: string) { + const input = screen.getByTestId('tag-input'); + fireEvent.change(input, { target: { value: label } }); + fireEvent.keyDown(input, { key: 'Enter' }); +} + +describe('TagsOfInterest', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCurrentUserPubky = ACTIVE_PUBKY; + useOnboardingStore.setState({ + hasHydrated: true, + interestTags: [], + experienceCompletedByPubky: {}, + }); + }); + + it('requests the configured number of popular tags and renders them as chips', () => { + render(); + + expect(vi.mocked(useHotTags)).toHaveBeenCalledWith({ limit: ONBOARDING_INTERESTS_SUGGESTED_COUNT }); + POPULAR_TAGS.forEach((tag) => { + expect(screen.getByTestId(`popular-tag-${tag}`)).toBeInTheDocument(); + }); + }); + + it('updates the "(N selected)" header as popular chips are toggled', () => { + render(); + + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (0 selected)'); + + fireEvent.click(screen.getByTestId('popular-tag-bitcoin')); + fireEvent.click(screen.getByTestId('popular-tag-art')); + + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (2 selected)'); + + fireEvent.click(screen.getByTestId('popular-tag-art')); + + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)'); + }); + + it('marks selected chips with accessible pressed state', () => { + render(); + + const chip = screen.getByTestId('popular-tag-bitcoin'); + expect(chip).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(chip); + + expect(chip).toHaveAttribute('aria-pressed', 'true'); + }); + + it('renders free-text tags as removable chips under Your interests', () => { + render(); + + addCustomTag('satoshi'); + + const chip = screen.getByTestId('interest-tag-satoshi'); + expect(chip).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Remove satoshi tag')); + + expect(screen.queryByTestId('interest-tag-satoshi')).not.toBeInTheDocument(); + }); + + it('selects the popular chip instead of duplicating when free text matches a popular label', () => { + render(); + + addCustomTag('Bitcoin'); + + expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.queryByTestId('interest-tag-bitcoin')).not.toBeInTheDocument(); + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)'); + }); + + it('disables only unselected popular chips at the cap and keeps removal working', () => { + render(); + + const selected = POPULAR_TAGS.slice(0, 5); + selected.forEach((tag) => fireEvent.click(screen.getByTestId(`popular-tag-${tag}`))); + + // Unselected chip locks, selected chips stay interactive + expect(screen.getByTestId('popular-tag-food')).toBeDisabled(); + selected.forEach((tag) => { + expect(screen.getByTestId(`popular-tag-${tag}`)).not.toBeDisabled(); + }); + + // Deselecting at the cap still works and unlocks the rest + fireEvent.click(screen.getByTestId('popular-tag-bitcoin')); + + expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('popular-tag-food')).not.toBeDisabled(); + }); + + it('keeps Continue enabled with zero tags and completes with an empty selection', () => { + render(); + + const continueButton = screen.getByRole('button', { name: /continue/i }); + expect(continueButton).not.toBeDisabled(); + + fireEvent.click(continueButton); + + const state = useOnboardingStore.getState(); + expect(state.interestTags).toEqual([]); + expect(state.experienceCompletedByPubky[ACTIVE_PUBKY]).toBe(true); + expect(mockPush).toHaveBeenCalledWith(APP_ROUTES.HOME); + }); + + it('persists the ordered selection and marks completion on Continue', () => { + render(); + + fireEvent.click(screen.getByTestId('popular-tag-music')); + addCustomTag('satoshi'); + fireEvent.click(screen.getByTestId('popular-tag-bitcoin')); + + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + + const state = useOnboardingStore.getState(); + expect(state.interestTags).toEqual(['music', 'satoshi', 'bitcoin']); + expect(state.experienceCompletedByPubky[ACTIVE_PUBKY]).toBe(true); + expect(mockPush).toHaveBeenCalledWith(APP_ROUTES.HOME); + }); + + it('navigates back to the profile step without completing', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /back/i })); + + expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.PROFILE); + expect(useOnboardingStore.getState().experienceCompletedByPubky[ACTIVE_PUBKY]).toBeUndefined(); + }); + + it('restores the selection after a Back round trip to the profile step', () => { + const { unmount } = render(); + + fireEvent.click(screen.getByTestId('popular-tag-bitcoin')); + addCustomTag('satoshi'); + fireEvent.click(screen.getByRole('button', { name: /back/i })); + + // Simulate the route change to the profile step and back + unmount(); + render(); + + expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('interest-tag-satoshi')).toBeInTheDocument(); + expect(useOnboardingStore.getState().interestTags).toEqual(['bitcoin', 'satoshi']); + }); + + it('redirects home without rendering when the active pubky already completed the experience', () => { + useOnboardingStore.setState({ + experienceCompletedByPubky: { [ACTIVE_PUBKY]: true }, + }); + + render(); + + expect(screen.queryByTestId('tags-of-interest-content')).not.toBeInTheDocument(); + expect(mockReplace).toHaveBeenCalledWith(APP_ROUTES.HOME); + }); + + it('still prompts a different pubky on the same browser', () => { + useOnboardingStore.setState({ + experienceCompletedByPubky: { 'someone-else': true }, + }); + + render(); + + expect(screen.getByTestId('tags-of-interest-content')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('holds rendering until the persisted completion map is rehydrated', () => { + useOnboardingStore.setState({ hasHydrated: false }); + + render(); + + expect(screen.queryByTestId('tags-of-interest-content')).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.tsx b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.tsx new file mode 100644 index 0000000000..214c641378 --- /dev/null +++ b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { APP_ROUTES } from '@/app/routes'; +import { OnboardingLayout } from '@/molecules/OnboardingLayout/OnboardingLayout'; +import { TagsOfInterestForm } from '@/organisms/TagsOfInterestForm/TagsOfInterestForm'; +import { TagsOfInterestHeader } from '@/organisms/TagsOfInterestHeader/TagsOfInterestHeader'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { useOnboardingStore } from '@/stores/onboarding/onboarding.store'; + +export function TagsOfInterest() { + const router = useRouter(); + const pubky = useAuthStore((state) => state.currentUserPubky); + const hasHydrated = useOnboardingStore((state) => state.hasHydrated); + const hasCompletedExperience = useOnboardingStore((state) => + pubky ? Boolean(state.experienceCompletedByPubky[pubky]) : false, + ); + + // Re-prompt guard: an account that already finished the Experience flow never sees it again. + useEffect(() => { + if (hasHydrated && hasCompletedExperience) { + router.replace(APP_ROUTES.HOME); + } + }, [hasHydrated, hasCompletedExperience, router]); + + // Hold rendering until the persisted completion map is rehydrated to avoid flashing + // the screen at completed users before the guard can redirect. + if (!hasHydrated || hasCompletedExperience) { + return null; + } + + return ( + + + + + ); +} diff --git a/src/config/tags.ts b/src/config/tags.ts index 10bee7bf1d..853fa64581 100644 --- a/src/config/tags.ts +++ b/src/config/tags.ts @@ -56,3 +56,10 @@ export const TAG_INPUT_WIDTH_AT_LIMIT = 162; /** Number of hot tags to display as featured cards on the Hot page */ export const HOT_TAGS_FEATURED_COUNT = 3; + +// ============================================================================= +// Onboarding Tags of Interest +// ============================================================================= + +/** Number of popular interest chips shown on the onboarding "Tags of interest" step (per design) */ +export const ONBOARDING_INTERESTS_SUGGESTED_COUNT = 21; diff --git a/src/core/stores/onboarding/onboarding.actions.ts b/src/core/stores/onboarding/onboarding.actions.ts index f155aab64d..0d113b0d40 100644 --- a/src/core/stores/onboarding/onboarding.actions.ts +++ b/src/core/stores/onboarding/onboarding.actions.ts @@ -1,3 +1,4 @@ +import type { Pubky } from '@/models/models.types'; import { type ZustandSet } from '../stores.types'; import { type OnboardingActions, @@ -13,6 +14,9 @@ export const createOnboardingActions = (set: ZustandSet): Onboa (state) => ({ ...onboardingInitialState, hasHydrated: state.hasHydrated, // Preserve hydration state during reset + // Preserve per-pubky Experience completion: logout and sign-in both call reset(), + // and a completed account must never be re-prompted with the tags step. + experienceCompletedByPubky: state.experienceCompletedByPubky, }), false, OnboardingActionTypes.RESET, @@ -38,4 +42,18 @@ export const createOnboardingActions = (set: ZustandSet): Onboa setInviteCode: (inviteCode: string) => { set({ inviteCode }, false, OnboardingActionTypes.SET_INVITE_CODE); }, + + setInterestTags: (interestTags: string[]) => { + set({ interestTags }, false, OnboardingActionTypes.SET_INTEREST_TAGS); + }, + + markExperienceCompleted: (pubky: Pubky) => { + set( + (state) => ({ + experienceCompletedByPubky: { ...state.experienceCompletedByPubky, [pubky]: true as const }, + }), + false, + OnboardingActionTypes.MARK_EXPERIENCE_COMPLETED, + ); + }, }); diff --git a/src/core/stores/onboarding/onboarding.store.test.ts b/src/core/stores/onboarding/onboarding.store.test.ts index af075fdbb8..4730680003 100644 --- a/src/core/stores/onboarding/onboarding.store.test.ts +++ b/src/core/stores/onboarding/onboarding.store.test.ts @@ -39,6 +39,8 @@ describe('OnboardingStore', () => { mnemonic: null, hasHydrated: false, showWelcomeDialog: false, + interestTags: [], + experienceCompletedByPubky: {}, }); }); @@ -620,4 +622,92 @@ describe('OnboardingStore', () => { expect(resetState.showWelcomeDialog).toBe(false); // Reset to initial state }); }); + + describe('Interest Tags', () => { + it('should have empty interestTags by default', () => { + expect(useOnboardingStore.getState().interestTags).toEqual([]); + }); + + it('should set interest tags preserving order', () => { + useOnboardingStore.getState().setInterestTags(['bitcoin', 'art', 'photography']); + + expect(useOnboardingStore.getState().interestTags).toEqual(['bitcoin', 'art', 'photography']); + }); + + it('should replace previous interest tags on set', () => { + const state = useOnboardingStore.getState(); + state.setInterestTags(['bitcoin']); + state.setInterestTags(['art', 'music']); + + expect(useOnboardingStore.getState().interestTags).toEqual(['art', 'music']); + }); + + it('should clear interestTags on reset to prevent cross-account leakage', () => { + useOnboardingStore.getState().setInterestTags(['bitcoin', 'art']); + + useOnboardingStore.getState().reset(); + + expect(useOnboardingStore.getState().interestTags).toEqual([]); + }); + }); + + describe('Experience Completion (per pubky)', () => { + const pubkyA = 'pubky-user-a'; + const pubkyB = 'pubky-user-b'; + + it('should have empty completion map by default', () => { + expect(useOnboardingStore.getState().experienceCompletedByPubky).toEqual({}); + }); + + it('should mark completion for a specific pubky only', () => { + useOnboardingStore.getState().markExperienceCompleted(pubkyA); + + const state = useOnboardingStore.getState(); + expect(state.experienceCompletedByPubky[pubkyA]).toBe(true); + expect(state.experienceCompletedByPubky[pubkyB]).toBeUndefined(); + }); + + it('should accumulate completion across multiple pubkys', () => { + const state = useOnboardingStore.getState(); + state.markExperienceCompleted(pubkyA); + state.markExperienceCompleted(pubkyB); + + const finalState = useOnboardingStore.getState(); + expect(finalState.experienceCompletedByPubky[pubkyA]).toBe(true); + expect(finalState.experienceCompletedByPubky[pubkyB]).toBe(true); + }); + + it('should preserve completion through reset (same-account logout/re-login)', () => { + const mockSecrets = createMockSecrets(); + const state = useOnboardingStore.getState(); + state.setSecrets(mockSecrets); + state.markExperienceCompleted(pubkyA); + + // Logout and sign-in both call reset() + state.reset(); + + const resetState = useOnboardingStore.getState(); + expect(resetState.secretKey).toBeNull(); + expect(resetState.experienceCompletedByPubky[pubkyA]).toBe(true); + }); + + it('should not flag a different account on the same browser after reset', () => { + const state = useOnboardingStore.getState(); + state.markExperienceCompleted(pubkyA); + + state.reset(); + + expect(useOnboardingStore.getState().experienceCompletedByPubky[pubkyB]).toBeUndefined(); + }); + + it('should survive repeated resets', () => { + const state = useOnboardingStore.getState(); + state.markExperienceCompleted(pubkyA); + + state.reset(); + state.reset(); + + expect(useOnboardingStore.getState().experienceCompletedByPubky[pubkyA]).toBe(true); + }); + }); }); diff --git a/src/core/stores/onboarding/onboarding.store.ts b/src/core/stores/onboarding/onboarding.store.ts index 6289b87037..03834bab0f 100644 --- a/src/core/stores/onboarding/onboarding.store.ts +++ b/src/core/stores/onboarding/onboarding.store.ts @@ -23,6 +23,8 @@ export const useOnboardingStore = create()( mnemonic: state.mnemonic, showWelcomeDialog: state.showWelcomeDialog, inviteCode: state.inviteCode, + interestTags: state.interestTags, + experienceCompletedByPubky: state.experienceCompletedByPubky, hasHydrated: false, // Will be set by rehydration handler }), diff --git a/src/core/stores/onboarding/onboarding.types.ts b/src/core/stores/onboarding/onboarding.types.ts index df99dbca1e..3b8eee4948 100644 --- a/src/core/stores/onboarding/onboarding.types.ts +++ b/src/core/stores/onboarding/onboarding.types.ts @@ -1,9 +1,19 @@ +import type { Pubky } from '@/models/models.types'; + export interface OnboardingState { secretKey: string | null; mnemonic: string | null; hasHydrated: boolean; showWelcomeDialog: boolean; inviteCode: string; + /** Ordered interest tags selected on the Tags of interest step (canonical: trimmed, lowercase). */ + interestTags: string[]; + /** + * Pubkys that finished the onboarding Experience (tags step). Keyed per account so a + * different user on the same browser is still prompted. Deliberately survives `reset()` + * — logout and sign-in both reset this store, and completion must outlive them. + */ + experienceCompletedByPubky: Record; } /** @@ -21,6 +31,8 @@ export interface OnboardingActions { clearSecrets: () => void; setHydrated: (hasHydrated: boolean) => void; setShowWelcomeDialog: (show: boolean) => void; + setInterestTags: (interestTags: string[]) => void; + markExperienceCompleted: (pubky: Pubky) => void; } export interface OnboardingSelectors { @@ -36,6 +48,8 @@ export const onboardingInitialState: OnboardingState = { hasHydrated: false, showWelcomeDialog: false, inviteCode: '', + interestTags: [], + experienceCompletedByPubky: {}, }; export enum OnboardingActionTypes { @@ -45,6 +59,8 @@ export enum OnboardingActionTypes { SET_HYDRATED = 'SET_HYDRATED', SET_SHOW_WELCOME_DIALOG = 'SET_SHOW_WELCOME_DIALOG', SET_INVITE_CODE = 'SET_INVITE_CODE', + SET_INTEREST_TAGS = 'SET_INTEREST_TAGS', + MARK_EXPERIENCE_COMPLETED = 'MARK_EXPERIENCE_COMPLETED', SET_SECRET_KEY = 'SET_SECRET_KEY', SET_MNEMONIC = 'SET_MNEMONIC', SET_KEYPAIR_FROM_MNEMONIC = 'SET_KEYPAIR_FROM_MNEMONIC', diff --git a/src/hooks/useCurrentUserProfile/useCurrentUserProfile.tsx b/src/hooks/useCurrentUserProfile/useCurrentUserProfile.tsx index 16d0533d82..23cb27f3c1 100644 --- a/src/hooks/useCurrentUserProfile/useCurrentUserProfile.tsx +++ b/src/hooks/useCurrentUserProfile/useCurrentUserProfile.tsx @@ -23,14 +23,14 @@ import type { UseCurrentUserProfileResult } from './useCurrentUserProfile.types' * return
{userDetails.name}
; * ``` */ -export function useCurrentUserProfile(): UseCurrentUserProfileResult { +export function useCurrentUserProfile({ enabled = true }: { enabled?: boolean } = {}): UseCurrentUserProfileResult { const currentUserPubky = useAuthStore((state) => state.currentUserPubky); const { data: userDetails } = useLocalFirstQuery({ queryFn: () => UserController.getDetails({ userId: currentUserPubky! }), fetchFn: () => UserController.fetchDetails({ userId: currentUserPubky! }), deps: [currentUserPubky], - enabled: !!currentUserPubky, + enabled: enabled && !!currentUserPubky, }); return { userDetails, currentUserPubky }; diff --git a/src/hooks/useInterestTags/useInterestTags.test.ts b/src/hooks/useInterestTags/useInterestTags.test.ts new file mode 100644 index 0000000000..f9bcf85e3e --- /dev/null +++ b/src/hooks/useInterestTags/useInterestTags.test.ts @@ -0,0 +1,148 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; +import { useInterestTags } from './useInterestTags'; + +describe('useInterestTags', () => { + it('starts with an empty selection below the limit', () => { + const { result } = renderHook(() => useInterestTags()); + + expect(result.current.selectedTags).toEqual([]); + expect(result.current.isAtLimit).toBe(false); + }); + + it('adds tags preserving selection order', () => { + const { result } = renderHook(() => useInterestTags()); + + act(() => result.current.addTag('bitcoin')); + act(() => result.current.addTag('art')); + act(() => result.current.addTag('photography')); + + expect(result.current.selectedTags).toEqual(['bitcoin', 'art', 'photography']); + }); + + it('canonicalizes labels on add (trim + lowercase)', () => { + const { result } = renderHook(() => useInterestTags()); + + act(() => result.current.addTag(' Bitcoin ')); + + expect(result.current.selectedTags).toEqual(['bitcoin']); + }); + + it('dedupes case-insensitively across popular and free-text entries', () => { + const { result } = renderHook(() => useInterestTags()); + + act(() => result.current.addTag('bitcoin')); + act(() => result.current.addTag('Bitcoin')); + act(() => result.current.addTag('BITCOIN ')); + + expect(result.current.selectedTags).toEqual(['bitcoin']); + }); + + it('rejects invalid labels (empty, whitespace-only, banned characters, overlength)', () => { + const { result } = renderHook(() => useInterestTags()); + + act(() => result.current.addTag('')); + act(() => result.current.addTag(' ')); + act(() => result.current.addTag('tag with space')); + act(() => result.current.addTag('tag,comma')); + act(() => result.current.addTag('tag:colon')); + act(() => result.current.addTag('a'.repeat(21))); + + expect(result.current.selectedTags).toEqual([]); + }); + + it(`caps the selection at STARTER_PACK_MAX_TAGS (${STARTER_PACK_MAX_TAGS})`, () => { + const { result } = renderHook(() => useInterestTags()); + + for (let i = 0; i < STARTER_PACK_MAX_TAGS + 2; i++) { + act(() => result.current.addTag(`tag${i}`)); + } + + expect(result.current.selectedTags).toHaveLength(STARTER_PACK_MAX_TAGS); + expect(result.current.selectedTags).toEqual(['tag0', 'tag1', 'tag2', 'tag3', 'tag4']); + expect(result.current.isAtLimit).toBe(true); + }); + + it('still allows removal at the cap', () => { + const { result } = renderHook(() => useInterestTags()); + + for (let i = 0; i < STARTER_PACK_MAX_TAGS; i++) { + act(() => result.current.addTag(`tag${i}`)); + } + expect(result.current.isAtLimit).toBe(true); + + act(() => result.current.removeTag('tag2')); + + expect(result.current.selectedTags).toEqual(['tag0', 'tag1', 'tag3', 'tag4']); + expect(result.current.isAtLimit).toBe(false); + }); + + it('removes tags matching on the canonical form', () => { + const { result } = renderHook(() => useInterestTags()); + + act(() => result.current.addTag('bitcoin')); + act(() => result.current.removeTag(' Bitcoin ')); + + expect(result.current.selectedTags).toEqual([]); + }); + + it('toggles: adds when unselected, removes when selected', () => { + const { result } = renderHook(() => useInterestTags()); + + act(() => result.current.toggleTag('bitcoin')); + expect(result.current.selectedTags).toEqual(['bitcoin']); + + act(() => result.current.toggleTag('Bitcoin')); + expect(result.current.selectedTags).toEqual([]); + }); + + it('reports isSelected using the canonical form', () => { + const { result } = renderHook(() => useInterestTags()); + + act(() => result.current.addTag('bitcoin')); + + expect(result.current.isSelected('Bitcoin')).toBe(true); + expect(result.current.isSelected('art')).toBe(false); + }); + + it('ignores additions beyond the cap without reordering existing tags', () => { + const { result } = renderHook(() => useInterestTags()); + + for (let i = 0; i < STARTER_PACK_MAX_TAGS; i++) { + act(() => result.current.addTag(`tag${i}`)); + } + + act(() => result.current.addTag('overflow')); + + expect(result.current.selectedTags).toEqual(['tag0', 'tag1', 'tag2', 'tag3', 'tag4']); + expect(result.current.isSelected('overflow')).toBe(false); + }); + + describe('initialTags seeding', () => { + it('seeds the selection preserving order', () => { + const { result } = renderHook(() => useInterestTags(['bitcoin', 'art'])); + + expect(result.current.selectedTags).toEqual(['bitcoin', 'art']); + expect(result.current.isSelected('bitcoin')).toBe(true); + }); + + it('sanitizes the seed: canonicalizes, drops invalid labels, dedupes, and caps', () => { + const { result } = renderHook(() => + useInterestTags([' Bitcoin ', 'bitcoin', 'bad tag', 'a'.repeat(21), 't1', 't2', 't3', 't4', 't5']), + ); + + expect(result.current.selectedTags).toEqual(['bitcoin', 't1', 't2', 't3', 't4']); + expect(result.current.isAtLimit).toBe(true); + }); + + it('remains fully interactive after seeding', () => { + const { result } = renderHook(() => useInterestTags(['bitcoin'])); + + act(() => result.current.toggleTag('bitcoin')); + act(() => result.current.addTag('art')); + + expect(result.current.selectedTags).toEqual(['art']); + }); + }); +}); diff --git a/src/hooks/useInterestTags/useInterestTags.ts b/src/hooks/useInterestTags/useInterestTags.ts new file mode 100644 index 0000000000..916096cfa7 --- /dev/null +++ b/src/hooks/useInterestTags/useInterestTags.ts @@ -0,0 +1,65 @@ +'use client'; + +import { useState } from 'react'; +import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; +import { isValidTagLabel } from '@/libs/utils/utils'; +import type { UseInterestTagsResult } from './useInterestTags.types'; + +/** Canonical form shared with starter pack stream IDs: trimmed + lowercase. */ +export function canonicalizeInterestTag(raw: string): string { + return raw.trim().toLowerCase(); +} + +/** + * Restores a previously persisted selection while re-enforcing the selection invariants + * (canonical labels, validity, order-preserving dedupe, cap) in case the stored value + * predates a rule change or was tampered with. + */ +function sanitizeInterestTags(tags: string[]): string[] { + const sanitized: string[] = []; + for (const raw of tags) { + const tag = canonicalizeInterestTag(raw); + if (!isValidTagLabel(tag) || sanitized.includes(tag)) continue; + sanitized.push(tag); + if (sanitized.length >= STARTER_PACK_MAX_TAGS) break; + } + return sanitized; +} + +/** + * Manages the ordered interest tag selection for the onboarding "Tags of interest" step. + * + * Selection order is preserved (it is part of the starter pack stream ID), labels are + * canonicalized to the stream ID contract, duplicates are ignored (a free-text entry that + * matches a popular chip simply selects that chip), and the selection is capped at + * `STARTER_PACK_MAX_TAGS`. An optional `initialTags` seed (e.g. the persisted selection) + * is sanitized through the same invariants and frozen at mount. + */ +export function useInterestTags(initialTags?: string[]): UseInterestTagsResult { + const [selectedTags, setSelectedTags] = useState(() => sanitizeInterestTags(initialTags ?? [])); + + const isAtLimit = selectedTags.length >= STARTER_PACK_MAX_TAGS; + + const isSelected = (raw: string): boolean => selectedTags.includes(canonicalizeInterestTag(raw)); + + const addTag = (raw: string): void => { + const tag = canonicalizeInterestTag(raw); + if (!isValidTagLabel(tag)) return; + setSelectedTags((prev) => (prev.includes(tag) || prev.length >= STARTER_PACK_MAX_TAGS ? prev : [...prev, tag])); + }; + + const removeTag = (raw: string): void => { + const tag = canonicalizeInterestTag(raw); + setSelectedTags((prev) => prev.filter((t) => t !== tag)); + }; + + const toggleTag = (raw: string): void => { + if (isSelected(raw)) { + removeTag(raw); + } else { + addTag(raw); + } + }; + + return { selectedTags, addTag, removeTag, toggleTag, isSelected, isAtLimit }; +} diff --git a/src/hooks/useInterestTags/useInterestTags.types.ts b/src/hooks/useInterestTags/useInterestTags.types.ts new file mode 100644 index 0000000000..7796489092 --- /dev/null +++ b/src/hooks/useInterestTags/useInterestTags.types.ts @@ -0,0 +1,17 @@ +export interface UseInterestTagsResult { + /** + * Ordered, deduped, canonical (trimmed, lowercase) selection. Order is selection + * order — it becomes the starter pack stream ID order downstream (#2388). + */ + selectedTags: string[]; + /** Adds a canonicalized tag if valid, not already selected, and below the cap. */ + addTag: (raw: string) => void; + /** Removes a tag (input canonicalized before matching). */ + removeTag: (raw: string) => void; + /** Adds when unselected, removes when selected. */ + toggleTag: (raw: string) => void; + /** Whether the canonicalized form of the given label is selected. */ + isSelected: (raw: string) => boolean; + /** Whether the selection reached the starter pack tag cap. */ + isAtLimit: boolean; +} diff --git a/src/hooks/useProfileForm/useProfileForm.test.tsx b/src/hooks/useProfileForm/useProfileForm.test.tsx index 42acb2cfea..c91c2a233a 100644 --- a/src/hooks/useProfileForm/useProfileForm.test.tsx +++ b/src/hooks/useProfileForm/useProfileForm.test.tsx @@ -1,11 +1,14 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ONBOARDING_ROUTES, PROFILE_ROUTES } from '@/app/routes'; import { ProfileController } from '@/controllers/profile/profile'; import type { NexusUserDetails } from '@/services/nexus/nexus.types'; import { useProfileForm } from './useProfileForm'; +const routerPush = vi.hoisted(() => vi.fn()); + vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: vi.fn(), back: vi.fn() }), + useRouter: () => ({ push: routerPush, back: vi.fn() }), })); vi.mock('@/controllers/auth/auth', () => ({ @@ -81,3 +84,63 @@ describe('useProfileForm profile link safety', () => { expect(ProfileController.commitUpdate).not.toHaveBeenCalled(); }); }); + +describe('useProfileForm post-save navigation', () => { + const userDetails: NexusUserDetails = { + id: pubky, + name: 'Valid User', + bio: '', + links: [], + status: null, + image: null, + indexed_at: 1, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('redirects to the onboarding tags step after a successful create', async () => { + const { result } = renderHook(() => useProfileForm({ mode: 'create', pubky, setShowWelcomeDialog: vi.fn() })); + + act(() => { + result.current.handlers.setName('Valid User'); + }); + + await act(async () => { + await result.current.handlers.handleSubmit(); + }); + + expect(ProfileController.commitCreate).toHaveBeenCalled(); + expect(routerPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS); + }); + + it('redirects to the own profile page after a successful edit by default', async () => { + const { result } = renderHook(() => useProfileForm({ mode: 'edit', pubky, userDetails })); + + await waitFor(() => expect(result.current.state.isLoading).toBe(false)); + + await act(async () => { + await result.current.handlers.handleSubmit(); + }); + + expect(ProfileController.commitUpdate).toHaveBeenCalled(); + expect(routerPush).toHaveBeenCalledWith(PROFILE_ROUTES.PROFILE); + }); + + it('honors the edit-mode redirectTo override (onboarding profile revisit)', async () => { + const { result } = renderHook(() => + useProfileForm({ mode: 'edit', pubky, userDetails, redirectTo: ONBOARDING_ROUTES.TAGS }), + ); + + await waitFor(() => expect(result.current.state.isLoading).toBe(false)); + + await act(async () => { + await result.current.handlers.handleSubmit(); + }); + + expect(ProfileController.commitUpdate).toHaveBeenCalled(); + expect(ProfileController.commitCreate).not.toHaveBeenCalled(); + expect(routerPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS); + }); +}); diff --git a/src/hooks/useProfileForm/useProfileForm.tsx b/src/hooks/useProfileForm/useProfileForm.tsx index 1d08eb5b6e..400bea3567 100644 --- a/src/hooks/useProfileForm/useProfileForm.tsx +++ b/src/hooks/useProfileForm/useProfileForm.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { z } from 'zod'; -import { HOME_ROUTES, PROFILE_ROUTES, SETTINGS_ROUTES } from '@/app/routes'; +import { ONBOARDING_ROUTES, PROFILE_ROUTES, SETTINGS_ROUTES } from '@/app/routes'; import { USER_BIO_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_NAME_MIN_LENGTH } from '@/config/user'; import { AuthController } from '@/controllers/auth/auth'; import { FileController } from '@/controllers/file/file'; @@ -44,6 +44,7 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn const { mode, pubky } = props; // Extract userDetails for edit mode to avoid object reference issues in useEffect const userDetails = props.mode === 'edit' ? props.userDetails : undefined; + const editRedirectTo = props.mode === 'edit' ? props.redirectTo : undefined; const setShowWelcomeDialog = props.mode === 'create' ? props.setShowWelcomeDialog : undefined; const router = useRouter(); @@ -329,7 +330,7 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn } await AuthController.bootstrapWithDelay(); setShowWelcomeDialog?.(true); - router.push(HOME_ROUTES.HOME); + router.push(ONBOARDING_ROUTES.TAGS); } else { await ProfileController.commitUpdate({ name: user.name, @@ -352,7 +353,7 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn toast({ title: 'Profile updated', }); - router.push(PROFILE_ROUTES.PROFILE); + router.push(editRedirectTo ?? PROFILE_ROUTES.PROFILE); } } catch (error) { const sizeLimitMessage = getImageUploadSizeLimitToastMessage(error); @@ -405,6 +406,7 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn avatarChanged, originalAvatarUrl, userDetails, + editRedirectTo, setShowWelcomeDialog, router, toast, diff --git a/src/hooks/useProfileForm/useProfileForm.types.ts b/src/hooks/useProfileForm/useProfileForm.types.ts index 3de9450bbc..8981da0729 100644 --- a/src/hooks/useProfileForm/useProfileForm.types.ts +++ b/src/hooks/useProfileForm/useProfileForm.types.ts @@ -84,6 +84,8 @@ export interface UseProfileFormPropsCreate extends UseProfileFormPropsBase { export interface UseProfileFormPropsEdit extends UseProfileFormPropsBase { mode: 'edit'; userDetails: NexusUserDetails | null | undefined; + /** Route to navigate to after a successful save (defaults to the own-profile page). */ + redirectTo?: string; } export type UseProfileFormProps = UseProfileFormPropsCreate | UseProfileFormPropsEdit;