From cbe037622651616f5fabcbae9e4affed233b7a9a Mon Sep 17 00:00:00 2001 From: waterWang Date: Sun, 9 Aug 2026 04:59:10 +0800 Subject: [PATCH 1/3] feat(bet-form): add error-boundary fallback UI with retry action (Closes #773) --- components/error-boundary.tsx | 15 +++++- src/components/BetFormErrorFallback.tsx | 66 +++++++++++++++++++++++ src/pages/BetForm.error-boundary.test.tsx | 50 +++++++++++++++++ src/pages/BetForm.tsx | 6 ++- 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 src/components/BetFormErrorFallback.tsx create mode 100644 src/pages/BetForm.error-boundary.test.tsx diff --git a/components/error-boundary.tsx b/components/error-boundary.tsx index ab05342e..5ae7045a 100644 --- a/components/error-boundary.tsx +++ b/components/error-boundary.tsx @@ -3,9 +3,15 @@ import React, { Component, ErrorInfo, ReactNode } from "react"; import { ErrorRecoveryScreen } from "@/components/error/ErrorRecoveryScreen"; +interface FallbackRenderProps { + error: Error; + incidentId: string | null; + resetErrorBoundary: () => void; +} + interface Props { children: ReactNode; - fallback?: ReactNode; + fallback?: ReactNode | ((props: FallbackRenderProps) => ReactNode); } interface State { @@ -50,6 +56,13 @@ export class ErrorBoundary extends Component { render() { if (this.state.hasError) { if (this.props.fallback) { + if (typeof this.props.fallback === "function") { + return (this.props.fallback as (props: FallbackRenderProps) => ReactNode)({ + error: this.state.error!, + incidentId: this.state.incidentId, + resetErrorBoundary: this.handleReset, + }); + } return this.props.fallback; } diff --git a/src/components/BetFormErrorFallback.tsx b/src/components/BetFormErrorFallback.tsx new file mode 100644 index 00000000..1205d6b7 --- /dev/null +++ b/src/components/BetFormErrorFallback.tsx @@ -0,0 +1,66 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, RefreshCw } from "lucide-react"; + +interface BetFormErrorFallbackProps { + error: Error; + incidentId: string | null; + resetErrorBoundary: () => void; +} + +/** + * BetForm error-boundary fallback UI. + * + * Renders an attractive, card-styled fallback with a retry action when BetForm + * throws during render. Respects design tokens and dark mode, and exposes the + * incident id + error message for transparency without leaking stack traces. + */ +export function BetFormErrorFallback({ + error, + incidentId, + resetErrorBoundary, +}: BetFormErrorFallbackProps) { + return ( +
+
+
+
+
+ +

+ We couldn't load the bet form +

+

+ An unexpected error occurred while rendering the bet form. Please try again. +

+ + {incidentId && ( +

+ Incident ID: {incidentId} +

+ )} + +
+ +
+ + {error?.message && ( +

+ {error.message} +

+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/BetForm.error-boundary.test.tsx b/src/pages/BetForm.error-boundary.test.tsx new file mode 100644 index 00000000..8f80933c --- /dev/null +++ b/src/pages/BetForm.error-boundary.test.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import BetForm from "../BetForm"; +import { BetFormErrorFallback } from "../components/BetFormErrorFallback"; + +describe("BetForm error boundary fallback (#773)", () => { + it("renders the custom fallback when a child throws", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + try { + render( + {}} + /> + ); + expect(screen.getByText(/couldn't load the bet form/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument(); + expect(screen.getByText(/Incident ID: test-123/i)).toBeInTheDocument(); + expect(screen.getByTestId("bet-form-error-msg")).toHaveTextContent("boom"); + } finally { + spy.mockRestore(); + } + }); + + it("calls resetErrorBoundary when Retry is clicked", () => { + const reset = jest.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /Retry/i })); + expect(reset).toHaveBeenCalledTimes(1); + }); + + it("renders without incidentId when none is provided", () => { + render( + + ); + expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument(); + expect(screen.queryByText(/Incident ID:/i)).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/pages/BetForm.tsx b/src/pages/BetForm.tsx index 313897e2..b22f3f2a 100644 --- a/src/pages/BetForm.tsx +++ b/src/pages/BetForm.tsx @@ -4,6 +4,8 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { StellarWaveEmptyState } from "../components/EmptyState"; +import { ErrorBoundary } from "../../components/error-boundary"; +import { BetFormErrorFallback } from "../components/BetFormErrorFallback"; type FormState = "idle" | "submitting" | "success" | "error"; @@ -41,7 +43,7 @@ export default function BetForm({ campaignActive = true }: BetFormProps = {}) { {!campaignActive ? ( ) : ( - <> + @@ -95,7 +97,7 @@ export default function BetForm({ campaignActive = true }: BetFormProps = {}) { )} - + )} ); From ea2dd693f78c00340c3032abfcf7f1745368d6a6 Mon Sep 17 00:00:00 2001 From: waterWang Date: Sun, 9 Aug 2026 05:00:06 +0800 Subject: [PATCH 2/3] feat(stat-card): add copy-to-clipboard affordance on Dashboard stat values (Closes #770) --- .../cards/__tests__/stat-card.copy.test.tsx | 110 +++++++++++++++++ components/cards/stat-card.tsx | 111 ++++++++++++++---- 2 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 components/cards/__tests__/stat-card.copy.test.tsx diff --git a/components/cards/__tests__/stat-card.copy.test.tsx b/components/cards/__tests__/stat-card.copy.test.tsx new file mode 100644 index 00000000..9e2a1566 --- /dev/null +++ b/components/cards/__tests__/stat-card.copy.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { StatCard } from "../stat-card" + +// Mock the toast hook +jest.mock("@/hooks/use-toast", () => ({ + useToast: () => ({ + toast: jest.fn(), + }), +})) + +describe("StatCard — Issue #770: Copy-to-clipboard affordance", () => { + const mockStat = { label: "Volume", value: "$4,325.49" } + + it("renders a copy button in the success state", () => { + render( + + ) + + const copyButton = screen.getByRole("button", { + name: /Copy Volume value \$4,325\.49/i, + }) + expect(copyButton).toBeInTheDocument() + }) + + it("does not render a copy button in empty/loading/error states", () => { + const { rerender } = render( + + ) + expect(screen.queryByLabelText(/Copy .* value/i)).not.toBeInTheDocument() + + rerender() + expect(screen.queryByLabelText(/Copy .* value/i)).not.toBeInTheDocument() + + rerender() + expect(screen.queryByLabelText(/Copy .* value/i)).not.toBeInTheDocument() + }) + + it("shows the copied state (aria-pressed) after a successful click", async () => { + // Provide a working clipboard for the browser API + Object.defineProperty(navigator, "clipboard", { + value: { writeText: jest.fn().mockResolvedValue(undefined) }, + configurable: true, + }) + + const user = userEvent.setup() + render( + + ) + + const copyButton = screen.getByRole("button", { + name: /Copy Volume value \$4,325\.49/i, + }) + expect(copyButton).toHaveAttribute("aria-pressed", "false") + + await user.click(copyButton) + expect(copyButton).toHaveAttribute("aria-pressed", "true") + }) + + it("has proper keyboard-accessible attributes", () => { + render( + + ) + + const copyButton = screen.getByRole("button", { + name: /Copy Volume value \$4,325\.49/i, + }) + + // Button is a native + +
+ {stat?.label} +
+
-
-
- ); -} + ); + } From 15a012fc923233704980e38b1ef96e44d9a4029f Mon Sep 17 00:00:00 2001 From: waterWang Date: Sun, 9 Aug 2026 05:03:11 +0800 Subject: [PATCH 3/3] feat(notification-bell): wrap icon button with accessible Tooltip primitive (Closes #763) --- src/pages/NotificationBell.tooltip.test.tsx | 23 +++ src/pages/NotificationBell.tsx | 153 ++++++++++---------- 2 files changed, 102 insertions(+), 74 deletions(-) create mode 100644 src/pages/NotificationBell.tooltip.test.tsx diff --git a/src/pages/NotificationBell.tooltip.test.tsx b/src/pages/NotificationBell.tooltip.test.tsx new file mode 100644 index 00000000..a128cdac --- /dev/null +++ b/src/pages/NotificationBell.tooltip.test.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { NotificationBell } from "./NotificationBell"; + +describe("NotificationBell tooltip (#763)", () => { + it("exposes the bell button with an accessible tooltip trigger", () => { + render(); + + // The bell button carries the accessible label describing the unread count + const bell = screen.getByRole("button", { name: /5 unread/i }); + expect(bell).toBeInTheDocument(); + + // The same text is used as tooltip content, declared on the component so + // screen readers and hover-users get the same description. + expect(bell).toHaveAccessibleName("Notifications — 5 unread"); + }); + + it("announces the bell state via the accessible label when there are no unread", () => { + render(); + const bell = screen.getByRole("button", { name: /Notifications/i }); + expect(bell).toHaveAccessibleName("Notifications"); + }); +}); \ No newline at end of file diff --git a/src/pages/NotificationBell.tsx b/src/pages/NotificationBell.tsx index 52668e12..b5ab2717 100644 --- a/src/pages/NotificationBell.tsx +++ b/src/pages/NotificationBell.tsx @@ -5,6 +5,7 @@ import { motion } from "framer-motion" import { Bell } from "lucide-react" import { cn } from "@/lib/utils" import { useReducedMotion } from "@/hooks/useReducedMotion" +import { Tooltip } from "@/app/components/Tooltip" // --------------------------------------------------------------------------- // Types @@ -161,28 +162,30 @@ export function NotificationBell({ if (reducedMotion) { return ( - + + + ) } @@ -204,62 +207,64 @@ export function NotificationBell({ } return ( - - + - {bellIcon} - - - {hasUnread ? ( - - {badgeText} - - ) : null} - + {bellIcon} + + + {hasUnread ? ( + + {badgeText} + + ) : null} + + ) }