Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default function SignupPage() {
{
onSuccess: () => {
setSuccessMessage('Account created successfully! Redirecting...');
setTimeout(() => router.push('/dashboard'), 1500);
setTimeout(() => router.push('/onboarding'), 1500);
},
},
);
Expand Down
77 changes: 77 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,80 @@ a[data-skip-link]:focus {
border-radius: 0;
}
}

/* ─────────────────────────────────────────────────────────────────────────────
Form Wizard Custom Styles (TeachLink Design System Extension)
───────────────────────────────────────────────────────────────────────────── */

.form-wizard {
@apply max-w-3xl mx-auto bg-white/75 dark:bg-slate-900/75 backdrop-blur-xl rounded-2xl border border-slate-200/50 dark:border-slate-800/50 shadow-2xl p-6 md:p-10 transition-all duration-300;
}

.wizard-progress-bar {
@apply flex items-center justify-between mb-8 border-b border-slate-100 dark:border-slate-800/80 pb-6;
}

.progress-step {
@apply flex flex-col items-center flex-1 relative cursor-pointer group transition-all duration-200;
}

.progress-step:not(:last-child)::after {
content: '';
@apply absolute top-5 left-1/2 w-full h-[2px] bg-slate-200 dark:bg-slate-800 -z-10 transition-all duration-300;
}

.progress-step.completed:not(:last-child)::after {
@apply bg-gradient-to-r from-cyan-500 to-blue-500;
}

.step-indicator {
@apply w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm border-2 border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 text-slate-400 dark:text-slate-600 transition-all duration-300 shadow-sm;
}

.progress-step.current .step-indicator {
@apply border-cyan-500 text-cyan-600 dark:text-cyan-400 scale-110 ring-4 ring-cyan-500/10 dark:ring-cyan-400/10;
}

.progress-step.completed .step-indicator {
@apply border-green-500 bg-green-500 text-white scale-100;
}

.progress-step.accessible:hover .step-indicator {
@apply border-cyan-400 text-cyan-500 dark:text-cyan-300 shadow-md;
}

.step-label {
@apply mt-2 text-xs font-semibold tracking-wide uppercase text-slate-400 dark:text-slate-500 transition-colors duration-200 text-center;
}

.progress-step.current .step-label {
@apply text-cyan-600 dark:text-cyan-400;
}

.progress-step.completed .step-label {
@apply text-slate-700 dark:text-slate-300;
}

.wizard-content {
@apply min-h-[350px] mb-8 transition-all duration-300;
}

.wizard-navigation {
@apply flex items-center justify-between border-t border-slate-100 dark:border-slate-800/80 pt-6;
}

.btn-previous {
@apply px-5 py-2 text-sm md:text-base rounded-lg border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300 font-medium hover:bg-slate-50 dark:hover:bg-slate-800/50 disabled:opacity-30 disabled:pointer-events-none transition-colors duration-200 outline-none;
}

.step-counter {
@apply text-xs md:text-sm font-medium text-slate-500 dark:text-slate-400;
}

.btn-next {
@apply px-6 py-2 text-sm md:text-base rounded-lg bg-gradient-to-r from-cyan-500 to-blue-500 text-white font-semibold shadow-lg shadow-cyan-500/20 hover:from-cyan-600 hover:to-blue-600 hover:shadow-cyan-600/25 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 outline-none;
}

.btn-complete {
@apply px-6 py-2 text-sm md:text-base rounded-lg bg-gradient-to-r from-emerald-500 to-teal-500 text-white font-semibold shadow-lg shadow-emerald-500/20 hover:from-emerald-600 hover:to-teal-600 hover:shadow-emerald-600/25 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 outline-none;
}
211 changes: 211 additions & 0 deletions src/app/onboarding/__tests__/onboarding.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import OnboardingPage from '../page';

// Mock useRouter from next/navigation
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));

// Mock notifications hook to prevent toasted popups during testing
const mockSuccess = vi.fn();
const mockError = vi.fn();
const mockLoading = vi.fn(() => 'toast-loading-id');
const mockDismiss = vi.fn();

vi.mock('@/hooks/use-notification', () => ({
useNotification: () => ({
success: mockSuccess,
error: mockError,
loading: mockLoading,
dismiss: mockDismiss,
}),
}));

describe('Onboarding Page', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
if (typeof window !== 'undefined') {
localStorage.clear();
}
});

afterEach(() => {
vi.useRealTimers();
});

it('renders Step 1 (Personal & Role Setup) successfully on mount', () => {
render(<OnboardingPage />);

expect(screen.getByText('Welcome to Onboarding')).toBeInTheDocument();
expect(screen.getByText('Tell us about yourself')).toBeInTheDocument();
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Date of Birth/i)).toBeInTheDocument();

// Check that we display the two role options
expect(screen.getByText('Student / Learner')).toBeInTheDocument();
expect(screen.getByText('Instructor / Teacher')).toBeInTheDocument();

// Check progress is showing Step 1 of 3
expect(screen.getByText('Step 1 of 3')).toBeInTheDocument();
});

it('shows error messages if required Step 1 fields are missing on Next click', async () => {
render(<OnboardingPage />);

const nextButton = screen.getByRole('button', { name: /Next/i });

await act(async () => {
fireEvent.click(nextButton);
});

// It should trigger validation errors
await waitFor(() => {
expect(screen.getByText('Username is required')).toBeInTheDocument();
expect(screen.getByText('Please select a role to continue')).toBeInTheDocument();
expect(screen.getByText('Date of birth is required')).toBeInTheDocument();
});
});

it('navigates to Step 2 (Preferences) when Step 1 fields are valid', async () => {
render(<OnboardingPage />);

// Fill username
const usernameInput = screen.getByLabelText(/Username/i);
fireEvent.change(usernameInput, { target: { value: 'janedoe' } });
fireEvent.blur(usernameInput);

// Click Student role card
const studentCard = screen.getByText('Student / Learner').closest('button')!;
fireEvent.click(studentCard);

// Fill date of birth
const dobInput = screen.getByLabelText(/Date of Birth/i);
fireEvent.change(dobInput, { target: { value: '1995-05-15' } });
fireEvent.blur(dobInput);

// Click Next
const nextButton = screen.getByRole('button', { name: /Next/i });

await act(async () => {
fireEvent.click(nextButton);
});

// Check we navigated to Step 2
await waitFor(() => {
expect(screen.getByText('Set your preferences')).toBeInTheDocument();
expect(screen.getByText('Step 2 of 3')).toBeInTheDocument();
});
});

it('validates Step 2 and moves to Step 3 (Wallet Connection)', async () => {
render(<OnboardingPage />);

// Step 1 setup
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'janedoe' } });
fireEvent.click(screen.getByText('Student / Learner').closest('button')!);
fireEvent.change(screen.getByLabelText(/Date of Birth/i), { target: { value: '1995-05-15' } });

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Next/i }));
});

// Step 2 validation fails on empty interest
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Next/i }));
});

await waitFor(() => {
expect(screen.getByText('Please select your primary area of interest')).toBeInTheDocument();
});

// Fill Step 2 fields
fireEvent.change(screen.getByLabelText(/Primary Interest/i), { target: { value: 'web3' } });
fireEvent.change(screen.getByLabelText(/Preferred Notification Channel/i), { target: { value: 'email' } });
fireEvent.change(screen.getByLabelText(/Interface Language/i), { target: { value: 'en' } });

// Click Next
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Next/i }));
});

// Check we navigated to Step 3
await waitFor(() => {
expect(screen.getByText('Decentralized Web3 Connection')).toBeInTheDocument();
expect(screen.getByText('Step 3 of 3')).toBeInTheDocument();
});
});

it('simulates Starknet Argent X wallet connection in Step 3', async () => {
render(<OnboardingPage />);

// Fast-track to Step 3
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'janedoe' } });
fireEvent.click(screen.getByText('Student / Learner').closest('button')!);
fireEvent.change(screen.getByLabelText(/Date of Birth/i), { target: { value: '1995-05-15' } });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });

fireEvent.change(screen.getByLabelText(/Primary Interest/i), { target: { value: 'web3' } });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });

// Click connect Argent X
const argentButton = screen.getByRole('button', { name: /Argent X/i });
fireEvent.click(argentButton);

// Verify loading state
expect(screen.getByText('Connecting')).toBeInTheDocument();

// Fast-forward mock connect time (1.5s)
await act(async () => {
vi.advanceTimersByTime(1500);
});

// Should display connected address and active state
await waitFor(() => {
expect(screen.getByText('Connected Wallet')).toBeInTheDocument();
expect(screen.getByText(/0x04828f731a54/)).toBeInTheDocument();
expect(mockSuccess).toHaveBeenCalledWith('Connected to Argent X successfully!');
});
});

it('completes onboarding and triggers redirection to dashboard', async () => {
render(<OnboardingPage />);

// Fast-track to Step 3
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'janedoe' } });
fireEvent.click(screen.getByText('Student / Learner').closest('button')!);
fireEvent.change(screen.getByLabelText(/Date of Birth/i), { target: { value: '1995-05-15' } });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });

fireEvent.change(screen.getByLabelText(/Primary Interest/i), { target: { value: 'web3' } });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });

// Complete onboarding (wallet connection is optional, so we can skip connecting it)
const completeButton = screen.getByRole('button', { name: /Complete/i });

await act(async () => {
fireEvent.click(completeButton);
});

// Check loading indicator shows up
expect(mockLoading).toHaveBeenCalledWith('Finalizing your registration profile...');

// Fast-forward mock registration API time (2.0s)
await act(async () => {
vi.advanceTimersByTime(2000);
});

// Should redirect to dashboard and display success toast
await waitFor(() => {
expect(mockSuccess).toHaveBeenCalledWith('Onboarding complete! Welcome to TeachLink.');
expect(mockPush).toHaveBeenCalledWith('/dashboard');
expect(localStorage.getItem('teachlink_onboarded')).toBe('true');
expect(localStorage.getItem('teachlink_user_role')).toBe('student');
});
});
});
Loading
Loading