From 746c2e292e3a560e56e7e331e5e4a6f102a6aef8 Mon Sep 17 00:00:00 2001 From: Annie <168873935+AnnieIj@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:30:48 +0000 Subject: [PATCH 1/2] feat: get started checklist Add StartedChecklist component for first-time user onboarding on the dashboard. Includes: - Checkable task list with sessionStorage persistence - Progress bar showing completion percentage - Dismissible card (hidden state persisted) - Animated entrance via framer-motion - All-complete celebration state - Accessible with ARIA labels and roles - 18 unit tests covering rendering, toggle, dismiss, all-complete state, accessibility, sessionStorage persistence, and custom task lists Closes #465 --- app/dashboard/StartedChecklist.test.tsx | 282 ++++++++++++++++++++++ app/dashboard/StartedChecklist.tsx | 302 ++++++++++++++++++++++++ jest.setup.js | 26 ++ 3 files changed, 610 insertions(+) create mode 100644 app/dashboard/StartedChecklist.test.tsx create mode 100644 app/dashboard/StartedChecklist.tsx diff --git a/app/dashboard/StartedChecklist.test.tsx b/app/dashboard/StartedChecklist.test.tsx new file mode 100644 index 00000000..34e65bda --- /dev/null +++ b/app/dashboard/StartedChecklist.test.tsx @@ -0,0 +1,282 @@ +import React from "react" +import { render, screen, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { + StartedChecklist, + DEFAULT_TASKS, +} from "./StartedChecklist" + +// Mock framer-motion to avoid animation loop issues in JSDOM. +// All motion components render as plain HTML elements with children. +jest.mock("framer-motion", () => { + const React = require("react") + const createMotionProxy = (): any => + new Proxy( + {}, + { + get: (_, key: string) => { + const Component = ({ children, ...props }: any) => + React.createElement(key, props, children) + Component.displayName = `motion.${key}` + return Component + }, + }, + ) + return { + __esModule: true, + motion: createMotionProxy(), + AnimatePresence: ({ children }: any) => + React.createElement(React.Fragment, null, children), + useAnimation: () => ({}), + useMotionValue: (v: any) => ({ + get: () => v, + set: () => {}, + }), + useTransform: (v: any) => v, + } +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Reset sessionStorage before each test so state doesn't leak. */ +function resetSessionStorage() { + sessionStorage.clear() +} + +/** Small wrapper to set up userEvent. */ +function setup(jsx: React.ReactElement) { + return { + user: userEvent.setup(), + ...render(jsx), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("StartedChecklist", () => { + beforeEach(() => { + resetSessionStorage() + }) + + // ---- Rendering ---------------------------------------------------------- + + it("renders all default tasks", () => { + setup() + DEFAULT_TASKS.forEach((task) => { + expect(screen.getByText(task.label)).toBeInTheDocument() + }) + }) + + it("shows the correct progress text initially", () => { + setup() + expect(screen.getByText("0 of 5 tasks completed")).toBeInTheDocument() + }) + + it("renders the progress bar", () => { + setup() + expect( + screen.getByRole("progressbar", { name: /checklist progress/i }), + ).toBeInTheDocument() + }) + + it("renders a dismiss button", () => { + setup() + expect( + screen.getByRole("button", { name: /dismiss checklist/i }), + ).toBeInTheDocument() + }) + + // ---- Toggling tasks ----------------------------------------------------- + + it("marks a task as completed when its checkbox is checked", async () => { + const { user } = setup() + const checkbox = screen.getByRole("checkbox", { + name: /mark "connect your wallet" as complete/i, + }) + await user.click(checkbox) + expect(screen.getByText("1 of 5 tasks completed")).toBeInTheDocument() + }) + + it("unmarks a task when its checkbox is unchecked", async () => { + const { user } = setup() + const checkbox = screen.getByRole("checkbox", { + name: /mark "connect your wallet" as complete/i, + }) + // Complete + await user.click(checkbox) + expect(screen.getByText("1 of 5 tasks completed")).toBeInTheDocument() + + // Re-query with the updated aria-label ("incomplete" since it's now checked) + const checkedCheckbox = screen.getByRole("checkbox", { + name: /mark "connect your wallet" as incomplete/i, + }) + // Uncomplete + await user.click(checkedCheckbox) + expect(screen.getByText("0 of 5 tasks completed")).toBeInTheDocument() + }) + + it("calls onTaskToggle with the updated completed IDs", async () => { + const onTaskToggle = jest.fn() + const { user } = setup() + const checkbox = screen.getByRole("checkbox", { + name: /mark "connect your wallet" as complete/i, + }) + await user.click(checkbox) + expect(onTaskToggle).toHaveBeenCalledWith(["connect-wallet"]) + }) + + // ---- Dismiss ------------------------------------------------------------ + + it("hides the checklist when dismissed", async () => { + const { user } = setup() + const dismissBtn = screen.getByRole("button", { + name: /dismiss checklist/i, + }) + await user.click(dismissBtn) + expect( + screen.queryByText("Get started"), + ).not.toBeInTheDocument() + }) + + it("calls onDismiss when dismissed", async () => { + const onDismiss = jest.fn() + const { user } = setup() + const dismissBtn = screen.getByRole("button", { + name: /dismiss checklist/i, + }) + await user.click(dismissBtn) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + // ---- All complete state ------------------------------------------------- + + it("shows celebration state when all tasks are completed", async () => { + const { user } = setup() + // Complete all tasks + for (const task of DEFAULT_TASKS) { + const checkbox = screen.getByRole("checkbox", { + name: new RegExp(`mark "${task.label.toLowerCase()}"`, "i"), + }) + await user.click(checkbox) + } + expect(screen.getByText("You're all set!")).toBeInTheDocument() + expect( + screen.getByText( + "You've completed all the onboarding steps. Happy predicting!", + ), + ).toBeInTheDocument() + // Two dismiss buttons: X icon and the "Dismiss checklist" button + const dismissButtons = screen.getAllByRole("button", { + name: /dismiss checklist/i, + }) + expect(dismissButtons.length).toBe(2) + }) + + it("sets progress bar to full when all tasks are done", async () => { + const { user } = setup() + for (const task of DEFAULT_TASKS) { + const checkbox = screen.getByRole("checkbox", { + name: new RegExp(`mark "${task.label.toLowerCase()}"`, "i"), + }) + await user.click(checkbox) + } + const progressBar = screen.getByRole("progressbar", { + name: /checklist progress/i, + }) + expect(progressBar).toBeInTheDocument() + // The progress bar updates its label when all complete + expect(progressBar).toHaveAttribute( + "aria-label", + "Checklist progress: 5 of 5 tasks completed", + ) + }) + + // ---- Accessibility ----------------------------------------------------- + + it("each task checkbox has an accessible label", () => { + setup() + DEFAULT_TASKS.forEach((task) => { + expect( + screen.getByRole("checkbox", { + name: new RegExp(`mark "${task.label.toLowerCase()}"`, "i"), + }), + ).toBeInTheDocument() + }) + }) + + it("the task list has an accessible role", () => { + setup() + expect( + screen.getByRole("list", { name: /onboarding checklist/i }), + ).toBeInTheDocument() + }) + + // ---- Custom tasks ------------------------------------------------------- + + it("accepts a custom task list", () => { + const customTasks = [ + { + id: "test-1", + label: "Test task 1", + description: "A custom task", + icon: () => , + }, + ] + setup() + expect(screen.getByText("Test task 1")).toBeInTheDocument() + expect(screen.getByText("0 of 1 tasks completed")).toBeInTheDocument() + }) + + // ---- SessionStorage persistence ----------------------------------------- + + it("persists completed tasks to sessionStorage", async () => { + const { user } = setup() + const checkbox = screen.getByRole("checkbox", { + name: /mark "connect your wallet" as complete/i, + }) + await user.click(checkbox) + + const stored = sessionStorage.getItem( + "predictify:started-checklist:completed", + ) + expect(stored).toBeTruthy() + expect(JSON.parse(stored!)).toEqual(["connect-wallet"]) + }) + + it("restores completed tasks from sessionStorage", () => { + sessionStorage.setItem( + "predictify:started-checklist:completed", + JSON.stringify(["connect-wallet", "browse-markets"]), + ) + setup() + expect(screen.getByText("2 of 5 tasks completed")).toBeInTheDocument() + }) + + it("persists dismissed state to sessionStorage", async () => { + const { user } = setup() + const dismissBtn = screen.getByRole("button", { + name: /dismiss checklist/i, + }) + await user.click(dismissBtn) + + const stored = sessionStorage.getItem( + "predictify:started-checklist:dismissed", + ) + expect(stored).toBe("true") + }) + + it("stays hidden when dismissed state is in sessionStorage", () => { + sessionStorage.setItem( + "predictify:started-checklist:dismissed", + "true", + ) + setup() + expect( + screen.queryByText("Get started"), + ).not.toBeInTheDocument() + }) +}) diff --git a/app/dashboard/StartedChecklist.tsx b/app/dashboard/StartedChecklist.tsx new file mode 100644 index 00000000..10c09390 --- /dev/null +++ b/app/dashboard/StartedChecklist.tsx @@ -0,0 +1,302 @@ +"use client" + +import { useCallback, useMemo } from "react" +import { motion } from "framer-motion" +import { + Wallet, + Search, + MousePointerClick, + Share2, + Trophy, + X, + ChevronRight, + PartyPopper, +} from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" +import { Progress } from "@/components/ui/progress" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { useSessionStorage } from "@/hooks/useSessionStorage" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ChecklistTask { + /** Unique key for the task. */ + id: string + /** Short label displayed next to the checkbox. */ + label: string + /** Longer description shown below the label. */ + description: string + /** Lucide icon to display. */ + icon: React.ComponentType<{ className?: string }> + /** Optional href for a link to the relevant page. */ + href?: string +} + +// --------------------------------------------------------------------------- +// Default tasks +// --------------------------------------------------------------------------- + +export const DEFAULT_TASKS: ChecklistTask[] = [ + { + id: "connect-wallet", + label: "Connect your wallet", + description: + "Link your Stellar wallet to start making predictions and earning rewards.", + icon: Wallet, + href: "/settings", + }, + { + id: "browse-markets", + label: "Browse prediction markets", + description: + "Explore markets across Politics, Crypto, Sports, and more.", + icon: Search, + href: "/events", + }, + { + id: "first-prediction", + label: "Place your first prediction", + description: + "Choose an outcome, set your amount, and submit your first prediction.", + icon: MousePointerClick, + href: "/events", + }, + { + id: "share-market", + label: "Share a market", + description: + "Share a prediction market with friends or on social media.", + icon: Share2, + }, + { + id: "explore-leaderboard", + label: "Explore the leaderboard", + description: + "See how you rank against other predictors on the platform.", + icon: Trophy, + href: "/leaderboard", + }, +] + +// --------------------------------------------------------------------------- +// Session-storage keys +// --------------------------------------------------------------------------- + +const COMPLETED_TASKS_KEY = "predictify:started-checklist:completed" +const DISMISSED_KEY = "predictify:started-checklist:dismissed" + +// --------------------------------------------------------------------------- +// Stable initial values (must be module-level to avoid infinite re-renders +// with useSessionStorage which depends on initialValue by reference). +// --------------------------------------------------------------------------- + +const INITIAL_COMPLETED: string[] = [] + +interface StartedChecklistProps { + /** Override the default task list. Useful for testing / customisation. */ + tasks?: ChecklistTask[] + /** Called when the user dismisses the checklist. */ + onDismiss?: () => void + /** Called when a task is toggled. Receives the updated set of completed IDs. */ + onTaskToggle?: (completedIds: string[]) => void +} + +/** + * A first-time-user onboarding checklist displayed on the dashboard. + * + * Features: + * - Checkable task list with session-storage persistence + * - Progress bar showing completion percentage + * - Dismissible (hidden state persisted in session storage) + * - Animated entrance / exit via framer-motion + * - Accessible: each task is labelled, progress is announced via `aria-valuenow` + * - All-complete celebration state with confetti-like icon swap + */ +export function StartedChecklist({ + tasks = DEFAULT_TASKS, + onDismiss, + onTaskToggle, +}: StartedChecklistProps) { + const [completedTaskIds, setCompletedTaskIds] = useSessionStorage( + COMPLETED_TASKS_KEY, + INITIAL_COMPLETED, + ) + const [dismissed, setDismissed] = useSessionStorage( + DISMISSED_KEY, + false, + ) + + // ---- derived data ------------------------------------------------------- + + // Task IDs that exist in the current task set + const currentTaskIds = useMemo(() => tasks.map((t) => t.id), [tasks]) + + const completedCount = completedTaskIds.filter((id) => currentTaskIds.includes(id)).length + const totalCount = tasks.length + const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 + const allComplete = completedCount === totalCount && totalCount > 0 + + // ---- handlers ----------------------------------------------------------- + + const handleToggle = useCallback( + (taskId: string, checked: boolean) => { + setCompletedTaskIds((prev) => { + const next = checked + ? [...prev, taskId] + : prev.filter((id) => id !== taskId) + onTaskToggle?.(next) + return next + }) + }, + [setCompletedTaskIds, onTaskToggle], + ) + + const handleDismiss = useCallback(() => { + setDismissed(true) + onDismiss?.() + }, [setDismissed, onDismiss]) + + // ---- render ------------------------------------------------------------- + + if (dismissed) return null + + return ( + + + {/* Progress bar at top */} +
+ +
+ + +
+ + {allComplete ? ( + + + ) : ( + Get started + )} + +

+ {allComplete + ? "You've completed all the onboarding steps. Happy predicting!" + : `${completedCount} of ${totalCount} tasks completed`} +

+
+ + {/* Dismiss button */} + +
+ + +
    + {tasks.map((task) => { + const isCompleted = completedTaskIds.includes(task.id) + const Icon = task.icon + + return ( +
  • + +
  • + ) + })} +
+ + {/* Bottom action */} + {!allComplete && ( +
+ Complete these steps to unlock the full experience +
+ )} + {allComplete && ( + + + + )} +
+
+
+ ) +} diff --git a/jest.setup.js b/jest.setup.js index f062b66a..233964cf 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -22,6 +22,32 @@ Object.defineProperty(window, 'scrollTo', { writable: true }) +// Mock performance for framer-motion (uses performance.now() internally). +// Must use Object.defineProperty because Node may already have a read-only +// performance global that prevents simple assignment. +Object.defineProperty(globalThis, 'performance', { + value: { + now: () => Date.now(), + mark: () => {}, + measure: () => {}, + getEntriesByName: () => [], + getEntriesByType: () => [], + clearMarks: () => {}, + clearMeasures: () => {}, + }, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, 'PerformanceObserver', { + value: class { + observe() {} + disconnect() {} + }, + writable: true, + configurable: true, +}); + // Mock requestAnimationFrame for count-up / animation hooks. // Each call advances the timestamp by 500 ms so animations complete // within two frames (500 ms > the hook's 400 ms default duration). From a2e8d456686f9633575e48812c5193c8cb4b2de4 Mon Sep 17 00:00:00 2001 From: Annie <168873935+AnnieIj@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:35:21 +0000 Subject: [PATCH 2/2] docs: add StartedChecklist component documentation --- docs/STARTED_CHECKLIST.md | 103 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/STARTED_CHECKLIST.md diff --git a/docs/STARTED_CHECKLIST.md b/docs/STARTED_CHECKLIST.md new file mode 100644 index 00000000..0c037b0d --- /dev/null +++ b/docs/STARTED_CHECKLIST.md @@ -0,0 +1,103 @@ +# Started Checklist — Dashboard Onboarding + +The `StartedChecklist` component provides a first-time-user onboarding checklist on the Predictify dashboard. It helps new users discover key platform features by guiding them through a set of tasks. + +## Quick Start + +```tsx +import { StartedChecklist } from "@/app/dashboard/StartedChecklist" + +// Render on the dashboard + +``` + +## Default Tasks + +| # | Task | href | +|---|------|------| +| 1 | Connect your wallet | `/settings` | +| 2 | Browse prediction markets | `/events` | +| 3 | Place your first prediction | `/events` | +| 4 | Share a market | — | +| 5 | Explore the leaderboard | `/leaderboard` | + +## Features + +- **Checkable task list** — each task can be toggled complete/incomplete +- **Progress bar** — shows `X of N tasks completed` at the top of the card +- **SessionStorage persistence** — completed tasks and dismissed state persist across page refreshes but not across browser sessions +- **Dismissible** — users can hide the checklist via the X button or the "Dismiss checklist" button (when all tasks are complete) +- **Celebration state** — when all tasks are completed, the card transforms to show a PartyPopper icon and congratulatory message +- **Animated entrance** — uses framer-motion for a subtle fade-in + slide-up on mount +- **Accessible** — ARIA labels on checkboxes, progress bar, and list; screen-reader friendly + +## API + +```tsx +interface StartedChecklistProps { + /** Override the default task list. */ + tasks?: ChecklistTask[] + /** Called when the user dismisses the checklist. */ + onDismiss?: () => void + /** Called when a task is toggled. Receives updated completed IDs. */ + onTaskToggle?: (completedIds: string[]) => void +} + +interface ChecklistTask { + id: string + label: string + description: string + icon: React.ComponentType<{ className?: string }> + href?: string +} +``` + +### Custom Tasks + +```tsx +import { Search, Wallet } from "lucide-react" + + console.log("Completed:", ids)} +/> +``` + +## SessionStorage Keys + +| Key | Type | Description | +|-----|------|-------------| +| `predictify:started-checklist:completed` | `string[]` | Array of completed task IDs | +| `predictify:started-checklist:dismissed` | `boolean` | Whether the checklist is hidden | + +## Testing + +18 unit tests cover: + +- Rendering (all tasks, progress bar, dismiss button) +- Toggling tasks (check, uncheck, onTaskToggle callback) +- Dismissing (hides component, onDismiss callback) +- All-complete celebration state +- Custom task lists +- SessionStorage persistence and restoration +- Accessibility (ARIA labels, roles) + +```bash +pnpm test -- app/dashboard/StartedChecklist.test.tsx +``` + +## Dependencies + +- `framer-motion` — entrance animation +- `@radix-ui/react-checkbox` — checkbox primitive +- `@radix-ui/react-progress` — progress bar primitive +- `lucide-react` — icons +- `@/hooks/useSessionStorage` — state persistence