diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index b9db503b..c3eb9692 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -34,7 +34,7 @@ export default function SignupPage() { { onSuccess: () => { setSuccessMessage('Account created successfully! Redirecting...'); - setTimeout(() => router.push('/dashboard'), 1500); + setTimeout(() => router.push('/onboarding'), 1500); }, }, ); diff --git a/src/app/globals.css b/src/app/globals.css index 2dc2e39c..94b7caf5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; +} diff --git a/src/app/onboarding/__tests__/onboarding.test.tsx b/src/app/onboarding/__tests__/onboarding.test.tsx new file mode 100644 index 00000000..d98046ef --- /dev/null +++ b/src/app/onboarding/__tests__/onboarding.test.tsx @@ -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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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'); + }); + }); +}); diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx new file mode 100644 index 00000000..5b8652dc --- /dev/null +++ b/src/app/onboarding/page.tsx @@ -0,0 +1,765 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + User, + GraduationCap, + Calendar, + Check, + Wallet, + LogOut, + Globe, + Bell, + FileText, + Loader2, + BookOpen, + Mail +} from 'lucide-react'; + +import { FormWizardController } from '@/form-management/components'; +import { FormStateManager } from '@/form-management/state/form-state-manager'; +import { ValidationEngineImpl } from '@/form-management/validation/validation-engine'; +import { useNotification } from '@/hooks/use-notification'; +import type { + WizardStep, + FieldDescriptor, + FormState +} from '@/form-management/types/core'; + +// Define field configuration for onboarding +const onboardingFields: FieldDescriptor[] = [ + { + id: 'username', + type: 'text', + label: 'Username', + placeholder: 'Choose a unique username', + required: true, + validation: [ + { type: 'required', message: 'Username is required' }, + { type: 'minLength', message: 'Username must be at least 3 characters', params: { minLength: 3 } }, + { type: 'pattern', message: 'Username can only contain letters, numbers, and underscores', params: { pattern: '^[a-zA-Z0-9_]+$' } } + ] + }, + { + id: 'role', + type: 'select', + label: 'Select Role', + required: true, + validation: [ + { type: 'required', message: 'Please select a role to continue' } + ] + }, + { + id: 'dob', + type: 'date', + label: 'Date of Birth', + required: true, + validation: [ + { type: 'required', message: 'Date of birth is required' } + ] + }, + { + id: 'bio', + type: 'textarea', + label: 'Short Bio', + placeholder: 'Tell us a bit about yourself, your background, or what you want to achieve...', + required: false, + validation: [ + { type: 'maxLength', message: 'Bio must be under 200 characters', params: { maxLength: 200 } } + ] + }, + { + id: 'interest', + type: 'select', + label: 'Learning / Teaching Interest', + required: true, + validation: [ + { type: 'required', message: 'Please select your primary area of interest' } + ] + }, + { + id: 'notifications', + type: 'select', + label: 'Notification Preference', + required: true, + validation: [ + { type: 'required', message: 'Please select a notification channel' } + ] + }, + { + id: 'language', + type: 'select', + label: 'Preferred Language', + required: true, + validation: [ + { type: 'required', message: 'Please select your preferred language' } + ] + }, + { + id: 'newsletter', + type: 'checkbox', + label: 'Subscribe to newsletter', + required: false, + validation: [] + }, + { + id: 'walletAddress', + type: 'text', + label: 'Starknet Wallet Address', + required: false, + validation: [] + } +]; + +// Define Wizard steps +const onboardingSteps: WizardStep[] = [ + { + index: 0, + id: 'personal-setup', + title: 'Personal & Role', + fields: ['username', 'role', 'dob', 'bio'], + isComplete: false, + isValid: false + }, + { + index: 1, + id: 'preferences', + title: 'Preferences', + fields: ['interest', 'notifications', 'language', 'newsletter'], + isComplete: false, + isValid: false + }, + { + index: 2, + id: 'wallet-integration', + title: 'Web3 Wallet', + fields: ['walletAddress'], + isComplete: false, + isValid: false + } +]; + +export default function OnboardingPage() { + const router = useRouter(); + const { success, error, loading, dismiss } = useNotification(); + + // Initialize state manager and validation engine + const [stateManager] = useState(() => { + const manager = new FormStateManager('user-onboarding'); + // Pre-populate with default values if empty + manager.setValues({ + newsletter: false, + language: 'en', + notifications: 'email', + walletAddress: '' + }); + return manager; + }); + + const [validationEngine] = useState(() => new ValidationEngineImpl(onboardingFields)); + const [formState, setFormState] = useState(stateManager.getState()); + + // Custom states for interactive elements + const [connectingWallet, setConnectingWallet] = useState<'argent' | 'braavos' | null>(null); + const [activeWallet, setActiveWallet] = useState<'argent' | 'braavos' | null>(null); + + // Subscribe to form state updates + useEffect(() => { + const subscription = stateManager.subscribeToChanges(() => { + setFormState(stateManager.getState()); + }); + return () => subscription.unsubscribe(); + }, [stateManager]); + + // Set document title for SEO/A11y + useEffect(() => { + document.title = 'User Onboarding - TeachLink'; + }, []); + + const handleFieldChange = async (fieldId: string, value: any) => { + stateManager.updateField(fieldId, value); + + // Perform real-time validation + const result = await validationEngine.validateField( + fieldId, + value, + stateManager.getState() + ); + stateManager.setValidationState(fieldId, result); + }; + + const handleFieldBlur = async (fieldId: string) => { + stateManager.markFieldTouched(fieldId); + + const value = stateManager.getFieldValue(fieldId); + const result = await validationEngine.validateField( + fieldId, + value, + stateManager.getState() + ); + stateManager.setValidationState(fieldId, result); + }; + + // Mock Starknet wallet connection + const handleConnectWallet = (walletType: 'argent' | 'braavos') => { + setConnectingWallet(walletType); + + // Simulate extension pop-up connection delay + setTimeout(() => { + let mockAddress = ''; + if (walletType === 'argent') { + mockAddress = '0x04828f731a54fd38cb6249c589b252df23fcd779fdfb746c0d0246a48b5ffb27'; + } else { + mockAddress = '0x01b87a8e734c56e3b8a6a69ef8148b3294c65330e791b8a53e6bcf12921a9876'; + } + + handleFieldChange('walletAddress', mockAddress); + setActiveWallet(walletType); + setConnectingWallet(null); + success(`Connected to ${walletType === 'argent' ? 'Argent X' : 'Braavos'} successfully!`); + }, 1500); + }; + + const handleDisconnectWallet = () => { + handleFieldChange('walletAddress', ''); + setActiveWallet(null); + success('Wallet disconnected'); + }; + + // Final onboarding submission + const handleComplete = async (values: Record) => { + const loadingToastId = loading('Finalizing your registration profile...'); + + try { + // Simulate API registration request delay + await new Promise((resolve) => setTimeout(resolve, 2000)); + + dismiss(loadingToastId); + success('Onboarding complete! Welcome to TeachLink.'); + + // Save onboarding preference state locally so other pages know user is onboarded + if (typeof window !== 'undefined') { + localStorage.setItem('teachlink_onboarded', 'true'); + localStorage.setItem('teachlink_user_role', values.role); + } + + // Redirect to main dashboard + router.push('/dashboard'); + } catch (err) { + dismiss(loadingToastId); + error('Failed to complete onboarding. Please try again.'); + } + }; + + // Step 1 UI Renderer + const renderPersonalStep = () => { + const usernameError = formState.touched.username && formState.validation.username && !formState.validation.username.isValid; + const roleError = formState.touched.role && formState.validation.role && !formState.validation.role.isValid; + const dobError = formState.touched.dob && formState.validation.dob && !formState.validation.dob.isValid; + const bioError = formState.touched.bio && formState.validation.bio && !formState.validation.bio.isValid; + + return ( +
+
+

Tell us about yourself

+

Establish your basic profile parameters.

+
+ + {/* Username */} +
+ +
+
+ +
+ handleFieldChange('username', e.target.value)} + onBlur={() => handleFieldBlur('username')} + aria-invalid={!!usernameError} + aria-describedby={usernameError ? 'username-error' : undefined} + className={`w-full pl-10 pr-4 py-3 bg-white dark:bg-slate-900 border ${ + usernameError ? 'border-red-500 ring-2 ring-red-500/10' : 'border-slate-200 dark:border-slate-800/80' + } rounded-lg outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all dark:text-slate-100`} + /> +
+ {usernameError && ( +

+ {formState.validation.username.errors[0]?.message} +

+ )} +
+ + {/* Role Selection */} +
+ +
+ {/* Student Card */} + + + {/* Instructor Card */} + +
+ {roleError && ( +

+ {formState.validation.role.errors[0]?.message} +

+ )} +
+ + {/* Date of Birth */} +
+ +
+
+ +
+ handleFieldChange('dob', e.target.value)} + onBlur={() => handleFieldBlur('dob')} + aria-invalid={!!dobError} + aria-describedby={dobError ? 'dob-error' : undefined} + className={`w-full pl-10 pr-4 py-3 bg-white dark:bg-slate-900 border ${ + dobError ? 'border-red-500 ring-2 ring-red-500/10' : 'border-slate-200 dark:border-slate-800/80' + } rounded-lg outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all dark:text-slate-100`} + /> +
+ {dobError && ( +

+ {formState.validation.dob.errors[0]?.message} +

+ )} +
+ + {/* Short Bio */} +
+ +
+
+ +
+