From 8fabd90bea5d746c5bc4a6112e96c8921251a702 Mon Sep 17 00:00:00 2001 From: Queen Vivan Date: Sat, 27 Jun 2026 21:53:07 +0100 Subject: [PATCH] feat(web): tier progress display, error boundary, toast system, modal with focus trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/web/src/features/referrals/components/shared/tier-progress.tsx — extracted TierProgress from affiliates-tab; renders zero/partial/maxed-out fixtures with ARIA progressbar and tier labels; 12 unit tests - apps/web/src/shared/components/ErrorBoundary.tsx — class-based boundary with reset() callback and optional fallback prop; 7 unit tests including retry flow - apps/web/src/shared/components/toast/ — useToast hook + ToastProvider; supports success/error/info variants, auto-dismiss with fake timers, manual dismiss, queue; 8 unit tests - apps/web/src/shared/components/Modal.tsx — dialog with focus trap, Escape-to-close, trigger-ref focus-return, backdrop click dismiss; 8 unit tests using user-event closes #312 closes #313 closes #314 closes #315 --- .../components/shared/tier-progress.test.tsx | 94 ++++++++++++++ .../components/shared/tier-progress.tsx | 85 +++++++++++++ .../shared/components/ErrorBoundary.test.tsx | 102 +++++++++++++++ .../src/shared/components/ErrorBoundary.tsx | 47 +++++++ apps/web/src/shared/components/Modal.test.tsx | 112 +++++++++++++++++ apps/web/src/shared/components/Modal.tsx | 104 ++++++++++++++++ apps/web/src/shared/components/toast/index.ts | 2 + .../shared/components/toast/toast.test.tsx | 107 ++++++++++++++++ .../web/src/shared/components/toast/toast.tsx | 117 ++++++++++++++++++ 9 files changed, 770 insertions(+) create mode 100644 apps/web/src/features/referrals/components/shared/tier-progress.test.tsx create mode 100644 apps/web/src/features/referrals/components/shared/tier-progress.tsx create mode 100644 apps/web/src/shared/components/ErrorBoundary.test.tsx create mode 100644 apps/web/src/shared/components/ErrorBoundary.tsx create mode 100644 apps/web/src/shared/components/Modal.test.tsx create mode 100644 apps/web/src/shared/components/Modal.tsx create mode 100644 apps/web/src/shared/components/toast/index.ts create mode 100644 apps/web/src/shared/components/toast/toast.test.tsx create mode 100644 apps/web/src/shared/components/toast/toast.tsx diff --git a/apps/web/src/features/referrals/components/shared/tier-progress.test.tsx b/apps/web/src/features/referrals/components/shared/tier-progress.test.tsx new file mode 100644 index 0000000..b2acde0 --- /dev/null +++ b/apps/web/src/features/referrals/components/shared/tier-progress.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, afterEach } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import { TierProgress } from "./tier-progress" + +afterEach(cleanup) + +describe("TierProgress", () => { + describe("zero volume (Bronze, no progress toward Silver)", () => { + it("renders current tier label", () => { + render() + expect(screen.getAllByText("Bronze").length).toBeGreaterThan(0) + }) + + it("renders next tier label", () => { + render() + expect(screen.getAllByText("Silver").length).toBeGreaterThan(0) + }) + + it("renders progress bar at 0%", () => { + render() + const bar = screen.getByRole("progressbar") + expect(bar).toHaveAttribute("aria-valuenow", "0") + }) + + it("renders remaining threshold copy", () => { + render() + expect(screen.getByLabelText("remaining volume")).toHaveTextContent("more needed") + }) + }) + + describe("partial progress (Bronze → Silver at 50%)", () => { + it("renders both tier labels", () => { + render() + expect(screen.getAllByText("Bronze").length).toBeGreaterThan(0) + expect(screen.getAllByText("Silver").length).toBeGreaterThan(0) + }) + + it("renders progressbar at 50", () => { + render() + const bar = screen.getByRole("progressbar") + expect(bar).toHaveAttribute("aria-valuenow", "50") + }) + + it("shows next-tier threshold copy", () => { + render() + expect(screen.getByLabelText("remaining volume")).toHaveTextContent("more needed") + }) + }) + + describe("maxed-out tier (Gold, level 3)", () => { + it("renders current tier label", () => { + render() + expect(screen.getAllByText("Gold").length).toBeGreaterThan(0) + }) + + it("renders maximum tier message", () => { + render() + expect(screen.getByText("Maximum tier reached!")).toBeInTheDocument() + }) + + it("does not render a progress bar", () => { + render() + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() + }) + + it("does not render 'more needed' copy", () => { + render() + expect(screen.queryByLabelText("remaining volume")).not.toBeInTheDocument() + }) + }) + + describe("tier label correctness", () => { + it("Silver → Gold shows correct labels", () => { + render() + expect(screen.getAllByText("Silver").length).toBeGreaterThan(0) + expect(screen.getAllByText("Gold").length).toBeGreaterThan(0) + }) + }) + + describe("progressbar aria attributes", () => { + it("has aria-valuemin=0 and aria-valuemax=100", () => { + render() + const bar = screen.getByRole("progressbar") + expect(bar).toHaveAttribute("aria-valuemin", "0") + expect(bar).toHaveAttribute("aria-valuemax", "100") + }) + + it("caps aria-valuenow at 100 when volume exceeds threshold", () => { + render() + const bar = screen.getByRole("progressbar") + expect(Number(bar.getAttribute("aria-valuenow"))).toBeLessThanOrEqual(100) + }) + }) +}) diff --git a/apps/web/src/features/referrals/components/shared/tier-progress.tsx b/apps/web/src/features/referrals/components/shared/tier-progress.tsx new file mode 100644 index 0000000..9bedc19 --- /dev/null +++ b/apps/web/src/features/referrals/components/shared/tier-progress.tsx @@ -0,0 +1,85 @@ +import { cn } from "@workspace/ui/lib/utils" +import { getTierByLevel, getNextTier } from "../../data/tiers" +import { formatUsd } from "@/shared/lib/format" + +type Props = { + tier: 1 | 2 | 3 + volumeUsd: number +} + +export function TierProgress({ tier, volumeUsd }: Props) { + const current = getTierByLevel(tier) + const next = getNextTier(tier) + + if (!next) { + return ( +
+ + {current.label} + + Maximum tier reached! +
+ ) + } + + const progress = Math.min((volumeUsd / next.minVolumeUsd) * 100, 100) + const remaining = Math.max(next.minVolumeUsd - volumeUsd, 0) + + return ( +
+
+
+ + {current.label} + + + + {next.label} + +
+ + {formatUsd(remaining, { compact: true })} more needed + +
+
+
+
+
+ ) +} diff --git a/apps/web/src/shared/components/ErrorBoundary.test.tsx b/apps/web/src/shared/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..cbc5618 --- /dev/null +++ b/apps/web/src/shared/components/ErrorBoundary.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { ErrorBoundary } from "./ErrorBoundary" + +afterEach(cleanup) + +function Thrower({ message = "boom" }: { message?: string }) { + throw new Error(message) +} + +function Stable({ text }: { text: string }) { + return

{text}

+} + +describe("ErrorBoundary", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("renders children when no error is thrown", () => { + render( + + + , + ) + expect(screen.getByText("all good")).toBeInTheDocument() + }) + + it("renders fallback message when a child throws", () => { + render( + + + , + ) + expect(screen.getByRole("alert")).toBeInTheDocument() + expect(screen.getByText("Something went wrong")).toBeInTheDocument() + }) + + it("renders the error message in the fallback", () => { + render( + + + , + ) + expect(screen.getByText("network failure")).toBeInTheDocument() + }) + + it("renders a retry button in the fallback", () => { + render( + + + , + ) + expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument() + }) + + it("re-renders children after clicking retry", async () => { + const user = userEvent.setup() + let shouldThrow = true + + function MaybeThrow() { + if (shouldThrow) throw new Error("oops") + return

recovered

+ } + + render( + + + , + ) + + expect(screen.getByRole("alert")).toBeInTheDocument() + shouldThrow = false + await user.click(screen.getByRole("button", { name: /try again/i })) + expect(screen.getByText("recovered")).toBeInTheDocument() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + }) + + it("renders a custom fallback prop when provided", () => { + render( + custom error UI
}> + + , + ) + expect(screen.getByText("custom error UI")).toBeInTheDocument() + expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument() + }) + + it("does not leak console.error noise across tests", () => { + render( + + + , + ) + expect(console.error).toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/shared/components/ErrorBoundary.tsx b/apps/web/src/shared/components/ErrorBoundary.tsx new file mode 100644 index 0000000..1d51e6c --- /dev/null +++ b/apps/web/src/shared/components/ErrorBoundary.tsx @@ -0,0 +1,47 @@ +import { Component, type ReactNode, type ErrorInfo } from "react" + +type Props = { + children: ReactNode + fallback?: ReactNode +} + +type State = { + error: Error | null +} + +export class ErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary]", error, info.componentStack) + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + if (this.state.error) { + if (this.props.fallback) return this.props.fallback + + return ( +
+

Something went wrong

+

{this.state.error.message}

+ +
+ ) + } + + return this.props.children + } +} diff --git a/apps/web/src/shared/components/Modal.test.tsx b/apps/web/src/shared/components/Modal.test.tsx new file mode 100644 index 0000000..064fe3d --- /dev/null +++ b/apps/web/src/shared/components/Modal.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, afterEach, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { useRef, useState } from "react" +import { Modal } from "./Modal" + +afterEach(cleanup) + +function ModalHarness({ + label = "Test modal", + initialOpen = false, +}: { + label?: string + initialOpen?: boolean +}) { + const [open, setOpen] = useState(initialOpen) + const triggerRef = useRef(null) + + return ( + <> + + setOpen(false)} + triggerRef={triggerRef} + aria-label={label} + > + + + + + ) +} + +describe("Modal", () => { + it("renders nothing when closed", () => { + render() + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + it("renders dialog when open", async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole("button", { name: "Open modal" })) + expect(screen.getByRole("dialog")).toBeInTheDocument() + }) + + it("moves focus inside the modal on open", async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole("button", { name: "Open modal" })) + const dialog = screen.getByRole("dialog") + expect(dialog.contains(document.activeElement)).toBe(true) + }) + + it("fires onClose and focus returns to trigger on Escape", async () => { + const user = userEvent.setup() + render() + const trigger = screen.getByRole("button", { name: "Open modal" }) + await user.click(trigger) + expect(screen.getByRole("dialog")).toBeInTheDocument() + await user.keyboard("{Escape}") + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + expect(document.activeElement).toBe(trigger) + }) + + it("fires onClose when close button inside modal is clicked", async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole("button", { name: "Open modal" })) + await user.click(screen.getByRole("button", { name: "Close" })) + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + it("traps tab focus within the modal", async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole("button", { name: "Open modal" })) + + const [closeBtn, anotherBtn] = screen.getAllByRole("button", { + name: /Close|Another focusable/, + }) + closeBtn.focus() + await user.tab() + expect(document.activeElement).toBe(anotherBtn) + await user.tab() + expect(document.activeElement).toBe(closeBtn) + }) + + it("closes when backdrop is clicked", async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole("button", { name: "Open modal" })) + const backdrop = screen.getByRole("presentation") + await user.click(backdrop) + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + it("does not call onClose when dialog content is clicked", async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render( + + + , + ) + await user.click(screen.getByRole("button", { name: "inner button" })) + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/shared/components/Modal.tsx b/apps/web/src/shared/components/Modal.tsx new file mode 100644 index 0000000..cef852f --- /dev/null +++ b/apps/web/src/shared/components/Modal.tsx @@ -0,0 +1,104 @@ +import { + useEffect, + useRef, + type ReactNode, + type RefObject, +} from "react" +import { cn } from "@workspace/ui/lib/utils" + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])' + +function trapFocus(container: HTMLElement, event: KeyboardEvent) { + const nodes = Array.from(container.querySelectorAll(FOCUSABLE)).filter( + (el) => !el.closest("[aria-hidden='true']"), + ) + if (!nodes.length) return + const first = nodes[0] + const last = nodes[nodes.length - 1] + + if (event.shiftKey) { + if (document.activeElement === first) { + event.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + event.preventDefault() + first.focus() + } + } +} + +type Props = { + open: boolean + onClose: () => void + /** Element that triggered the modal — focus returns here on close */ + triggerRef?: RefObject + children: ReactNode + className?: string + "aria-label"?: string + "aria-labelledby"?: string +} + +export function Modal({ + open, + onClose, + triggerRef, + children, + className, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, +}: Props) { + const dialogRef = useRef(null) + const savedFocus = useRef(null) + + useEffect(() => { + if (open) { + savedFocus.current = (triggerRef?.current ?? document.activeElement) as HTMLElement + const firstFocusable = dialogRef.current?.querySelector(FOCUSABLE) + firstFocusable?.focus() + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose() + return + } + if (e.key === "Tab" && dialogRef.current) { + trapFocus(dialogRef.current, e) + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + } else { + savedFocus.current?.focus() + } + }, [open, onClose, triggerRef]) + + if (!open) return null + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
+ {children} +
+
+ ) +} diff --git a/apps/web/src/shared/components/toast/index.ts b/apps/web/src/shared/components/toast/index.ts new file mode 100644 index 0000000..7d325ff --- /dev/null +++ b/apps/web/src/shared/components/toast/index.ts @@ -0,0 +1,2 @@ +export { useToast, ToastProvider } from "./toast" +export type { ToastVariant, ToastItem } from "./toast" diff --git a/apps/web/src/shared/components/toast/toast.test.tsx b/apps/web/src/shared/components/toast/toast.test.tsx new file mode 100644 index 0000000..64b467e --- /dev/null +++ b/apps/web/src/shared/components/toast/toast.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, afterEach, vi } from "vitest" +import { cleanup, render, screen, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { ToastProvider, useToast } from "./toast" + +afterEach(cleanup) + +function Trigger({ + message, + variant, +}: { + message: string + variant?: "success" | "error" | "info" +}) { + const { show } = useToast() + return ( + + ) +} + +function setup(message: string, variant?: "success" | "error" | "info") { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + render( + + + , + ) + return user +} + +describe("toast notification system", () => { + it("shows a success toast with visible text", async () => { + const user = setup("Payment confirmed", "success") + await user.click(screen.getByRole("button", { name: "show" })) + expect(screen.getByText("Payment confirmed")).toBeInTheDocument() + }) + + it("shows an error toast with visible text", async () => { + const user = setup("Transaction failed", "error") + await user.click(screen.getByRole("button", { name: "show" })) + expect(screen.getByText("Transaction failed")).toBeInTheDocument() + }) + + it("shows an info toast with visible text", async () => { + const user = setup("Loading wallet…", "info") + await user.click(screen.getByRole("button", { name: "show" })) + expect(screen.getByText("Loading wallet…")).toBeInTheDocument() + }) + + it("toast has accessible role", async () => { + const user = setup("Hello", "info") + await user.click(screen.getByRole("button", { name: "show" })) + expect(screen.getAllByRole("status").length).toBeGreaterThan(0) + }) + + it("dismisses toast manually via dismiss button", async () => { + const user = setup("Click to dismiss", "info") + await user.click(screen.getByRole("button", { name: "show" })) + expect(screen.getByText("Click to dismiss")).toBeInTheDocument() + await user.click(screen.getByRole("button", { name: "Dismiss" })) + expect(screen.queryByText("Click to dismiss")).not.toBeInTheDocument() + }) + + it("auto-dismisses after duration with fake timers", async () => { + vi.useFakeTimers() + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + render( + + + , + ) + await user.click(screen.getByRole("button", { name: "show" })) + expect(screen.getByText("auto gone")).toBeInTheDocument() + act(() => { vi.advanceTimersByTime(5000) }) + expect(screen.queryByText("auto gone")).not.toBeInTheDocument() + vi.useRealTimers() + }) + + it("queues multiple toasts", async () => { + const user = userEvent.setup() + render( + + + + , + ) + const [first, second] = screen.getAllByRole("button", { name: "show" }) + await user.click(first) + await user.click(second) + expect(screen.getByText("first")).toBeInTheDocument() + expect(screen.getByText("second")).toBeInTheDocument() + }) + + it("defaults to info variant when no variant is supplied", async () => { + const user = userEvent.setup() + render( + + + , + ) + await user.click(screen.getByRole("button", { name: "show" })) + const toast = screen.getByLabelText(/Info: default variant/) + expect(toast).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/shared/components/toast/toast.tsx b/apps/web/src/shared/components/toast/toast.tsx new file mode 100644 index 0000000..c95a42d --- /dev/null +++ b/apps/web/src/shared/components/toast/toast.tsx @@ -0,0 +1,117 @@ +import { + createContext, + useCallback, + useContext, + useState, + useRef, + useEffect, + type ReactNode, +} from "react" +import { cn } from "@workspace/ui/lib/utils" + +export type ToastVariant = "success" | "error" | "info" + +export type ToastItem = { + id: string + message: string + variant: ToastVariant + /** Auto-dismiss after this many ms. 0 = no auto-dismiss. */ + duration: number +} + +type ToastContextValue = { + toasts: ToastItem[] + show: (message: string, variant?: ToastVariant, duration?: number) => void + dismiss: (id: string) => void +} + +const ToastContext = createContext(null) + +let _counter = 0 +function nextId() { + return `toast-${++_counter}` +} + +const VARIANT_CLASSES: Record = { + success: "bg-green-900/90 text-green-100 border-green-700/50", + error: "bg-red-900/90 text-red-100 border-red-700/50", + info: "bg-slate-800/90 text-slate-100 border-slate-700/50", +} + +const VARIANT_LABEL: Record = { + success: "Success", + error: "Error", + info: "Info", +} + +const DEFAULT_DURATION = 4000 + +function Toast({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) { + const timerRef = useRef | null>(null) + + useEffect(() => { + if (item.duration <= 0) return + timerRef.current = setTimeout(() => onDismiss(item.id), item.duration) + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [item.id, item.duration, onDismiss]) + + return ( +
+ {item.message} + +
+ ) +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + const show = useCallback( + (message: string, variant: ToastVariant = "info", duration = DEFAULT_DURATION) => { + const item: ToastItem = { id: nextId(), message, variant, duration } + setToasts((prev) => [...prev, item]) + }, + [], + ) + + return ( + + {children} +
+ {toasts.map((t) => ( + + ))} +
+
+ ) +} + +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error("useToast must be used inside ") + return ctx +}