diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx
new file mode 100644
index 0000000..72517c4
--- /dev/null
+++ b/src/app/analytics/page.tsx
@@ -0,0 +1,167 @@
+"use client";
+
+import { usePageTitle } from "@/hooks/usePageTitle";
+import {
+ Box,
+ Card,
+ CardBody,
+ Grid,
+ Heading,
+ Progress,
+ Stat,
+ StatArrow,
+ StatHelpText,
+ StatLabel,
+ StatNumber,
+ Table,
+ Tbody,
+ Td,
+ Text,
+ Th,
+ Thead,
+ Tr,
+} from "@chakra-ui/react";
+
+const MOCK_STATS = {
+ totalStaked: "125,000",
+ totalRewards: "3,420",
+ activeStakers: 47,
+ avgApr: "12.4%",
+ rewardChange: 8.2,
+ stakerChange: -2.1,
+};
+
+const MOCK_POOLS = [
+ { name: "USDC Pool", staked: "45,000", rewards: "1,230", apr: "11.2%", tvl: "$45,000" },
+ { name: "XLM Pool", staked: "32,000", rewards: "890", apr: "13.8%", tvl: "$32,000" },
+ { name: "AQUA Pool", staked: "28,000", rewards: "780", apr: "14.1%", tvl: "$28,000" },
+ { name: "yXLM Pool", staked: "20,000", rewards: "520", apr: "10.5%", tvl: "$20,000" },
+];
+
+export default function AnalyticsPage() {
+ usePageTitle("Analytics");
+
+ return (
+
+
+ Staking Analytics
+
+
+
+
+
+
+ Total Staked
+ {MOCK_STATS.totalStaked}
+
+
+ {MOCK_STATS.rewardChange}%
+
+
+
+
+
+
+
+
+ Total Rewards
+ {MOCK_STATS.totalRewards}
+
+
+ {MOCK_STATS.rewardChange}%
+
+
+
+
+
+
+
+
+ Active Stakers
+ {MOCK_STATS.activeStakers}
+
+
+ {Math.abs(MOCK_STATS.stakerChange)}%
+
+
+
+
+
+
+
+
+ Average APR
+ {MOCK_STATS.avgApr}
+ Across all pools
+
+
+
+
+
+
+
+
+ Pool Performance
+
+
+
+
+ | Pool |
+ Staked |
+ Rewards |
+ APR |
+ TVL |
+ Utilization |
+
+
+
+ {MOCK_POOLS.map((pool) => (
+
+ | {pool.name} |
+ {pool.staked} |
+ {pool.rewards} |
+
+ {pool.apr}
+ |
+ {pool.tvl} |
+
+
+ |
+
+ ))}
+
+
+
+
+
+
+
+
+ Reward Distribution
+
+
+ Rewards are distributed proportionally to stake amount and duration.
+
+
+ {MOCK_POOLS.map((pool) => {
+ const pct = (parseFloat(pool.rewards.replace(",", "")) / parseFloat(MOCK_STATS.totalRewards.replace(",", ""))) * 100;
+ return (
+
+
+ {pool.name} — {pct.toFixed(1)}%
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f654ee3..02aafab 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -65,7 +65,12 @@ export default async function RootLayout({
+
+
+
+
+
{/* #404 — In server mode the middleware sets a nonce-bearing CSP header,
so the static meta tag would duplicate/override it. Only render the
diff --git a/src/components/LiveRegion/LiveRegion.tsx b/src/components/LiveRegion/LiveRegion.tsx
index 12ae679..59769f7 100644
--- a/src/components/LiveRegion/LiveRegion.tsx
+++ b/src/components/LiveRegion/LiveRegion.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useEffect, useRef, useState } from "react";
import { VisuallyHidden } from "@chakra-ui/react";
type LiveRegionProps = {
@@ -23,3 +24,33 @@ export default function LiveRegion({
);
}
+
+/**
+ * Hook that announces messages via a shared live region. Use this to
+ * announce dynamic updates (filter changes, sort, data refresh, action
+ * results) so screen readers receive frequent, meaningful feedback (#597).
+ *
+ * @returns announce function — call with a message to announce it.
+ */
+export function useAnnounce() {
+ const [message, setMessage] = useState("");
+ const timeoutRef = useRef>();
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, []);
+
+ const announce = (msg: string, politeness: "polite" | "assertive" = "polite") => {
+ // Clear then set to force re-announcement of identical messages.
+ setMessage("");
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setMessage(msg), 50);
+
+ // Expose politeness via data attribute for the LiveRegion consumer.
+ return politeness;
+ };
+
+ return { message, announce };
+}
diff --git a/src/hooks/useToast.tsx b/src/hooks/useToast.tsx
index 701a33a..b1dbd77 100644
--- a/src/hooks/useToast.tsx
+++ b/src/hooks/useToast.tsx
@@ -28,6 +28,18 @@ const DEFAULT_TOAST_OPTIONS: ToastOptions = {
position: "bottom-right",
};
+/**
+ * Container style that ensures toasts stack vertically with spacing instead
+ * of overlapping (#596). Chakra's default toast container uses absolute
+ * positioning which can cause overlap at the same position.
+ */
+const TOAST_CONTAINER_STYLE = {
+ maxWidth: "380px",
+ gap: "8px",
+ display: "flex",
+ flexDirection: "column-reverse" as const,
+};
+
export function useToast() {
const chakraToast = useChakraToast();
@@ -48,6 +60,7 @@ export function useToast() {
status: "success",
...DEFAULT_TOAST_OPTIONS,
...options,
+ containerStyle: TOAST_CONTAINER_STYLE,
});
},
[chakraToast]
@@ -64,6 +77,7 @@ export function useToast() {
status: "error",
...DEFAULT_TOAST_OPTIONS,
...options,
+ containerStyle: TOAST_CONTAINER_STYLE,
});
},
[chakraToast]
@@ -80,6 +94,7 @@ export function useToast() {
status: "info",
...DEFAULT_TOAST_OPTIONS,
...options,
+ containerStyle: TOAST_CONTAINER_STYLE,
});
},
[chakraToast]
@@ -96,6 +111,7 @@ export function useToast() {
status: "warning",
...DEFAULT_TOAST_OPTIONS,
...options,
+ containerStyle: TOAST_CONTAINER_STYLE,
});
},
[chakraToast]
@@ -121,6 +137,7 @@ export function useToast() {
status: "error",
...DEFAULT_TOAST_OPTIONS,
duration: 8000,
+ containerStyle: TOAST_CONTAINER_STYLE,
description: (
{normalized.userMessage}
@@ -148,6 +165,7 @@ export function useToast() {
status: "error",
...DEFAULT_TOAST_OPTIONS,
duration: 6000, // Slightly longer for errors
+ containerStyle: TOAST_CONTAINER_STYLE,
});
}
@@ -187,6 +205,7 @@ export function useToast() {
isClosable: false,
...DEFAULT_TOAST_OPTIONS,
...toastOptions,
+ containerStyle: TOAST_CONTAINER_STYLE,
})
: null;
@@ -207,6 +226,7 @@ export function useToast() {
status: "success",
...DEFAULT_TOAST_OPTIONS,
...toastOptions,
+ containerStyle: TOAST_CONTAINER_STYLE,
});
}