Skip to content

Commit fcdcf53

Browse files
committed
feat(onboarding): implement multi-step user onboarding registration wizard
1 parent 2689138 commit fcdcf53

6 files changed

Lines changed: 1066 additions & 4 deletions

File tree

src/app/(auth)/signup/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export default function SignupPage() {
3434
{
3535
onSuccess: () => {
3636
setSuccessMessage('Account created successfully! Redirecting...');
37-
setTimeout(() => router.push('/dashboard'), 1500);
37+
setTimeout(() => router.push('/onboarding'), 1500);
3838
},
3939
},
4040
);

src/app/globals.css

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,80 @@ a[data-skip-link]:focus {
185185
border-radius: 0;
186186
}
187187
}
188+
189+
/* ─────────────────────────────────────────────────────────────────────────────
190+
Form Wizard Custom Styles (TeachLink Design System Extension)
191+
───────────────────────────────────────────────────────────────────────────── */
192+
193+
.form-wizard {
194+
@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;
195+
}
196+
197+
.wizard-progress-bar {
198+
@apply flex items-center justify-between mb-8 border-b border-slate-100 dark:border-slate-800/80 pb-6;
199+
}
200+
201+
.progress-step {
202+
@apply flex flex-col items-center flex-1 relative cursor-pointer group transition-all duration-200;
203+
}
204+
205+
.progress-step:not(:last-child)::after {
206+
content: '';
207+
@apply absolute top-5 left-1/2 w-full h-[2px] bg-slate-200 dark:bg-slate-800 -z-10 transition-all duration-300;
208+
}
209+
210+
.progress-step.completed:not(:last-child)::after {
211+
@apply bg-gradient-to-r from-cyan-500 to-blue-500;
212+
}
213+
214+
.step-indicator {
215+
@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;
216+
}
217+
218+
.progress-step.current .step-indicator {
219+
@apply border-cyan-500 text-cyan-600 dark:text-cyan-400 scale-110 ring-4 ring-cyan-500/10 dark:ring-cyan-400/10;
220+
}
221+
222+
.progress-step.completed .step-indicator {
223+
@apply border-green-500 bg-green-500 text-white scale-100;
224+
}
225+
226+
.progress-step.accessible:hover .step-indicator {
227+
@apply border-cyan-400 text-cyan-500 dark:text-cyan-300 shadow-md;
228+
}
229+
230+
.step-label {
231+
@apply mt-2 text-xs font-semibold tracking-wide uppercase text-slate-400 dark:text-slate-500 transition-colors duration-200 text-center;
232+
}
233+
234+
.progress-step.current .step-label {
235+
@apply text-cyan-600 dark:text-cyan-400;
236+
}
237+
238+
.progress-step.completed .step-label {
239+
@apply text-slate-700 dark:text-slate-300;
240+
}
241+
242+
.wizard-content {
243+
@apply min-h-[350px] mb-8 transition-all duration-300;
244+
}
245+
246+
.wizard-navigation {
247+
@apply flex items-center justify-between border-t border-slate-100 dark:border-slate-800/80 pt-6;
248+
}
249+
250+
.btn-previous {
251+
@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;
252+
}
253+
254+
.step-counter {
255+
@apply text-xs md:text-sm font-medium text-slate-500 dark:text-slate-400;
256+
}
257+
258+
.btn-next {
259+
@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;
260+
}
261+
262+
.btn-complete {
263+
@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;
264+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
3+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
4+
import OnboardingPage from '../page';
5+
6+
// Mock useRouter from next/navigation
7+
const mockPush = vi.fn();
8+
vi.mock('next/navigation', () => ({
9+
useRouter: () => ({
10+
push: mockPush,
11+
}),
12+
}));
13+
14+
// Mock notifications hook to prevent toasted popups during testing
15+
const mockSuccess = vi.fn();
16+
const mockError = vi.fn();
17+
const mockLoading = vi.fn(() => 'toast-loading-id');
18+
const mockDismiss = vi.fn();
19+
20+
vi.mock('@/hooks/use-notification', () => ({
21+
useNotification: () => ({
22+
success: mockSuccess,
23+
error: mockError,
24+
loading: mockLoading,
25+
dismiss: mockDismiss,
26+
}),
27+
}));
28+
29+
describe('Onboarding Page', () => {
30+
beforeEach(() => {
31+
vi.useFakeTimers();
32+
vi.clearAllMocks();
33+
if (typeof window !== 'undefined') {
34+
localStorage.clear();
35+
}
36+
});
37+
38+
afterEach(() => {
39+
vi.useRealTimers();
40+
});
41+
42+
it('renders Step 1 (Personal & Role Setup) successfully on mount', () => {
43+
render(<OnboardingPage />);
44+
45+
expect(screen.getByText('Welcome to Onboarding')).toBeInTheDocument();
46+
expect(screen.getByText('Tell us about yourself')).toBeInTheDocument();
47+
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
48+
expect(screen.getByLabelText(/Date of Birth/i)).toBeInTheDocument();
49+
50+
// Check that we display the two role options
51+
expect(screen.getByText('Student / Learner')).toBeInTheDocument();
52+
expect(screen.getByText('Instructor / Teacher')).toBeInTheDocument();
53+
54+
// Check progress is showing Step 1 of 3
55+
expect(screen.getByText('Step 1 of 3')).toBeInTheDocument();
56+
});
57+
58+
it('shows error messages if required Step 1 fields are missing on Next click', async () => {
59+
render(<OnboardingPage />);
60+
61+
const nextButton = screen.getByRole('button', { name: /Next/i });
62+
63+
await act(async () => {
64+
fireEvent.click(nextButton);
65+
});
66+
67+
// It should trigger validation errors
68+
await waitFor(() => {
69+
expect(screen.getByText('Username is required')).toBeInTheDocument();
70+
expect(screen.getByText('Please select a role to continue')).toBeInTheDocument();
71+
expect(screen.getByText('Date of birth is required')).toBeInTheDocument();
72+
});
73+
});
74+
75+
it('navigates to Step 2 (Preferences) when Step 1 fields are valid', async () => {
76+
render(<OnboardingPage />);
77+
78+
// Fill username
79+
const usernameInput = screen.getByLabelText(/Username/i);
80+
fireEvent.change(usernameInput, { target: { value: 'janedoe' } });
81+
fireEvent.blur(usernameInput);
82+
83+
// Click Student role card
84+
const studentCard = screen.getByText('Student / Learner').closest('button')!;
85+
fireEvent.click(studentCard);
86+
87+
// Fill date of birth
88+
const dobInput = screen.getByLabelText(/Date of Birth/i);
89+
fireEvent.change(dobInput, { target: { value: '1995-05-15' } });
90+
fireEvent.blur(dobInput);
91+
92+
// Click Next
93+
const nextButton = screen.getByRole('button', { name: /Next/i });
94+
95+
await act(async () => {
96+
fireEvent.click(nextButton);
97+
});
98+
99+
// Check we navigated to Step 2
100+
await waitFor(() => {
101+
expect(screen.getByText('Set your preferences')).toBeInTheDocument();
102+
expect(screen.getByText('Step 2 of 3')).toBeInTheDocument();
103+
});
104+
});
105+
106+
it('validates Step 2 and moves to Step 3 (Wallet Connection)', async () => {
107+
render(<OnboardingPage />);
108+
109+
// Step 1 setup
110+
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'janedoe' } });
111+
fireEvent.click(screen.getByText('Student / Learner').closest('button')!);
112+
fireEvent.change(screen.getByLabelText(/Date of Birth/i), { target: { value: '1995-05-15' } });
113+
114+
await act(async () => {
115+
fireEvent.click(screen.getByRole('button', { name: /Next/i }));
116+
});
117+
118+
// Step 2 validation fails on empty interest
119+
await act(async () => {
120+
fireEvent.click(screen.getByRole('button', { name: /Next/i }));
121+
});
122+
123+
await waitFor(() => {
124+
expect(screen.getByText('Please select your primary area of interest')).toBeInTheDocument();
125+
});
126+
127+
// Fill Step 2 fields
128+
fireEvent.change(screen.getByLabelText(/Primary Interest/i), { target: { value: 'web3' } });
129+
fireEvent.change(screen.getByLabelText(/Preferred Notification Channel/i), { target: { value: 'email' } });
130+
fireEvent.change(screen.getByLabelText(/Interface Language/i), { target: { value: 'en' } });
131+
132+
// Click Next
133+
await act(async () => {
134+
fireEvent.click(screen.getByRole('button', { name: /Next/i }));
135+
});
136+
137+
// Check we navigated to Step 3
138+
await waitFor(() => {
139+
expect(screen.getByText('Decentralized Web3 Connection')).toBeInTheDocument();
140+
expect(screen.getByText('Step 3 of 3')).toBeInTheDocument();
141+
});
142+
});
143+
144+
it('simulates Starknet Argent X wallet connection in Step 3', async () => {
145+
render(<OnboardingPage />);
146+
147+
// Fast-track to Step 3
148+
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'janedoe' } });
149+
fireEvent.click(screen.getByText('Student / Learner').closest('button')!);
150+
fireEvent.change(screen.getByLabelText(/Date of Birth/i), { target: { value: '1995-05-15' } });
151+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });
152+
153+
fireEvent.change(screen.getByLabelText(/Primary Interest/i), { target: { value: 'web3' } });
154+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });
155+
156+
// Click connect Argent X
157+
const argentButton = screen.getByRole('button', { name: /Argent X/i });
158+
fireEvent.click(argentButton);
159+
160+
// Verify loading state
161+
expect(screen.getByText('Connecting')).toBeInTheDocument();
162+
163+
// Fast-forward mock connect time (1.5s)
164+
await act(async () => {
165+
vi.advanceTimersByTime(1500);
166+
});
167+
168+
// Should display connected address and active state
169+
await waitFor(() => {
170+
expect(screen.getByText('Connected Wallet')).toBeInTheDocument();
171+
expect(screen.getByText(/0x04828f731a54/)).toBeInTheDocument();
172+
expect(mockSuccess).toHaveBeenCalledWith('Connected to Argent X successfully!');
173+
});
174+
});
175+
176+
it('completes onboarding and triggers redirection to dashboard', async () => {
177+
render(<OnboardingPage />);
178+
179+
// Fast-track to Step 3
180+
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'janedoe' } });
181+
fireEvent.click(screen.getByText('Student / Learner').closest('button')!);
182+
fireEvent.change(screen.getByLabelText(/Date of Birth/i), { target: { value: '1995-05-15' } });
183+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });
184+
185+
fireEvent.change(screen.getByLabelText(/Primary Interest/i), { target: { value: 'web3' } });
186+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });
187+
188+
// Complete onboarding (wallet connection is optional, so we can skip connecting it)
189+
const completeButton = screen.getByRole('button', { name: /Complete/i });
190+
191+
await act(async () => {
192+
fireEvent.click(completeButton);
193+
});
194+
195+
// Check loading indicator shows up
196+
expect(mockLoading).toHaveBeenCalledWith('Finalizing your registration profile...');
197+
198+
// Fast-forward mock registration API time (2.0s)
199+
await act(async () => {
200+
vi.advanceTimersByTime(2000);
201+
});
202+
203+
// Should redirect to dashboard and display success toast
204+
await waitFor(() => {
205+
expect(mockSuccess).toHaveBeenCalledWith('Onboarding complete! Welcome to TeachLink.');
206+
expect(mockPush).toHaveBeenCalledWith('/dashboard');
207+
expect(localStorage.getItem('teachlink_onboarded')).toBe('true');
208+
expect(localStorage.getItem('teachlink_user_role')).toBe('student');
209+
});
210+
});
211+
});

0 commit comments

Comments
 (0)