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",