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
70 changes: 62 additions & 8 deletions src/app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -138,6 +160,7 @@ function ViewToggle({ value, onChange, disabled }: ViewToggleProps) {
<div
role="group"
aria-label="Analytics view"
data-testid="analytics-view-toggle"
className="inline-flex rounded-lg overflow-hidden border border-[#333] bg-[#111]"
>
{(['user', 'protocol'] as ViewMode[]).map((mode) => {
Expand Down Expand Up @@ -252,7 +275,7 @@ function UserAnalyticsView({ data, state, onRetry, hasWallet }: UserAnalyticsVie
return (
<div className="space-y-6">
{/* KPI Cards */}
<section aria-label="Your key metrics">
<section aria-label="Your key metrics" data-testid="analytics-kpi-section">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard
label="Total Commitments"
Expand Down Expand Up @@ -308,7 +331,7 @@ function UserAnalyticsView({ data, state, onRetry, hasWallet }: UserAnalyticsVie
</section>

{/* Trend Charts */}
<section aria-label="Your trend charts">
<section aria-label="Your trend charts" data-testid="analytics-charts-section">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<AnalyticsTrendLineChart
title="Compliance Score Trend"
Expand Down Expand Up @@ -480,6 +503,17 @@ export default function AnalyticsPage() {
const [protocolData, setProtocolData] = useState<ProtocolAnalyticsData | null>(null);
const [protocolState, setProtocolState] = useState<LoadState>('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;
Expand Down Expand Up @@ -548,14 +582,34 @@ export default function AnalyticsPage() {
<h1 className="text-white text-lg font-semibold tracking-wide">Analytics</h1>
</div>

<ViewToggle
value={view}
onChange={handleViewChange}
disabled={false}
/>
<div className="flex items-center gap-3">
<button
type="button"
onClick={startTour}
className="text-xs font-medium text-[#666] hover:text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0ff0fc] rounded"
data-testid="analytics-tour-button"
>
Take a tour
</button>
<ViewToggle
value={view}
onChange={handleViewChange}
disabled={false}
/>
</div>
</div>
</header>

<GuidedTour
isActive={isTourActive}
currentStepIndex={tourStepIndex}
currentStepConfig={tourStep}
totalSteps={tourTotalSteps}
onNext={nextTourStep}
onBack={prevTourStep}
onSkip={skipTour}
/>

{/* Body */}
<div className="px-6 sm:px-10 lg:px-16 py-8 space-y-6">
{/* Toggle-while-loading notice */}
Expand Down
97 changes: 97 additions & 0 deletions src/components/onboarding/GuidedTour.test.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof GuidedTour>> = {}) {
const props: React.ComponentProps<typeof GuidedTour> = {
isActive: true,
currentStepIndex: 0,
currentStepConfig: STEP,
totalSteps: 3,
onNext: vi.fn(),
onBack: vi.fn(),
onSkip: vi.fn(),
...overrides,
};
return { props, ...render(<GuidedTour {...props} />) };
}

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);
});
});
110 changes: 110 additions & 0 deletions src/components/onboarding/GuidedTour.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog
isOpen={isActive}
onClose={onSkip}
labelledById="guided-tour-title"
describedById="guided-tour-content"
className="w-full max-w-sm rounded-2xl border border-[rgba(0,212,255,0.3)] bg-[#0a0a0b] p-6 text-white shadow-[0_0_30px_rgba(0,212,255,0.15)]"
>
<p className="text-xs font-medium uppercase tracking-wide text-[#0ff0fc]" aria-live="polite">
Step {currentStepIndex + 1} of {totalSteps}
</p>
<h2 id="guided-tour-title" className="mt-2 text-lg font-semibold">
{currentStepConfig.title}
</h2>
<p id="guided-tour-content" className="mt-2 text-sm text-white/70">
{currentStepConfig.content}
</p>

<button
type="button"
onClick={handleScrollToTarget}
className="mt-3 text-xs font-medium text-[#0ff0fc] underline underline-offset-2 hover:text-white"
>
Show me on the page
</button>

<div className="mt-6 flex items-center justify-between gap-2">
<button
type="button"
onClick={onSkip}
className="text-sm text-white/50 hover:text-white"
data-testid="tour-skip"
>
Skip tour
</button>
<div className="flex items-center gap-2">
{!isFirstStep && (
<button
type="button"
onClick={onBack}
className="rounded-lg border border-white/20 px-3 py-1.5 text-sm font-medium hover:border-white/40"
data-testid="tour-back"
>
Back
</button>
)}
<button
type="button"
onClick={onNext}
className="rounded-lg bg-[#0ff0fc] px-3 py-1.5 text-sm font-semibold text-black hover:brightness-110"
data-testid="tour-next"
>
{isLastStep ? 'Finish' : 'Next'}
</button>
</div>
</div>
</Dialog>
);
}
Loading
Loading