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