Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/app/onboarding/tags/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <TagsOfInterest />;
}
2 changes: 2 additions & 0 deletions src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum ONBOARDING_ROUTES {
PUBKY = '/onboarding/pubky',
SCAN = '/onboarding/scan',
HUMAN = '/onboarding/human',
TAGS = '/onboarding/tags',
}

export enum AUTH_ROUTES {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/components/molecules/Header/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/components/molecules/Header/Header.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ exports[`Header Components - Snapshots > matches snapshot for HeaderOnboarding 1
<div
data-current="3"
data-testid="progress-steps"
data-total="5"
data-total="4"
>
Progress Steps
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/molecules/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const HeaderTitle = ({ currentTitle }: { currentTitle: string }) => {
);
};
export const HeaderOnboarding = ({ currentStep }: { currentStep: number }) => {
return <ProgressSteps currentStep={currentStep} totalSteps={5} />;
return <ProgressSteps currentStep={currentStep} totalSteps={4} />;
};
export function HeaderSocialLinks({ ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
Expand Down
4 changes: 2 additions & 2 deletions src/components/molecules/PostTag/PostTag.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { HTMLAttributes, MouseEvent } from 'react';
import type { ButtonHTMLAttributes, MouseEvent } from 'react';

export interface PostTagProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onClick' | 'color'> {
export interface PostTagProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'color'> {
/** Tag label text */
label: string;
/** Number of posts with this tag (optional) */
Expand Down
102 changes: 95 additions & 7 deletions src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -460,12 +469,16 @@ describe('CreateProfileForm', () => {
vi.mocked(useAuthStore).mockReturnValue({
selectCurrentUserPubky: vi.fn(() => mockPubky),
});
vi.mocked(useAuthStore.getState).mockReturnValue(
asOpaque<ReturnType<typeof useAuthStore.getState>>({ 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();
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ReturnType<typeof useAuthStore.getState>>({ hasProfile: true }),
);
vi.mocked(useCurrentUserProfile).mockReturnValue({
userDetails: revisitUserDetails,
currentUserPubky: mockPubky,
});
});

it('renders in edit mode prefilled from the current user details', async () => {
render(<CreateProfileForm />);

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(<CreateProfileForm />);

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(<CreateProfileForm />);

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();
});
});
});
23 changes: 17 additions & 6 deletions src/components/organisms/CreateProfileForm/CreateProfileForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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 },
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const avatarFallbackSeed = pubky || state.name || 'user';
const avatarFallbackInitial =
extractInitials({
Expand Down Expand Up @@ -206,7 +216,8 @@ export const CreateProfileForm = () => {
</Card>
<ProfileNavigation
className="onboarding-nav mt-auto flex-col sm:flex-row lg:pt-0"
backButtonDisabled={true}
backButtonDisabled={!isRevisit}
onHandleBackButton={isRevisit ? () => router.push(ONBOARDING_ROUTES.TAGS) : undefined}
continueButtonDisabled={isSubmitDisabled}
continueButtonLoading={state.isSaving}
continueText={state.submitText}
Expand Down
12 changes: 7 additions & 5 deletions src/components/organisms/Header/Header.constants.ts
Original file line number Diff line number Diff line change
@@ -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<string, { step: number; title: string }> = {
'/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' },
};
Loading
Loading