diff --git a/src/app/onboarding/__tests__/onboarding.test.tsx b/src/app/onboarding/__tests__/onboarding.test.tsx
index d98046ef..08e1da3d 100644
--- a/src/app/onboarding/__tests__/onboarding.test.tsx
+++ b/src/app/onboarding/__tests__/onboarding.test.tsx
@@ -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,
@@ -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();
@@ -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 () => {
@@ -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();
+
+ 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 });
});
});
diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx
index 3ae363c9..7a3de75c 100644
--- a/src/app/onboarding/page.tsx
+++ b/src/app/onboarding/page.tsx
@@ -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 {
@@ -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
@@ -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(onboardingSteps[0]);
+ const [hasFinishedOnboarding, setHasFinishedOnboarding] = useState(false);
+ const currentStepRef = React.useRef(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(() => {
@@ -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);
@@ -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') {
@@ -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}
diff --git a/src/components/forms/FormWizardController.tsx b/src/components/forms/FormWizardController.tsx
index 09c19106..4b8a3371 100644
--- a/src/components/forms/FormWizardController.tsx
+++ b/src/components/forms/FormWizardController.tsx
@@ -23,6 +23,7 @@ interface FormWizardControllerProps {
formState: FormState;
stateManager: FormStateManager;
onStepChange?: (step: WizardStep) => void;
+ onStepComplete?: (step: WizardStep) => void;
onComplete?: (values: Record) => void | Promise;
allowNonLinearNavigation?: boolean;
validateBeforeNext?: boolean;
@@ -36,6 +37,7 @@ export const FormWizardController: React.FC = ({
formState,
stateManager,
onStepChange,
+ onStepComplete,
onComplete,
allowNonLinearNavigation = false,
validateBeforeNext = true,
@@ -99,6 +101,7 @@ export const FormWizardController: React.FC = ({
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);
@@ -132,6 +135,8 @@ export const FormWizardController: React.FC = ({
setCompletedSteps([...completedSteps, currentStepIndex]);
}
+ onStepComplete?.(currentStep);
+
if (currentStep.conditionalNext) {
const nextStepIndex = currentStep.conditionalNext(formState);
setCurrentStepIndex(nextStepIndex);
@@ -165,6 +170,7 @@ export const FormWizardController: React.FC = ({
const isValid = await validateCurrentStep();
if (!isValid) return;
+ onStepComplete?.(currentStep);
await completeMutation.mutate(formState.values);
};
diff --git a/src/form-management/README.md b/src/form-management/README.md
index 789fa669..da1ba8a0 100644
--- a/src/form-management/README.md
+++ b/src/form-management/README.md
@@ -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
diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts
index a70ba297..6f888cbb 100644
--- a/src/utils/analytics.ts
+++ b/src/utils/analytics.ts
@@ -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'
@@ -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 };