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
65 changes: 51 additions & 14 deletions src/app/onboarding/__tests__/onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,22 @@ vi.mock('next/navigation', () => ({
}),
}));

// Mock analytics hook to verify onboarding event tracking
const mockTrack = vi.fn();

// 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/useAnalytics', () => ({
useAnalytics: () => ({
track: mockTrack,
trackPageView: vi.fn(),
}),
}));

vi.mock('@/hooks/use-notification', () => ({
useNotification: () => ({
success: mockSuccess,
Expand All @@ -28,7 +38,7 @@ vi.mock('@/hooks/use-notification', () => ({

describe('Onboarding Page', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.useRealTimers();
vi.clearAllMocks();
if (typeof window !== 'undefined') {
localStorage.clear();
Expand Down Expand Up @@ -157,20 +167,17 @@ describe('Onboarding Page', () => {
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);
// Verify the wallet button is disabled while connecting
await waitFor(() => {
expect(argentButton).toBeDisabled();
});

// Should display connected address and active state
// Wait for connection simulation to complete
await waitFor(() => {
expect(screen.getByText('Connected Wallet')).toBeInTheDocument();
expect(screen.getByText(/0x04828f731a54/)).toBeInTheDocument();
expect(mockSuccess).toHaveBeenCalledWith('Connected to Argent X successfully!');
});
}, { timeout: 3000 });
});

it('completes onboarding and triggers redirection to dashboard', async () => {
Expand All @@ -195,17 +202,47 @@ describe('Onboarding Page', () => {
// 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');
}, { timeout: 3000 });
});

it('tracks onboarding lifecycle events and continues when analytics fails', async () => {
mockTrack.mockImplementation(() => {
throw new Error('Analytics sink failure');
});

render(<OnboardingPage />);

await waitFor(() => {
expect(mockTrack).toHaveBeenCalledWith('onboarding_started', expect.any(Object));
});

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 })); });
await waitFor(() => {
expect(mockTrack).toHaveBeenCalledWith('onboarding_step_completed', expect.any(Object));
});

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' } });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Next/i })); });
await waitFor(() => {
expect(mockTrack).toHaveBeenCalledWith('onboarding_step_completed', expect.any(Object));
});

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

await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/dashboard');
}, { timeout: 3000 });
});
});
65 changes: 64 additions & 1 deletion src/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React, { useState, useEffect } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import {
Expand All @@ -22,6 +22,8 @@ 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 { useAnalytics } from '@/hooks/useAnalytics';
import type { EventProperties } from '@/utils/analytics';
import type { WizardStep, FieldDescriptor, FormState } from '@/form-management/types/core';

// Define field configuration for onboarding
Expand Down Expand Up @@ -142,6 +144,21 @@ const onboardingSteps: WizardStep[] = [
export default function OnboardingPage() {
const router = useRouter();
const { success, error, loading, dismiss } = useNotification();
const { track } = useAnalytics({ context: { feature: 'onboarding' }, trackPageView: false });
const [currentStep, setCurrentStep] = useState<WizardStep>(onboardingSteps[0]);
const [hasFinishedOnboarding, setHasFinishedOnboarding] = useState(false);
const currentStepRef = React.useRef<WizardStep>(onboardingSteps[0]);

const safeTrack = useCallback(
(name: string, properties: EventProperties = {}) => {
try {
track(name as any, properties);
} catch (err) {
console.warn('[Onboarding Analytics] Failed to track event', err);
}
},
[track],
);

// Initialize state manager and validation engine
const [stateManager] = useState(() => {
Expand Down Expand Up @@ -176,6 +193,36 @@ export default function OnboardingPage() {
document.title = 'User Onboarding - TeachLink';
}, []);

useEffect(() => {
safeTrack('onboarding_started', {
stepId: currentStep.id,
stepIndex: currentStep.index,
stepTitle: currentStep.title,
});
}, [safeTrack, currentStep.id, currentStep.index, currentStep.title]);

useEffect(() => {
currentStepRef.current = currentStep;
}, [currentStep]);

useEffect(() => {
const handleAbandon = () => {
if (!hasFinishedOnboarding) {
safeTrack('onboarding_abandoned', {
stepId: currentStepRef.current.id,
stepIndex: currentStepRef.current.index,
stepTitle: currentStepRef.current.title,
});
}
};

window.addEventListener('beforeunload', handleAbandon);
return () => {
handleAbandon();
window.removeEventListener('beforeunload', handleAbandon);
};
}, [hasFinishedOnboarding, safeTrack]);

const handleFieldChange = async (fieldId: string, value: any) => {
stateManager.updateField(fieldId, value);

Expand Down Expand Up @@ -228,6 +275,14 @@ export default function OnboardingPage() {

dismiss(loadingToastId);
success('Onboarding complete! Welcome to TeachLink.');
setHasFinishedOnboarding(true);
safeTrack('onboarding_completed', {
stepId: currentStep.id,
stepIndex: currentStep.index,
stepTitle: currentStep.title,
role: values.role,
walletConnected: !!values.walletAddress,
});

// Save onboarding preference state locally so other pages know user is onboarded
if (typeof window !== 'undefined') {
Expand Down Expand Up @@ -830,6 +885,14 @@ export default function OnboardingPage() {
formState={formState}
stateManager={stateManager}
fields={onboardingFields}
onStepChange={setCurrentStep}
onStepComplete={(step) =>
safeTrack('onboarding_step_completed', {
stepId: step.id,
stepIndex: step.index,
stepTitle: step.title,
})
}
onComplete={handleComplete}
allowNonLinearNavigation={false}
validateBeforeNext={true}
Expand Down
6 changes: 6 additions & 0 deletions src/components/forms/FormWizardController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface FormWizardControllerProps {
formState: FormState;
stateManager: FormStateManager;
onStepChange?: (step: WizardStep) => void;
onStepComplete?: (step: WizardStep) => void;
onComplete?: (values: Record<string, any>) => void | Promise<void>;
allowNonLinearNavigation?: boolean;
validateBeforeNext?: boolean;
Expand All @@ -36,6 +37,7 @@ export const FormWizardController: React.FC<FormWizardControllerProps> = ({
formState,
stateManager,
onStepChange,
onStepComplete,
onComplete,
allowNonLinearNavigation = false,
validateBeforeNext = true,
Expand Down Expand Up @@ -99,6 +101,7 @@ export const FormWizardController: React.FC<FormWizardControllerProps> = ({
let allValid = true;

for (const fieldId of stepFields) {
stateManager.markFieldTouched(fieldId);
const value = formState.values[fieldId];
const result = await validationEngine.validateField(fieldId, value, formState);
stateManager.setValidationState(fieldId, result);
Expand Down Expand Up @@ -132,6 +135,8 @@ export const FormWizardController: React.FC<FormWizardControllerProps> = ({
setCompletedSteps([...completedSteps, currentStepIndex]);
}

onStepComplete?.(currentStep);

if (currentStep.conditionalNext) {
const nextStepIndex = currentStep.conditionalNext(formState);
setCurrentStepIndex(nextStepIndex);
Expand Down Expand Up @@ -165,6 +170,7 @@ export const FormWizardController: React.FC<FormWizardControllerProps> = ({
const isValid = await validateCurrentStep();
if (!isValid) return;

onStepComplete?.(currentStep);
await completeMutation.mutate(formState.values);
};

Expand Down
2 changes: 1 addition & 1 deletion src/form-management/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A comprehensive TypeScript solution for creating, managing, and processing compl
- **Advanced Validation**: Synchronous and asynchronous validation with custom rules
- **Auto-Save Functionality**: Automatic data persistence and recovery
- **Multi-Step Navigation**: Wizard-style forms with progress tracking
- **Analytics Tracking**: Form interaction and completion metrics
- **Analytics Tracking**: Form interaction and completion metrics, including onboarding lifecycle events
- **Accessibility Support**: Full WCAG compliance and screen reader support
- **Performance Optimized**: Virtual scrolling and lazy loading for large forms
- **Property-Based Testing**: Comprehensive testing with fast-check
Expand Down
10 changes: 10 additions & 0 deletions src/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export type EventName =
| 'login'
| 'logout'
| 'signup'
// Onboarding
| 'onboarding_started'
| 'onboarding_step_completed'
| 'onboarding_completed'
| 'onboarding_abandoned'
// Courses
| 'course_view'
| 'course_started'
Expand Down Expand Up @@ -170,6 +175,11 @@ class Analytics {
this.globalProperties = {};
}

/** Reset adapters to the default console adapter. Useful for tests. */
clearAdapters(): void {
this.adapters = [consoleAdapter];
}

/** Properties merged into every subsequent event */
setGlobalProperties(properties: EventProperties): void {
this.globalProperties = { ...this.globalProperties, ...properties };
Expand Down
Loading