forked from MergeFi/frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathStatCard.tsx
More file actions
273 lines (254 loc) · 10.3 KB
/
Copy pathStatCard.tsx
File metadata and controls
273 lines (254 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
/**
* StatCard — headline-figure card used on all three dashboards.
*
* STATES
* ──────
* loading → animated shimmer skeleton; never shows a value that could be
* mistaken for a real zero while a fetch is in flight.
* error → em-dash + "Error loading data" sub-label in rose tone;
* aria-label on the wrapper so screen readers announce the failure.
* loaded → value rendered, with two sub-cases:
* zero → deliberate "0 …" + zeroLabel below (visually distinct from a
* skeleton or error so a genuine $0 balance is never ambiguous).
* normal → formatted value with responsive font-size scaling for large
* numbers; a title tooltip always exposes the full exact figure
* (no silent truncation of financial data).
*
* LARGE-NUMBER POLICY
* ───────────────────
* Financial figures are NOT abbreviated (no "1.2M"). Abbreviation loses
* precision that sponsors/contributors need to see. Instead the value text
* scales down gracefully via font-size and the full unabbreviated figure is
* always available via the title attribute (keyboard-navigable, hover tooltip).
*/
import { cn } from "@/lib/utils";
import { ArrowUpRight, ArrowDownRight, AlertCircle } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Sparkline } from "./Sparkline";
// ─── Types ──────────────────────────────────────────────────────────────────
export type StatCardStatus = "loading" | "error" | "loaded";
/** Controls how a raw numeric value is formatted inside the card. */
export type StatCardFormat = "currency" | "count" | "percent" | "raw";
export interface StatCardProps {
label: string;
/**
* The data value.
* - Pass a `number` for currency/count/percent values — the card will format
* it using `format` and apply responsive font scaling.
* - Pass a pre-formatted `string` (e.g. "94%", "1.2 XLM") to skip internal
* formatting; large-number scaling still applies based on string length.
* - Omit (or pass undefined) when `status` is "loading" or "error".
*/
value?: number | string;
/** Explicit rendering state. Defaults to "loaded". */
status?: StatCardStatus;
/**
* How to format a numeric `value`. Ignored when `value` is already a string.
* - "currency" → locale-formatted number + " USDC" (or asset via asset prop)
* - "count" → locale-formatted integer
* - "percent" → value * 100 rounded to nearest integer + "%"
* - "raw" → String(value), no locale formatting
*/
format?: StatCardFormat;
/** Currency asset label appended when format="currency". Defaults to "USDC". */
asset?: "USDC" | "XLM";
/** Sub-label shown beneath "0" in the zero state. Defaults to "No activity yet". */
zeroLabel?: string;
icon?: LucideIcon;
trend?: number;
sparkline?: number[];
className?: string;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/**
* Format a numeric value according to the requested format.
* Returns both the display string and the full exact string for the tooltip.
*/
function formatValue(
value: number,
format: StatCardFormat,
asset: "USDC" | "XLM",
): { display: string; exact: string } {
switch (format) {
case "currency": {
const formatted = value.toLocaleString("en-US", {
maximumFractionDigits: 2,
});
const display = `${formatted} ${asset}`;
// Exact value for tooltip always shows full precision
const exact = `${value.toLocaleString("en-US", { maximumFractionDigits: 6 })} ${asset}`;
return { display, exact };
}
case "percent": {
const pct = `${Math.round(value * 100)}%`;
return { display: pct, exact: pct };
}
case "count": {
const s = value.toLocaleString("en-US", { maximumFractionDigits: 0 });
return { display: s, exact: s };
}
case "raw":
default: {
const s = String(value);
return { display: s, exact: s };
}
}
}
/**
* Return a Tailwind font-size class based on the rendered string length.
* Shrinks gracefully for long financial figures; never truncates silently.
*
* Thresholds (character counts):
* ≤ 10 chars → text-2xl (e.g. "42,500 USDC")
* ≤ 15 chars → text-xl (e.g. "142,500 USDC")
* ≤ 20 chars → text-lg (e.g. "1,234,567 USDC")
* > 20 chars → text-base (very large; still readable, never clipped)
*/
function valueFontClass(str: string): string {
const len = str.length;
if (len <= 10) return "text-2xl";
if (len <= 15) return "text-xl";
if (len <= 20) return "text-lg";
return "text-base";
}
// ─── Component ──────────────────────────────────────────────────────────────
export function StatCard({
label,
value,
status = "loaded",
format = "raw",
asset = "USDC",
zeroLabel = "No activity yet",
icon: Icon,
trend,
sparkline,
className,
}: StatCardProps) {
const trendUp = typeof trend === "number" && trend >= 0;
// ── Shared card shell ────────────────────────────────────────────────────
const shell = (children: React.ReactNode, ariaLabel?: string) => (
<div
role="region"
aria-label={ariaLabel ?? label}
className={cn(
"rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900",
className,
)}
>
{/* Header row: label + icon */}
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500 dark:text-slate-400">{label}</p>
{Icon && (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-50 dark:bg-indigo-500/10">
<Icon className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
</span>
)}
</div>
{children}
</div>
);
// ── LOADING state ────────────────────────────────────────────────────────
if (status === "loading") {
return shell(
<div className="mt-3 space-y-2">
{/* Skeleton mimicking the value row height */}
<div
data-testid="statcard-skeleton"
className="h-8 w-3/4 animate-pulse rounded-lg bg-slate-100 dark:bg-slate-800"
/>
{/* Skeleton mimicking an optional trend row */}
<div className="h-3.5 w-1/2 animate-pulse rounded-md bg-slate-100 dark:bg-slate-800" />
</div>,
);
}
// ── ERROR state ──────────────────────────────────────────────────────────
if (status === "error") {
return shell(
<div className="mt-2" data-testid="statcard-error">
<p className="text-2xl font-semibold text-slate-400 dark:text-slate-600">
—
</p>
<p className="mt-1 flex items-center gap-1 text-xs font-medium text-rose-500 dark:text-rose-400">
<AlertCircle className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
Error loading data
</p>
</div>,
`${label}: Error loading data`,
);
}
// ── LOADED state ─────────────────────────────────────────────────────────
// Resolve display string and exact tooltip string
let displayStr: string;
let exactStr: string;
if (typeof value === "number") {
const formatted = formatValue(value, format, asset);
displayStr = formatted.display;
exactStr = formatted.exact;
} else {
// Pre-formatted string — pass through; no internal formatting applied
displayStr = value ?? "—";
exactStr = displayStr;
}
const isNumericZero = typeof value === "number" && value === 0;
const fontClass = valueFontClass(displayStr);
// ── Zero sub-state ───────────────────────────────────────────────────────
if (isNumericZero) {
return shell(
<div className="mt-2" data-testid="statcard-zero">
<p
className={cn(
"font-semibold text-slate-900 dark:text-white",
fontClass,
)}
title={exactStr}
>
{displayStr}
</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{zeroLabel}
</p>
</div>,
);
}
// ── Normal populated value ───────────────────────────────────────────────
return shell(
<div className="mt-2 flex items-end justify-between gap-2">
<div>
<p
className={cn(
"font-semibold text-slate-900 dark:text-white",
fontClass,
)}
// Full exact figure always accessible on hover/focus — financial
// figures must never be ambiguously abbreviated.
title={exactStr}
data-testid="statcard-value"
>
{displayStr}
</p>
{typeof trend === "number" && (
<p
className={cn(
"mt-1 flex items-center gap-0.5 text-xs font-medium",
trendUp
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400",
)}
>
{trendUp ? (
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ArrowDownRight className="h-3.5 w-3.5" aria-hidden="true" />
)}
{Math.abs(trend)}% vs last period
</p>
)}
</div>
{sparkline && (
<div className="text-indigo-500 dark:text-indigo-400">
<Sparkline data={sparkline} />
</div>
)}
</div>,
);
}