diff --git a/app/admin/stats-v2/page.tsx b/app/admin/stats-v2/page.tsx new file mode 100644 index 00000000..3d0a553d --- /dev/null +++ b/app/admin/stats-v2/page.tsx @@ -0,0 +1,158 @@ +'use client'; + +/** + * Stats V2 Dashboard (Experimental) + * + * This page uses a new cross-chain indexer API that provides Monarch transaction + * data across all chains with a single API call. + * + * NOTE: This API is experimental and may be reverted due to cost concerns. + * The old stats page at /admin/stats should be kept as a fallback. + * + * Features: + * - Cross-chain volume aggregation + * - Volume breakdown by chain + * - Supply and withdraw transaction tables + * - ETH/BTC price estimation for USD values + */ + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import ButtonGroup from '@/components/ui/button-group'; +import { Spinner } from '@/components/ui/spinner'; +import Header from '@/components/layout/header/Header'; +import { PasswordGate } from '@/features/admin-v2/components/password-gate'; +import { StatsOverviewCards } from '@/features/admin-v2/components/stats-overview-cards'; +import { StatsVolumeChart } from '@/features/admin-v2/components/stats-volume-chart'; +import { ChainVolumeChart } from '@/features/admin-v2/components/chain-volume-chart'; +import { StatsTransactionsTable } from '@/features/admin-v2/components/stats-transactions-table'; +import { useMonarchTransactions, type TimeFrame } from '@/hooks/useMonarchTransactions'; +import { useAdminAuth } from '@/stores/useAdminAuth'; + +function StatsV2Content() { + const [timeframe, setTimeframe] = useState('30D'); + const { logout } = useAdminAuth(); + + const { + transactions, + supplies, + withdraws, + chainStats, + dailyVolumes, + totalSupplyVolumeUsd, + totalWithdrawVolumeUsd, + totalVolumeUsd, + isLoading, + error, + } = useMonarchTransactions(timeframe); + + const timeframeOptions = [ + { key: '1D', label: '1D', value: '1D' }, + { key: '7D', label: '7D', value: '7D' }, + { key: '30D', label: '30D', value: '30D' }, + { key: '90D', label: '90D', value: '90D' }, + { key: 'ALL', label: 'ALL', value: 'ALL' }, + ]; + + if (error) { + return ( +
+
+
+
+

Error Loading Data

+

{error.message}

+ +
+
+
+ ); + } + + return ( +
+
+
+ {/* Header */} +
+
+
+

Monarch Stats

+
+
+
+ setTimeframe(value as TimeFrame)} + size="sm" + variant="default" + /> + +
+
+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {/* Overview Cards */} + + + {/* Charts Grid */} +
+ {/* Aggregated Volume Chart */} + + + {/* Chain Breakdown Chart */} + +
+ + {/* Transactions Table */} + +
+ )} +
+
+ ); +} + +export default function StatsV2Page() { + return ( + + + + ); +} diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts new file mode 100644 index 00000000..c4744993 --- /dev/null +++ b/app/api/admin/auth/route.ts @@ -0,0 +1,81 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +/** + * Admin authentication endpoint + * + * POST: Login - validates password, sets httpOnly cookie + * DELETE: Logout - clears the cookie + * + * Environment variable (server-side only): + * - ADMIN_V2_PASSWORD_HASH: Expected password hash + * + * To generate a hash, run in Node: + * node -e "let h=0;for(const c of 'your-password'){h=(h<<5)-h+c.charCodeAt(0);h=h&h;}console.log(h.toString(16))" + */ + +const EXPECTED_HASH = process.env.ADMIN_V2_PASSWORD_HASH; +const COOKIE_NAME = 'monarch_admin_session'; + +function hashPassword(password: string): string { + let hash = 0; + for (const char of password) { + const charCode = char.charCodeAt(0); + hash = (hash << 5) - hash + charCode; + hash &= hash; + } + return hash.toString(16); +} + +export async function POST(request: NextRequest) { + if (!EXPECTED_HASH) { + return NextResponse.json({ error: 'Authentication not configured' }, { status: 500 }); + } + + try { + const { password } = await request.json(); + + if (!password || typeof password !== 'string') { + return NextResponse.json({ error: 'Password required' }, { status: 400 }); + } + + const hash = hashPassword(password); + + if (hash !== EXPECTED_HASH) { + await new Promise((resolve) => setTimeout(resolve, 200)); + return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); + } + + // Set httpOnly cookie - client can't read this via JS + const cookieStore = await cookies(); + cookieStore.set(COOKIE_NAME, hash, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/api/admin', + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} + +export async function DELETE() { + const cookieStore = await cookies(); + cookieStore.delete(COOKIE_NAME); + return NextResponse.json({ success: true }); +} + +export async function GET() { + // Check if user is authenticated + const cookieStore = await cookies(); + const session = cookieStore.get(COOKIE_NAME); + + if (!session?.value || session.value !== EXPECTED_HASH) { + return NextResponse.json({ authenticated: false }); + } + + return NextResponse.json({ authenticated: true }); +} diff --git a/app/api/admin/monarch-indexer/route.ts b/app/api/admin/monarch-indexer/route.ts new file mode 100644 index 00000000..d2fbd66e --- /dev/null +++ b/app/api/admin/monarch-indexer/route.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +/** + * Proxy API route for Monarch Indexer + * + * Validates auth via httpOnly cookie, then proxies to the actual indexer. + * The real endpoint URL is never exposed to the client. + * + * Environment variables (server-side only): + * - MONARCH_INDEXER_ENDPOINT: The actual GraphQL endpoint URL + * - ADMIN_V2_PASSWORD_HASH: Expected password hash (for cookie validation) + */ + +const INDEXER_ENDPOINT = process.env.MONARCH_INDEXER_ENDPOINT; +const EXPECTED_HASH = process.env.ADMIN_V2_PASSWORD_HASH; +const COOKIE_NAME = 'monarch_admin_session'; + +export async function POST(request: NextRequest) { + // Validate auth cookie + const cookieStore = await cookies(); + const session = cookieStore.get(COOKIE_NAME); + + if (!EXPECTED_HASH || !session?.value || session.value !== EXPECTED_HASH) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!INDEXER_ENDPOINT) { + return NextResponse.json({ error: 'Indexer endpoint not configured' }, { status: 500 }); + } + + try { + const body = await request.json(); + + const response = await fetch(INDEXER_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + return NextResponse.json({ error: `Indexer request failed: ${response.status}` }, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Monarch indexer proxy error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/constants/chartColors.ts b/src/constants/chartColors.ts index 6afbe5f3..a3385e14 100644 --- a/src/constants/chartColors.ts +++ b/src/constants/chartColors.ts @@ -14,6 +14,7 @@ type ChartColorConfig = { type ChartPaletteConfig = { supply: ChartColorConfig; borrow: ChartColorConfig; + withdraw: ChartColorConfig; apyAtTarget: ChartColorConfig; risk: ChartColorConfig; pie: readonly string[]; @@ -33,6 +34,7 @@ export const CHART_PALETTES: Record = { classic: { supply: createColorConfig('#4E79A7'), // Blue borrow: createColorConfig('#59A14F'), // Green + withdraw: createColorConfig('#F28E2B'), // Orange apyAtTarget: createColorConfig('#EDC948'), // Yellow risk: createColorConfig('#E15759'), // Red pie: [ @@ -53,6 +55,7 @@ export const CHART_PALETTES: Record = { earth: { supply: createColorConfig('#B26333'), // Burnt sienna borrow: createColorConfig('#89392D'), // Rust/terracotta + withdraw: createColorConfig('#CD853F'), // Peru apyAtTarget: createColorConfig('#A48A7A'), // Taupe risk: createColorConfig('#411E1D'), // Dark maroon pie: [ @@ -73,6 +76,7 @@ export const CHART_PALETTES: Record = { forest: { supply: createColorConfig('#223A30'), // Dark forest green borrow: createColorConfig('#8DA99D'), // Sage green + withdraw: createColorConfig('#6B8E23'), // Olive drab apyAtTarget: createColorConfig('#727472'), // Medium gray risk: createColorConfig('#7A7C7B'), // Gray pie: [ @@ -93,6 +97,7 @@ export const CHART_PALETTES: Record = { simple: { supply: createColorConfig('#2563EB'), // Blue-600 borrow: createColorConfig('#16A34A'), // Green-600 + withdraw: createColorConfig('#F97316'), // Orange-500 apyAtTarget: createColorConfig('#EAB308'), // Yellow-500 risk: createColorConfig('#DC2626'), // Red-600 pie: [ diff --git a/src/data-sources/monarch-indexer/fetchers.ts b/src/data-sources/monarch-indexer/fetchers.ts new file mode 100644 index 00000000..6f82e72e --- /dev/null +++ b/src/data-sources/monarch-indexer/fetchers.ts @@ -0,0 +1,44 @@ +/** + * Monarch Indexer GraphQL Fetcher + * + * Calls our internal API route which proxies to the actual indexer. + * Auth is handled via httpOnly cookie (set during login). + * + * NOTE: This API is experimental and may be reverted due to cost concerns. + * The old stats page at /admin/stats should be kept as a fallback. + */ + +type GraphQLVariables = Record; + +/** + * Fetches data from the monarch indexer via our API proxy. + * Requires valid session cookie (set via /api/admin/auth). + */ +export async function monarchIndexerFetcher(query: string, variables?: GraphQLVariables): Promise { + const response = await fetch('/api/admin/monarch-indexer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', // Include cookies + body: JSON.stringify({ + query, + variables, + }), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in again'); + } + throw new Error(`Monarch Indexer request failed: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + + if (result.errors) { + throw new Error(`Monarch Indexer GraphQL error: ${JSON.stringify(result.errors)}`); + } + + return result as T; +} diff --git a/src/data-sources/monarch-indexer/index.ts b/src/data-sources/monarch-indexer/index.ts new file mode 100644 index 00000000..64ab5bc2 --- /dev/null +++ b/src/data-sources/monarch-indexer/index.ts @@ -0,0 +1,17 @@ +/** + * Monarch Indexer Data Source + * + * A new cross-chain indexer API for fetching Monarch transactions + * across all chains with a single API call. + * + * NOTE: This API is experimental and may be reverted due to cost concerns. + * The old stats page at /admin/stats should be kept as a fallback. + */ + +export { monarchIndexerFetcher } from './fetchers'; +export { + fetchMonarchTransactions, + type MonarchSupplyTransaction, + type MonarchWithdrawTransaction, + type TimeRange, +} from './transactions'; diff --git a/src/data-sources/monarch-indexer/transactions.ts b/src/data-sources/monarch-indexer/transactions.ts new file mode 100644 index 00000000..0d69fef0 --- /dev/null +++ b/src/data-sources/monarch-indexer/transactions.ts @@ -0,0 +1,136 @@ +/** + * Monarch Indexer Transactions + * + * Fetches Monarch supply/withdraw transactions across all chains. + * Auth is handled via httpOnly cookie. + * Uses separate pagination for supplies and withdraws to ensure complete data. + * Freezes endTimestamp at fetch start to ensure consistent pagination. + */ + +import { monarchIndexerFetcher } from './fetchers'; + +export type MonarchSupplyTransaction = { + txHash: string; + timestamp: number; + market_id: string; + assets: string; + chainId: number; + onBehalf: string; +}; + +export type MonarchWithdrawTransaction = { + txHash: string; + timestamp: number; + market_id: string; + assets: string; + chainId: number; + onBehalf: string; +}; + +type SuppliesResponse = { + data: { + Morpho_Supply?: MonarchSupplyTransaction[]; + }; +}; + +type WithdrawsResponse = { + data: { + Morpho_Withdraw?: MonarchWithdrawTransaction[]; + }; +}; + +export type TimeRange = { + startTimestamp: number; + endTimestamp?: number; +}; + +type FrozenTimeRange = { + startTimestamp: number; + endTimestamp: number; +}; + +const MAX_PAGES = 50; + +async function fetchSuppliesPage(timeRange: FrozenTimeRange, limit: number, offset: number): Promise { + const query = ` + query MonarchSupplies($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { + Morpho_Supply( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, + limit: $limit, offset: $offset, order_by: {timestamp: desc} + ) { txHash timestamp market_id assets chainId onBehalf } + } + `; + + const variables = { + startTimestamp: timeRange.startTimestamp, + endTimestamp: timeRange.endTimestamp, + limit, + offset, + }; + + const response = await monarchIndexerFetcher(query, variables); + return response.data.Morpho_Supply ?? []; +} + +async function fetchWithdrawsPage(timeRange: FrozenTimeRange, limit: number, offset: number): Promise { + const query = ` + query MonarchWithdraws($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { + Morpho_Withdraw( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, + limit: $limit, offset: $offset, order_by: {timestamp: desc} + ) { txHash timestamp market_id assets chainId onBehalf } + } + `; + + const variables = { + startTimestamp: timeRange.startTimestamp, + endTimestamp: timeRange.endTimestamp, + limit, + offset, + }; + + const response = await monarchIndexerFetcher(query, variables); + return response.data.Morpho_Withdraw ?? []; +} + +/** + * Fetches all supply and withdraw transactions with independent pagination. + * Each collection is fetched completely before returning. + * Freezes endTimestamp at start to ensure consistent results during pagination. + */ +export async function fetchMonarchTransactions( + timeRange: TimeRange, + limit = 1000, +): Promise<{ + supplies: MonarchSupplyTransaction[]; + withdraws: MonarchWithdrawTransaction[]; +}> { + // Freeze the end timestamp at function start to ensure consistent pagination + // This prevents skipped/duplicate data if new transactions arrive during fetching + const frozenTimeRange: FrozenTimeRange = { + startTimestamp: timeRange.startTimestamp, + endTimestamp: timeRange.endTimestamp ?? Math.floor(Date.now() / 1000), + }; + + // Fetch supplies with independent pagination + const allSupplies: MonarchSupplyTransaction[] = []; + let suppliesOffset = 0; + for (let page = 0; page < MAX_PAGES; page++) { + const supplies = await fetchSuppliesPage(frozenTimeRange, limit, suppliesOffset); + allSupplies.push(...supplies); + if (supplies.length < limit) break; + suppliesOffset += limit; + } + + // Fetch withdraws with independent pagination + const allWithdraws: MonarchWithdrawTransaction[] = []; + let withdrawsOffset = 0; + for (let page = 0; page < MAX_PAGES; page++) { + const withdraws = await fetchWithdrawsPage(frozenTimeRange, limit, withdrawsOffset); + allWithdraws.push(...withdraws); + if (withdraws.length < limit) break; + withdrawsOffset += limit; + } + + return { supplies: allSupplies, withdraws: allWithdraws }; +} diff --git a/src/features/admin-v2/components/chain-volume-chart.tsx b/src/features/admin-v2/components/chain-volume-chart.tsx new file mode 100644 index 00000000..b575270b --- /dev/null +++ b/src/features/admin-v2/components/chain-volume-chart.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import Image from 'next/image'; +import { Card } from '@/components/ui/card'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { Spinner } from '@/components/ui/spinner'; +import { useChartColors } from '@/constants/chartColors'; +import { formatReadable } from '@/utils/balance'; +import { getNetworkImg, getNetworkName } from '@/utils/networks'; +import { ChartGradients, chartTooltipCursor, chartLegendStyle } from '@/features/market-detail/components/charts/chart-utils'; +import type { DailyVolume, ChainStats } from '@/hooks/useMonarchTransactions'; + +type ChainVolumeChartProps = { + dailyVolumes: DailyVolume[]; + chainStats: ChainStats[]; + isLoading: boolean; +}; + +// Chain to pie color index mapping (consistent ordering) +const CHAIN_COLOR_INDEX: Record = { + 1: 0, // Mainnet + 8453: 1, // Base + 137: 3, // Polygon + 130: 4, // Unichain + 42161: 6, // Arbitrum + 999: 5, // HyperEVM + 143: 2, // Monad +}; + +export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainVolumeChartProps) { + const chartColors = useChartColors(); + + // Get color for a chain using the current palette + const getChainColor = (chainId: number): string => { + const index = CHAIN_COLOR_INDEX[chainId] ?? 8; + return chartColors.pie[index] ?? chartColors.pie[8]; + }; + + // Get unique chain IDs from stats + const chainIds = useMemo(() => chainStats.map((s) => s.chainId), [chainStats]); + + // Track hidden chains instead of visible - all chains visible by default + const [hiddenChains, setHiddenChains] = useState>(new Set()); + + // Derive visible chains (pure computation, no side effects) + const visibleChains = useMemo(() => { + const visible: Record = {}; + for (const chainId of chainIds) { + visible[chainId] = !hiddenChains.has(chainId); + } + return visible; + }, [chainIds, hiddenChains]); + + const chartData = useMemo(() => { + return dailyVolumes.map((v) => { + const dataPoint: Record = { x: v.timestamp }; + for (const chainId of chainIds) { + const chainData = v.byChain[chainId]; + dataPoint[`chain_${chainId}`] = chainData ? chainData.supplyVolumeUsd + chainData.withdrawVolumeUsd : 0; + } + return dataPoint; + }); + }, [dailyVolumes, chainIds]); + + const gradients = useMemo(() => { + return chainIds.map((chainId) => ({ + id: `chain_${chainId}Gradient`, + color: getChainColor(chainId), + })); + }, [chainIds, chartColors]); + + const formatYAxis = (value: number) => `$${formatReadable(value)}`; + + const handleLegendClick = (e: { dataKey?: unknown }) => { + if (!e.dataKey || typeof e.dataKey !== 'string') return; + const chainId = Number(e.dataKey.replace('chain_', '')); + setHiddenChains((prev) => { + const next = new Set(prev); + if (next.has(chainId)) { + next.delete(chainId); + } else { + next.add(chainId); + } + return next; + }); + }; + + const legendFormatter = (value: string, entry: { dataKey?: unknown }) => { + if (!entry.dataKey || typeof entry.dataKey !== 'string') return value; + const chainId = Number(entry.dataKey.replace('chain_', '')); + const isVisible = visibleChains[chainId] ?? true; + return ( + + {value} + + ); + }; + + return ( + + {/* Header: Chain Stats */} +
+

Volume by Chain

+
+ {chainStats.map((stat) => { + const networkImg = getNetworkImg(stat.chainId); + const networkName = getNetworkName(stat.chainId) ?? `Chain ${stat.chainId}`; + return ( +
+ {networkImg && ( + {networkName} + )} +
+

{networkName}

+

+ ${formatReadable(stat.totalVolumeUsd)} +

+
+
+ ); + })} +
+
+ + {/* Chart Body */} +
+ {isLoading ? ( +
+ +
+ ) : chartData.length === 0 ? ( +
No data available
+ ) : ( + + + + + new Date(time * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + { + if (!active || !payload) return null; + return ( +
+

+ {new Date((label ?? 0) * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+ {payload.map((entry) => { + if (!entry.dataKey || typeof entry.dataKey !== 'string') return null; + const chainId = Number(entry.dataKey.replace('chain_', '')); + const networkName = getNetworkName(chainId) ?? `Chain ${chainId}`; + return ( +
+
+ + {networkName} +
+ ${formatReadable(Number(entry.value) || 0)} +
+ ); + })} +
+
+ ); + }} + /> + + {chainIds.map((chainId) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/features/admin-v2/components/password-gate.tsx b/src/features/admin-v2/components/password-gate.tsx new file mode 100644 index 00000000..beaff65c --- /dev/null +++ b/src/features/admin-v2/components/password-gate.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useState, useEffect, type FormEvent } from 'react'; +import { Card, CardBody } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; +import { useAdminAuth } from '@/stores/useAdminAuth'; + +type PasswordGateProps = { + children: React.ReactNode; +}; + +export function PasswordGate({ children }: PasswordGateProps) { + const { isAuthenticated, isLoading, isCheckingAuth, error, authenticate, checkAuth } = useAdminAuth(); + const [password, setPassword] = useState(''); + + // Check auth status on mount (validates existing cookie) + useEffect(() => { + void checkAuth(); + }, [checkAuth]); + + // Show spinner while checking existing auth + if (isCheckingAuth) { + return ( +
+ +
+ ); + } + + if (isAuthenticated) { + return <>{children}; + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const success = await authenticate(password); + if (!success) { + setPassword(''); + } + }; + + return ( +
+ + +
+

Stats V2 (Experimental)

+

This page uses an experimental API that may be reverted due to cost concerns.

+
+ +
+ + + +
+ +

Contact the team if you need access credentials.

+
+
+
+ ); +} diff --git a/src/features/admin-v2/components/stats-overview-cards.tsx b/src/features/admin-v2/components/stats-overview-cards.tsx new file mode 100644 index 00000000..3276d147 --- /dev/null +++ b/src/features/admin-v2/components/stats-overview-cards.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Card, CardBody } from '@/components/ui/card'; +import { formatReadable } from '@/utils/balance'; +import type { ChainStats } from '@/hooks/useMonarchTransactions'; + +type StatsOverviewCardsProps = { + totalSupplyVolumeUsd: number; + totalWithdrawVolumeUsd: number; + totalVolumeUsd: number; + supplyCount: number; + withdrawCount: number; + chainStats: ChainStats[]; +}; + +type StatCardProps = { + title: string; + value: string; + subtitle?: string; +}; + +function StatCard({ title, value, subtitle }: StatCardProps) { + return ( + + +

{title}

+
+

{value}

+ {subtitle &&

{subtitle}

} +
+
+
+ ); +} + +export function StatsOverviewCards({ + totalSupplyVolumeUsd, + totalWithdrawVolumeUsd, + totalVolumeUsd, + supplyCount, + withdrawCount, + chainStats, +}: StatsOverviewCardsProps) { + const totalTransactions = supplyCount + withdrawCount; + const activeChains = chainStats.length; + + return ( +
+ + + + +
+ ); +} diff --git a/src/features/admin-v2/components/stats-transactions-table.tsx b/src/features/admin-v2/components/stats-transactions-table.tsx new file mode 100644 index 00000000..d0474e25 --- /dev/null +++ b/src/features/admin-v2/components/stats-transactions-table.tsx @@ -0,0 +1,407 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import moment from 'moment'; +import { ChevronUpIcon, ChevronDownIcon } from '@radix-ui/react-icons'; +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '@/components/ui/table'; +import { AddressIdentity } from '@/components/shared/address-identity'; +import { TablePagination } from '@/components/shared/table-pagination'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { TransactionIdentity } from '@/components/shared/transaction-identity'; +import { MarketIdBadge } from '@/features/markets/components/market-id-badge'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem } from '@/components/ui/dropdown-menu'; +import { formatReadable } from '@/utils/balance'; +import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import type { EnrichedTransaction } from '@/hooks/useMonarchTransactions'; + +type StatsTransactionsTableProps = { + transactions: EnrichedTransaction[]; + isLoading: boolean; +}; + +type SortKey = 'timestamp' | 'usdValue'; +type SortDirection = 'asc' | 'desc'; + +type SortableHeaderProps = { + label: string; + sortKeyValue: SortKey; + currentSortKey: SortKey; + sortDirection: SortDirection; + onSort: (key: SortKey) => void; +}; + +function getChainFilterLabel(selectedChains: number[]): string { + if (selectedChains.length === 0) return 'All chains'; + if (selectedChains.length === 1) return getNetworkName(selectedChains[0]) ?? 'Chain'; + return `${selectedChains.length} chains`; +} + +function getTypeFilterLabel(selectedTypes: ('supply' | 'withdraw')[]): string { + if (selectedTypes.length === 0) return 'All types'; + if (selectedTypes.length === 1) return selectedTypes[0] === 'supply' ? 'Supply' : 'Withdraw'; + return 'Both types'; +} + +function SortableHeader({ label, sortKeyValue, currentSortKey, sortDirection, onSort }: SortableHeaderProps) { + return ( + onSort(sortKeyValue)} + style={{ padding: '0.5rem' }} + > +
+
{label}
+ {currentSortKey === sortKeyValue && + (sortDirection === 'asc' ? : )} +
+
+ ); +} + +export function StatsTransactionsTable({ transactions, isLoading }: StatsTransactionsTableProps) { + const [sortKey, setSortKey] = useState('timestamp'); + const [sortDirection, setSortDirection] = useState('desc'); + const [currentPage, setCurrentPage] = useState(1); + const [selectedChains, setSelectedChains] = useState([]); + const [selectedTypes, setSelectedTypes] = useState<('supply' | 'withdraw')[]>([]); + const entriesPerPage = 15; + + // Get unique chain IDs from transactions + const uniqueChainIds = useMemo(() => { + const chains = new Set(transactions.map((tx) => tx.chainId)); + return Array.from(chains).sort((a, b) => a - b); + }, [transactions]); + + const handleSort = (key: SortKey) => { + if (key === sortKey) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('desc'); + } + }; + + // Filter transactions by selected chains and types + const filteredData = useMemo(() => { + let filtered = transactions; + + if (selectedChains.length > 0) { + filtered = filtered.filter((tx) => selectedChains.includes(tx.chainId)); + } + + if (selectedTypes.length > 0) { + filtered = filtered.filter((tx) => selectedTypes.includes(tx.type)); + } + + return filtered; + }, [transactions, selectedChains, selectedTypes]); + + // Sort filtered data + const sortedData = useMemo(() => { + return [...filteredData].sort((a, b) => { + const valueA = sortKey === 'timestamp' ? a.timestamp : a.usdValue; + const valueB = sortKey === 'timestamp' ? b.timestamp : b.usdValue; + + if (sortDirection === 'asc') { + return valueA < valueB ? -1 : valueA > valueB ? 1 : 0; + } + return valueA > valueB ? -1 : valueA < valueB ? 1 : 0; + }); + }, [filteredData, sortKey, sortDirection]); + + const indexOfLastEntry = currentPage * entriesPerPage; + const indexOfFirstEntry = indexOfLastEntry - entriesPerPage; + const currentEntries = sortedData.slice(indexOfFirstEntry, indexOfLastEntry); + const totalPages = Math.ceil(sortedData.length / entriesPerPage); + + // Reset to page 1 when filters change + const handleChainToggle = (chainId: number, checked: boolean) => { + if (checked) { + setSelectedChains([...selectedChains, chainId]); + } else { + setSelectedChains(selectedChains.filter((c) => c !== chainId)); + } + setCurrentPage(1); + }; + + const handleTypeToggle = (type: 'supply' | 'withdraw', checked: boolean) => { + if (checked) { + setSelectedTypes([...selectedTypes, type]); + } else { + setSelectedTypes(selectedTypes.filter((t) => t !== type)); + } + setCurrentPage(1); + }; + + return ( +
+
+
+
+

Recent Transactions

+

+ {filteredData.length} transaction{filteredData.length !== 1 ? 's' : ''} + {filteredData.length !== transactions.length && ` (filtered from ${transactions.length})`} +

+
+
+ {/* Chain Filter */} + + + + + + {uniqueChainIds.map((chainId) => { + const networkImg = getNetworkImg(chainId); + const networkName = getNetworkName(chainId) ?? `Chain ${chainId}`; + return ( + handleChainToggle(chainId, !!checked)} + startContent={ + networkImg ? ( + {networkName} + ) : undefined + } + > + {networkName} + + ); + })} + + + + {/* Type Filter */} + + + + + + handleTypeToggle('supply', !!checked)} + > + Supply + + handleTypeToggle('withdraw', !!checked)} + > + Withdraw + + + +
+
+
+
+ {sortedData.length === 0 ? ( +
{isLoading ? 'Loading transactions...' : 'No transaction data available'}
+ ) : ( + <> + + + + Chain + Type + User + Asset + Market + + Tx Hash + + + + + {currentEntries.map((tx, idx) => { + const networkImg = getNetworkImg(tx.chainId); + const networkName = getNetworkName(tx.chainId) ?? `Chain ${tx.chainId}`; + const marketPath = tx.market ? `/market/${tx.chainId}/${tx.market.uniqueKey}` : null; + + return ( + + {/* Chain */} + +
+ {networkImg && ( + + )} + {networkName} +
+
+ + {/* Type */} + + + {tx.type === 'supply' ? 'Supply' : 'Withdraw'} + + + + {/* User */} + + + + + {/* Asset */} + +
+ {tx.market && ( + + )} + {getTruncatedAssetName(tx.loanSymbol ?? 'Unknown')} +
+
+ + {/* Market */} + + {tx.market && marketPath ? ( + +
+ + +
+ + ) : ( + + )} +
+ + {/* Amount (USD) */} + + + ${formatReadable(tx.usdValue)} + + ({formatReadable(tx.assetsFormatted)} {tx.loanSymbol}) + + + + + {/* Tx Hash */} + + + + + {/* Time */} + + {moment.unix(tx.timestamp).fromNow()} + +
+ ); + })} +
+
+
+ +
+ + )} +
+
+ ); +} diff --git a/src/features/admin-v2/components/stats-volume-chart.tsx b/src/features/admin-v2/components/stats-volume-chart.tsx new file mode 100644 index 00000000..7403dd18 --- /dev/null +++ b/src/features/admin-v2/components/stats-volume-chart.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Card } from '@/components/ui/card'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { Spinner } from '@/components/ui/spinner'; +import { useChartColors } from '@/constants/chartColors'; +import { formatReadable } from '@/utils/balance'; +import { + ChartGradients, + ChartTooltipContent, + createLegendClickHandler, + chartTooltipCursor, + chartLegendStyle, +} from '@/features/market-detail/components/charts/chart-utils'; +import type { DailyVolume } from '@/hooks/useMonarchTransactions'; + +type StatsVolumeChartProps = { + dailyVolumes: DailyVolume[]; + totalSupplyVolumeUsd: number; + totalWithdrawVolumeUsd: number; + isLoading: boolean; +}; + +function createStatsVolumeGradients(colors: ReturnType) { + return [ + { id: 'supplyGradient', color: colors.supply.stroke }, + { id: 'withdrawGradient', color: colors.withdraw.stroke }, + ]; +} + +export function StatsVolumeChart({ dailyVolumes, totalSupplyVolumeUsd, totalWithdrawVolumeUsd, isLoading }: StatsVolumeChartProps) { + const chartColors = useChartColors(); + const [visibleLines, setVisibleLines] = useState({ + supply: true, + withdraw: true, + }); + + const chartData = useMemo(() => { + return dailyVolumes.map((v) => ({ + x: v.timestamp, + supply: v.supplyVolumeUsd, + withdraw: v.withdrawVolumeUsd, + })); + }, [dailyVolumes]); + + const formatYAxis = (value: number) => `$${formatReadable(value)}`; + const formatValue = (value: number) => `$${formatReadable(value)}`; + + const legendHandlers = createLegendClickHandler({ visibleLines, setVisibleLines }); + + const totalVolume = totalSupplyVolumeUsd + totalWithdrawVolumeUsd; + + return ( + + {/* Header: Live Stats */} +
+
+
+

Total Volume

+ ${formatReadable(totalVolume)} +
+
+

Supply Volume

+ + ${formatReadable(totalSupplyVolumeUsd)} + +
+
+

Withdraw Volume

+ + ${formatReadable(totalWithdrawVolumeUsd)} + +
+
+
+ + {/* Chart Body */} +
+ {isLoading ? ( +
+ +
+ ) : chartData.length === 0 ? ( +
No data available
+ ) : ( + + + + + new Date(time * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + ( + + )} + /> + + + + + + )} +
+ + {/* Footer: Summary */} +
+

Period Summary

+
+
+ Transactions + {dailyVolumes.length} days +
+
+ Avg Daily Volume + ${formatReadable(dailyVolumes.length > 0 ? totalVolume / dailyVolumes.length : 0)} +
+
+
+
+ ); +} diff --git a/src/features/admin-v2/index.ts b/src/features/admin-v2/index.ts new file mode 100644 index 00000000..0e496806 --- /dev/null +++ b/src/features/admin-v2/index.ts @@ -0,0 +1,14 @@ +/** + * Admin V2 Feature + * + * Experimental cross-chain stats dashboard that uses the monarch indexer API. + * + * NOTE: This feature is experimental and may be removed if the API costs + * prove too high. The original /admin/stats page should remain as a fallback. + */ + +export { PasswordGate } from './components/password-gate'; +export { StatsOverviewCards } from './components/stats-overview-cards'; +export { StatsVolumeChart } from './components/stats-volume-chart'; +export { ChainVolumeChart } from './components/chain-volume-chart'; +export { StatsTransactionsTable } from './components/stats-transactions-table'; diff --git a/src/hooks/useMonarchTransactions.ts b/src/hooks/useMonarchTransactions.ts new file mode 100644 index 00000000..54869d58 --- /dev/null +++ b/src/hooks/useMonarchTransactions.ts @@ -0,0 +1,333 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { formatUnits } from 'viem'; +import { + fetchMonarchTransactions, + type MonarchSupplyTransaction, + type MonarchWithdrawTransaction, + type TimeRange, +} from '@/data-sources/monarch-indexer'; +import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; +import { useTokenPrices } from '@/hooks/useTokenPrices'; +import type { Market } from '@/utils/types'; + +export type TimeFrame = '1D' | '7D' | '30D' | '90D' | 'ALL'; + +const TIMEFRAME_TO_SECONDS: Record = { + '1D': 24 * 60 * 60, + '7D': 7 * 24 * 60 * 60, + '30D': 30 * 24 * 60 * 60, + '90D': 90 * 24 * 60 * 60, + ALL: 365 * 24 * 60 * 60, // 1 year as "ALL" +}; + +// Known ETH-pegged tokens (WETH variants) +const ETH_PEGGED_SYMBOLS = new Set(['WETH', 'ETH', 'wstETH', 'cbETH', 'rETH', 'stETH', 'STETH', 'weETH', 'ezETH']); + +// Known BTC-pegged tokens +const BTC_PEGGED_SYMBOLS = new Set(['WBTC', 'BTC', 'tBTC', 'cbBTC', 'sBTC', 'renBTC', 'BTCB']); + +export type EnrichedTransaction = { + txHash: string; + timestamp: number; + marketId: string; + assets: string; + assetsFormatted: number; + usdValue: number; + chainId: number; + type: 'supply' | 'withdraw'; + market?: Market; + loanSymbol?: string; + onBehalf: string; +}; + +export type ChainStats = { + chainId: number; + supplyCount: number; + withdrawCount: number; + supplyVolumeUsd: number; + withdrawVolumeUsd: number; + totalVolumeUsd: number; +}; + +export type DailyVolume = { + date: string; + timestamp: number; + supplyVolumeUsd: number; + withdrawVolumeUsd: number; + totalVolumeUsd: number; + byChain: Record; +}; + +type UseMonarchTransactionsReturn = { + transactions: EnrichedTransaction[]; + supplies: EnrichedTransaction[]; + withdraws: EnrichedTransaction[]; + chainStats: ChainStats[]; + dailyVolumes: DailyVolume[]; + totalSupplyVolumeUsd: number; + totalWithdrawVolumeUsd: number; + totalVolumeUsd: number; + uniqueUsers: number; + isLoading: boolean; + error: Error | null; +}; + +export const useMonarchTransactions = (timeframe: TimeFrame): UseMonarchTransactionsReturn => { + const { allMarkets, loading: marketsLoading } = useProcessedMarkets(); + + // Calculate time range based on timeframe + const timeRange = useMemo((): TimeRange => { + const now = Math.floor(Date.now() / 1000); + return { + startTimestamp: now - TIMEFRAME_TO_SECONDS[timeframe], + endTimestamp: now, + }; + }, [timeframe]); + + // Fetch transactions (auth via httpOnly cookie) + const { + data: rawTransactions, + isLoading: txLoading, + error, + } = useQuery({ + queryKey: ['monarch-transactions', timeRange.startTimestamp, timeRange.endTimestamp], + queryFn: () => fetchMonarchTransactions(timeRange, 1000), + staleTime: 2 * 60 * 1000, // 2 minutes + refetchInterval: 5 * 60 * 1000, // 5 minutes + enabled: !marketsLoading, + }); + + // Create market lookup map + const marketMap = useMemo(() => { + const map = new Map(); + for (const market of allMarkets) { + if (market.uniqueKey) { + map.set(market.uniqueKey.toLowerCase(), market); + } + } + return map; + }, [allMarkets]); + + // Get unique loan tokens for price fetching + const uniqueTokens = useMemo(() => { + const tokens: Array<{ address: string; chainId: number }> = []; + const seen = new Set(); + + for (const market of allMarkets) { + const key = `${market.loanAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`; + if (!seen.has(key)) { + seen.add(key); + tokens.push({ + address: market.loanAsset.address, + chainId: market.morphoBlue.chain.id, + }); + } + } + + return tokens; + }, [allMarkets]); + + // Fetch token prices + const { prices: tokenPrices, isLoading: pricesLoading } = useTokenPrices(uniqueTokens); + + // Get ETH and BTC prices from fetched prices (find first match) + const ethPrice = useMemo(() => { + for (const [key, price] of tokenPrices) { + const address = key.split('-')[0]; + const market = allMarkets.find((m) => m.loanAsset.address.toLowerCase() === address && ETH_PEGGED_SYMBOLS.has(m.loanAsset.symbol)); + if (market) return price; + } + return 0; + }, [tokenPrices, allMarkets]); + + const btcPrice = useMemo(() => { + for (const [key, price] of tokenPrices) { + const address = key.split('-')[0]; + const market = allMarkets.find((m) => m.loanAsset.address.toLowerCase() === address && BTC_PEGGED_SYMBOLS.has(m.loanAsset.symbol)); + if (market) return price; + } + return 0; + }, [tokenPrices, allMarkets]); + + // Enrich transactions with market data and USD values + const enrichedData = useMemo(() => { + if (!rawTransactions) { + return { + supplies: [] as EnrichedTransaction[], + withdraws: [] as EnrichedTransaction[], + transactions: [] as EnrichedTransaction[], + }; + } + + const getUsdValue = (assets: string, market: Market | undefined): number => { + if (!market) return 0; + + const decimals = market.loanAsset.decimals; + const formatted = Number(formatUnits(BigInt(assets), decimals)); + const symbol = market.loanAsset.symbol; + + // Check if it's an ETH-pegged token + if (ETH_PEGGED_SYMBOLS.has(symbol)) { + return formatted * ethPrice; + } + + // Check if it's a BTC-pegged token + if (BTC_PEGGED_SYMBOLS.has(symbol)) { + return formatted * btcPrice; + } + + // Try to get price from token prices map + const priceKey = `${market.loanAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`; + const price = tokenPrices.get(priceKey); + if (price) { + return formatted * price; + } + + // Assume stablecoins are $1 + if (symbol.includes('USD') || symbol.includes('DAI') || symbol.includes('USDT') || symbol.includes('USDC')) { + return formatted; + } + + return 0; + }; + + const enrichTx = (tx: MonarchSupplyTransaction | MonarchWithdrawTransaction, type: 'supply' | 'withdraw'): EnrichedTransaction => { + const market = marketMap.get(tx.market_id.toLowerCase()); + const decimals = market?.loanAsset.decimals ?? 18; + const formatted = Number(formatUnits(BigInt(tx.assets), decimals)); + + return { + txHash: tx.txHash, + timestamp: tx.timestamp, + marketId: tx.market_id, + assets: tx.assets, + assetsFormatted: formatted, + usdValue: getUsdValue(tx.assets, market), + chainId: tx.chainId, + type, + market, + loanSymbol: market?.loanAsset.symbol, + onBehalf: tx.onBehalf, + }; + }; + + const supplies = rawTransactions.supplies.map((tx) => enrichTx(tx, 'supply')); + const withdraws = rawTransactions.withdraws.map((tx) => enrichTx(tx, 'withdraw')); + const transactions = [...supplies, ...withdraws].sort((a, b) => b.timestamp - a.timestamp); + + return { supplies, withdraws, transactions }; + }, [rawTransactions, marketMap, tokenPrices, ethPrice, btcPrice]); + + // Calculate chain stats + const chainStats = useMemo(() => { + const statsMap = new Map(); + + for (const tx of enrichedData.supplies) { + const stats = statsMap.get(tx.chainId) ?? { + chainId: tx.chainId, + supplyCount: 0, + withdrawCount: 0, + supplyVolumeUsd: 0, + withdrawVolumeUsd: 0, + totalVolumeUsd: 0, + }; + stats.supplyCount++; + stats.supplyVolumeUsd += tx.usdValue; + stats.totalVolumeUsd += tx.usdValue; + statsMap.set(tx.chainId, stats); + } + + for (const tx of enrichedData.withdraws) { + const stats = statsMap.get(tx.chainId) ?? { + chainId: tx.chainId, + supplyCount: 0, + withdrawCount: 0, + supplyVolumeUsd: 0, + withdrawVolumeUsd: 0, + totalVolumeUsd: 0, + }; + stats.withdrawCount++; + stats.withdrawVolumeUsd += tx.usdValue; + stats.totalVolumeUsd += tx.usdValue; + statsMap.set(tx.chainId, stats); + } + + return Array.from(statsMap.values()).sort((a, b) => b.totalVolumeUsd - a.totalVolumeUsd); + }, [enrichedData]); + + // Calculate daily volumes + const dailyVolumes = useMemo(() => { + const volumeMap = new Map(); + + const addToVolume = (tx: EnrichedTransaction) => { + const date = new Date(tx.timestamp * 1000).toISOString().split('T')[0]; + const existing = volumeMap.get(date) ?? { + date, + timestamp: new Date(date).getTime() / 1000, + supplyVolumeUsd: 0, + withdrawVolumeUsd: 0, + totalVolumeUsd: 0, + byChain: {}, + }; + + if (tx.type === 'supply') { + existing.supplyVolumeUsd += tx.usdValue; + } else { + existing.withdrawVolumeUsd += tx.usdValue; + } + existing.totalVolumeUsd += tx.usdValue; + + // Track by chain + const chainData = existing.byChain[tx.chainId] ?? { supplyVolumeUsd: 0, withdrawVolumeUsd: 0 }; + if (tx.type === 'supply') { + chainData.supplyVolumeUsd += tx.usdValue; + } else { + chainData.withdrawVolumeUsd += tx.usdValue; + } + existing.byChain[tx.chainId] = chainData; + + volumeMap.set(date, existing); + }; + + for (const tx of enrichedData.transactions) { + addToVolume(tx); + } + + return Array.from(volumeMap.values()).sort((a, b) => a.timestamp - b.timestamp); + }, [enrichedData.transactions]); + + // Calculate totals + const totals = useMemo(() => { + const totalSupplyVolumeUsd = enrichedData.supplies.reduce((sum, tx) => sum + tx.usdValue, 0); + const totalWithdrawVolumeUsd = enrichedData.withdraws.reduce((sum, tx) => sum + tx.usdValue, 0); + return { + totalSupplyVolumeUsd, + totalWithdrawVolumeUsd, + totalVolumeUsd: totalSupplyVolumeUsd + totalWithdrawVolumeUsd, + }; + }, [enrichedData]); + + // Calculate unique users from onBehalf addresses + const uniqueUsers = useMemo(() => { + const users = new Set(); + for (const tx of enrichedData.transactions) { + users.add(tx.onBehalf.toLowerCase()); + } + return users.size; + }, [enrichedData.transactions]); + + const isLoading = marketsLoading || txLoading || pricesLoading; + + return { + transactions: enrichedData.transactions, + supplies: enrichedData.supplies, + withdraws: enrichedData.withdraws, + chainStats, + dailyVolumes, + ...totals, + uniqueUsers, + isLoading, + error: error as Error | null, + }; +}; diff --git a/src/stores/useAdminAuth.ts b/src/stores/useAdminAuth.ts new file mode 100644 index 00000000..8f70fad3 --- /dev/null +++ b/src/stores/useAdminAuth.ts @@ -0,0 +1,100 @@ +import { create } from 'zustand'; + +/** + * Admin authentication store for stats-v2 pages. + * + * Authentication flow: + * 1. User enters password on client + * 2. Password is sent to /api/admin/auth for server-side validation + * 3. Server validates against ADMIN_V2_PASSWORD_HASH env var + * 4. If valid, server sets httpOnly cookie (not accessible via JS) + * 5. Subsequent API requests include cookie automatically + * + * No sensitive data is stored client-side. + * Set ADMIN_V2_PASSWORD_HASH in your .env file. + * + * To generate a hash, run in Node: + * node -e "let h=0;for(const c of 'your-password'){h=(h<<5)-h+c.charCodeAt(0);h=h&h;}console.log(h.toString(16))" + */ + +type AdminAuthState = { + isAuthenticated: boolean; + isLoading: boolean; + isCheckingAuth: boolean; + error: string | null; +}; + +type AdminAuthActions = { + checkAuth: () => Promise; + authenticate: (password: string) => Promise; + logout: () => Promise; + clearError: () => void; +}; + +type AdminAuthStore = AdminAuthState & AdminAuthActions; + +export const useAdminAuth = create()((set) => ({ + isAuthenticated: false, + isLoading: false, + isCheckingAuth: true, + error: null, + + checkAuth: async () => { + set({ isCheckingAuth: true }); + try { + const response = await fetch('/api/admin/auth', { + method: 'GET', + credentials: 'same-origin', + }); + if (!response.ok) { + set({ isAuthenticated: false, isCheckingAuth: false }); + return; + } + const data = await response.json(); + set({ isAuthenticated: data.authenticated, isCheckingAuth: false }); + } catch { + set({ isAuthenticated: false, isCheckingAuth: false }); + } + }, + + authenticate: async (password: string) => { + set({ isLoading: true, error: null }); + + try { + const response = await fetch('/api/admin/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ password }), + }); + + const data = await response.json(); + + if (!response.ok) { + set({ isLoading: false, error: data.error ?? 'Authentication failed' }); + return false; + } + + set({ isAuthenticated: true, isLoading: false, error: null }); + return true; + } catch { + set({ isLoading: false, error: 'Network error' }); + return false; + } + }, + + logout: async () => { + try { + await fetch('/api/admin/auth', { + method: 'DELETE', + credentials: 'same-origin', + }); + } finally { + set({ isAuthenticated: false, error: null }); + } + }, + + clearError: () => { + set({ error: null }); + }, +}));