From 00d7513a2f4b1f33255c6dc4e162ef56bf788012 Mon Sep 17 00:00:00 2001 From: satyakwok <119509589+satyakwok@users.noreply.github.com> Date: Thu, 25 Jun 2026 05:56:27 +0200 Subject: [PATCH 1/3] fix(scan): make chain-paused banner prominent on home The idle/unreachable banner used an 8% orange tint at 11px in a thin strip; on the desktop stats ribbon it blended into the page and users missed that the chain had stopped producing blocks. Raise to a 16% tint with a 2px border, 13px semibold label, an AlertTriangle icon, and role="alert" so the state is visible and announced. --- apps/scan/app/[locale]/HomeContent.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/scan/app/[locale]/HomeContent.tsx b/apps/scan/app/[locale]/HomeContent.tsx index 8487196..2fdc9e6 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"; @@ -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 From 4ca9dc33c97087b44744da5e8775dfdc192cf3d8 Mon Sep 17 00:00:00 2001 From: satyakwok <119509589+satyakwok@users.noreply.github.com> Date: Thu, 25 Jun 2026 06:13:59 +0200 Subject: [PATCH 2/3] fix(scan): distinct error state in stat cards, skeleton for analytics loading Stat cards now show a retry control when a fetch fails with no data, instead of the same dash used for an empty value. Wired to the home stats cards via the error and retry the hooks already expose. Analytics loading uses a skeleton instead of the empty-state box. --- apps/scan/app/[locale]/HomeContent.tsx | 10 +++++++- apps/scan/app/[locale]/analytics/page.tsx | 7 +----- apps/scan/components/common/StatCard.tsx | 30 +++++++++++++++++++---- apps/scan/messages/en.json | 1 + apps/scan/messages/id.json | 1 + 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/scan/app/[locale]/HomeContent.tsx b/apps/scan/app/[locale]/HomeContent.tsx index 2fdc9e6..fb955ec 100644 --- a/apps/scan/app/[locale]/HomeContent.tsx +++ b/apps/scan/app/[locale]/HomeContent.tsx @@ -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 @@ -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]/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/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/messages/en.json b/apps/scan/messages/en.json index 9122a90..db18905 100644 --- a/apps/scan/messages/en.json +++ b/apps/scan/messages/en.json @@ -182,6 +182,7 @@ }, "common": { "loading": "Loading...", + "failed": "Failed", "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..5515c0b 100644 --- a/apps/scan/messages/id.json +++ b/apps/scan/messages/id.json @@ -182,6 +182,7 @@ }, "common": { "loading": "Memuat...", + "failed": "Gagal", "retry": "Coba Lagi", "go_home": "Ke Beranda", "try_again": "Coba Lagi", From 717d360517544a4c2cd6ef8949b3570c3de72920 Mon Sep 17 00:00:00 2001 From: satyakwok <119509589+satyakwok@users.noreply.github.com> Date: Thu, 25 Jun 2026 06:29:53 +0200 Subject: [PATCH 3/3] fix(scan): error state on validators and accounts when fetch fails fetchValidators and fetchAccountsTop returned [] on a failed request, so an outage looked like a genuinely empty list. Return null on failure so the hook flags an error, and render a retry state on both pages instead of the empty placeholder. --- apps/scan/app/[locale]/accounts/page.tsx | 23 ++++++++++++++-- apps/scan/app/[locale]/validators/page.tsx | 31 +++++++++++++++++----- apps/scan/lib/api.ts | 11 +++++--- apps/scan/messages/en.json | 2 ++ apps/scan/messages/id.json | 2 ++ 5 files changed, 58 insertions(+), 11 deletions(-) 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]/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/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 db18905..1ed4820 100644 --- a/apps/scan/messages/en.json +++ b/apps/scan/messages/en.json @@ -183,6 +183,8 @@ "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 5515c0b..6039f18 100644 --- a/apps/scan/messages/id.json +++ b/apps/scan/messages/id.json @@ -183,6 +183,8 @@ "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",