+ // Forwards rest props so components that set their own data-testid (e.g. the
+ // profile form skeleton) keep it instead of the mock's fallback.
+ Container: ({
+ children,
+ className,
+ ...props
+ }: { children?: React.ReactNode; className?: string } & Record) => (
+
{children}
),
@@ -460,12 +475,16 @@ describe('CreateProfileForm', () => {
vi.mocked(useAuthStore).mockReturnValue({
selectCurrentUserPubky: vi.fn(() => mockPubky),
});
+ vi.mocked(useAuthStore.getState).mockReturnValue(
+ asOpaque>({ hasProfile: false }),
+ );
// Reset all mock functions
mockPush.mockReset();
mockToast.mockReset();
vi.mocked(FileController.commitCreate).mockReset();
vi.mocked(ProfileController.commitCreate).mockReset();
+ vi.mocked(ProfileController.commitUpdate).mockReset();
vi.mocked(UserValidator.check).mockReset();
vi.mocked(AuthController.bootstrapWithDelay).mockReset();
});
@@ -865,8 +884,8 @@ describe('CreateProfileForm', () => {
// Button text should change to "Try again!"
expect(continueButton).toHaveTextContent('Try again!');
- // Should not navigate to feed page
- expect(mockPush).not.toHaveBeenCalledWith(HOME_ROUTES.HOME);
+ // Should not navigate to the tags step
+ expect(mockPush).not.toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
});
// Verify the mocks were called in the correct order
@@ -915,8 +934,8 @@ describe('CreateProfileForm', () => {
// Wait for the success handling to complete
await waitFor(() => {
- // Should navigate to feed page
- expect(mockPush).toHaveBeenCalledWith(HOME_ROUTES.HOME);
+ // Should navigate to the onboarding tags step
+ expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
});
// Verify that setShowWelcomeDialog(true) was called
@@ -1047,4 +1066,96 @@ describe('CreateProfileForm', () => {
expect(screen.getByTestId('card')).toBeInTheDocument();
});
});
+
+ describe('Profile revisit (Back from tags step, hasProfile=true)', () => {
+ const revisitUserDetails = {
+ id: mockPubky,
+ name: 'Existing Name',
+ bio: 'Existing bio',
+ links: [],
+ status: null,
+ image: null,
+ indexed_at: 1,
+ };
+
+ beforeEach(() => {
+ vi.mocked(useAuthStore.getState).mockReturnValue(
+ asOpaque>({ hasProfile: true }),
+ );
+ vi.mocked(useCurrentUserProfile).mockReturnValue({
+ userDetails: revisitUserDetails,
+ currentUserPubky: mockPubky,
+ });
+ });
+
+ it('renders in edit mode prefilled from the current user details', async () => {
+ render();
+
+ await waitFor(() => {
+ const nameInput = screen.getAllByTestId('molecules-input')[0];
+ expect(nameInput).toHaveValue('Existing Name');
+ });
+
+ const continueButton = screen.getByTestId('continue-button');
+ expect(continueButton).toHaveTextContent('Save Profile');
+ });
+
+ it('renders the loading skeleton instead of the interactive form until the profile arrives', () => {
+ // Local miss + pending/failed fetch: userDetails has not arrived. An interactive
+ // form here would let edits (text, avatar file) be overwritten or resurface once
+ // hydration lands, so nothing editable may render.
+ vi.mocked(useCurrentUserProfile).mockReturnValue({
+ userDetails: null,
+ currentUserPubky: mockPubky,
+ });
+
+ render();
+
+ expect(screen.getByTestId('profile-form-skeleton')).toBeInTheDocument();
+ expect(screen.queryAllByTestId('molecules-input')).toHaveLength(0);
+ expect(screen.queryByTestId('continue-button')).not.toBeInTheDocument();
+ expect(ProfileController.commitUpdate).not.toHaveBeenCalled();
+ });
+
+ it('enables the back button and navigates to the tags step without saving', async () => {
+ render();
+
+ const backButton = screen.getByTestId('back-button');
+ expect(backButton).not.toBeDisabled();
+
+ fireEvent.click(backButton);
+
+ expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
+ expect(ProfileController.commitUpdate).not.toHaveBeenCalled();
+ expect(ProfileController.commitCreate).not.toHaveBeenCalled();
+ });
+
+ it('submits via commitUpdate (not commitCreate) and returns to the tags step', async () => {
+ vi.mocked(UserValidator.check).mockReturnValue({
+ data: {
+ name: 'Existing Name',
+ bio: 'Existing bio',
+ links: [],
+ },
+ error: [],
+ });
+ vi.mocked(ProfileController.commitUpdate).mockResolvedValue(undefined);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId('molecules-input')[0]).toHaveValue('Existing Name');
+ });
+
+ fireEvent.click(screen.getByTestId('continue-button'));
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
+ });
+
+ expect(ProfileController.commitUpdate).toHaveBeenCalled();
+ expect(ProfileController.commitCreate).not.toHaveBeenCalled();
+ expect(AuthController.bootstrapWithDelay).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx b/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx
index 40a2a81b72..887e38c0a4 100644
--- a/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx
+++ b/src/components/organisms/CreateProfileForm/CreateProfileForm.tsx
@@ -1,6 +1,9 @@
'use client';
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
import { File, Trash2 } from 'lucide-react';
+import { ONBOARDING_ROUTES } from '@/app/routes';
import { Avatar, AvatarFallback, AvatarImage } from '@/atoms/Avatar/Avatar';
import { Button } from '@/atoms/Button/Button';
import { Card } from '@/atoms/Card/Card';
@@ -9,26 +12,34 @@ import { Heading } from '@/atoms/Heading/Heading';
import { Label } from '@/atoms/Label/Label';
import { Typography } from '@/atoms/Typography/Typography';
import { USER_MAX_LINKS } from '@/config/user';
+import { useCurrentUserProfile } from '@/hooks/useCurrentUserProfile/useCurrentUserProfile';
import { useProfileForm } from '@/hooks/useProfileForm/useProfileForm';
import { extractInitials } from '@/libs/utils/utils';
import { FacehashAvatar } from '@/molecules/FacehashAvatar/FacehashAvatar';
import { InputField } from '@/molecules/InputField/InputField';
import { ProfileNavigation } from '@/molecules/ProfileNavigation/ProfileNavigation';
import { TextareaField } from '@/molecules/TextareaField/TextareaField';
+import { ProfileFormSkeleton } from '@/organisms/ProfileFormSkeleton/ProfileFormSkeleton';
import { useAuthStore } from '@/stores/auth/auth.store';
import { useOnboardingStore } from '@/stores/onboarding/onboarding.store';
import { DialogAddLink } from '../DialogAddLink/DialogAddLink';
import { DialogCropImage } from '../DialogCropImage/DialogCropImage';
export const CreateProfileForm = () => {
+ const router = useRouter();
const { setShowWelcomeDialog } = useOnboardingStore();
const authStore = useAuthStore();
const pubky = authStore.selectCurrentUserPubky();
- const { state, errors, handlers, cropDialog, fileInputRef, isSubmitDisabled } = useProfileForm({
- mode: 'create',
- pubky,
- setShowWelcomeDialog,
- });
+ // Frozen at mount: a user with a profile is revisiting this step (Back from the tags step),
+ // so the form must edit, not re-create. hasProfile flips true mid-create-submit
+ // (bootstrapWithDelay), and freezing prevents the form from remounting as an editor then.
+ const [isRevisit] = useState(() => useAuthStore.getState().hasProfile === true);
+ const { userDetails } = useCurrentUserProfile({ enabled: isRevisit });
+ const { state, errors, handlers, cropDialog, fileInputRef, isSubmitDisabled } = useProfileForm(
+ isRevisit
+ ? { mode: 'edit', pubky, userDetails, redirectTo: ONBOARDING_ROUTES.TAGS }
+ : { mode: 'create', pubky, setShowWelcomeDialog },
+ );
const avatarFallbackSeed = pubky || state.name || 'user';
const avatarFallbackInitial =
extractInitials({
@@ -37,6 +48,12 @@ export const CreateProfileForm = () => {
}) ||
avatarFallbackSeed.charAt(0).toUpperCase() ||
'U';
+ // Revisit mode only: isLoading stays true until the current profile hydrates the form.
+ // Rendering the interactive form before then would let edits (text, avatar file) be
+ // silently overwritten or resurface once hydration lands, so show the skeleton instead.
+ if (state.isLoading) {
+ return ;
+ }
return (
<>
@@ -206,7 +223,8 @@ export const CreateProfileForm = () => {
router.push(ONBOARDING_ROUTES.TAGS) : undefined}
continueButtonDisabled={isSubmitDisabled}
continueButtonLoading={state.isSaving}
continueText={state.submitText}
diff --git a/src/components/organisms/Header/Header.constants.ts b/src/components/organisms/Header/Header.constants.ts
index 4875ea37d7..fa35233439 100644
--- a/src/components/organisms/Header/Header.constants.ts
+++ b/src/components/organisms/Header/Header.constants.ts
@@ -1,10 +1,12 @@
-// Map paths to step numbers and header titles
+// Map paths to step numbers and header titles.
+// 4-step model per the onboarding design: account (1), keys (2), profile (3), experience (4).
export const pathToStepConfig: Record = {
'/onboarding/human': { step: 1, title: 'Create account' },
'/onboarding/install': { step: 2, title: 'Identity keys' },
- '/onboarding/scan': { step: 3, title: 'Use Pubky Ring' },
- '/onboarding/pubky': { step: 3, title: 'Your pubky' },
- '/onboarding/backup': { step: 4, title: 'Backup' },
- '/onboarding/profile': { step: 5, title: 'Profile' },
+ '/onboarding/scan': { step: 2, title: 'Use Pubky Ring' },
+ '/onboarding/pubky': { step: 2, title: 'Your pubky' },
+ '/onboarding/backup': { step: 2, title: 'Backup' },
+ '/onboarding/profile': { step: 3, title: 'Profile' },
+ '/onboarding/tags': { step: 4, title: 'Experience' },
'/logout': { step: 1, title: 'Signed out' },
};
diff --git a/src/components/organisms/Header/Header.test.tsx b/src/components/organisms/Header/Header.test.tsx
index 56d95cd7ec..828dcef3e1 100644
--- a/src/components/organisms/Header/Header.test.tsx
+++ b/src/components/organisms/Header/Header.test.tsx
@@ -85,6 +85,7 @@ vi.mock('@/app/routes', async (importOriginal) => {
PUBKY: '/onboarding/pubky',
BACKUP: '/onboarding/backup',
PROFILE: '/onboarding/profile',
+ TAGS: '/onboarding/tags',
},
};
});
@@ -282,7 +283,7 @@ describe('Header', () => {
render();
const onboardingHeader = screen.getByTestId('onboarding-header');
- expect(onboardingHeader).toHaveAttribute('data-step', '3');
+ expect(onboardingHeader).toHaveAttribute('data-step', '2');
expect(screen.getByTestId('logo')).toBeInTheDocument();
});
@@ -292,7 +293,7 @@ describe('Header', () => {
render();
const onboardingHeader = screen.getByTestId('onboarding-header');
- expect(onboardingHeader).toHaveAttribute('data-step', '3');
+ expect(onboardingHeader).toHaveAttribute('data-step', '2');
expect(screen.getByTestId('logo')).toBeInTheDocument();
});
@@ -302,7 +303,7 @@ describe('Header', () => {
render();
const onboardingHeader = screen.getByTestId('onboarding-header');
- expect(onboardingHeader).toHaveAttribute('data-step', '4');
+ expect(onboardingHeader).toHaveAttribute('data-step', '2');
expect(screen.getByTestId('logo')).toBeInTheDocument();
});
@@ -424,7 +425,7 @@ describe('Header', () => {
});
describe('Logo Configuration', () => {
- it('renders logo with noLink=true when on profile step (step 5)', () => {
+ it('renders logo with noLink=true when on profile step', () => {
mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE);
render();
@@ -433,7 +434,16 @@ describe('Header', () => {
expect(logo).toHaveAttribute('data-no-link', 'true');
});
- it('renders logo with noLink=false when not on profile step', () => {
+ it('renders logo with noLink=true when on tags step', () => {
+ mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.TAGS);
+
+ render();
+
+ const logo = screen.getByTestId('logo');
+ expect(logo).toHaveAttribute('data-no-link', 'true');
+ });
+
+ it('renders logo with noLink=false when not on a post-auth onboarding step', () => {
mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.INSTALL);
render();
@@ -482,7 +492,17 @@ describe('Header', () => {
render();
const onboardingHeader = screen.getByTestId('onboarding-header');
- expect(onboardingHeader).toHaveAttribute('data-step', '5');
+ expect(onboardingHeader).toHaveAttribute('data-step', '3');
+ expect(screen.getByTestId('logo')).toBeInTheDocument();
+ });
+
+ it('displays correct step for tags path', () => {
+ mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.TAGS);
+
+ render();
+
+ const onboardingHeader = screen.getByTestId('onboarding-header');
+ expect(onboardingHeader).toHaveAttribute('data-step', '4');
expect(screen.getByTestId('logo')).toBeInTheDocument();
});
});
@@ -509,7 +529,7 @@ describe('Header', () => {
expect(screen.getByTestId('header-title')).toHaveTextContent('Signed out');
});
- it('renders HeaderTitle when on step 5 (profile) even if signed in', () => {
+ it('renders HeaderTitle when on the profile step even if signed in', () => {
mockCurrentUserPubky = 'test-pubky-123';
mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE);
@@ -519,7 +539,17 @@ describe('Header', () => {
expect(screen.getByTestId('header-title')).toHaveTextContent('Profile');
});
- it('does not render HeaderTitle when signed in and not on step 5', () => {
+ it('renders HeaderTitle when on the tags step even if signed in', () => {
+ mockCurrentUserPubky = 'test-pubky-123';
+ mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.TAGS);
+
+ render();
+
+ expect(screen.getByTestId('header-title')).toBeInTheDocument();
+ expect(screen.getByTestId('header-title')).toHaveTextContent('Experience');
+ });
+
+ it('does not render HeaderTitle when signed in and not on a post-auth onboarding step', () => {
mockCurrentUserPubky = 'test-pubky-123';
mockUsePathname.mockReturnValue(ROOT_ROUTES);
@@ -536,6 +566,7 @@ describe('Header', () => {
{ path: ONBOARDING_ROUTES.PUBKY, expectedTitle: 'Your pubky' },
{ path: ONBOARDING_ROUTES.BACKUP, expectedTitle: 'Backup' },
{ path: ONBOARDING_ROUTES.PROFILE, expectedTitle: 'Profile' },
+ { path: ONBOARDING_ROUTES.TAGS, expectedTitle: 'Experience' },
{ path: AUTH_ROUTES.LOGOUT, expectedTitle: 'Signed out' },
];
@@ -552,18 +583,24 @@ describe('Header', () => {
});
});
- describe('Step 5 (Profile) Specific Logic', () => {
- it('renders logo with noLink=true only on step 5 (profile)', () => {
- mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE);
+ describe('Post-auth Onboarding Steps (Profile, Tags) Specific Logic', () => {
+ it('renders logo with noLink=true on the profile and tags steps', () => {
+ const postAuthPaths = [ONBOARDING_ROUTES.PROFILE, ONBOARDING_ROUTES.TAGS];
- render();
+ postAuthPaths.forEach((path) => {
+ mockUsePathname.mockReturnValue(path);
- const logo = screen.getByTestId('logo');
- expect(logo).toHaveAttribute('data-no-link', 'true');
+ const { rerender } = render();
+
+ const logo = screen.getByTestId('logo');
+ expect(logo).toHaveAttribute('data-no-link', 'true');
+
+ rerender(<>>); // Clear for next iteration
+ });
});
it('renders logo with noLink=false on all other steps', () => {
- const nonProfilePaths = [
+ const nonPostAuthPaths = [
ONBOARDING_ROUTES.INSTALL,
ONBOARDING_ROUTES.SCAN,
ONBOARDING_ROUTES.PUBKY,
@@ -573,7 +610,7 @@ describe('Header', () => {
AUTH_ROUTES.LOGOUT,
];
- nonProfilePaths.forEach((path) => {
+ nonPostAuthPaths.forEach((path) => {
mockUsePathname.mockReturnValue(path);
const { rerender } = render();
@@ -585,7 +622,7 @@ describe('Header', () => {
});
});
- it('shows HeaderTitle on step 5 regardless of authentication state', () => {
+ it('shows HeaderTitle on the profile step regardless of authentication state', () => {
// Test with authenticated user
mockCurrentUserPubky = 'test-pubky-123';
mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE);
@@ -777,11 +814,11 @@ describe('Header', () => {
expect(onboardingHeader).toHaveAttribute('data-step', '2');
// Change pathname
- mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.BACKUP);
+ mockUsePathname.mockReturnValue(ONBOARDING_ROUTES.PROFILE);
rerender();
onboardingHeader = screen.getByTestId('onboarding-header');
- expect(onboardingHeader).toHaveAttribute('data-step', '4');
+ expect(onboardingHeader).toHaveAttribute('data-step', '3');
});
it('updates HeaderTitle visibility when authentication state changes on configured routes', () => {
diff --git a/src/components/organisms/Header/Header.tsx b/src/components/organisms/Header/Header.tsx
index 26cac5418f..efc1437700 100644
--- a/src/components/organisms/Header/Header.tsx
+++ b/src/components/organisms/Header/Header.tsx
@@ -2,6 +2,7 @@
import { usePathname } from 'next/navigation';
import type { MouseEvent } from 'react';
+import { ONBOARDING_ROUTES } from '@/app/routes';
import { usePublicRoute } from '@/hooks/usePublicRoute/usePublicRoute';
import { cn } from '@/libs/utils/utils';
import {
@@ -27,6 +28,9 @@ export function Header() {
const stepConfig = pathname ? pathToStepConfig[pathname] : undefined;
const currentStep = stepConfig?.step ?? 1;
const currentTitle = stepConfig?.title;
+ // Onboarding steps reached after authentication (profile setup and tags of interest).
+ // Step numbers are not unique in the 4-step model, so match on the path instead.
+ const isPostAuthOnboardingStep = pathname === ONBOARDING_ROUTES.PROFILE || pathname === ONBOARDING_ROUTES.TAGS;
// Hide header on mobile when:
// - User is on a core explore route (/home, /hot, /search, /collections) — MobileHeader + MobileFooter
@@ -35,8 +39,8 @@ export function Header() {
const shouldHideHeaderOnMobile =
isCoreExploreRoute || isDynamicPublicRoute || (isAuthenticated && !isOnboarding && !isDynamicPublicRoute);
// Show title only for onboarding/logout pages (when stepConfig exists) and user is not authenticated,
- // or during profile setup (step 5)
- const shouldShowTitle = currentTitle && (!isAuthenticated || currentStep === 5);
+ // or during the post-auth onboarding steps (profile setup, tags of interest)
+ const shouldShowTitle = currentTitle && (!isAuthenticated || isPostAuthOnboardingStep);
// App-shell layout: authenticated app pages and Explore mode (unauthenticated on a
// public route, e.g. feed/post/profile) both render the feed + sidebars, so the header
@@ -85,7 +89,7 @@ export function Header() {
classNameNav={classNameNav}
className={cn(isLandingPage && 'p-0 sm:py-6', shouldHideHeaderOnMobile && 'hidden lg:block')}
>
-
+
{shouldShowTitle && }
{renderHeaderContent()}
diff --git a/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx
new file mode 100644
index 0000000000..c98d1ce122
--- /dev/null
+++ b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { ProfileFormSkeleton } from './ProfileFormSkeleton';
+
+describe('ProfileFormSkeleton', () => {
+ it('renders the skeleton container with its own test id', () => {
+ render();
+
+ expect(screen.getByTestId('profile-form-skeleton')).toBeInTheDocument();
+ });
+
+ it('renders no interactive controls', () => {
+ render();
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ });
+});
+
+describe('ProfileFormSkeleton - Snapshots', () => {
+ it('matches snapshot', () => {
+ const { container } = render();
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx.snap b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx.snap
new file mode 100644
index 0000000000..27e85cc1ca
--- /dev/null
+++ b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.test.tsx.snap
@@ -0,0 +1,112 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ProfileFormSkeleton - Snapshots > matches snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/components/organisms/Settings/EditProfileForm/EditProfileForm.skeleton.tsx b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.tsx
similarity index 87%
rename from src/components/organisms/Settings/EditProfileForm/EditProfileForm.skeleton.tsx
rename to src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.tsx
index 2b4c2ff993..1f4de3dbca 100644
--- a/src/components/organisms/Settings/EditProfileForm/EditProfileForm.skeleton.tsx
+++ b/src/components/organisms/ProfileFormSkeleton/ProfileFormSkeleton.tsx
@@ -2,9 +2,13 @@ import { Card } from '@/atoms/Card/Card';
import { Container } from '@/atoms/Container/Container';
import { Skeleton } from '@/atoms/Skeleton/Skeleton';
-export function EditProfileFormSkeleton() {
+/**
+ * Loading placeholder for the profile form card (name/bio, links, avatar, bottom buttons).
+ * Shared by the Settings EditProfileForm and the onboarding CreateProfileForm revisit mode.
+ */
+export function ProfileFormSkeleton() {
return (
-
+
{/* Profile fields */}
diff --git a/src/components/organisms/Settings/EditProfileForm/EditProfileForm.tsx b/src/components/organisms/Settings/EditProfileForm/EditProfileForm.tsx
index b9e60a289e..766458ae21 100644
--- a/src/components/organisms/Settings/EditProfileForm/EditProfileForm.tsx
+++ b/src/components/organisms/Settings/EditProfileForm/EditProfileForm.tsx
@@ -16,9 +16,9 @@ import { extractInitials } from '@/libs/utils/utils';
import { FacehashAvatar } from '@/molecules/FacehashAvatar/FacehashAvatar';
import { InputField } from '@/molecules/InputField/InputField';
import { TextareaField } from '@/molecules/TextareaField/TextareaField';
+import { ProfileFormSkeleton } from '@/organisms/ProfileFormSkeleton/ProfileFormSkeleton';
import { DialogAddLink } from '../../DialogAddLink/DialogAddLink';
import { DialogCropImage } from '../../DialogCropImage/DialogCropImage';
-import { EditProfileFormSkeleton } from './EditProfileForm.skeleton';
export const EditProfileForm = () => {
const { userDetails, currentUserPubky } = useCurrentUserProfile();
@@ -36,7 +36,7 @@ export const EditProfileForm = () => {
avatarFallbackSeed.charAt(0).toUpperCase() ||
'U';
if (state.isLoading) {
- return ;
+ return ;
}
return (
<>
diff --git a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx
new file mode 100644
index 0000000000..bb841cf131
--- /dev/null
+++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.test.tsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { APP_ROUTES, ONBOARDING_ROUTES } from '@/app/routes';
+import { STARTER_PACK_MAX_TAGS } from '@/config/nexus';
+import { useOnboardingStore } from '@/stores/onboarding/onboarding.store';
+import { TagsOfInterestForm } from './TagsOfInterestForm';
+
+const mockPush = vi.fn();
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+}));
+
+const ACTIVE_PUBKY = 'form-test-pubky';
+vi.mock('@/stores/auth/auth.store', () => ({
+ useAuthStore: (selector: (state: { currentUserPubky: string | null }) => unknown) =>
+ selector({ currentUserPubky: ACTIVE_PUBKY }),
+}));
+
+const POPULAR_TAGS = ['bitcoin', 'art', 'music'];
+vi.mock('@/hooks/useHotTags/useHotTags', () => ({
+ useHotTags: vi.fn(() => ({
+ tags: POPULAR_TAGS.map((name) => ({ name, count: 10 })),
+ rawTags: [],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ })),
+}));
+
+vi.mock('@/molecules/TagInput/TagInput', () => ({
+ TagInput: ({
+ onTagAdd,
+ currentTagsCount,
+ maxTags,
+ }: {
+ onTagAdd: (tag: string) => void;
+ currentTagsCount?: number;
+ maxTags?: number;
+ }) => (
+ {
+ if (e.key === 'Enter') {
+ onTagAdd((e.target as HTMLInputElement).value);
+ }
+ }}
+ />
+ ),
+}));
+
+describe('TagsOfInterestForm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useOnboardingStore.setState({
+ hasHydrated: true,
+ interestTags: [],
+ experienceCompletedByPubky: {},
+ });
+ });
+
+ it('renders the illustration, both sections, the tag input, and navigation', () => {
+ render();
+
+ expect(screen.getByAltText('Tags of interest')).toBeInTheDocument();
+ expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (0 selected)');
+ expect(screen.getByText('Select which topics you find interesting.')).toBeInTheDocument();
+ expect(screen.getByText('Your interests')).toBeInTheDocument();
+ expect(screen.getByText('Add other topics you like.')).toBeInTheDocument();
+ const tagInput = screen.getByTestId('tag-input');
+ expect(tagInput).toHaveAttribute('data-max-tags', String(STARTER_PACK_MAX_TAGS));
+ expect(screen.getByRole('button', { name: /back/i })).not.toBeDisabled();
+ expect(screen.getByRole('button', { name: /continue/i })).not.toBeDisabled();
+ });
+
+ it('seeds the selection from the persisted store tags', () => {
+ useOnboardingStore.setState({ interestTags: ['bitcoin', 'satoshi'] });
+
+ render();
+
+ expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByTestId('interest-tag-satoshi')).toBeInTheDocument();
+ expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)');
+ });
+
+ it('sanitizes an invalid persisted seed instead of trusting it', () => {
+ useOnboardingStore.setState({ interestTags: [' Bitcoin ', 'bitcoin', 'bad tag', 'a'.repeat(21)] });
+
+ render();
+
+ expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true');
+ expect(useOnboardingStore.getState().interestTags).toEqual(['bitcoin']);
+ });
+
+ it('syncs every selection change to the store without navigating', () => {
+ render();
+
+ fireEvent.click(screen.getByTestId('popular-tag-art'));
+
+ expect(useOnboardingStore.getState().interestTags).toEqual(['art']);
+ expect(mockPush).not.toHaveBeenCalled();
+ });
+
+ it('preserves the selection when navigating Back to the profile step', () => {
+ render();
+
+ fireEvent.click(screen.getByTestId('popular-tag-bitcoin'));
+ fireEvent.click(screen.getByRole('button', { name: /back/i }));
+
+ expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.PROFILE);
+ const state = useOnboardingStore.getState();
+ expect(state.interestTags).toEqual(['bitcoin']);
+ expect(state.experienceCompletedByPubky[ACTIVE_PUBKY]).toBeUndefined();
+ });
+
+ it('marks completion and navigates home on Continue', () => {
+ render();
+
+ fireEvent.click(screen.getByTestId('popular-tag-bitcoin'));
+ fireEvent.click(screen.getByRole('button', { name: /continue/i }));
+
+ const state = useOnboardingStore.getState();
+ expect(state.interestTags).toEqual(['bitcoin']);
+ expect(state.experienceCompletedByPubky[ACTIVE_PUBKY]).toBe(true);
+ expect(mockPush).toHaveBeenCalledWith(APP_ROUTES.HOME);
+ });
+});
diff --git a/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx
new file mode 100644
index 0000000000..e50b59daf8
--- /dev/null
+++ b/src/components/organisms/TagsOfInterestForm/TagsOfInterestForm.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Image from 'next/image';
+import { useRouter } from 'next/navigation';
+import { APP_ROUTES, ONBOARDING_ROUTES } from '@/app/routes';
+import { Card } from '@/atoms/Card/Card';
+import { Container } from '@/atoms/Container/Container';
+import { Heading } from '@/atoms/Heading/Heading';
+import { Typography } from '@/atoms/Typography/Typography';
+import { STARTER_PACK_MAX_TAGS } from '@/config/nexus';
+import { ONBOARDING_INTERESTS_SUGGESTED_COUNT } from '@/config/tags';
+import { useHotTags } from '@/hooks/useHotTags/useHotTags';
+import { canonicalizeInterestTag, useInterestTags } from '@/hooks/useInterestTags/useInterestTags';
+import { PostTag } from '@/molecules/PostTag/PostTag';
+import { ProfileNavigation } from '@/molecules/ProfileNavigation/ProfileNavigation';
+import { TagInput } from '@/molecules/TagInput/TagInput';
+import { useAuthStore } from '@/stores/auth/auth.store';
+import { useOnboardingStore } from '@/stores/onboarding/onboarding.store';
+
+export const TagsOfInterestForm = () => {
+ const router = useRouter();
+ const pubky = useAuthStore((state) => state.currentUserPubky);
+ const setInterestTags = useOnboardingStore((state) => state.setInterestTags);
+ const markExperienceCompleted = useOnboardingStore((state) => state.markExperienceCompleted);
+
+ const { tags: popularTags } = useHotTags({ limit: ONBOARDING_INTERESTS_SUGGESTED_COUNT });
+ // Seed from the persisted selection (frozen at mount) so a round trip to the profile
+ // step — Back button or browser back — restores the tags and their order.
+ const [initialTags] = useState(() => useOnboardingStore.getState().interestTags);
+ const { selectedTags, addTag, removeTag, toggleTag, isSelected, isAtLimit } = useInterestTags(initialTags);
+
+ // Persist every change rather than only on Continue: Back, browser back, and guard
+ // redirects all bypass the Continue handler and must not lose the selection.
+ useEffect(() => {
+ setInterestTags(selectedTags);
+ }, [selectedTags, setInterestTags]);
+
+ const popularLabels = new Set(popularTags.map((tag) => canonicalizeInterestTag(tag.name)));
+ const selectedPopularCount = selectedTags.filter((tag) => popularLabels.has(tag)).length;
+ const customTags = selectedTags.filter((tag) => !popularLabels.has(tag));
+
+ const handleContinue = () => {
+ // Selection is already persisted by the sync effect above.
+ // TEMPORARY(#2388): Tags is currently the last Experience screen, so completion is
+ // written here. #2388 relocates this write to the Follow screen's Finish action and
+ // retargets Continue to the follow route.
+ if (pubky) {
+ markExperienceCompleted(pubky);
+ }
+ router.push(APP_ROUTES.HOME);
+ };
+
+ const handleBack = () => {
+ router.push(ONBOARDING_ROUTES.PROFILE);
+ };
+
+ return (
+
+
+ {/* Illustration Section */}
+
+
+
+
+ {/* Popular Interests Section */}
+
+
+
+ {'Popular interests'}
+ {` (${selectedPopularCount} selected)`}
+
+
+ {'Select which topics you find interesting.'}
+
+
+
+ {popularTags.map((tag) => {
+ const selected = isSelected(tag.name);
+ return (
+ toggleTag(tag.name)}
+ data-testid={`popular-tag-${canonicalizeInterestTag(tag.name)}`}
+ />
+ );
+ })}
+
+
+
+ {/* Your Interests Section */}
+
+
+
+ {'Your interests'}
+
+
+ {'Add other topics you like.'}
+
+
+
+ ({ label }))}
+ maxTags={STARTER_PACK_MAX_TAGS}
+ currentTagsCount={selectedTags.length}
+ limitReachedPlaceholder={`${STARTER_PACK_MAX_TAGS} tags max`}
+ showEmojiButton={!isAtLimit}
+ enableApiSuggestions
+ excludeFromApiSuggestions={selectedTags}
+ addOnSuggestionClick
+ containerVariant="dashed"
+ />
+ {customTags.length > 0 && (
+
+ {customTags.map((tag) => (
+ removeTag(tag)}
+ onClick={() => removeTag(tag)}
+ data-testid={`interest-tag-${tag}`}
+ />
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx
new file mode 100644
index 0000000000..da8a6f6baa
--- /dev/null
+++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { TagsOfInterestHeader } from './TagsOfInterestHeader';
+
+describe('TagsOfInterestHeader', () => {
+ it('renders the title with the brand-highlighted word', () => {
+ render();
+
+ // toHaveTextContent (not a role name query): the space sits inside the brand span,
+ // whose edge whitespace the accessible-name computation trims away.
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Tags of interest.');
+ expect(screen.getByText('interest.')).toHaveClass('text-brand');
+ });
+
+ it('renders the subtitle', () => {
+ render();
+
+ expect(screen.getByText('Select topics to get suggestions on who to follow.')).toBeInTheDocument();
+ });
+});
+
+describe('TagsOfInterestHeader - Snapshots', () => {
+ it('matches snapshot', () => {
+ const { container } = render();
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap
new file mode 100644
index 0000000000..5bee0fc70a
--- /dev/null
+++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.test.tsx.snap
@@ -0,0 +1,25 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`TagsOfInterestHeader - Snapshots > matches snapshot 1`] = `
+
+
+ Tags of
+
+ interest.
+
+
+
+ Select topics to get suggestions on who to follow.
+
+
+`;
diff --git a/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx
new file mode 100644
index 0000000000..4b1b5ea899
--- /dev/null
+++ b/src/components/organisms/TagsOfInterestHeader/TagsOfInterestHeader.tsx
@@ -0,0 +1,17 @@
+import { PageHeader } from '@/atoms/PageHeader/PageHeader';
+import { PageSubtitle } from '@/atoms/PageSubtitle/PageSubtitle';
+import { PageTitle } from '@/molecules/Page/Page';
+
+export const TagsOfInterestHeader = () => {
+ return (
+
+
+ {/* Space lives inside the span: a trailing space on the text node would end up
+ as trailing whitespace in snapshot files and trip `git diff --check`. */}
+ {'Tags of'}
+ {' interest.'}
+
+ {'Select topics to get suggestions on who to follow.'}
+
+ );
+};
diff --git a/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx
new file mode 100644
index 0000000000..352f00903c
--- /dev/null
+++ b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.test.tsx
@@ -0,0 +1,235 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { APP_ROUTES, ONBOARDING_ROUTES } from '@/app/routes';
+import { ONBOARDING_INTERESTS_SUGGESTED_COUNT } from '@/config/tags';
+import { useHotTags } from '@/hooks/useHotTags/useHotTags';
+import { useOnboardingStore } from '@/stores/onboarding/onboarding.store';
+import { TagsOfInterest } from './TagsOfInterest';
+
+const mockPush = vi.fn();
+const mockReplace = vi.fn();
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush, replace: mockReplace }),
+}));
+
+const ACTIVE_PUBKY = 'onboarding-test-pubky';
+let mockCurrentUserPubky: string | null = ACTIVE_PUBKY;
+vi.mock('@/stores/auth/auth.store', () => ({
+ useAuthStore: (selector: (state: { currentUserPubky: string | null }) => unknown) =>
+ selector({ currentUserPubky: mockCurrentUserPubky }),
+}));
+
+const POPULAR_TAGS = ['bitcoin', 'art', 'music', 'photography', 'travel', 'food'];
+vi.mock('@/hooks/useHotTags/useHotTags', () => ({
+ useHotTags: vi.fn(() => ({
+ tags: POPULAR_TAGS.map((name) => ({ name, count: 10 })),
+ rawTags: [],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ })),
+}));
+
+vi.mock('@/molecules/TagInput/TagInput', () => ({
+ TagInput: ({
+ onTagAdd,
+ currentTagsCount,
+ maxTags,
+ }: {
+ onTagAdd: (tag: string) => void;
+ currentTagsCount?: number;
+ maxTags?: number;
+ }) => (
+ {
+ if (e.key === 'Enter') {
+ onTagAdd((e.target as HTMLInputElement).value);
+ }
+ }}
+ />
+ ),
+}));
+
+function addCustomTag(label: string) {
+ const input = screen.getByTestId('tag-input');
+ fireEvent.change(input, { target: { value: label } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+}
+
+describe('TagsOfInterest', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockCurrentUserPubky = ACTIVE_PUBKY;
+ useOnboardingStore.setState({
+ hasHydrated: true,
+ interestTags: [],
+ experienceCompletedByPubky: {},
+ });
+ });
+
+ it('requests the configured number of popular tags and renders them as chips', () => {
+ render();
+
+ expect(vi.mocked(useHotTags)).toHaveBeenCalledWith({ limit: ONBOARDING_INTERESTS_SUGGESTED_COUNT });
+ POPULAR_TAGS.forEach((tag) => {
+ expect(screen.getByTestId(`popular-tag-${tag}`)).toBeInTheDocument();
+ });
+ });
+
+ it('updates the "(N selected)" header as popular chips are toggled', () => {
+ render();
+
+ expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (0 selected)');
+
+ fireEvent.click(screen.getByTestId('popular-tag-bitcoin'));
+ fireEvent.click(screen.getByTestId('popular-tag-art'));
+
+ expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (2 selected)');
+
+ fireEvent.click(screen.getByTestId('popular-tag-art'));
+
+ expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)');
+ });
+
+ it('marks selected chips with accessible pressed state', () => {
+ render();
+
+ const chip = screen.getByTestId('popular-tag-bitcoin');
+ expect(chip).toHaveAttribute('aria-pressed', 'false');
+
+ fireEvent.click(chip);
+
+ expect(chip).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('renders free-text tags as removable chips under Your interests', () => {
+ render();
+
+ addCustomTag('satoshi');
+
+ const chip = screen.getByTestId('interest-tag-satoshi');
+ expect(chip).toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText('Remove satoshi tag'));
+
+ expect(screen.queryByTestId('interest-tag-satoshi')).not.toBeInTheDocument();
+ });
+
+ it('selects the popular chip instead of duplicating when free text matches a popular label', () => {
+ render();
+
+ addCustomTag('Bitcoin');
+
+ expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.queryByTestId('interest-tag-bitcoin')).not.toBeInTheDocument();
+ expect(screen.getByText('Popular interests')).toHaveTextContent('Popular interests (1 selected)');
+ });
+
+ it('disables only unselected popular chips at the cap and keeps removal working', () => {
+ render();
+
+ const selected = POPULAR_TAGS.slice(0, 5);
+ selected.forEach((tag) => fireEvent.click(screen.getByTestId(`popular-tag-${tag}`)));
+
+ // Unselected chip locks, selected chips stay interactive
+ expect(screen.getByTestId('popular-tag-food')).toBeDisabled();
+ selected.forEach((tag) => {
+ expect(screen.getByTestId(`popular-tag-${tag}`)).not.toBeDisabled();
+ });
+
+ // Deselecting at the cap still works and unlocks the rest
+ fireEvent.click(screen.getByTestId('popular-tag-bitcoin'));
+
+ expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'false');
+ expect(screen.getByTestId('popular-tag-food')).not.toBeDisabled();
+ });
+
+ it('keeps Continue enabled with zero tags and completes with an empty selection', () => {
+ render();
+
+ const continueButton = screen.getByRole('button', { name: /continue/i });
+ expect(continueButton).not.toBeDisabled();
+
+ fireEvent.click(continueButton);
+
+ const state = useOnboardingStore.getState();
+ expect(state.interestTags).toEqual([]);
+ expect(state.experienceCompletedByPubky[ACTIVE_PUBKY]).toBe(true);
+ expect(mockPush).toHaveBeenCalledWith(APP_ROUTES.HOME);
+ });
+
+ it('persists the ordered selection and marks completion on Continue', () => {
+ render();
+
+ fireEvent.click(screen.getByTestId('popular-tag-music'));
+ addCustomTag('satoshi');
+ fireEvent.click(screen.getByTestId('popular-tag-bitcoin'));
+
+ fireEvent.click(screen.getByRole('button', { name: /continue/i }));
+
+ const state = useOnboardingStore.getState();
+ expect(state.interestTags).toEqual(['music', 'satoshi', 'bitcoin']);
+ expect(state.experienceCompletedByPubky[ACTIVE_PUBKY]).toBe(true);
+ expect(mockPush).toHaveBeenCalledWith(APP_ROUTES.HOME);
+ });
+
+ it('navigates back to the profile step without completing', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /back/i }));
+
+ expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.PROFILE);
+ expect(useOnboardingStore.getState().experienceCompletedByPubky[ACTIVE_PUBKY]).toBeUndefined();
+ });
+
+ it('restores the selection after a Back round trip to the profile step', () => {
+ const { unmount } = render();
+
+ fireEvent.click(screen.getByTestId('popular-tag-bitcoin'));
+ addCustomTag('satoshi');
+ fireEvent.click(screen.getByRole('button', { name: /back/i }));
+
+ // Simulate the route change to the profile step and back
+ unmount();
+ render();
+
+ expect(screen.getByTestId('popular-tag-bitcoin')).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByTestId('interest-tag-satoshi')).toBeInTheDocument();
+ expect(useOnboardingStore.getState().interestTags).toEqual(['bitcoin', 'satoshi']);
+ });
+
+ it('redirects home without rendering when the active pubky already completed the experience', () => {
+ useOnboardingStore.setState({
+ experienceCompletedByPubky: { [ACTIVE_PUBKY]: true },
+ });
+
+ render();
+
+ expect(screen.queryByTestId('tags-of-interest-content')).not.toBeInTheDocument();
+ expect(mockReplace).toHaveBeenCalledWith(APP_ROUTES.HOME);
+ });
+
+ it('still prompts a different pubky on the same browser', () => {
+ useOnboardingStore.setState({
+ experienceCompletedByPubky: { 'someone-else': true },
+ });
+
+ render();
+
+ expect(screen.getByTestId('tags-of-interest-content')).toBeInTheDocument();
+ expect(mockReplace).not.toHaveBeenCalled();
+ });
+
+ it('holds rendering until the persisted completion map is rehydrated', () => {
+ useOnboardingStore.setState({ hasHydrated: false });
+
+ render();
+
+ expect(screen.queryByTestId('tags-of-interest-content')).not.toBeInTheDocument();
+ expect(mockReplace).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.tsx b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.tsx
new file mode 100644
index 0000000000..214c641378
--- /dev/null
+++ b/src/components/templates/Onboarding/TagsOfInterest/TagsOfInterest.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { APP_ROUTES } from '@/app/routes';
+import { OnboardingLayout } from '@/molecules/OnboardingLayout/OnboardingLayout';
+import { TagsOfInterestForm } from '@/organisms/TagsOfInterestForm/TagsOfInterestForm';
+import { TagsOfInterestHeader } from '@/organisms/TagsOfInterestHeader/TagsOfInterestHeader';
+import { useAuthStore } from '@/stores/auth/auth.store';
+import { useOnboardingStore } from '@/stores/onboarding/onboarding.store';
+
+export function TagsOfInterest() {
+ const router = useRouter();
+ const pubky = useAuthStore((state) => state.currentUserPubky);
+ const hasHydrated = useOnboardingStore((state) => state.hasHydrated);
+ const hasCompletedExperience = useOnboardingStore((state) =>
+ pubky ? Boolean(state.experienceCompletedByPubky[pubky]) : false,
+ );
+
+ // Re-prompt guard: an account that already finished the Experience flow never sees it again.
+ useEffect(() => {
+ if (hasHydrated && hasCompletedExperience) {
+ router.replace(APP_ROUTES.HOME);
+ }
+ }, [hasHydrated, hasCompletedExperience, router]);
+
+ // Hold rendering until the persisted completion map is rehydrated to avoid flashing
+ // the screen at completed users before the guard can redirect.
+ if (!hasHydrated || hasCompletedExperience) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/config/tags.ts b/src/config/tags.ts
index 10bee7bf1d..853fa64581 100644
--- a/src/config/tags.ts
+++ b/src/config/tags.ts
@@ -56,3 +56,10 @@ export const TAG_INPUT_WIDTH_AT_LIMIT = 162;
/** Number of hot tags to display as featured cards on the Hot page */
export const HOT_TAGS_FEATURED_COUNT = 3;
+
+// =============================================================================
+// Onboarding Tags of Interest
+// =============================================================================
+
+/** Number of popular interest chips shown on the onboarding "Tags of interest" step (per design) */
+export const ONBOARDING_INTERESTS_SUGGESTED_COUNT = 21;
diff --git a/src/core/stores/onboarding/onboarding.actions.ts b/src/core/stores/onboarding/onboarding.actions.ts
index f155aab64d..0d113b0d40 100644
--- a/src/core/stores/onboarding/onboarding.actions.ts
+++ b/src/core/stores/onboarding/onboarding.actions.ts
@@ -1,3 +1,4 @@
+import type { Pubky } from '@/models/models.types';
import { type ZustandSet } from '../stores.types';
import {
type OnboardingActions,
@@ -13,6 +14,9 @@ export const createOnboardingActions = (set: ZustandSet): Onboa
(state) => ({
...onboardingInitialState,
hasHydrated: state.hasHydrated, // Preserve hydration state during reset
+ // Preserve per-pubky Experience completion: logout and sign-in both call reset(),
+ // and a completed account must never be re-prompted with the tags step.
+ experienceCompletedByPubky: state.experienceCompletedByPubky,
}),
false,
OnboardingActionTypes.RESET,
@@ -38,4 +42,18 @@ export const createOnboardingActions = (set: ZustandSet): Onboa
setInviteCode: (inviteCode: string) => {
set({ inviteCode }, false, OnboardingActionTypes.SET_INVITE_CODE);
},
+
+ setInterestTags: (interestTags: string[]) => {
+ set({ interestTags }, false, OnboardingActionTypes.SET_INTEREST_TAGS);
+ },
+
+ markExperienceCompleted: (pubky: Pubky) => {
+ set(
+ (state) => ({
+ experienceCompletedByPubky: { ...state.experienceCompletedByPubky, [pubky]: true as const },
+ }),
+ false,
+ OnboardingActionTypes.MARK_EXPERIENCE_COMPLETED,
+ );
+ },
});
diff --git a/src/core/stores/onboarding/onboarding.store.test.ts b/src/core/stores/onboarding/onboarding.store.test.ts
index af075fdbb8..4730680003 100644
--- a/src/core/stores/onboarding/onboarding.store.test.ts
+++ b/src/core/stores/onboarding/onboarding.store.test.ts
@@ -39,6 +39,8 @@ describe('OnboardingStore', () => {
mnemonic: null,
hasHydrated: false,
showWelcomeDialog: false,
+ interestTags: [],
+ experienceCompletedByPubky: {},
});
});
@@ -620,4 +622,92 @@ describe('OnboardingStore', () => {
expect(resetState.showWelcomeDialog).toBe(false); // Reset to initial state
});
});
+
+ describe('Interest Tags', () => {
+ it('should have empty interestTags by default', () => {
+ expect(useOnboardingStore.getState().interestTags).toEqual([]);
+ });
+
+ it('should set interest tags preserving order', () => {
+ useOnboardingStore.getState().setInterestTags(['bitcoin', 'art', 'photography']);
+
+ expect(useOnboardingStore.getState().interestTags).toEqual(['bitcoin', 'art', 'photography']);
+ });
+
+ it('should replace previous interest tags on set', () => {
+ const state = useOnboardingStore.getState();
+ state.setInterestTags(['bitcoin']);
+ state.setInterestTags(['art', 'music']);
+
+ expect(useOnboardingStore.getState().interestTags).toEqual(['art', 'music']);
+ });
+
+ it('should clear interestTags on reset to prevent cross-account leakage', () => {
+ useOnboardingStore.getState().setInterestTags(['bitcoin', 'art']);
+
+ useOnboardingStore.getState().reset();
+
+ expect(useOnboardingStore.getState().interestTags).toEqual([]);
+ });
+ });
+
+ describe('Experience Completion (per pubky)', () => {
+ const pubkyA = 'pubky-user-a';
+ const pubkyB = 'pubky-user-b';
+
+ it('should have empty completion map by default', () => {
+ expect(useOnboardingStore.getState().experienceCompletedByPubky).toEqual({});
+ });
+
+ it('should mark completion for a specific pubky only', () => {
+ useOnboardingStore.getState().markExperienceCompleted(pubkyA);
+
+ const state = useOnboardingStore.getState();
+ expect(state.experienceCompletedByPubky[pubkyA]).toBe(true);
+ expect(state.experienceCompletedByPubky[pubkyB]).toBeUndefined();
+ });
+
+ it('should accumulate completion across multiple pubkys', () => {
+ const state = useOnboardingStore.getState();
+ state.markExperienceCompleted(pubkyA);
+ state.markExperienceCompleted(pubkyB);
+
+ const finalState = useOnboardingStore.getState();
+ expect(finalState.experienceCompletedByPubky[pubkyA]).toBe(true);
+ expect(finalState.experienceCompletedByPubky[pubkyB]).toBe(true);
+ });
+
+ it('should preserve completion through reset (same-account logout/re-login)', () => {
+ const mockSecrets = createMockSecrets();
+ const state = useOnboardingStore.getState();
+ state.setSecrets(mockSecrets);
+ state.markExperienceCompleted(pubkyA);
+
+ // Logout and sign-in both call reset()
+ state.reset();
+
+ const resetState = useOnboardingStore.getState();
+ expect(resetState.secretKey).toBeNull();
+ expect(resetState.experienceCompletedByPubky[pubkyA]).toBe(true);
+ });
+
+ it('should not flag a different account on the same browser after reset', () => {
+ const state = useOnboardingStore.getState();
+ state.markExperienceCompleted(pubkyA);
+
+ state.reset();
+
+ expect(useOnboardingStore.getState().experienceCompletedByPubky[pubkyB]).toBeUndefined();
+ });
+
+ it('should survive repeated resets', () => {
+ const state = useOnboardingStore.getState();
+ state.markExperienceCompleted(pubkyA);
+
+ state.reset();
+ state.reset();
+
+ expect(useOnboardingStore.getState().experienceCompletedByPubky[pubkyA]).toBe(true);
+ });
+ });
});
diff --git a/src/core/stores/onboarding/onboarding.store.ts b/src/core/stores/onboarding/onboarding.store.ts
index 6289b87037..03834bab0f 100644
--- a/src/core/stores/onboarding/onboarding.store.ts
+++ b/src/core/stores/onboarding/onboarding.store.ts
@@ -23,6 +23,8 @@ export const useOnboardingStore = create()(
mnemonic: state.mnemonic,
showWelcomeDialog: state.showWelcomeDialog,
inviteCode: state.inviteCode,
+ interestTags: state.interestTags,
+ experienceCompletedByPubky: state.experienceCompletedByPubky,
hasHydrated: false, // Will be set by rehydration handler
}),
diff --git a/src/core/stores/onboarding/onboarding.types.ts b/src/core/stores/onboarding/onboarding.types.ts
index df99dbca1e..3b8eee4948 100644
--- a/src/core/stores/onboarding/onboarding.types.ts
+++ b/src/core/stores/onboarding/onboarding.types.ts
@@ -1,9 +1,19 @@
+import type { Pubky } from '@/models/models.types';
+
export interface OnboardingState {
secretKey: string | null;
mnemonic: string | null;
hasHydrated: boolean;
showWelcomeDialog: boolean;
inviteCode: string;
+ /** Ordered interest tags selected on the Tags of interest step (canonical: trimmed, lowercase). */
+ interestTags: string[];
+ /**
+ * Pubkys that finished the onboarding Experience (tags step). Keyed per account so a
+ * different user on the same browser is still prompted. Deliberately survives `reset()`
+ * — logout and sign-in both reset this store, and completion must outlive them.
+ */
+ experienceCompletedByPubky: Record;
}
/**
@@ -21,6 +31,8 @@ export interface OnboardingActions {
clearSecrets: () => void;
setHydrated: (hasHydrated: boolean) => void;
setShowWelcomeDialog: (show: boolean) => void;
+ setInterestTags: (interestTags: string[]) => void;
+ markExperienceCompleted: (pubky: Pubky) => void;
}
export interface OnboardingSelectors {
@@ -36,6 +48,8 @@ export const onboardingInitialState: OnboardingState = {
hasHydrated: false,
showWelcomeDialog: false,
inviteCode: '',
+ interestTags: [],
+ experienceCompletedByPubky: {},
};
export enum OnboardingActionTypes {
@@ -45,6 +59,8 @@ export enum OnboardingActionTypes {
SET_HYDRATED = 'SET_HYDRATED',
SET_SHOW_WELCOME_DIALOG = 'SET_SHOW_WELCOME_DIALOG',
SET_INVITE_CODE = 'SET_INVITE_CODE',
+ SET_INTEREST_TAGS = 'SET_INTEREST_TAGS',
+ MARK_EXPERIENCE_COMPLETED = 'MARK_EXPERIENCE_COMPLETED',
SET_SECRET_KEY = 'SET_SECRET_KEY',
SET_MNEMONIC = 'SET_MNEMONIC',
SET_KEYPAIR_FROM_MNEMONIC = 'SET_KEYPAIR_FROM_MNEMONIC',
diff --git a/src/hooks/useCurrentUserProfile/useCurrentUserProfile.tsx b/src/hooks/useCurrentUserProfile/useCurrentUserProfile.tsx
index 16d0533d82..23cb27f3c1 100644
--- a/src/hooks/useCurrentUserProfile/useCurrentUserProfile.tsx
+++ b/src/hooks/useCurrentUserProfile/useCurrentUserProfile.tsx
@@ -23,14 +23,14 @@ import type { UseCurrentUserProfileResult } from './useCurrentUserProfile.types'
* return
{userDetails.name}
;
* ```
*/
-export function useCurrentUserProfile(): UseCurrentUserProfileResult {
+export function useCurrentUserProfile({ enabled = true }: { enabled?: boolean } = {}): UseCurrentUserProfileResult {
const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
const { data: userDetails } = useLocalFirstQuery({
queryFn: () => UserController.getDetails({ userId: currentUserPubky! }),
fetchFn: () => UserController.fetchDetails({ userId: currentUserPubky! }),
deps: [currentUserPubky],
- enabled: !!currentUserPubky,
+ enabled: enabled && !!currentUserPubky,
});
return { userDetails, currentUserPubky };
diff --git a/src/hooks/useInterestTags/useInterestTags.test.ts b/src/hooks/useInterestTags/useInterestTags.test.ts
new file mode 100644
index 0000000000..f9bcf85e3e
--- /dev/null
+++ b/src/hooks/useInterestTags/useInterestTags.test.ts
@@ -0,0 +1,148 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { STARTER_PACK_MAX_TAGS } from '@/config/nexus';
+import { useInterestTags } from './useInterestTags';
+
+describe('useInterestTags', () => {
+ it('starts with an empty selection below the limit', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ expect(result.current.selectedTags).toEqual([]);
+ expect(result.current.isAtLimit).toBe(false);
+ });
+
+ it('adds tags preserving selection order', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ act(() => result.current.addTag('bitcoin'));
+ act(() => result.current.addTag('art'));
+ act(() => result.current.addTag('photography'));
+
+ expect(result.current.selectedTags).toEqual(['bitcoin', 'art', 'photography']);
+ });
+
+ it('canonicalizes labels on add (trim + lowercase)', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ act(() => result.current.addTag(' Bitcoin '));
+
+ expect(result.current.selectedTags).toEqual(['bitcoin']);
+ });
+
+ it('dedupes case-insensitively across popular and free-text entries', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ act(() => result.current.addTag('bitcoin'));
+ act(() => result.current.addTag('Bitcoin'));
+ act(() => result.current.addTag('BITCOIN '));
+
+ expect(result.current.selectedTags).toEqual(['bitcoin']);
+ });
+
+ it('rejects invalid labels (empty, whitespace-only, banned characters, overlength)', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ act(() => result.current.addTag(''));
+ act(() => result.current.addTag(' '));
+ act(() => result.current.addTag('tag with space'));
+ act(() => result.current.addTag('tag,comma'));
+ act(() => result.current.addTag('tag:colon'));
+ act(() => result.current.addTag('a'.repeat(21)));
+
+ expect(result.current.selectedTags).toEqual([]);
+ });
+
+ it(`caps the selection at STARTER_PACK_MAX_TAGS (${STARTER_PACK_MAX_TAGS})`, () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ for (let i = 0; i < STARTER_PACK_MAX_TAGS + 2; i++) {
+ act(() => result.current.addTag(`tag${i}`));
+ }
+
+ expect(result.current.selectedTags).toHaveLength(STARTER_PACK_MAX_TAGS);
+ expect(result.current.selectedTags).toEqual(['tag0', 'tag1', 'tag2', 'tag3', 'tag4']);
+ expect(result.current.isAtLimit).toBe(true);
+ });
+
+ it('still allows removal at the cap', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ for (let i = 0; i < STARTER_PACK_MAX_TAGS; i++) {
+ act(() => result.current.addTag(`tag${i}`));
+ }
+ expect(result.current.isAtLimit).toBe(true);
+
+ act(() => result.current.removeTag('tag2'));
+
+ expect(result.current.selectedTags).toEqual(['tag0', 'tag1', 'tag3', 'tag4']);
+ expect(result.current.isAtLimit).toBe(false);
+ });
+
+ it('removes tags matching on the canonical form', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ act(() => result.current.addTag('bitcoin'));
+ act(() => result.current.removeTag(' Bitcoin '));
+
+ expect(result.current.selectedTags).toEqual([]);
+ });
+
+ it('toggles: adds when unselected, removes when selected', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ act(() => result.current.toggleTag('bitcoin'));
+ expect(result.current.selectedTags).toEqual(['bitcoin']);
+
+ act(() => result.current.toggleTag('Bitcoin'));
+ expect(result.current.selectedTags).toEqual([]);
+ });
+
+ it('reports isSelected using the canonical form', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ act(() => result.current.addTag('bitcoin'));
+
+ expect(result.current.isSelected('Bitcoin')).toBe(true);
+ expect(result.current.isSelected('art')).toBe(false);
+ });
+
+ it('ignores additions beyond the cap without reordering existing tags', () => {
+ const { result } = renderHook(() => useInterestTags());
+
+ for (let i = 0; i < STARTER_PACK_MAX_TAGS; i++) {
+ act(() => result.current.addTag(`tag${i}`));
+ }
+
+ act(() => result.current.addTag('overflow'));
+
+ expect(result.current.selectedTags).toEqual(['tag0', 'tag1', 'tag2', 'tag3', 'tag4']);
+ expect(result.current.isSelected('overflow')).toBe(false);
+ });
+
+ describe('initialTags seeding', () => {
+ it('seeds the selection preserving order', () => {
+ const { result } = renderHook(() => useInterestTags(['bitcoin', 'art']));
+
+ expect(result.current.selectedTags).toEqual(['bitcoin', 'art']);
+ expect(result.current.isSelected('bitcoin')).toBe(true);
+ });
+
+ it('sanitizes the seed: canonicalizes, drops invalid labels, dedupes, and caps', () => {
+ const { result } = renderHook(() =>
+ useInterestTags([' Bitcoin ', 'bitcoin', 'bad tag', 'a'.repeat(21), 't1', 't2', 't3', 't4', 't5']),
+ );
+
+ expect(result.current.selectedTags).toEqual(['bitcoin', 't1', 't2', 't3', 't4']);
+ expect(result.current.isAtLimit).toBe(true);
+ });
+
+ it('remains fully interactive after seeding', () => {
+ const { result } = renderHook(() => useInterestTags(['bitcoin']));
+
+ act(() => result.current.toggleTag('bitcoin'));
+ act(() => result.current.addTag('art'));
+
+ expect(result.current.selectedTags).toEqual(['art']);
+ });
+ });
+});
diff --git a/src/hooks/useInterestTags/useInterestTags.ts b/src/hooks/useInterestTags/useInterestTags.ts
new file mode 100644
index 0000000000..916096cfa7
--- /dev/null
+++ b/src/hooks/useInterestTags/useInterestTags.ts
@@ -0,0 +1,65 @@
+'use client';
+
+import { useState } from 'react';
+import { STARTER_PACK_MAX_TAGS } from '@/config/nexus';
+import { isValidTagLabel } from '@/libs/utils/utils';
+import type { UseInterestTagsResult } from './useInterestTags.types';
+
+/** Canonical form shared with starter pack stream IDs: trimmed + lowercase. */
+export function canonicalizeInterestTag(raw: string): string {
+ return raw.trim().toLowerCase();
+}
+
+/**
+ * Restores a previously persisted selection while re-enforcing the selection invariants
+ * (canonical labels, validity, order-preserving dedupe, cap) in case the stored value
+ * predates a rule change or was tampered with.
+ */
+function sanitizeInterestTags(tags: string[]): string[] {
+ const sanitized: string[] = [];
+ for (const raw of tags) {
+ const tag = canonicalizeInterestTag(raw);
+ if (!isValidTagLabel(tag) || sanitized.includes(tag)) continue;
+ sanitized.push(tag);
+ if (sanitized.length >= STARTER_PACK_MAX_TAGS) break;
+ }
+ return sanitized;
+}
+
+/**
+ * Manages the ordered interest tag selection for the onboarding "Tags of interest" step.
+ *
+ * Selection order is preserved (it is part of the starter pack stream ID), labels are
+ * canonicalized to the stream ID contract, duplicates are ignored (a free-text entry that
+ * matches a popular chip simply selects that chip), and the selection is capped at
+ * `STARTER_PACK_MAX_TAGS`. An optional `initialTags` seed (e.g. the persisted selection)
+ * is sanitized through the same invariants and frozen at mount.
+ */
+export function useInterestTags(initialTags?: string[]): UseInterestTagsResult {
+ const [selectedTags, setSelectedTags] = useState(() => sanitizeInterestTags(initialTags ?? []));
+
+ const isAtLimit = selectedTags.length >= STARTER_PACK_MAX_TAGS;
+
+ const isSelected = (raw: string): boolean => selectedTags.includes(canonicalizeInterestTag(raw));
+
+ const addTag = (raw: string): void => {
+ const tag = canonicalizeInterestTag(raw);
+ if (!isValidTagLabel(tag)) return;
+ setSelectedTags((prev) => (prev.includes(tag) || prev.length >= STARTER_PACK_MAX_TAGS ? prev : [...prev, tag]));
+ };
+
+ const removeTag = (raw: string): void => {
+ const tag = canonicalizeInterestTag(raw);
+ setSelectedTags((prev) => prev.filter((t) => t !== tag));
+ };
+
+ const toggleTag = (raw: string): void => {
+ if (isSelected(raw)) {
+ removeTag(raw);
+ } else {
+ addTag(raw);
+ }
+ };
+
+ return { selectedTags, addTag, removeTag, toggleTag, isSelected, isAtLimit };
+}
diff --git a/src/hooks/useInterestTags/useInterestTags.types.ts b/src/hooks/useInterestTags/useInterestTags.types.ts
new file mode 100644
index 0000000000..7796489092
--- /dev/null
+++ b/src/hooks/useInterestTags/useInterestTags.types.ts
@@ -0,0 +1,17 @@
+export interface UseInterestTagsResult {
+ /**
+ * Ordered, deduped, canonical (trimmed, lowercase) selection. Order is selection
+ * order — it becomes the starter pack stream ID order downstream (#2388).
+ */
+ selectedTags: string[];
+ /** Adds a canonicalized tag if valid, not already selected, and below the cap. */
+ addTag: (raw: string) => void;
+ /** Removes a tag (input canonicalized before matching). */
+ removeTag: (raw: string) => void;
+ /** Adds when unselected, removes when selected. */
+ toggleTag: (raw: string) => void;
+ /** Whether the canonicalized form of the given label is selected. */
+ isSelected: (raw: string) => boolean;
+ /** Whether the selection reached the starter pack tag cap. */
+ isAtLimit: boolean;
+}
diff --git a/src/hooks/useProfileForm/useProfileForm.test.tsx b/src/hooks/useProfileForm/useProfileForm.test.tsx
index 42acb2cfea..c91c2a233a 100644
--- a/src/hooks/useProfileForm/useProfileForm.test.tsx
+++ b/src/hooks/useProfileForm/useProfileForm.test.tsx
@@ -1,11 +1,14 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ONBOARDING_ROUTES, PROFILE_ROUTES } from '@/app/routes';
import { ProfileController } from '@/controllers/profile/profile';
import type { NexusUserDetails } from '@/services/nexus/nexus.types';
import { useProfileForm } from './useProfileForm';
+const routerPush = vi.hoisted(() => vi.fn());
+
vi.mock('next/navigation', () => ({
- useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
+ useRouter: () => ({ push: routerPush, back: vi.fn() }),
}));
vi.mock('@/controllers/auth/auth', () => ({
@@ -81,3 +84,63 @@ describe('useProfileForm profile link safety', () => {
expect(ProfileController.commitUpdate).not.toHaveBeenCalled();
});
});
+
+describe('useProfileForm post-save navigation', () => {
+ const userDetails: NexusUserDetails = {
+ id: pubky,
+ name: 'Valid User',
+ bio: '',
+ links: [],
+ status: null,
+ image: null,
+ indexed_at: 1,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('redirects to the onboarding tags step after a successful create', async () => {
+ const { result } = renderHook(() => useProfileForm({ mode: 'create', pubky, setShowWelcomeDialog: vi.fn() }));
+
+ act(() => {
+ result.current.handlers.setName('Valid User');
+ });
+
+ await act(async () => {
+ await result.current.handlers.handleSubmit();
+ });
+
+ expect(ProfileController.commitCreate).toHaveBeenCalled();
+ expect(routerPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
+ });
+
+ it('redirects to the own profile page after a successful edit by default', async () => {
+ const { result } = renderHook(() => useProfileForm({ mode: 'edit', pubky, userDetails }));
+
+ await waitFor(() => expect(result.current.state.isLoading).toBe(false));
+
+ await act(async () => {
+ await result.current.handlers.handleSubmit();
+ });
+
+ expect(ProfileController.commitUpdate).toHaveBeenCalled();
+ expect(routerPush).toHaveBeenCalledWith(PROFILE_ROUTES.PROFILE);
+ });
+
+ it('honors the edit-mode redirectTo override (onboarding profile revisit)', async () => {
+ const { result } = renderHook(() =>
+ useProfileForm({ mode: 'edit', pubky, userDetails, redirectTo: ONBOARDING_ROUTES.TAGS }),
+ );
+
+ await waitFor(() => expect(result.current.state.isLoading).toBe(false));
+
+ await act(async () => {
+ await result.current.handlers.handleSubmit();
+ });
+
+ expect(ProfileController.commitUpdate).toHaveBeenCalled();
+ expect(ProfileController.commitCreate).not.toHaveBeenCalled();
+ expect(routerPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
+ });
+});
diff --git a/src/hooks/useProfileForm/useProfileForm.tsx b/src/hooks/useProfileForm/useProfileForm.tsx
index 1d08eb5b6e..400bea3567 100644
--- a/src/hooks/useProfileForm/useProfileForm.tsx
+++ b/src/hooks/useProfileForm/useProfileForm.tsx
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
-import { HOME_ROUTES, PROFILE_ROUTES, SETTINGS_ROUTES } from '@/app/routes';
+import { ONBOARDING_ROUTES, PROFILE_ROUTES, SETTINGS_ROUTES } from '@/app/routes';
import { USER_BIO_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_NAME_MIN_LENGTH } from '@/config/user';
import { AuthController } from '@/controllers/auth/auth';
import { FileController } from '@/controllers/file/file';
@@ -44,6 +44,7 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn
const { mode, pubky } = props;
// Extract userDetails for edit mode to avoid object reference issues in useEffect
const userDetails = props.mode === 'edit' ? props.userDetails : undefined;
+ const editRedirectTo = props.mode === 'edit' ? props.redirectTo : undefined;
const setShowWelcomeDialog = props.mode === 'create' ? props.setShowWelcomeDialog : undefined;
const router = useRouter();
@@ -329,7 +330,7 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn
}
await AuthController.bootstrapWithDelay();
setShowWelcomeDialog?.(true);
- router.push(HOME_ROUTES.HOME);
+ router.push(ONBOARDING_ROUTES.TAGS);
} else {
await ProfileController.commitUpdate({
name: user.name,
@@ -352,7 +353,7 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn
toast({
title: 'Profile updated',
});
- router.push(PROFILE_ROUTES.PROFILE);
+ router.push(editRedirectTo ?? PROFILE_ROUTES.PROFILE);
}
} catch (error) {
const sizeLimitMessage = getImageUploadSizeLimitToastMessage(error);
@@ -405,6 +406,7 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn
avatarChanged,
originalAvatarUrl,
userDetails,
+ editRedirectTo,
setShowWelcomeDialog,
router,
toast,
diff --git a/src/hooks/useProfileForm/useProfileForm.types.ts b/src/hooks/useProfileForm/useProfileForm.types.ts
index 3de9450bbc..8981da0729 100644
--- a/src/hooks/useProfileForm/useProfileForm.types.ts
+++ b/src/hooks/useProfileForm/useProfileForm.types.ts
@@ -84,6 +84,8 @@ export interface UseProfileFormPropsCreate extends UseProfileFormPropsBase {
export interface UseProfileFormPropsEdit extends UseProfileFormPropsBase {
mode: 'edit';
userDetails: NexusUserDetails | null | undefined;
+ /** Route to navigate to after a successful save (defaults to the own-profile page). */
+ redirectTo?: string;
}
export type UseProfileFormProps = UseProfileFormPropsCreate | UseProfileFormPropsEdit;