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 (
+
+ );
+}
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,
+ };
+}