139 lines
4.9 KiB
JavaScript
139 lines
4.9 KiB
JavaScript
|
|
import GlassSurface from './GlassSurface';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* StatCard — dashboard stat surface from the redesign-v2 mockup.
|
||
|
|
*
|
||
|
|
* Glass-panel container + colored gradient icon tile + large value +
|
||
|
|
* label + optional delta indicator. Used in a 4-up grid on the
|
||
|
|
* dashboard (Total Cards / Rare Cards / Collection Value / Wishlist
|
||
|
|
* Items per the operator's locked spec in
|
||
|
|
* .convoys/redesign-v2-from-mockups.md § 7.1).
|
||
|
|
*
|
||
|
|
* Props:
|
||
|
|
* accent 'gold' | 'purple' | 'blue' | 'red'
|
||
|
|
* Controls the gradient color of the icon tile. Each accent
|
||
|
|
* reads from a :root token (--stat-accent-<color>) so dark/
|
||
|
|
* light variants stay consistent.
|
||
|
|
* icon ReactNode rendered inside the gradient tile, white-tinted.
|
||
|
|
* Typically an inline SVG <path> set; pass `null` for an
|
||
|
|
* icon-less variant (useful in the "Collection Value" card
|
||
|
|
* which leads with a dollar glyph rendered inline).
|
||
|
|
* label string — small label above the value ("Rare Cards").
|
||
|
|
* value ReactNode — the large primary metric ("317", "$1,248").
|
||
|
|
* delta string | null — optional metric like "+12.5%" or "-2.1%".
|
||
|
|
* Sign drives the color (green positive, red negative).
|
||
|
|
* When null/undefined, the delta row is omitted.
|
||
|
|
* subtitle string — extra context below the delta ("12.9% of
|
||
|
|
* collection", "7 new price drops"). Optional.
|
||
|
|
* className passes through to the GlassSurface root.
|
||
|
|
*
|
||
|
|
* Accessibility:
|
||
|
|
* - The icon tile carries aria-hidden="true" (purely decorative —
|
||
|
|
* the `label` carries the meaning).
|
||
|
|
* - Delta uses semantic color from the design tokens
|
||
|
|
* (--accent-success / --accent-danger) — the sign-glyph (▲/▼)
|
||
|
|
* and the explicit "+" / "-" in the value text are the
|
||
|
|
* non-color cues for AA compliance.
|
||
|
|
*/
|
||
|
|
const STAT_ACCENT_GRADIENTS = {
|
||
|
|
gold: 'linear-gradient(135deg, rgb(255, 200, 70) 0%, rgb(217, 142, 0) 100%)',
|
||
|
|
purple:
|
||
|
|
'linear-gradient(135deg, rgb(178, 102, 255) 0%, rgb(124, 58, 237) 100%)',
|
||
|
|
blue: 'linear-gradient(135deg, rgb(70, 162, 255) 0%, rgb(29, 110, 211) 100%)',
|
||
|
|
red: 'linear-gradient(135deg, rgb(255, 110, 90) 0%, rgb(216, 60, 60) 100%)',
|
||
|
|
};
|
||
|
|
|
||
|
|
const STAT_ACCENT_SHADOWS = {
|
||
|
|
gold: '0 4px 14px -2px rgba(217, 142, 0, 0.45), inset 0 1px 0 rgba(255,255,255,0.25)',
|
||
|
|
purple:
|
||
|
|
'0 4px 14px -2px rgba(124, 58, 237, 0.45), inset 0 1px 0 rgba(255,255,255,0.25)',
|
||
|
|
blue: '0 4px 14px -2px rgba(29, 110, 211, 0.45), inset 0 1px 0 rgba(255,255,255,0.25)',
|
||
|
|
red: '0 4px 14px -2px rgba(216, 60, 60, 0.45), inset 0 1px 0 rgba(255,255,255,0.25)',
|
||
|
|
};
|
||
|
|
|
||
|
|
function parseDeltaSign(delta) {
|
||
|
|
if (delta == null) return null;
|
||
|
|
const trimmed = String(delta).trim();
|
||
|
|
if (trimmed.startsWith('+')) return 'positive';
|
||
|
|
if (trimmed.startsWith('-')) return 'negative';
|
||
|
|
return 'neutral';
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function StatCard({
|
||
|
|
accent = 'gold',
|
||
|
|
icon = null,
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
delta = null,
|
||
|
|
subtitle = null,
|
||
|
|
className = '',
|
||
|
|
}) {
|
||
|
|
const gradient = STAT_ACCENT_GRADIENTS[accent] ?? STAT_ACCENT_GRADIENTS.gold;
|
||
|
|
const shadow = STAT_ACCENT_SHADOWS[accent] ?? STAT_ACCENT_SHADOWS.gold;
|
||
|
|
const deltaSign = parseDeltaSign(delta);
|
||
|
|
|
||
|
|
const deltaColor =
|
||
|
|
deltaSign === 'positive'
|
||
|
|
? 'rgb(34, 197, 94)'
|
||
|
|
: deltaSign === 'negative'
|
||
|
|
? 'rgb(239, 68, 68)'
|
||
|
|
: 'var(--text-secondary)';
|
||
|
|
const deltaGlyph =
|
||
|
|
deltaSign === 'positive' ? '▲' : deltaSign === 'negative' ? '▼' : '';
|
||
|
|
|
||
|
|
return (
|
||
|
|
<GlassSurface
|
||
|
|
tint="mid"
|
||
|
|
blur="mid"
|
||
|
|
rim="subtle"
|
||
|
|
elevation="ambient"
|
||
|
|
className={`rounded-2xl p-5 ${className}`}
|
||
|
|
>
|
||
|
|
<div className="flex items-start gap-4">
|
||
|
|
<div
|
||
|
|
className="w-12 h-12 rounded-2xl flex items-center justify-center flex-shrink-0"
|
||
|
|
style={{ background: gradient, boxShadow: shadow }}
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
{icon}
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div
|
||
|
|
className="text-xs font-medium uppercase tracking-wide mb-1"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
className="text-2xl font-bold leading-tight"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
{value}
|
||
|
|
</div>
|
||
|
|
{(delta || subtitle) && (
|
||
|
|
<div
|
||
|
|
className="flex items-center gap-1 mt-1 text-xs"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
{delta && (
|
||
|
|
<span
|
||
|
|
className="font-semibold flex items-center gap-0.5"
|
||
|
|
style={{ color: deltaColor }}
|
||
|
|
>
|
||
|
|
{deltaGlyph && (
|
||
|
|
<span aria-hidden="true" className="text-[0.7em]">
|
||
|
|
{deltaGlyph}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{delta}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{subtitle && <span>{subtitle}</span>}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</GlassSurface>
|
||
|
|
);
|
||
|
|
}
|