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}
+
+ Try again
+
+
+ )
+ }
+
+ 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(true)}>
+ Open modal
+
+ setOpen(false)}
+ triggerRef={triggerRef}
+ aria-label={label}
+ >
+ setOpen(false)}>Close
+ Another focusable
+
+ >
+ )
+}
+
+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(
+
+ inner button
+ ,
+ )
+ 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 (
+ show(message, variant)}>
+ show
+
+ )
+}
+
+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}
+ onDismiss(item.id)}
+ className="shrink-0 opacity-70 hover:opacity-100"
+ >
+ ✕
+
+
+ )
+}
+
+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
+}