diff --git a/frontend/src/app/(authenticated)/dashboard/page.tsx b/frontend/src/app/(authenticated)/dashboard/page.tsx index 1e82470c..638ba7df 100644 --- a/frontend/src/app/(authenticated)/dashboard/page.tsx +++ b/frontend/src/app/(authenticated)/dashboard/page.tsx @@ -6,7 +6,11 @@ import RecentPayments from "@/components/RecentPayments"; import WithdrawalModal from "@/components/WithdrawalModal"; import DashboardSkeleton from "@/components/DashboardSkeleton"; import Link from "next/link"; -import { useMerchantHydrated, useHydrateMerchantStore, useMerchantApiKey } from "@/lib/merchant-store"; +import { + useMerchantHydrated, + useHydrateMerchantStore, + useMerchantApiKey, +} from "@/lib/merchant-store"; import { useTranslations } from "next-intl"; import FirstApiKeyModal from "@/components/FirstApiKeyModal"; @@ -35,7 +39,7 @@ export default function DashboardPage() { if (hydrated && !loading && !apiKey) { const timer = setTimeout(() => { setIsFirstKeyModalOpen(true); - }, 1500); + }, 1500); return () => clearTimeout(timer); } }, [hydrated, loading, apiKey]); @@ -48,41 +52,58 @@ export default function DashboardPage() {

{t("title")}

-

- {t("description")} -

+

{t("description")}

{/* Left Column: Metrics and Activity */}
-

{t("paymentMetrics")}

+

+ {t("paymentMetrics")} +

-

{t("recentActivity")}

- +

+ {t("recentActivity")} +

+ {t("viewAllPayments")} →
- + {/* */}
{/* Right Column: Quick Actions & Guides */}
- setIsWithdrawOpen(false)} + setIsWithdrawOpen(false)} /> - setIsFirstKeyModalOpen(false)} + setIsFirstKeyModalOpen(false)} />
); diff --git a/frontend/src/components/MetricsSkeleton.tsx b/frontend/src/components/MetricsSkeleton.tsx new file mode 100644 index 00000000..583f6060 --- /dev/null +++ b/frontend/src/components/MetricsSkeleton.tsx @@ -0,0 +1,116 @@ +/** + * MetricsSkeleton + * + * A pixel-accurate skeleton of the full PaymentMetrics layout. + * Mirrors every section in render order: + * 1. 4-card summary grid (total volume, total payments, confirmed, success rate) + * 2. Chart panel + * a. Header row (title/subtitle + range pills + export button) + * b. Asset toggle pills + * c. Chart area + * + * All dimensions are taken directly from the live component so there is zero + * layout shift once real data replaces the skeleton. + */ + +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; + +// ─── Individual section skeletons ───────────────────────────────────────────── + +/** + * Matches the 4-column `summary` stat grid. + * On small screens the grid collapses to 2 columns (sm:grid-cols-2), + * on large screens it expands to 4 (lg:grid-cols-4). + */ +function SummaryGridSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ {/* Label — e.g. "7-Day Volume" */} + + + {/* Value + unit on the same baseline */} +
+ + +
+
+ ))} +
+ ); +} + +/** + * Matches the chart panel: the outer card with its header, toggle strip, and + * chart area. + * + * CHART_HEIGHT is 300 px in the live component — keep in sync if changed. + */ +function ChartPanelSkeleton() { + return ( +
+ {/* ── Header row ── */} +
+ {/* Title + subtitle (left side) */} +
+ + +
+ + {/* Range pill group + export button (right side) */} +
+ {/* Range selector — 3 pills inside a rounded container */} +
+ + + +
+ + {/* Export button */} + +
+
+ + {/* ── Asset toggle pills ── */} +
+ + +
+ + {/* ── Chart area ── */} +
+ +
+
+ ); +} + +// ─── Composed export ────────────────────────────────────────────────────────── + +/** + * Drop-in replacement for the full `PaymentMetrics` component during loading. + * + * Usage in `PaymentMetrics.tsx`: + * + * ```tsx + * import MetricsSkeleton from "@/components/MetricsSkeleton"; + * + * // Replace the existing inline skeleton block with: + * if (showSkeleton || loading || !hydrated) return ; + * ``` + */ +export default function MetricsSkeleton() { + return ( + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/PaymentMetrics.tsx b/frontend/src/components/PaymentMetrics.tsx index 0c195349..83c5ae3b 100644 --- a/frontend/src/components/PaymentMetrics.tsx +++ b/frontend/src/components/PaymentMetrics.tsx @@ -3,7 +3,16 @@ import { useEffect, useRef, useState, type RefObject } from "react"; import { useLocale, useTranslations } from "next-intl"; import * as Recharts from "recharts"; -const { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } = Recharts; +const { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} = Recharts; import toast from "react-hot-toast"; import { useHydrateMerchantStore, @@ -16,9 +25,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; -import "react-loading-skeleton/dist/skeleton.css"; import { localeToLanguageTag } from "@/i18n/config"; +import MetricsSkeleton from "@/components/MetricsSkeleton"; type TimeRange = "7D" | "30D" | "1Y"; type ExportFormat = "png" | "svg"; @@ -64,7 +72,7 @@ function colorForAsset(asset: string, index: number): string { function computeMovingAverages( data: VolumeDataPoint[], assets: string[], - window = 7, + window = 7 ): Record { const result: Record = {}; for (const asset of assets) { @@ -81,7 +89,11 @@ function computeMovingAverages( return result; } -function buildSvgMarkup(svg: SVGSVGElement): { markup: string; width: number; height: number } { +function buildSvgMarkup(svg: SVGSVGElement): { + markup: string; + width: number; + height: number; +} { const clone = svg.cloneNode(true) as SVGSVGElement; const bounds = svg.getBoundingClientRect(); const width = Math.max(Math.round(bounds.width), 1); @@ -96,7 +108,10 @@ function buildSvgMarkup(svg: SVGSVGElement): { markup: string; width: number; he clone.setAttribute("viewBox", `0 0 ${width} ${height}`); } - const background = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + const background = document.createElementNS( + "http://www.w3.org/2000/svg", + "rect" + ); background.setAttribute("width", "100%"); background.setAttribute("height", "100%"); background.setAttribute("fill", "#0f172a"); @@ -121,11 +136,13 @@ function downloadBlob(blob: Blob, filename: string) { async function exportChart( containerRef: RefObject, format: ExportFormat, - filename: string, + filename: string ) { const svg = containerRef.current?.querySelector("svg"); if (!svg) { - throw new Error("Chart export is unavailable until the chart finishes rendering."); + throw new Error( + "Chart export is unavailable until the chart finishes rendering." + ); } const { markup, width, height } = buildSvgMarkup(svg); @@ -144,7 +161,8 @@ async function exportChart( const image = await new Promise((resolve, reject) => { const nextImage = new Image(); nextImage.onload = () => resolve(nextImage); - nextImage.onerror = () => reject(new Error("Failed to load chart for PNG export.")); + nextImage.onerror = () => + reject(new Error("Failed to load chart for PNG export.")); nextImage.src = url; }); @@ -182,7 +200,10 @@ function ChartExportButton({ }: { containerRef: RefObject; exporting: boolean; - onExport: (format: ExportFormat, containerRef: RefObject) => Promise; + onExport: ( + format: ExportFormat, + containerRef: RefObject + ) => Promise; t: ReturnType; }) { return ( @@ -206,11 +227,7 @@ function ChartExportButton({ strokeLinecap="round" strokeLinejoin="round" /> - + {exporting ? t("exporting") : t("downloadImage")} @@ -227,7 +244,11 @@ function ChartExportButton({ ); } -export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton?: boolean }) { +export default function PaymentMetrics({ + showSkeleton = false, +}: { + showSkeleton?: boolean; +}) { const t = useTranslations("paymentMetrics"); const locale = localeToLanguageTag(useLocale()); const [summary, setSummary] = useState(null); @@ -254,12 +275,19 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? signal: controller.signal, }) .then((response) => - response.ok ? response.json() : Promise.reject(new Error(t("fetchMetricsFailed"))), + response.ok + ? response.json() + : Promise.reject(new Error(t("fetchMetricsFailed"))) ) .then((data: MetricsResponse) => setSummary(data)) .catch((fetchError) => { - if (fetchError instanceof Error && fetchError.name === "AbortError") return; - setError(fetchError instanceof Error ? fetchError.message : t("fetchMetricsFailed")); + if (fetchError instanceof Error && fetchError.name === "AbortError") + return; + setError( + fetchError instanceof Error + ? fetchError.message + : t("fetchMetricsFailed") + ); }); return () => controller.abort(); @@ -283,12 +311,17 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? .then((response) => response.ok ? response.json() - : Promise.reject(new Error(t("fetchVolumeFailed"))), + : Promise.reject(new Error(t("fetchVolumeFailed"))) ) .then((data: VolumeResponse) => setVolumeData(data)) .catch((fetchError) => { - if (fetchError instanceof Error && fetchError.name === "AbortError") return; - setError(fetchError instanceof Error ? fetchError.message : t("fetchVolumeFailed")); + if (fetchError instanceof Error && fetchError.name === "AbortError") + return; + setError( + fetchError instanceof Error + ? fetchError.message + : t("fetchVolumeFailed") + ); }) .finally(() => setLoading(false)); @@ -306,12 +339,16 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? const handleExport = async ( format: ExportFormat, - containerRef: RefObject, + containerRef: RefObject ) => { setExporting(true); try { - await exportChart(containerRef, format, `multi-asset-volume-${range.toLowerCase()}`); + await exportChart( + containerRef, + format, + `multi-asset-volume-${range.toLowerCase()}` + ); toast.success(t("exportSuccess", { format: format.toUpperCase() })); } catch (exportError) { const message = @@ -322,44 +359,9 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? } }; + // ── Loading / hydration gate — use the extracted skeleton ───────────────── if (showSkeleton || loading || !hydrated) { - return ( - -
-
- {[...Array(2)].map((_, i) => ( -
- -
- - -
-
- ))} -
- -
-
-
- - -
-
- - -
-
-
- - -
-
- -
-
-
-
- ); + return ; } if (error) { @@ -385,7 +387,7 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? day: "numeric", }), ...Object.fromEntries( - assets.map((asset) => [`${asset}_ma`, maAverages[asset]?.[i] ?? 0]), + assets.map((asset) => [`${asset}_ma`, maAverages[asset]?.[i] ?? 0]) ), })); @@ -453,12 +455,8 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? >
-

- {t("chartTitle")} -

-

- {t("chartSubtitle")} -

+

{t("chartTitle")}

+

{t("chartSubtitle")}

@@ -473,7 +471,9 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? : "text-slate-400 hover:text-white" }`} aria-pressed={range === nextRange} - aria-label={t("showRange", { range: t(`ranges.${nextRange}`) })} + aria-label={t("showRange", { + range: t(`ranges.${nextRange}`), + })} > {nextRange} @@ -510,7 +510,11 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? }`} style={{ borderColor: color, color }} aria-pressed={!hidden} - aria-label={hidden ? t("showAsset", { asset }) : t("hideAsset", { asset })} + aria-label={ + hidden + ? t("showAsset", { asset }) + : t("hideAsset", { asset }) + } > - ), + ) )} {assets.map((asset, index) => hiddenAssets.has(asset) ? null : ( @@ -598,7 +602,7 @@ export default function PaymentMetrics({ showSkeleton = false }: { showSkeleton? animationDuration={400} connectNulls /> - ), + ) )}