From ea67a94b55a81acf591eed92fd1148e67f3bd5fc Mon Sep 17 00:00:00 2001 From: Edukpe David Date: Thu, 25 Jun 2026 15:51:59 +0000 Subject: [PATCH 1/2] feat: Integrate analytics tracking into user onboarding flow - Added new analytics event types for onboarding lifecycle tracking: - onboarding_started: Track when user initiates onboarding - onboarding_step_completed: Track completion of each step - onboarding_completed: Track successful onboarding completion - onboarding_abandoned: Track when user leaves without completing - Integrated useAnalytics hook into OnboardingPage component - Added safe event tracking with error handling to prevent analytics failures from blocking onboarding - Added onStepChange and onStepComplete callbacks to FormWizardController - Track step details (id, index, title) and completion metadata - Added comprehensive test coverage for analytics event tracking - Verified all 7 onboarding tests pass with proper timer handling This enables analytics teams to track user onboarding funnel completion rates and identify drop-off points. --- .../onboarding/__tests__/onboarding.test.tsx | 65 +++++++++++++++---- src/app/onboarding/page.tsx | 62 +++++++++++++++++- src/components/forms/FormWizardController.tsx | 6 ++ src/form-management/README.md | 2 +- src/utils/analytics.ts | 10 +++ 5 files changed, 129 insertions(+), 16 deletions(-) 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..f4c4594e 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, { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -22,6 +22,7 @@ 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 { WizardStep, FieldDescriptor, FormState } from '@/form-management/types/core'; // Define field configuration for onboarding @@ -142,6 +143,18 @@ 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 = (name: string, properties: Record = {}) => { + try { + track(name as any, properties); + } catch (err) { + console.warn('[Onboarding Analytics] Failed to track event', err); + } + }; // Initialize state manager and validation engine const [stateManager] = useState(() => { @@ -176,6 +189,37 @@ export default function OnboardingPage() { document.title = 'User Onboarding - TeachLink'; }, []); + useEffect(() => { + safeTrack('onboarding_started', { + stepId: currentStep.id, + stepIndex: currentStep.index, + stepTitle: currentStep.title, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + 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]); + const handleFieldChange = async (fieldId: string, value: any) => { stateManager.updateField(fieldId, value); @@ -228,6 +272,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 +882,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 }; From 60d42a42c63f682d539c390fbe733e8172e3a9b6 Mon Sep 17 00:00:00 2001 From: dedukpe Date: Fri, 26 Jun 2026 16:07:09 +0100 Subject: [PATCH 2/2] feat(onboarding): add analytics integration tracking --- src/app/onboarding/page.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index f4c4594e..7a3de75c 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -23,6 +23,7 @@ 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 @@ -148,13 +149,16 @@ export default function OnboardingPage() { const [hasFinishedOnboarding, setHasFinishedOnboarding] = useState(false); const currentStepRef = React.useRef(onboardingSteps[0]); - const safeTrack = (name: string, properties: Record = {}) => { - try { - track(name as any, properties); - } catch (err) { - console.warn('[Onboarding Analytics] Failed to track event', err); - } - }; + 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(() => { @@ -195,8 +199,7 @@ export default function OnboardingPage() { stepIndex: currentStep.index, stepTitle: currentStep.title, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [safeTrack, currentStep.id, currentStep.index, currentStep.title]); useEffect(() => { currentStepRef.current = currentStep; @@ -218,7 +221,7 @@ export default function OnboardingPage() { handleAbandon(); window.removeEventListener('beforeunload', handleAbandon); }; - }, [hasFinishedOnboarding]); + }, [hasFinishedOnboarding, safeTrack]); const handleFieldChange = async (fieldId: string, value: any) => { stateManager.updateField(fieldId, value);