Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions src/app/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box p={6} maxW="1200px" mx="auto">
<Heading size="lg" mb={6}>
Staking Analytics
</Heading>

<Grid templateColumns={{ base: "1fr", md: "repeat(4, 1fr)" }} gap={4} mb={8}>
<Card>
<CardBody>
<Stat>
<StatLabel>Total Staked</StatLabel>
<StatNumber>{MOCK_STATS.totalStaked}</StatNumber>
<StatHelpText>
<StatArrow type="increase" />
{MOCK_STATS.rewardChange}%
</StatHelpText>
</Stat>
</CardBody>
</Card>

<Card>
<CardBody>
<Stat>
<StatLabel>Total Rewards</StatLabel>
<StatNumber>{MOCK_STATS.totalRewards}</StatNumber>
<StatHelpText>
<StatArrow type="increase" />
{MOCK_STATS.rewardChange}%
</StatHelpText>
</Stat>
</CardBody>
</Card>

<Card>
<CardBody>
<Stat>
<StatLabel>Active Stakers</StatLabel>
<StatNumber>{MOCK_STATS.activeStakers}</StatNumber>
<StatHelpText>
<StatArrow type="decrease" />
{Math.abs(MOCK_STATS.stakerChange)}%
</StatHelpText>
</Stat>
</CardBody>
</Card>

<Card>
<CardBody>
<Stat>
<StatLabel>Average APR</StatLabel>
<StatNumber>{MOCK_STATS.avgApr}</StatNumber>
<StatHelpText>Across all pools</StatHelpText>
</Stat>
</CardBody>
</Card>
</Grid>

<Card mb={8}>
<CardBody>
<Heading size="md" mb={4}>
Pool Performance
</Heading>
<Table variant="simple">
<Thead>
<Tr>
<Th>Pool</Th>
<Th isNumeric>Staked</Th>
<Th isNumeric>Rewards</Th>
<Th isNumeric>APR</Th>
<Th isNumeric>TVL</Th>
<Th>Utilization</Th>
</Tr>
</Thead>
<Tbody>
{MOCK_POOLS.map((pool) => (
<Tr key={pool.name}>
<Td fontWeight="medium">{pool.name}</Td>
<Td isNumeric>{pool.staked}</Td>
<Td isNumeric>{pool.rewards}</Td>
<Td isNumeric color="green.400">
{pool.apr}
</Td>
<Td isNumeric>{pool.tvl}</Td>
<Td>
<Progress
value={parseFloat(pool.apr) * 5}
size="sm"
colorScheme="teal"
borderRadius="full"
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>

<Card>
<CardBody>
<Heading size="md" mb={2}>
Reward Distribution
</Heading>
<Text color="gray.400" mb={4}>
Rewards are distributed proportionally to stake amount and duration.
</Text>
<Box>
{MOCK_POOLS.map((pool) => {
const pct = (parseFloat(pool.rewards.replace(",", "")) / parseFloat(MOCK_STATS.totalRewards.replace(",", ""))) * 100;
return (
<Box key={pool.name} mb={3}>
<Text fontSize="sm" mb={1}>
{pool.name} — {pct.toFixed(1)}%
</Text>
<Progress value={pct} size="sm" colorScheme="teal" borderRadius="full" />
</Box>
);
})}
</Box>
</CardBody>
</Card>
</Box>
);
}
5 changes: 5 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ export default async function RootLayout({
<html lang="en" className={`${inter.variable} ${inter.className}`} suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://horizon.stellar.org" />
<link rel="preconnect" href="https://soroban-testnet.stellar.org" />
<link rel="preconnect" href="https://soroban.stellar.org" />
<link rel="preconnect" href="https://stellar.expert" />
<link rel="dns-prefetch" href="https://horizon.stellar.org" />
<link rel="dns-prefetch" href="https://soroban-testnet.stellar.org" />
<link rel="dns-prefetch" href="https://soroban.stellar.org" />
<link rel="dns-prefetch" href="https://stellar.expert" />
{/* #404 — In server mode the middleware sets a nonce-bearing CSP header,
so the static meta tag would duplicate/override it. Only render the
Expand Down
31 changes: 31 additions & 0 deletions src/components/LiveRegion/LiveRegion.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { VisuallyHidden } from "@chakra-ui/react";

type LiveRegionProps = {
Expand All @@ -23,3 +24,33 @@ export default function LiveRegion({
</VisuallyHidden>
);
}

/**
* 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<ReturnType<typeof setTimeout>>();

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 };
}
20 changes: 20 additions & 0 deletions src/hooks/useToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -48,6 +60,7 @@ export function useToast() {
status: "success",
...DEFAULT_TOAST_OPTIONS,
...options,
containerStyle: TOAST_CONTAINER_STYLE,
});
},
[chakraToast]
Expand All @@ -64,6 +77,7 @@ export function useToast() {
status: "error",
...DEFAULT_TOAST_OPTIONS,
...options,
containerStyle: TOAST_CONTAINER_STYLE,
});
},
[chakraToast]
Expand All @@ -80,6 +94,7 @@ export function useToast() {
status: "info",
...DEFAULT_TOAST_OPTIONS,
...options,
containerStyle: TOAST_CONTAINER_STYLE,
});
},
[chakraToast]
Expand All @@ -96,6 +111,7 @@ export function useToast() {
status: "warning",
...DEFAULT_TOAST_OPTIONS,
...options,
containerStyle: TOAST_CONTAINER_STYLE,
});
},
[chakraToast]
Expand All @@ -121,6 +137,7 @@ export function useToast() {
status: "error",
...DEFAULT_TOAST_OPTIONS,
duration: 8000,
containerStyle: TOAST_CONTAINER_STYLE,
description: (
<Box>
<Text>{normalized.userMessage}</Text>
Expand Down Expand Up @@ -148,6 +165,7 @@ export function useToast() {
status: "error",
...DEFAULT_TOAST_OPTIONS,
duration: 6000, // Slightly longer for errors
containerStyle: TOAST_CONTAINER_STYLE,
});
}

Expand Down Expand Up @@ -187,6 +205,7 @@ export function useToast() {
isClosable: false,
...DEFAULT_TOAST_OPTIONS,
...toastOptions,
containerStyle: TOAST_CONTAINER_STYLE,
})
: null;

Expand All @@ -207,6 +226,7 @@ export function useToast() {
status: "success",
...DEFAULT_TOAST_OPTIONS,
...toastOptions,
containerStyle: TOAST_CONTAINER_STYLE,
});
}

Expand Down
Loading