= {
+ healthy: "bg-green-500/20 text-green-400",
+ degraded: "bg-yellow-500/20 text-yellow-400",
+ down: "bg-red-500/20 text-red-400",
+ unknown: "bg-gray-500/20 text-gray-400",
+ };
+
+ const label = status.charAt(0).toUpperCase() + status.slice(1);
+
+ return (
+
+ {label}
+
+ );
+}
+
+/**
+ * Format a percentage value with proper aria-label
+ */
+function PercentageMetric({
+ label,
+ value,
+ unit = "%",
+}: {
+ label: string;
+ value: number;
+ unit?: string;
+}) {
+ const displayValue = `${value.toFixed(1)}${unit}`;
+ const ariaLabel = `${label}: ${displayValue}`;
+
+ return (
+
+ {label}
+
+ {displayValue}
+
+
+ );
+}
+
+/**
+ * Format a numeric value (time, TVL, etc.) with aria-label
+ */
+function MetricValue({
+ label,
+ value,
+ unit,
+}: {
+ label: string;
+ value: number | string;
+ unit: string;
+}) {
+ const displayValue = typeof value === "number"
+ ? value.toLocaleString("en-US", { maximumFractionDigits: 2 })
+ : value;
+ const ariaLabel = `${label}: ${displayValue} ${unit}`;
+
+ return (
+
+ {label}
+
+ {displayValue} {unit}
+
+
+ );
+}
+
+/**
+ * Format TVL value with proper scaling
+ */
+function formatTVL(value: number): string {
+ if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(2)}B`;
+ if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`;
+ if (value >= 1_000) return `$${(value / 1_000).toFixed(2)}K`;
+ return `$${value.toFixed(2)}`;
+}
+
+/**
+ * Format timestamp to relative time (e.g., "2 minutes ago")
+ */
+function formatRelativeTime(timestamp: string): string {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
+
+ if (seconds < 60) return "just now";
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+/**
+ * Loading skeleton variant of the card
+ */
+function BridgeSummaryCardSkeleton({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
+
+/**
+ * Error variant of the card
+ */
+function BridgeSummaryCardError({
+ error,
+ className = "",
+}: {
+ error: string | null | undefined;
+ className?: string;
+}) {
+ return (
+
+
+
+ ⚠️
+
+
+
+ Unable to load bridge summary
+
+ {error && (
+
{error}
+ )}
+
+
+
+ );
+}
+
+/**
+ * Compact variant: shows only bridge name and status
+ */
+function CompactVariant({ summary }: { summary: BridgeSummary }) {
+ return (
+
+
+
+ {summary.name}
+
+
+
+
+ );
+}
+
+/**
+ * Standard variant: shows name, status, coverage, and performance
+ */
+function StandardVariant({ summary }: { summary: BridgeSummary }) {
+ return (
+
+ {/* Header */}
+
+
+ {summary.name}
+
+
+
+
+ {/* Body */}
+
+ {/* Coverage Section */}
+
+
+ {/* Performance Section */}
+
+
+ {/* TVL Section */}
+
+
+
+ {/* Footer */}
+
+
+ Updated {formatRelativeTime(summary.lastUpdated)}
+
+
+
+ );
+}
+
+/**
+ * Detailed variant: shows all available bridge data
+ */
+function DetailedVariant({ summary }: { summary: BridgeSummary }) {
+ return (
+
+ {/* Header */}
+
+
+ {summary.name}
+
+
+
+
+ {/* Body */}
+
+ {/* Coverage Section */}
+
+
+ Coverage & Reliability
+
+
+
+
+ {/* Performance Section */}
+
+
+ Performance Metrics
+
+
+
+
+
+
+ {/* Value & Supply Section */}
+
+
+ Assets & Liquidity
+
+
+
+
+
+
+ Mismatch
+ 1
+ ? "text-red-400"
+ : summary.mismatchPercentage > 0.5
+ ? "text-yellow-400"
+ : "text-green-400"
+ }`}
+ aria-label={`Supply mismatch: ${summary.mismatchPercentage.toFixed(2)}%`}
+ >
+ {summary.mismatchPercentage.toFixed(2)}%
+
+
+
+
+
+
+ {/* Footer */}
+
+
+ Updated {formatRelativeTime(summary.lastUpdated)}
+
+
+
+ );
+}
+
+/**
+ * BridgeSummaryCard component
+ *
+ * Displays bridge status, coverage, and performance metrics in a card layout.
+ * Supports three variants (compact, standard, detailed) and handles loading/error states.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // With loading state
+ *
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // With error state
+ *
+ * ```
+ */
+export default function BridgeSummaryCard({
+ summary,
+ variant = "standard",
+ isLoading = false,
+ isError = false,
+ error = null,
+ className = "",
+ "data-testid": dataTestId = "bridge-summary-card",
+}: BridgeSummaryCardProps) {
+ // Loading state
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ // Error state
+ if (isError || !summary) {
+ return (
+
+ );
+ }
+
+ // Render variant
+ const cardElement = (() => {
+ switch (variant) {
+ case "compact":
+ return ;
+ case "detailed":
+ return ;
+ case "standard":
+ default:
+ return ;
+ }
+ })();
+
+ return (
+
+ {cardElement}
+
+ );
+}
diff --git a/frontend/src/components/BridgeSummaryCard/BridgeSummaryGrid.test.tsx b/frontend/src/components/BridgeSummaryCard/BridgeSummaryGrid.test.tsx
new file mode 100644
index 00000000..56f5b95f
--- /dev/null
+++ b/frontend/src/components/BridgeSummaryCard/BridgeSummaryGrid.test.tsx
@@ -0,0 +1,288 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "../../test/utils";
+import BridgeSummaryGrid from "./BridgeSummaryGrid";
+import type { BridgeSummary } from "../../types";
+
+const mockBridges: BridgeSummary[] = [
+ {
+ id: "circle",
+ name: "Circle",
+ status: "healthy",
+ coverage: 99.5,
+ performance: 234.5,
+ totalValueLocked: 500_000_000,
+ supplyOnStellar: 400_000_000,
+ supplyOnSource: 400_000_000,
+ mismatchPercentage: 0,
+ lastUpdated: new Date().toISOString(),
+ },
+ {
+ id: "wormhole",
+ name: "Wormhole",
+ status: "degraded",
+ coverage: 95.2,
+ performance: 450.8,
+ totalValueLocked: 200_000_000,
+ supplyOnStellar: 180_000_000,
+ supplyOnSource: 190_000_000,
+ mismatchPercentage: 5.26,
+ lastUpdated: new Date().toISOString(),
+ },
+ {
+ id: "bridging-protocol",
+ name: "Bridging Protocol",
+ status: "healthy",
+ coverage: 98.0,
+ performance: 300.0,
+ totalValueLocked: 300_000_000,
+ supplyOnStellar: 250_000_000,
+ supplyOnSource: 250_000_000,
+ mismatchPercentage: 0.5,
+ lastUpdated: new Date().toISOString(),
+ },
+];
+
+describe("BridgeSummaryGrid", () => {
+ describe("Populated State", () => {
+ it("renders all bridge summaries", () => {
+ render();
+
+ expect(screen.getByText("Circle")).toBeInTheDocument();
+ expect(screen.getByText("Wormhole")).toBeInTheDocument();
+ expect(screen.getByText("Bridging Protocol")).toBeInTheDocument();
+ });
+
+ it("creates a card for each summary", () => {
+ render();
+
+ const cards = screen.getAllByTestId(/bridge-summary-card-/);
+ expect(cards).toHaveLength(3);
+ });
+
+ it("applies the responsive grid classes", () => {
+ const { container } = render();
+
+ const grid = container.firstChild;
+ expect(grid).toHaveClass("grid");
+ expect(grid).toHaveClass("grid-cols-1");
+ expect(grid).toHaveClass("md:grid-cols-2");
+ expect(grid).toHaveClass("lg:grid-cols-3");
+ expect(grid).toHaveClass("xl:grid-cols-4");
+ });
+
+ it("applies gap utility classes", () => {
+ const { container } = render();
+
+ const grid = container.firstChild;
+ expect(grid).toHaveClass("gap-4");
+ });
+
+ it("passes variant prop to each card", () => {
+ render();
+
+ // All cards should show detailed information
+ const coverageLabels = screen.getAllByText("Coverage & Reliability");
+ expect(coverageLabels).toHaveLength(3);
+ });
+
+ it("has proper ARIA attributes for grid", () => {
+ render();
+
+ const grid = screen.getByRole("region", { name: /Bridge summaries/ });
+ expect(grid).toBeInTheDocument();
+ });
+ });
+
+ describe("Loading State", () => {
+ it("renders skeleton cards when isLoading is true", () => {
+ render();
+
+ const skeletons = screen.getAllByTestId(/bridge-summary-card-skeleton-/);
+ expect(skeletons).toHaveLength(4); // Default loadingCount is 4
+ });
+
+ it("renders custom number of skeleton cards", () => {
+ render();
+
+ const skeletons = screen.getAllByTestId(/bridge-summary-card-skeleton-/);
+ expect(skeletons).toHaveLength(6);
+ });
+
+ it("displays loading status aria-label", () => {
+ render();
+
+ expect(screen.getByRole("status", { name: /Loading bridge summaries/ })).toBeInTheDocument();
+ });
+
+ it("does not render actual card data when loading", () => {
+ render();
+
+ expect(screen.queryByText("Circle")).not.toBeInTheDocument();
+ });
+
+ it("applies correct variant to skeleton cards", () => {
+ render();
+
+ // Skeleton cards should respect the variant prop
+ const skeletons = screen.getAllByTestId(/bridge-summary-card-skeleton-/);
+ expect(skeletons).toHaveLength(4);
+ });
+ });
+
+ describe("Error State", () => {
+ it("displays error message when isError is true", () => {
+ render();
+
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ expect(screen.getByText("Unable to load bridges")).toBeInTheDocument();
+ });
+
+ it("displays custom error message", () => {
+ render();
+
+ expect(screen.getByText("Connection timeout")).toBeInTheDocument();
+ });
+
+ it("error message spans full grid width", () => {
+ const { container } = render();
+
+ const alertEl = container.querySelector("[role='alert']");
+ expect(alertEl?.parentElement?.firstChild).toHaveClass("col-span-full");
+ });
+
+ it("does not render card data when in error state", () => {
+ render();
+
+ expect(screen.queryByText("Circle")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Empty State", () => {
+ it("displays empty message when summaries array is empty", () => {
+ render();
+
+ expect(screen.getByText("No bridges available")).toBeInTheDocument();
+ });
+
+ it("empty message spans full grid width", () => {
+ const { container } = render();
+
+ const emptyDiv = screen.getByText("No bridges available").closest("div");
+ expect(emptyDiv).toHaveClass("col-span-full");
+ });
+
+ it("displays empty state when summaries is undefined", () => {
+ render();
+
+ expect(screen.getByText("No bridges available")).toBeInTheDocument();
+ });
+ });
+
+ describe("Props", () => {
+ it("accepts custom className prop", () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toHaveClass("custom-grid-class");
+ });
+
+ it("applies className alongside grid classes", () => {
+ const { container } = render(
+
+ );
+
+ const grid = container.firstChild;
+ expect(grid).toHaveClass("grid");
+ expect(grid).toHaveClass("mt-6");
+ });
+
+ it("defaults to standard variant", () => {
+ render();
+
+ // Standard variant shows specific sections
+ const coverage = screen.getAllByText("Coverage");
+ expect(coverage.length).toBeGreaterThan(0);
+ });
+
+ it("passes loadingCount prop to control skeleton count", () => {
+ render();
+
+ const skeletons = screen.getAllByTestId(/bridge-summary-card-skeleton-/);
+ expect(skeletons).toHaveLength(8);
+ });
+ });
+
+ describe("Responsive Behavior", () => {
+ it("grid adapts layout at different breakpoints via CSS classes", () => {
+ const { container } = render();
+
+ const grid = container.firstChild;
+
+ // Mobile: 1 column
+ expect(grid).toHaveClass("grid-cols-1");
+
+ // Tablet and up: 2 columns
+ expect(grid).toHaveClass("md:grid-cols-2");
+
+ // Desktop and up: 3 columns
+ expect(grid).toHaveClass("lg:grid-cols-3");
+
+ // Large screens and up: 4 columns
+ expect(grid).toHaveClass("xl:grid-cols-4");
+ });
+
+ it("maintains consistent gap between items", () => {
+ const { container } = render();
+
+ const grid = container.firstChild;
+ expect(grid).toHaveClass("gap-4");
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("has proper ARIA role for grid container", () => {
+ render();
+
+ expect(screen.getByRole("region")).toBeInTheDocument();
+ });
+
+ it("loading state has proper role and aria-label", () => {
+ render();
+
+ expect(screen.getByRole("status", { name: /Loading bridge summaries/ })).toBeInTheDocument();
+ });
+
+ it("error state has proper role for alert", () => {
+ render();
+
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ });
+
+ it("each card has unique data-testid for identification", () => {
+ render();
+
+ expect(screen.getByTestId("bridge-summary-card-circle")).toBeInTheDocument();
+ expect(screen.getByTestId("bridge-summary-card-wormhole")).toBeInTheDocument();
+ expect(screen.getByTestId("bridge-summary-card-bridging-protocol")).toBeInTheDocument();
+ });
+ });
+
+ describe("Performance", () => {
+ it("renders large lists efficiently", () => {
+ const largeBridgeList = Array.from({ length: 100 }, (_, i) => ({
+ ...mockBridges[0],
+ id: `bridge-${i}`,
+ name: `Bridge ${i}`,
+ }));
+
+ const { container } = render();
+
+ const cards = screen.getAllByTestId(/bridge-summary-card-/);
+ expect(cards).toHaveLength(100);
+
+ // Grid still has proper classes
+ expect(container.firstChild).toHaveClass("grid");
+ });
+ });
+});
diff --git a/frontend/src/components/BridgeSummaryCard/BridgeSummaryGrid.tsx b/frontend/src/components/BridgeSummaryCard/BridgeSummaryGrid.tsx
new file mode 100644
index 00000000..d0a08139
--- /dev/null
+++ b/frontend/src/components/BridgeSummaryCard/BridgeSummaryGrid.tsx
@@ -0,0 +1,110 @@
+import BridgeSummaryCard from "./BridgeSummaryCard";
+import type { BridgeSummary } from "../../types";
+
+interface BridgeSummaryGridProps {
+ /** Array of bridge summaries to display */
+ summaries?: BridgeSummary[];
+ /** When true, shows loading skeletons for each card */
+ isLoading?: boolean;
+ /** When true, shows error state */
+ isError?: boolean;
+ /** Optional error message */
+ error?: string | null;
+ /** Card variant to display */
+ variant?: "compact" | "standard" | "detailed";
+ /** Optional CSS classes for the grid container */
+ className?: string;
+ /** Number of skeleton cards to show while loading */
+ loadingCount?: number;
+}
+
+/**
+ * BridgeSummaryGrid component
+ *
+ * Renders a responsive grid of bridge summary cards.
+ * Handles loading and error states for the entire collection.
+ *
+ * @example
+ * ```tsx
+ * const { data: summaries, isLoading } = useBridgeSummaries();
+ *
+ *
+ * ```
+ */
+export default function BridgeSummaryGrid({
+ summaries = [],
+ isLoading = false,
+ isError = false,
+ error = null,
+ variant = "standard",
+ className = "",
+ loadingCount = 4,
+}: BridgeSummaryGridProps) {
+ // Responsive grid: 1 column on mobile, 2 on tablet, 3 on desktop, 4 on large screens
+ const gridClasses = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4";
+
+ // Error state for entire grid
+ if (isError) {
+ return (
+
+
+
+ Unable to load bridges
+
+ {error && (
+
{error}
+ )}
+
+
+ );
+ }
+
+ // Loading state
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: loadingCount }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ // Empty state
+ if (summaries.length === 0) {
+ return (
+
+ );
+ }
+
+ // Populated state
+ return (
+
+ {summaries.map((summary) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/BridgeSummaryCard/index.ts b/frontend/src/components/BridgeSummaryCard/index.ts
new file mode 100644
index 00000000..f8ca3763
--- /dev/null
+++ b/frontend/src/components/BridgeSummaryCard/index.ts
@@ -0,0 +1,4 @@
+export { default as BridgeSummaryCard } from "./BridgeSummaryCard";
+export { default as BridgeSummaryGrid } from "./BridgeSummaryGrid";
+
+
diff --git a/frontend/src/components/Navbar.test.tsx b/frontend/src/components/Navbar.test.tsx
index f0ffc187..186810e5 100644
--- a/frontend/src/components/Navbar.test.tsx
+++ b/frontend/src/components/Navbar.test.tsx
@@ -1,10 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { NotificationProvider } from "../context/NotificationContext";
-import { WebSocketProvider } from "../contexts/WebSocketContext";
import { WatchlistProvider } from "../hooks/useWatchlist";
-import ThemeProvider from "../theme/ThemeProvider";
import Navbar from "./Navbar";
import { useNotificationStore } from "../stores/notificationStore";
@@ -21,27 +17,18 @@ describe("Navbar", () => {
it("toggles the mobile navigation panel", () => {
render(
-
-
-
-
-
-
-
-
-
-
-
+
+
+
);
- const toggle = screen.getByRole("button", { name: /toggle navigation/i });
- expect(document.getElementById("mobile-nav-links")).toBeNull();
+ const trigger = screen.getByRole("button", { name: /open notifications/i });
+ fireEvent.click(trigger);
- fireEvent.click(toggle);
- expect(document.getElementById("mobile-nav-links")).toBeTruthy();
+ fireEvent.keyDown(document, { key: "Escape" });
- fireEvent.click(toggle);
- expect(document.getElementById("mobile-nav-links")).toBeNull();
+ expect(screen.queryByRole("dialog", { name: "Notifications" })).not.toBeInTheDocument();
+ expect(document.activeElement).toBe(trigger);
});
});
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx
index d2dfc2b3..b0ae9993 100644
--- a/frontend/src/components/Navbar.tsx
+++ b/frontend/src/components/Navbar.tsx
@@ -1,10 +1,10 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useWatchlist } from "../hooks/useWatchlist";
import EntitySwitcher from "./EntitySwitcher";
import GlobalSearch from "./search/GlobalSearch";
-const NAV_LINKS = [
+const navLinks = [
{ to: "/", label: "Dashboard" },
{ to: "/bridges", label: "Bridges" },
{ to: "/analytics", label: "Analytics" },
@@ -13,39 +13,31 @@ const NAV_LINKS = [
{ to: "/alerts", label: "Alerts" },
];
-function matchesRoute(pathname: string, to: string): boolean {
- if (to === "/") return pathname === "/";
- return pathname === to || pathname.startsWith(`${to}/`);
-}
-
export default function Navbar() {
const location = useLocation();
const { activeSymbols } = useWatchlist();
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
+ const notificationTriggerRef = useRef(null);
+ const previousDrawerOpen = useRef(false);
+ const unreadCount = useNotificationStore(selectUnreadCount);
+
+ useNotificationLiveUpdates();
useEffect(() => {
- setMobileMenuOpen(false);
- }, [location.pathname]);
+ if (previousDrawerOpen.current && !isNotificationsOpen) {
+ notificationTriggerRef.current?.focus();
+ }
+ previousDrawerOpen.current = isNotificationsOpen;
+ }, [isNotificationsOpen]);
return (
-