diff --git a/apps/scan/app/[locale]/HomeContent.tsx b/apps/scan/app/[locale]/HomeContent.tsx index 8487196..fb955ec 100644 --- a/apps/scan/app/[locale]/HomeContent.tsx +++ b/apps/scan/app/[locale]/HomeContent.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import { Link } from "@/i18n/navigation"; -import { Blocks, ArrowUpDown, Search, Clock, Loader2 } from "lucide-react"; +import { Blocks, ArrowUpDown, Search, Clock, Loader2, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { RailBadge, classifyRail, type Rail } from "@/components/common/RailBadge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -90,7 +90,7 @@ export function HomeContent({ initial }: { initial: HomeBundle }) { // most often (sent SRX → didn't show up under EVM scan, etc.) so they // get top billing in the pill row. const [railFilter, setRailFilter] = useState<"all" | Rail>("all"); - const { data: stats, loading: statsLoading, refetch: refetchStats } = useStats(network, initial.stats); + const { data: stats, loading: statsLoading, error: statsError, refetch: refetchStats, retry: retryStats } = useStats(network, initial.stats); const { data: blocks, loading: blocksLoading, refetch: refetchBlocks } = useBlocks(network, 10, initial.blocks); const { data: txs, loading: txsLoading, refetch: refetchTxs } = useTransactions(network, 10, initial.txs); // Live block height via WebSocket. newHeads (proposed) + sentrix_finalized @@ -261,13 +261,13 @@ export function HomeContent({ initial }: { initial: HomeBundle }) { fixed header (top-16 = h-16 of header). */} {(isChainIdle || chainUnreachable) && ( -
-
- - +
+
+ + {chainUnreachable ? "RPC offline" : `${network === "testnet" ? "Testnet" : "Chain"} paused`} - + {chainUnreachable ? "Couldn't reach the chain RPC — retrying. Operators are aware." : latestBlockAgeSec !== null @@ -382,6 +382,8 @@ export function HomeContent({ initial }: { initial: HomeBundle }) { label={t("stats.active_validators")} value={stats ? String(stats.active_validators) : "—"} loading={statsLoading} + error={statsError} + onRetry={retryStats} accent="var(--gold)" subline={t("stats.subline_active_bft")} /> @@ -389,6 +391,8 @@ export function HomeContent({ initial }: { initial: HomeBundle }) { label={t("stats.tokens_deployed")} value={stats ? String(stats.deployed_tokens) : "—"} loading={statsLoading} + error={statsError} + onRetry={retryStats} accent="var(--gold-l)" subline={t("stats.subline_src20_contracts")} /> @@ -397,6 +401,8 @@ export function HomeContent({ initial }: { initial: HomeBundle }) { value={stats ? formatBurnedSrx(stats.total_burned_srx) : "—"} title={stats ? `${stats.total_burned_srx.toLocaleString(undefined, { maximumFractionDigits: 8 })} SRX` : undefined} loading={statsLoading} + error={statsError} + onRetry={retryStats} accent="var(--red)" subline={t("stats.subline_fee_burn")} /> @@ -404,6 +410,8 @@ export function HomeContent({ initial }: { initial: HomeBundle }) { label={t("stats.block_reward")} value={stats ? `${stats.next_block_reward_srx} SRX` : "—"} loading={statsLoading} + error={statsError} + onRetry={retryStats} accent="var(--gold)" subline={t("stats.subline_claimable")} /> diff --git a/apps/scan/app/[locale]/accounts/page.tsx b/apps/scan/app/[locale]/accounts/page.tsx index 27176a5..8639069 100644 --- a/apps/scan/app/[locale]/accounts/page.tsx +++ b/apps/scan/app/[locale]/accounts/page.tsx @@ -1,8 +1,9 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; import { useSearchParams, useRouter, usePathname } from "next/navigation"; -import { Users } from "lucide-react"; +import { Users, AlertTriangle, RotateCw } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Address } from "@/components/common/Address"; @@ -20,6 +21,7 @@ const PAGE_SIZE = 25; // registry (`lib/labels.tsx`) so premine wallets, validator hosts, and // SentrixSafe surface their human names automatically. export default function AccountsPage() { + const tc = useTranslations("common"); const { network } = useNetwork(); useNetworkFromQuery(); const searchParams = useSearchParams(); @@ -41,7 +43,7 @@ export default function AccountsPage() { setPageState(fresh); }, [searchParams]); - const { data, loading } = useAccountsTop(network, 100); + const { data, loading, error, retry } = useAccountsTop(network, 100); const totalPages = useMemo( () => Math.max(1, Math.ceil((data?.length ?? 0) / PAGE_SIZE)), [data], @@ -80,6 +82,23 @@ export default function AccountsPage() { ))}
+ ) : error ? ( + + + {tc("retry")} + + } + /> ) : paged.length > 0 ? ( <>
diff --git a/apps/scan/app/[locale]/analytics/page.tsx b/apps/scan/app/[locale]/analytics/page.tsx index 85d4efc..20b833d 100644 --- a/apps/scan/app/[locale]/analytics/page.tsx +++ b/apps/scan/app/[locale]/analytics/page.tsx @@ -7,7 +7,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { PageHeader } from "@/components/common/PageHeader"; import { StatCard } from "@/components/common/StatCard"; -import { EmptyState } from "@/components/common/EmptyState"; import { useNetwork, useNetworkFromQuery } from "@/lib/network-context"; import { useStats, useChainPerformance, useValidators } from "@/lib/hooks"; import { formatNumber, formatSRX } from "@/lib/format"; @@ -94,11 +93,7 @@ export default function AnalyticsPage() { {/* Charts */} {!perf && !daily ? ( - - - - - + ) : ( )} diff --git a/apps/scan/app/[locale]/validators/page.tsx b/apps/scan/app/[locale]/validators/page.tsx index a82652a..ee36458 100644 --- a/apps/scan/app/[locale]/validators/page.tsx +++ b/apps/scan/app/[locale]/validators/page.tsx @@ -3,13 +3,14 @@ import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { Link } from "@/i18n/navigation"; -import { Users, CheckCircle, XCircle, AlertTriangle, ArrowUpDown } from "lucide-react"; +import { Users, CheckCircle, XCircle, AlertTriangle, ArrowUpDown, RotateCw } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Address } from "@/components/common/Address"; import { Pagination } from "@/components/common/Pagination"; import { PageHeader } from "@/components/common/PageHeader"; import { StatCard } from "@/components/common/StatCard"; +import { EmptyState } from "@/components/common/EmptyState"; import { useNetwork, useNetworkFromQuery } from "@/lib/network-context"; import { useValidators } from "@/lib/hooks"; import { formatNumber } from "@/lib/format"; @@ -27,10 +28,11 @@ function StatusIcon({ status }: { status?: string }) { export default function ValidatorsPage() { const t = useTranslations("validators"); + const tc = useTranslations("common"); const { network } = useNetwork(); // Deeplink network switch. useNetworkFromQuery(); - const { data: validators, loading } = useValidators(network); + const { data: validators, loading, error, retry } = useValidators(network); const [sortKey, setSortKey] = useState("none"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [statusFilter, setStatusFilter] = useState("all"); @@ -111,10 +113,10 @@ export default function ValidatorsPage() { {/* Summary */}
- - - - + + + +
@@ -145,6 +147,23 @@ export default function ValidatorsPage() {
{Array.from({ length: 10 }).map((_, i) => )}
+ ) : error ? ( + + + {tc("retry")} + + } + /> ) : paged.length > 0 ? ( <> {/* Desktop table */} diff --git a/apps/scan/components/common/StatCard.tsx b/apps/scan/components/common/StatCard.tsx index 21f4647..21f3a8d 100644 --- a/apps/scan/components/common/StatCard.tsx +++ b/apps/scan/components/common/StatCard.tsx @@ -1,5 +1,8 @@ +"use client"; + import type { ReactNode } from "react"; -import { TrendingDown, TrendingUp } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { TrendingDown, TrendingUp, RotateCw } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; // Split a formatted value (e.g. "14.2K", "3.1s", "12 tx", "14,109 SRX") into a number part @@ -14,6 +17,11 @@ interface StatCardProps { label: ReactNode; value: string; loading?: boolean; + /** Fetch failed and there's no data to show. Renders a distinct error+retry state, + * not the same dash the card shows for a genuinely empty value. */ + error?: boolean; + /** Re-run the failed fetch. When set, the error state shows a retry control. */ + onRetry?: () => void; /** CSS color (e.g. `var(--gold)`, `var(--green)`) applied to the trailing unit and hover glow. */ accent?: string; /** Title tooltip on the value (useful when long values truncate). */ @@ -78,7 +86,8 @@ function Sparkline({ data, color }: { data: number[]; color: string }) { // Playfair serif number, tinted em-unit, animated gold corner lines on hover. One primitive // so every page (home + detail summary rows) reads from the same vocabulary instead of // shadcn's grey `text-lg font-semibold font-mono`. -export function StatCard({ label, value, loading = false, accent = "var(--gold)", title, spark, delta, subline }: StatCardProps) { +export function StatCard({ label, value, loading = false, error = false, onRetry, accent = "var(--gold)", title, spark, delta, subline }: StatCardProps) { + const tc = useTranslations("common"); const { num, unit } = splitValue(value); const showDelta = delta != null && Number.isFinite(delta) && Math.abs(delta) >= 0.05; const deltaUp = (delta ?? 0) >= 0; @@ -105,7 +114,7 @@ export function StatCard({ label, value, loading = false, accent = "var(--gold)"
{label}
- {showDelta && !loading && ( + {showDelta && !loading && !error && ( {loading ? ( + ) : error ? ( + ) : ( <> {num} @@ -140,12 +160,12 @@ export function StatCard({ label, value, loading = false, accent = "var(--gold)" )}
- {subline && !loading && ( + {subline && !loading && !error && (
{subline}
)} - {spark && spark.length > 1 && !loading && ( + {spark && spark.length > 1 && !loading && !error && (
diff --git a/apps/scan/lib/api.ts b/apps/scan/lib/api.ts index d6e2772..b919e58 100644 --- a/apps/scan/lib/api.ts +++ b/apps/scan/lib/api.ts @@ -588,7 +588,9 @@ export async function fetchValidators(network: NetworkId) { apiFetch<{ validators: RawValidator[] } | RawValidator[]>(network, "/staking/validators"), apiFetch<{ validators: RawValidator[] } | RawValidator[]>(network, "/validators"), ]); - if (!stakingRes) return []; + // Primary endpoint failed: return null so the hook flags an error state rather than + // rendering an empty validator set. A successful empty response still returns []. + if (stakingRes === null) return null; const stakingList = Array.isArray(stakingRes) ? stakingRes : (stakingRes.validators ?? []); const namedList = namedRes ? (Array.isArray(namedRes) ? namedRes : (namedRes.validators ?? [])) @@ -1365,11 +1367,14 @@ export async function fetchEventLogs( } // ── /accounts/top (real richlist with tx_count) ───────────────────────────── -export async function fetchAccountsTop(network: NetworkId, limit = 100): Promise { +export async function fetchAccountsTop(network: NetworkId, limit = 100): Promise { const res = await apiFetch<{ accounts: Array<{ address: string; balance_srx: number; percentage: number; tx_count?: number; name?: string | null }>; }>(network, `/accounts/top?limit=${limit}`); - if (!res?.accounts) return []; + // Keep a failed fetch (null) separate from a successful empty list ([]). Collapsing + // both to [] made an outage look like a genuinely empty richlist. + if (res === null) return null; + if (!res.accounts) return []; return res.accounts.map((a, i) => ({ rank: i + 1, address: a.address, diff --git a/apps/scan/messages/en.json b/apps/scan/messages/en.json index 9122a90..1ed4820 100644 --- a/apps/scan/messages/en.json +++ b/apps/scan/messages/en.json @@ -182,6 +182,9 @@ }, "common": { "loading": "Loading...", + "failed": "Failed", + "failed_to_load": "Failed to load", + "failed_to_load_hint": "Couldn't fetch data from the server. Try again.", "retry": "Retry", "go_home": "Go home", "try_again": "Try again", diff --git a/apps/scan/messages/id.json b/apps/scan/messages/id.json index c4eb3a9..6039f18 100644 --- a/apps/scan/messages/id.json +++ b/apps/scan/messages/id.json @@ -182,6 +182,9 @@ }, "common": { "loading": "Memuat...", + "failed": "Gagal", + "failed_to_load": "Gagal memuat data", + "failed_to_load_hint": "Tidak bisa mengambil data dari server. Coba lagi.", "retry": "Coba Lagi", "go_home": "Ke Beranda", "try_again": "Coba Lagi",