From e196227a23bc213ccaa0ef4cadcc73140bfdece5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 12 Jun 2026 16:00:35 -0500 Subject: [PATCH] Add animated verify-campaign tutorial after publishing statement Once an organization publishes its verifier statement on /organizations, show an interactive walkthrough demonstrating how to verify a campaign: a looping three-step animation on a mock campaign card where a cursor opens the three-dots menu and selects 'Verify this campaign'. Step list is clickable to scrub; motion is gated behind prefers-reduced-motion. --- .../organizations/VerifyTutorial.tsx | 337 ++++++++++++++++++ src/locales/ar.json | 18 +- src/locales/en.json | 27 +- src/locales/es.json | 27 +- src/locales/fa.json | 18 +- src/locales/fr.json | 27 +- src/locales/hi.json | 18 +- src/locales/id.json | 18 +- src/locales/km.json | 18 +- src/locales/ps.json | 18 +- src/locales/pt.json | 27 +- src/locales/ru.json | 27 +- src/locales/sn.json | 18 +- src/locales/sw.json | 18 +- src/locales/tr.json | 18 +- src/locales/zh-Hant.json | 18 +- src/locales/zh.json | 18 +- src/pages/OrganizationsPage.tsx | 11 +- tailwind.config.ts | 9 +- 19 files changed, 671 insertions(+), 19 deletions(-) create mode 100644 src/components/organizations/VerifyTutorial.tsx diff --git a/src/components/organizations/VerifyTutorial.tsx b/src/components/organizations/VerifyTutorial.tsx new file mode 100644 index 00000000..98980262 --- /dev/null +++ b/src/components/organizations/VerifyTutorial.tsx @@ -0,0 +1,337 @@ +import { useEffect, useReducer, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + BadgeCheck, + MoreHorizontal, + MousePointer2, +} from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +/** + * An animated, interactive tutorial shown on /organizations once an + * organization has published its verifier statement. It demonstrates the + * exact gesture a verifier uses to vouch for a campaign: tapping the + * three-dots (kebab) button on a campaign card and choosing + * "Verify this campaign". + * + * The component renders a faithful mock campaign card and drives a small + * three-step state machine that mimics a cursor opening the kebab menu and + * clicking the verify item. It auto-advances on a timer, loops, and exposes + * clickable step dots so users can scrub. Motion is fully gated behind + * `motion-safe:` / a `prefers-reduced-motion` check — with reduced motion the + * cursor and looping are disabled and the final state is shown statically. + */ + +type Phase = 'idle' | 'menuOpen' | 'verified'; + +const PHASE_ORDER: Phase[] = ['idle', 'menuOpen', 'verified']; + +// How long each phase is held before auto-advancing (ms). +const PHASE_DURATION: Record = { + idle: 2200, + menuOpen: 2600, + verified: 3000, +}; + +interface State { + phase: Phase; + /** Bumps on every manual interaction to pause autoplay briefly. */ + paused: boolean; +} + +type Action = + | { type: 'advance' } + | { type: 'goto'; phase: Phase }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'advance': { + const idx = PHASE_ORDER.indexOf(state.phase); + const next = PHASE_ORDER[(idx + 1) % PHASE_ORDER.length]; + return { phase: next, paused: false }; + } + case 'goto': + return { phase: action.phase, paused: true }; + default: + return state; + } +} + +function usePrefersReducedMotion(): boolean { + const ref = useRef(false); + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + ref.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + return ref.current; +} + +export function VerifyTutorial({ className }: { className?: string }) { + const { t } = useTranslation(); + const reducedMotion = usePrefersReducedMotion(); + + const [state, dispatch] = useReducer(reducer, { + phase: (reducedMotion ? 'verified' : 'idle') as Phase, + paused: false, + }); + + // Autoplay timer. Disabled under reduced motion, or while paused after a + // manual interaction (resumes on the next phase change). + useEffect(() => { + if (reducedMotion || state.paused) return; + const id = window.setTimeout( + () => dispatch({ type: 'advance' }), + PHASE_DURATION[state.phase], + ); + return () => window.clearTimeout(id); + }, [state.phase, state.paused, reducedMotion]); + + // When a user scrubs (paused), resume autoplay after a grace period. + useEffect(() => { + if (!state.paused || reducedMotion) return; + const id = window.setTimeout( + () => dispatch({ type: 'advance' }), + PHASE_DURATION[state.phase] + 1500, + ); + return () => window.clearTimeout(id); + }, [state.paused, state.phase, reducedMotion]); + + const phaseIndex = PHASE_ORDER.indexOf(state.phase); + const menuVisible = state.phase === 'menuOpen' || state.phase === 'verified'; + const verified = state.phase === 'verified'; + + const stepCopy = [ + { + title: t('organizations.tutorial.steps.open.title'), + body: t('organizations.tutorial.steps.open.body'), + }, + { + title: t('organizations.tutorial.steps.verify.title'), + body: t('organizations.tutorial.steps.verify.body'), + }, + { + title: t('organizations.tutorial.steps.confirm.title'), + body: t('organizations.tutorial.steps.confirm.body'), + }, + ]; + + return ( +
+ {/* Header */} +
+
+

+ + {t('organizations.tutorial.eyebrow')} +

+

+ {t('organizations.tutorial.title')} +

+

+ {t('organizations.tutorial.lede')} +

+
+
+ +
+ {/* ── Left: animated mock campaign card ───────────────────────── */} + + + {/* ── Right: step list, synced to the animation ───────────────── */} +
    + {stepCopy.map((step, i) => { + const active = i === phaseIndex; + const done = i < phaseIndex; + return ( +
  1. + +
  2. + ); + })} +
+
+
+ ); +} + +// ── The animated mock card ─────────────────────────────────────────────── + +interface DemoStageProps { + phaseIndex: number; + menuVisible: boolean; + verified: boolean; + reducedMotion: boolean; +} + +function DemoStage({ + phaseIndex, + menuVisible, + verified, + reducedMotion, +}: DemoStageProps) { + const { t } = useTranslation(); + + return ( +