diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx index 8e357edc..36e86bd1 100644 --- a/src/app/analytics/page.tsx +++ b/src/app/analytics/page.tsx @@ -31,7 +31,29 @@ import { KPICard } from '@/components/KPICard'; import { useWallet } from '@/hooks/useWallet'; import AnalyticsTrendLineChart from '@/components/analytics/AnalyticsTrendLineChart'; import AnalyticsTrendBarChart from '@/components/analytics/AnalyticsTrendBarChart'; -import { KeyboardShortcutsOverlay } from '@/components/shell/KeyboardShortcutsOverlay'; +import { usePageTour, type PageTourStep } from '@/hooks/usePageTour'; +import { GuidedTour } from '@/components/onboarding/GuidedTour'; + +const ANALYTICS_TOUR_STEPS: PageTourStep[] = [ + { + targetSelector: '[data-testid="analytics-view-toggle"]', + title: 'Switch views', + content: 'Toggle between "My Stats" (your own commitments) and "Protocol" (protocol-wide) analytics.', + position: 'bottom', + }, + { + targetSelector: '[data-testid="analytics-kpi-section"]', + title: 'Key metrics', + content: 'These cards summarize your commitment activity at a glance -- totals, active count, value committed, and fees earned.', + position: 'bottom', + }, + { + targetSelector: '[data-testid="analytics-charts-section"]', + title: 'Trend charts', + content: 'Track how your compliance score and earned fees have moved over recent periods.', + position: 'top', + }, +]; // ============================================================================ // TYPES @@ -138,6 +160,7 @@ function ViewToggle({ value, onChange, disabled }: ViewToggleProps) {
{(['user', 'protocol'] as ViewMode[]).map((mode) => { @@ -252,7 +275,7 @@ function UserAnalyticsView({ data, state, onRetry, hasWallet }: UserAnalyticsVie return (
{/* KPI Cards */} -
+
{/* Trend Charts */} -
+
(null); const [protocolState, setProtocolState] = useState('idle'); + const { + isActive: isTourActive, + currentStepIndex: tourStepIndex, + currentStep: tourStep, + totalSteps: tourTotalSteps, + startTour, + nextStep: nextTourStep, + prevStep: prevTourStep, + skipTour, + } = usePageTour(ANALYTICS_TOUR_STEPS, 'commitlabs:seen-analytics-tour'); + // ─── Fetch user analytics ───────────────────────────────────────────────── const fetchUserAnalytics = useCallback(async () => { if (!address) return; @@ -548,14 +582,34 @@ export default function AnalyticsPage() {

Analytics

- +
+ + +
+ + {/* Body */}
{/* Toggle-while-loading notice */} diff --git a/src/components/onboarding/GuidedTour.test.tsx b/src/components/onboarding/GuidedTour.test.tsx new file mode 100644 index 00000000..927cd81f --- /dev/null +++ b/src/components/onboarding/GuidedTour.test.tsx @@ -0,0 +1,97 @@ +/** + * @vitest-environment happy-dom + */ + +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { GuidedTour, type GuidedTourStepLike } from '@/components/onboarding/GuidedTour'; + +const STEP: GuidedTourStepLike = { + targetSelector: '#target', + title: 'Step title', + content: 'Step content', +}; + +function renderTour(overrides: Partial> = {}) { + const props: React.ComponentProps = { + isActive: true, + currentStepIndex: 0, + currentStepConfig: STEP, + totalSteps: 3, + onNext: vi.fn(), + onBack: vi.fn(), + onSkip: vi.fn(), + ...overrides, + }; + return { props, ...render() }; +} + +describe('GuidedTour', () => { + afterEach(() => { + cleanup(); + }); + + it('renders nothing when inactive', () => { + renderTour({ isActive: false }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('renders nothing when there is no current step', () => { + renderTour({ currentStepConfig: null }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('renders the dialog with the step title, content, and progress', () => { + renderTour({ currentStepIndex: 1, totalSteps: 3 }); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(screen.getByText('Step title')).toBeTruthy(); + expect(screen.getByText('Step content')).toBeTruthy(); + expect(screen.getByText('Step 2 of 3')).toBeTruthy(); + }); + + it('hides the Back button on the first step', () => { + renderTour({ currentStepIndex: 0 }); + expect(screen.queryByTestId('tour-back')).toBeNull(); + }); + + it('shows the Back button after the first step and calls onBack', () => { + const { props } = renderTour({ currentStepIndex: 1 }); + const backButton = screen.getByTestId('tour-back'); + fireEvent.click(backButton); + expect(props.onBack).toHaveBeenCalledTimes(1); + }); + + it('labels the last step\'s advance button "Finish"', () => { + renderTour({ currentStepIndex: 2, totalSteps: 3 }); + expect(screen.getByTestId('tour-next').textContent).toBe('Finish'); + }); + + it('labels a non-final step\'s advance button "Next" and calls onNext', () => { + const { props } = renderTour({ currentStepIndex: 0, totalSteps: 3 }); + const nextButton = screen.getByTestId('tour-next'); + expect(nextButton.textContent).toBe('Next'); + fireEvent.click(nextButton); + expect(props.onNext).toHaveBeenCalledTimes(1); + }); + + it('calls onSkip when "Skip tour" is clicked', () => { + const { props } = renderTour(); + fireEvent.click(screen.getByTestId('tour-skip')); + expect(props.onSkip).toHaveBeenCalledTimes(1); + }); + + it('scrolls the target element into view when "Show me on the page" is clicked', () => { + const target = document.createElement('div'); + target.id = 'target'; + const scrollIntoView = vi.fn(); + target.scrollIntoView = scrollIntoView; + document.body.appendChild(target); + + renderTour(); + fireEvent.click(screen.getByText('Show me on the page')); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); + + document.body.removeChild(target); + }); +}); diff --git a/src/components/onboarding/GuidedTour.tsx b/src/components/onboarding/GuidedTour.tsx new file mode 100644 index 00000000..1461afb9 --- /dev/null +++ b/src/components/onboarding/GuidedTour.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Dialog } from '@/components/ui/Dialog'; + +export interface GuidedTourStepLike { + targetSelector: string; + title: string; + content: string; + position?: 'top' | 'bottom' | 'left' | 'right'; +} + +export interface GuidedTourProps { + isActive: boolean; + currentStepIndex: number; + currentStepConfig: GuidedTourStepLike | null | undefined; + totalSteps: number; + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +/** + * Step-by-step tour dialog, shared by the create-wizard tour + * (`useGuidedTour`) and any page using the generic `usePageTour` hook. + * + * Renders as an accessible modal dialog (via the shared `Dialog` primitive: + * focus trap, Escape-to-close, background `inert`) rather than a tooltip + * anchored to `targetSelector` -- `targetSelector` is used only to scroll + * the referenced element into view so it's visible behind/around the + * dialog, not for precise pixel positioning. + */ +export function GuidedTour({ + isActive, + currentStepIndex, + currentStepConfig, + totalSteps, + onNext, + onBack, + onSkip, +}: GuidedTourProps) { + if (!isActive || !currentStepConfig) return null; + + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex >= totalSteps - 1; + + const handleScrollToTarget = () => { + if (typeof document === 'undefined') return; + const target = document.querySelector(currentStepConfig.targetSelector); + target?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }; + + return ( + +

+ Step {currentStepIndex + 1} of {totalSteps} +

+

+ {currentStepConfig.title} +

+

+ {currentStepConfig.content} +

+ + + +
+ +
+ {!isFirstStep && ( + + )} + +
+
+
+ ); +} diff --git a/src/hooks/usePageTour.test.ts b/src/hooks/usePageTour.test.ts new file mode 100644 index 00000000..13ad1089 --- /dev/null +++ b/src/hooks/usePageTour.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom + +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { usePageTour, type PageTourStep } from '@/hooks/usePageTour'; + +const STEPS: PageTourStep[] = [ + { targetSelector: '#a', title: 'A', content: 'First step' }, + { targetSelector: '#b', title: 'B', content: 'Second step' }, + { targetSelector: '#c', title: 'C', content: 'Third step' }, +]; + +describe('usePageTour', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('starts inactive with no current step', () => { + const { result } = renderHook(() => usePageTour(STEPS, 'test-tour')); + expect(result.current.isActive).toBe(false); + expect(result.current.currentStep).toBeNull(); + expect(result.current.totalSteps).toBe(3); + }); + + it('reads previously-seen state from localStorage', () => { + localStorage.setItem('test-tour', 'true'); + const { result } = renderHook(() => usePageTour(STEPS, 'test-tour')); + expect(result.current.hasSeenTour).toBe(true); + }); + + it('startTour activates the first step', () => { + const { result } = renderHook(() => usePageTour(STEPS, 'test-tour')); + + act(() => { + result.current.startTour(); + }); + + expect(result.current.isActive).toBe(true); + expect(result.current.currentStepIndex).toBe(0); + expect(result.current.currentStep).toEqual(STEPS[0]); + }); + + it('nextStep advances through steps and ends the tour after the last one', () => { + const { result } = renderHook(() => usePageTour(STEPS, 'test-tour')); + + act(() => { + result.current.startTour(); + }); + act(() => { + result.current.nextStep(); + }); + expect(result.current.currentStepIndex).toBe(1); + expect(result.current.currentStep).toEqual(STEPS[1]); + + act(() => { + result.current.nextStep(); + }); + expect(result.current.currentStepIndex).toBe(2); + + act(() => { + result.current.nextStep(); + }); + expect(result.current.isActive).toBe(false); + expect(result.current.hasSeenTour).toBe(true); + expect(localStorage.getItem('test-tour')).toBe('true'); + }); + + it('prevStep moves backward and clamps at the first step', () => { + const { result } = renderHook(() => usePageTour(STEPS, 'test-tour')); + + act(() => { + result.current.startTour(); + result.current.nextStep(); + }); + expect(result.current.currentStepIndex).toBe(1); + + act(() => { + result.current.prevStep(); + }); + expect(result.current.currentStepIndex).toBe(0); + + act(() => { + result.current.prevStep(); + }); + expect(result.current.currentStepIndex).toBe(0); + }); + + it('skipTour ends the tour immediately and persists seen state', () => { + const { result } = renderHook(() => usePageTour(STEPS, 'test-tour')); + + act(() => { + result.current.startTour(); + result.current.skipTour(); + }); + + expect(result.current.isActive).toBe(false); + expect(result.current.hasSeenTour).toBe(true); + expect(localStorage.getItem('test-tour')).toBe('true'); + }); + + it('startTour is a no-op when there are no steps', () => { + const { result } = renderHook(() => usePageTour([], 'empty-tour')); + + act(() => { + result.current.startTour(); + }); + + expect(result.current.isActive).toBe(false); + }); + + it('uses independent storage keys per page', () => { + localStorage.setItem('tour-a', 'true'); + const { result } = renderHook(() => usePageTour(STEPS, 'tour-b')); + expect(result.current.hasSeenTour).toBe(false); + }); +}); diff --git a/src/hooks/usePageTour.ts b/src/hooks/usePageTour.ts new file mode 100644 index 00000000..8e8f2bbe --- /dev/null +++ b/src/hooks/usePageTour.ts @@ -0,0 +1,88 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +export interface PageTourStep { + targetSelector: string; + title: string; + content: string; + position?: 'top' | 'bottom' | 'left' | 'right'; +} + +/** + * Generic, page-agnostic guided-tour hook. + * + * Unlike the create-wizard's `useGuidedTour` (which is coupled to the + * wizard's 3-step model), this hook only tracks a step index over a flat + * list of `PageTourStep`s and persists "seen" state to `localStorage` under + * a caller-supplied key, so it can be reused on any page. + * + * - Does not auto-start: callers call `startTour()` explicitly (e.g. from a + * "Take a tour" button), keeping the tour opt-in rather than an unsolicited + * overlay on every visit. + * - `hasSeenTour` reflects whether the tour was previously completed/skipped + * for this `storageKey`, so callers can decide whether to auto-prompt. + */ +export function usePageTour(steps: PageTourStep[], storageKey: string) { + const [isActive, setIsActive] = useState(false); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [hasSeenTour, setHasSeenTour] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + try { + setHasSeenTour(localStorage.getItem(storageKey) === 'true'); + } catch { + // Ignore privacy-mode/storage-disabled errors; default to "not seen". + } + }, [storageKey]); + + const persistSeen = useCallback(() => { + setHasSeenTour(true); + if (typeof window === 'undefined') return; + try { + localStorage.setItem(storageKey, 'true'); + } catch { + // Ignore quota/privacy errors. + } + }, [storageKey]); + + const startTour = useCallback(() => { + if (steps.length === 0) return; + setCurrentStepIndex(0); + setIsActive(true); + }, [steps.length]); + + const endTour = useCallback(() => { + setIsActive(false); + persistSeen(); + }, [persistSeen]); + + const nextStep = useCallback(() => { + setCurrentStepIndex((index) => { + const next = index + 1; + if (next >= steps.length) { + setIsActive(false); + persistSeen(); + return index; + } + return next; + }); + }, [steps.length, persistSeen]); + + const prevStep = useCallback(() => { + setCurrentStepIndex((index) => Math.max(0, index - 1)); + }, []); + + return { + isActive, + currentStepIndex, + currentStep: isActive ? (steps[currentStepIndex] ?? null) : null, + totalSteps: steps.length, + hasSeenTour, + startTour, + skipTour: endTour, + nextStep, + prevStep, + }; +}