From 2015cf1b31807f8ae6d74c098771fdf38fd13c2d Mon Sep 17 00:00:00 2001 From: Orlando Goncalves Date: Fri, 21 Aug 2026 13:00:00 -0500 Subject: [PATCH 1/3] feat(ui): add onboarding tags of interest step Insert a "Tags of interest" step after profile creation where users pick up to 5 ordered interest tags (21 popular hot-tag chips + free text) that seed the starter pack follow recommendations in #2388. Changes: - Add /onboarding/tags route, page, template, and organisms - Remap onboarding stepper to the 4-step design model - Persist interestTags and per-pubky experienceCompletedByPubky in the onboarding store; completion survives reset() so an account is never re-prompted after logout/re-login - Redirect profile create success to the tags step; enable safe Back by rendering the profile step in edit mode when a profile already exists - Widen PostTag props to ButtonHTMLAttributes so chips accept disabled Completion is written on Tags Continue as a TEMPORARY(#2388) placement; #2388 relocates it to the Follow screen's Finish action. Refs: #2387 --- src/app/onboarding/tags/page.tsx | 11 + src/app/routes.ts | 2 + .../molecules/Header/Header.test.tsx | 2 +- .../molecules/Header/Header.test.tsx.snap | 2 +- src/components/molecules/Header/Header.tsx | 2 +- .../molecules/PostTag/PostTag.types.ts | 4 +- .../CreateProfileForm.test.tsx | 102 +++++++- .../CreateProfileForm/CreateProfileForm.tsx | 23 +- .../organisms/Header/Header.constants.ts | 12 +- .../organisms/Header/Header.test.tsx | 75 ++++-- src/components/organisms/Header/Header.tsx | 10 +- .../TagsOfInterestForm/TagsOfInterestForm.tsx | 126 ++++++++++ .../TagsOfInterestHeader.tsx | 15 ++ .../TagsOfInterest/TagsOfInterest.test.tsx | 219 ++++++++++++++++++ .../TagsOfInterest/TagsOfInterest.tsx | 39 ++++ src/config/tags.ts | 7 + .../stores/onboarding/onboarding.actions.ts | 18 ++ .../onboarding/onboarding.store.test.ts | 90 +++++++ .../stores/onboarding/onboarding.store.ts | 2 + .../stores/onboarding/onboarding.types.ts | 16 ++ .../useCurrentUserProfile.tsx | 4 +- .../useInterestTags/useInterestTags.test.ts | 121 ++++++++++ src/hooks/useInterestTags/useInterestTags.ts | 48 ++++ .../useInterestTags/useInterestTags.types.ts | 17 ++ .../useProfileForm/useProfileForm.test.tsx | 65 +++++- src/hooks/useProfileForm/useProfileForm.tsx | 8 +- .../useProfileForm/useProfileForm.types.ts | 2 + 27 files changed, 991 insertions(+), 51 deletions(-) create mode 100644 src/app/onboarding/tags/page.tsx create mode 100644 src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx create mode 100644 src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx create mode 100644 src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx create mode 100644 src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.tsx create mode 100644 src/hooks/useInterestTags/useInterestTags.test.ts create mode 100644 src/hooks/useInterestTags/useInterestTags.ts create mode 100644 src/hooks/useInterestTags/useInterestTags.types.ts 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/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..9ccfb70b9b 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', () => ({ @@ -460,12 +469,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 +878,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 +928,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 +1060,79 @@ 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('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..ca30ac9295 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,6 +12,7 @@ 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'; @@ -21,14 +25,20 @@ 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({ @@ -206,7 +216,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/TagsOfInterestForm/TagsOfInterestForm.tsx b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx new file mode 100644 index 0000000000..c2f73f6028 --- /dev/null +++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx @@ -0,0 +1,126 @@ +'use client'; + +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 { 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 }); + const { selectedTags, addTag, removeTag, toggleTag, isSelected, isAtLimit } = useInterestTags(); + + 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 = () => { + setInterestTags(selectedTags); + // 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)`} + + + {popularTags.map((tag) => { + const selected = isSelected(tag.name); + return ( + toggleTag(tag.name)} + data-testid={`popular-tag-${canonicalizeInterestTag(tag.name)}`} + /> + ); + })} + + + + {/* Your Interests Section */} + + + {'Your interests'} + + + ({ 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.tsx b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx new file mode 100644 index 0000000000..9547c30c2b --- /dev/null +++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx @@ -0,0 +1,15 @@ +import { PageHeader } from '@/atoms/PageHeader/PageHeader'; +import { PageSubtitle } from '@/atoms/PageSubtitle/PageSubtitle'; +import { PageTitle } from '@/molecules/Page/Page'; + +export const TagsOfInterestHeader = () => { + return ( + + + {'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..d3f6e6c93c --- /dev/null +++ b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx @@ -0,0 +1,219 @@ +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 (0 selected)')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('popular-tag-bitcoin')); + fireEvent.click(screen.getByTestId('popular-tag-art')); + + expect(screen.getByText('Popular interests (2 selected)')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('popular-tag-art')); + + expect(screen.getByText('Popular interests (1 selected)')).toBeInTheDocument(); + }); + + 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 (1 selected)')).toBeInTheDocument(); + }); + + 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('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..de319b94b9 --- /dev/null +++ b/src/hooks/useInterestTags/useInterestTags.test.ts @@ -0,0 +1,121 @@ +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); + }); +}); diff --git a/src/hooks/useInterestTags/useInterestTags.ts b/src/hooks/useInterestTags/useInterestTags.ts new file mode 100644 index 0000000000..42c41054d9 --- /dev/null +++ b/src/hooks/useInterestTags/useInterestTags.ts @@ -0,0 +1,48 @@ +'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(); +} + +/** + * 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`. + */ +export function useInterestTags(): UseInterestTagsResult { + const [selectedTags, setSelectedTags] = useState([]); + + 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; From 40abd92727adc70c26c9d035e507d02e9c90b3c6 Mon Sep 17 00:00:00 2001 From: Orlando Goncalves Date: Fri, 21 Aug 2026 13:19:19 -0500 Subject: [PATCH 2/3] fix(ui): persist interest selection across back-nav and gate revisit submit Address PR review findings: - Seed useInterestTags from the persisted store selection (sanitized through the same canonical/validity/dedupe/cap invariants) and sync the store on every change, so Back, browser back, and guard redirects no longer discard the selected tags or their order - Disable the profile revisit submit while the current profile is still loading, preventing an empty edit form from overwriting the existing bio, links, and avatar when local data is absent and the fetch fails - Add colocated tests for TagsOfInterestForm and TagsOfInterestHeader per docs/component-testing.md Refs: #2387 --- .../CreateProfileForm.test.tsx | 18 +++ .../CreateProfileForm/CreateProfileForm.tsx | 4 +- .../TagsOfInterestForm.test.tsx | 127 ++++++++++++++++++ .../TagsOfInterestForm/TagsOfInterestForm.tsx | 14 +- .../TagsOfInterestHeader.test.tsx | 25 ++++ .../TagsOfInterestHeader.test.tsx.snap | 25 ++++ .../TagsOfInterest/TagsOfInterest.test.tsx | 16 +++ .../useInterestTags/useInterestTags.test.ts | 27 ++++ src/hooks/useInterestTags/useInterestTags.ts | 23 +++- 9 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx create mode 100644 src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx create mode 100644 src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap diff --git a/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx b/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx index 9ccfb70b9b..134d999673 100644 --- a/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx +++ b/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx @@ -1094,6 +1094,24 @@ describe('CreateProfileForm', () => { expect(continueButton).toHaveTextContent('Save Profile'); }); + it('keeps Continue disabled while the current profile has not loaded', () => { + // Local miss + failed fetch: userDetails never arrives, so submitting the empty + // defaults would overwrite the existing bio, links, and avatar. + vi.mocked(useCurrentUserProfile).mockReturnValue({ + userDetails: null, + currentUserPubky: mockPubky, + }); + + render(); + + // Even with a valid name typed in, the loading gate must hold the submit + const nameInput = screen.getAllByTestId('molecules-input')[0]; + fireEvent.change(nameInput, { target: { value: 'Valid Name' } }); + + expect(screen.getByTestId('continue-button')).toBeDisabled(); + expect(ProfileController.commitUpdate).not.toHaveBeenCalled(); + }); + it('enables the back button and navigates to the tags step without saving', async () => { render(); diff --git a/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx b/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx index ca30ac9295..bf9fe4f54e 100644 --- a/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx +++ b/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx @@ -218,7 +218,9 @@ export const CreateProfileForm = () => { className="onboarding-nav mt-auto flex-col sm:flex-row lg:pt-0" backButtonDisabled={!isRevisit} onHandleBackButton={isRevisit ? () => router.push(ONBOARDING_ROUTES.TAGS) : undefined} - continueButtonDisabled={isSubmitDisabled} + // isLoading stays true in revisit mode until the current profile arrives; submitting + // the still-empty defaults would overwrite the existing bio, links, and avatar. + continueButtonDisabled={isSubmitDisabled || state.isLoading} continueButtonLoading={state.isSaving} continueText={state.submitText} onContinue={handlers.handleSubmit} diff --git a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx new file mode 100644 index 0000000000..82ca32e05a --- /dev/null +++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx @@ -0,0 +1,127 @@ +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 (0 selected)')).toBeInTheDocument(); + expect(screen.getByText('Your interests')).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 (1 selected)')).toBeInTheDocument(); + }); + + 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 index c2f73f6028..e3f281e067 100644 --- a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx +++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx @@ -1,5 +1,6 @@ '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'; @@ -23,14 +24,23 @@ export const TagsOfInterestForm = () => { const markExperienceCompleted = useOnboardingStore((state) => state.markExperienceCompleted); const { tags: popularTags } = useHotTags({ limit: ONBOARDING_INTERESTS_SUGGESTED_COUNT }); - const { selectedTags, addTag, removeTag, toggleTag, isSelected, isAtLimit } = useInterestTags(); + // 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 = () => { - setInterestTags(selectedTags); + // 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. diff --git a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx new file mode 100644 index 0000000000..86e0b9acd0 --- /dev/null +++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx @@ -0,0 +1,25 @@ +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(); + + expect(screen.getByRole('heading', { name: 'Tags of interest.' })).toBeInTheDocument(); + 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..18257d732e --- /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/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx index d3f6e6c93c..bcd0da554d 100644 --- a/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx +++ b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx @@ -186,6 +186,22 @@ describe('TagsOfInterest', () => { 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 }, diff --git a/src/hooks/useInterestTags/useInterestTags.test.ts b/src/hooks/useInterestTags/useInterestTags.test.ts index de319b94b9..f9bcf85e3e 100644 --- a/src/hooks/useInterestTags/useInterestTags.test.ts +++ b/src/hooks/useInterestTags/useInterestTags.test.ts @@ -118,4 +118,31 @@ describe('useInterestTags', () => { 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 index 42c41054d9..916096cfa7 100644 --- a/src/hooks/useInterestTags/useInterestTags.ts +++ b/src/hooks/useInterestTags/useInterestTags.ts @@ -10,16 +10,33 @@ 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`. + * `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(): UseInterestTagsResult { - const [selectedTags, setSelectedTags] = useState([]); +export function useInterestTags(initialTags?: string[]): UseInterestTagsResult { + const [selectedTags, setSelectedTags] = useState(() => sanitizeInterestTags(initialTags ?? [])); const isAtLimit = selectedTags.length >= STARTER_PACK_MAX_TAGS; From f932bdbacad93057c37cc34cb3f8b350f36e61c5 Mon Sep 17 00:00:00 2001 From: Orlando Goncalves Date: Fri, 21 Aug 2026 14:28:25 -0500 Subject: [PATCH 3/3] fix(ui): address tags step review round 2 - Hide the FAB on onboarding routes so it no longer overlaps Continue - Render a skeleton during profile revisit hydration instead of an interactive form (promotes the Settings skeleton to a shared ProfileFormSkeleton per the skeleton promotion rule) - Match Figma interest-section hierarchy: muted regular "(N selected)" counter and both helper subtitles - Restructure title JSX so the snapshot has no trailing whitespace --- src/components/molecules/Fab/Fab.test.tsx | 12 ++ src/components/molecules/Fab/Fab.tsx | 9 +- .../CreateProfileForm.test.tsx | 25 ++-- .../CreateProfileForm/CreateProfileForm.tsx | 11 +- .../ProfileFormSkeleton.test.tsx | 25 ++++ .../ProfileFormSkeleton.test.tsx.snap | 112 ++++++++++++++++++ .../ProfileFormSkeleton.tsx} | 8 +- .../EditProfileForm/EditProfileForm.tsx | 4 +- .../TagsOfInterestForm.test.tsx | 6 +- .../TagsOfInterestForm/TagsOfInterestForm.tsx | 24 +++- .../TagsOfInterestHeader.test.tsx | 4 +- .../TagsOfInterestHeader.test.tsx.snap | 4 +- .../TagsOfInterestHeader.tsx | 6 +- .../TagsOfInterest/TagsOfInterest.test.tsx | 8 +- 14 files changed, 223 insertions(+), 35 deletions(-) create mode 100644 src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx create mode 100644 src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx.snap rename src/components/organisms/{Settings/EditProfileForm/EditProfileForm.skeleton.tsx => ProfileFormSkeleton/ProfileFormSkeleton.tsx} (87%) 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/organisms/CreateProfileForm/CreateProfileForm.test.tsx b/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx index 134d999673..200c8bd428 100644 --- a/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx +++ b/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx @@ -195,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}
), @@ -1094,9 +1100,10 @@ describe('CreateProfileForm', () => { expect(continueButton).toHaveTextContent('Save Profile'); }); - it('keeps Continue disabled while the current profile has not loaded', () => { - // Local miss + failed fetch: userDetails never arrives, so submitting the empty - // defaults would overwrite the existing bio, links, and avatar. + 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, @@ -1104,11 +1111,9 @@ describe('CreateProfileForm', () => { render(); - // Even with a valid name typed in, the loading gate must hold the submit - const nameInput = screen.getAllByTestId('molecules-input')[0]; - fireEvent.change(nameInput, { target: { value: 'Valid Name' } }); - - expect(screen.getByTestId('continue-button')).toBeDisabled(); + 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(); }); diff --git a/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx b/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx index bf9fe4f54e..887e38c0a4 100644 --- a/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx +++ b/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx @@ -19,6 +19,7 @@ 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'; @@ -47,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 ( <> @@ -218,9 +225,7 @@ export const CreateProfileForm = () => { className="onboarding-nav mt-auto flex-col sm:flex-row lg:pt-0" backButtonDisabled={!isRevisit} onHandleBackButton={isRevisit ? () => router.push(ONBOARDING_ROUTES.TAGS) : undefined} - // isLoading stays true in revisit mode until the current profile arrives; submitting - // the still-empty defaults would overwrite the existing bio, links, and avatar. - continueButtonDisabled={isSubmitDisabled || state.isLoading} + continueButtonDisabled={isSubmitDisabled} continueButtonLoading={state.isSaving} continueText={state.submitText} onContinue={handlers.handleSubmit} 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 index 82ca32e05a..bb841cf131 100644 --- a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx +++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx @@ -65,8 +65,10 @@ describe('TagsOfInterestForm', () => { render(); expect(screen.getByAltText('Tags of interest')).toBeInTheDocument(); - expect(screen.getByText('Popular interests (0 selected)')).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(); @@ -80,7 +82,7 @@ describe('TagsOfInterestForm', () => { expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByTestId('interest-tag-satoshi')).toBeInTheDocument(); - expect(screen.getByText('Popular interests (1 selected)')).toBeInTheDocument(); + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)'); }); it('sanitizes an invalid persisted seed instead of trusting it', () => { diff --git a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx index e3f281e067..e50b59daf8 100644 --- a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx +++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx @@ -7,6 +7,7 @@ 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'; @@ -64,9 +65,15 @@ export const TagsOfInterestForm = () => { {/* Popular Interests Section */} - - {`Popular interests (${selectedPopularCount} selected)`} - + + + {'Popular interests'} + {` (${selectedPopularCount} selected)`} + + + {'Select which topics you find interesting.'} + + {popularTags.map((tag) => { const selected = isSelected(tag.name); @@ -87,9 +94,14 @@ export const TagsOfInterestForm = () => { {/* Your Interests Section */} - - {'Your interests'} - + + + {'Your interests'} + + + {'Add other topics you like.'} + + { it('renders the title with the brand-highlighted word', () => { render(); - expect(screen.getByRole('heading', { name: 'Tags of interest.' })).toBeInTheDocument(); + // 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'); }); diff --git a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap index 18257d732e..5bee0fc70a 100644 --- a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap +++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap @@ -9,11 +9,11 @@ exports[`TagsOfInterestHeader - Snapshots > matches snapshot 1`] = ` class="font-bold text-foreground text-5xl lg:text-6xl" data-testid="heading-1" > - Tags of + Tags of - interest. + interest.

{ return ( - {'Tags of '} - {'interest.'} + {/* 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 index bcd0da554d..352f00903c 100644 --- a/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx +++ b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx @@ -83,16 +83,16 @@ describe('TagsOfInterest', () => { it('updates the "(N selected)" header as popular chips are toggled', () => { render(); - expect(screen.getByText('Popular interests (0 selected)')).toBeInTheDocument(); + 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 (2 selected)')).toBeInTheDocument(); + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (2 selected)'); fireEvent.click(screen.getByTestId('popular-tag-art')); - expect(screen.getByText('Popular interests (1 selected)')).toBeInTheDocument(); + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)'); }); it('marks selected chips with accessible pressed state', () => { @@ -126,7 +126,7 @@ describe('TagsOfInterest', () => { expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true'); expect(screen.queryByTestId('interest-tag-bitcoin')).not.toBeInTheDocument(); - expect(screen.getByText('Popular interests (1 selected)')).toBeInTheDocument(); + expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)'); }); it('disables only unselected popular chips at the cap and keeps removal working', () => {