feat(design-system): redesign v2 #4 — StatCard primitive + dashboard wiring #104
4 changed files with 304 additions and 48 deletions
138
components/ui/StatCard.js
Normal file
138
components/ui/StatCard.js
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,3 +3,4 @@ export { default as Modal } from './Modal';
|
||||||
export { default as Button } from './Button';
|
export { default as Button } from './Button';
|
||||||
export { default as Input } from './Input';
|
export { default as Input } from './Input';
|
||||||
export { default as SearchBar } from './SearchBar';
|
export { default as SearchBar } from './SearchBar';
|
||||||
|
export { default as StatCard } from './StatCard';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import PermissionIndicator from '../components/PermissionIndicator';
|
import PermissionIndicator from '../components/PermissionIndicator';
|
||||||
import { Button } from '../components/ui';
|
import { Button, StatCard } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
||||||
|
|
@ -116,53 +116,106 @@ export default function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards — 4-up grid from operator mockup
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
(.convoys/redesign-v2-from-mockups.md § 7.1).
|
||||||
<div className="glass-panel rounded-2xl p-6">
|
Metrics: Total Cards / Rare Cards / Collection Value /
|
||||||
<div className="flex items-center">
|
Wishlist Items. Real data where available; placeholders
|
||||||
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
with TODO comments where the concept doesn't exist yet. */}
|
||||||
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
<StatCard
|
||||||
|
accent="blue"
|
||||||
|
label="Total Cards"
|
||||||
|
value={collections
|
||||||
|
.reduce((total, col) => total + (col.cardCount || 0), 0)
|
||||||
|
.toLocaleString()}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgb(255, 255, 255)"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
}
|
||||||
<div>
|
/>
|
||||||
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{collections.length}</p>
|
{/* TODO(rarity-aggregation convoy): swap placeholder 0
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Lists</p>
|
for a real count once the user_cards.rarity column is
|
||||||
</div>
|
populated by the import jobs. Operator-approved
|
||||||
</div>
|
placeholder per .convoys/redesign-v2-from-mockups.md
|
||||||
</div>
|
§ 7.1 ("placeholders for what we don't have"). */}
|
||||||
|
<StatCard
|
||||||
<div className="glass-panel rounded-2xl p-6">
|
accent="purple"
|
||||||
<div className="flex items-center">
|
label="Rare Cards"
|
||||||
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-gold)' }}>
|
value="0"
|
||||||
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
subtitle="Coming soon"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgb(255, 255, 255)"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 2l3 7h7l-5.5 4.5L18 21l-6-4-6 4 1.5-7.5L2 9h7l3-7z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
}
|
||||||
<div>
|
/>
|
||||||
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
<StatCard
|
||||||
{collections.reduce((total, col) => total + (col.cardCount || 0), 0)}
|
accent="gold"
|
||||||
</p>
|
label="Collection Value"
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Total Cards</p>
|
value={`$${collections
|
||||||
</div>
|
.reduce((total, col) => total + (col.value || 0), 0)
|
||||||
</div>
|
.toLocaleString()}`}
|
||||||
</div>
|
icon={
|
||||||
|
<svg
|
||||||
<div className="glass-panel rounded-2xl p-6">
|
className="h-6 w-6"
|
||||||
<div className="flex items-center">
|
fill="none"
|
||||||
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-flame)' }}>
|
stroke="rgb(255, 255, 255)"
|
||||||
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
viewBox="0 0 24 24"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
}
|
||||||
<div>
|
/>
|
||||||
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
{/* TODO(wishlist-feature convoy): real wishlist count
|
||||||
${collections.reduce((total, col) => total + (col.value || 0), 0).toLocaleString()}
|
ships when the wishlist table + API land. Operator-
|
||||||
</p>
|
approved placeholder for now. */}
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Total Value</p>
|
<StatCard
|
||||||
</div>
|
accent="red"
|
||||||
</div>
|
label="Wishlist Items"
|
||||||
</div>
|
value="0"
|
||||||
|
subtitle="Coming soon"
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgb(255, 255, 255)"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 016.364 0L12 7.636l1.318-1.318a4.5 4.5 0 116.364 6.364L12 20.364l-7.682-7.682a4.5 4.5 0 010-6.364z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collections Grid */}
|
{/* Collections Grid */}
|
||||||
|
|
|
||||||
64
test/components/StatCard.test.js
Normal file
64
test/components/StatCard.test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import StatCard from '../../components/ui/StatCard';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('<StatCard>', () => {
|
||||||
|
test('renders label and value', () => {
|
||||||
|
render(<StatCard accent="gold" label="Total Cards" value="2,458" />);
|
||||||
|
expect(screen.getByText('Total Cards')).toBeDefined();
|
||||||
|
expect(screen.getByText('2,458')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders positive delta in green with up-arrow', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<StatCard accent="gold" label="Cards" value="100" delta="+12.5%" />
|
||||||
|
);
|
||||||
|
const deltaEl = container.querySelector('[style*="34, 197, 94"]');
|
||||||
|
expect(deltaEl).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain('+12.5%');
|
||||||
|
expect(container.textContent).toContain('▲');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders negative delta in red with down-arrow', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<StatCard accent="red" label="Cards" value="100" delta="-2.1%" />
|
||||||
|
);
|
||||||
|
const deltaEl = container.querySelector('[style*="239, 68, 68"]');
|
||||||
|
expect(deltaEl).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain('-2.1%');
|
||||||
|
expect(container.textContent).toContain('▼');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits the delta row when delta prop is not provided', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<StatCard accent="purple" label="Wishlist" value="0" />
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain('▲');
|
||||||
|
expect(container.textContent).not.toContain('▼');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders all 4 accent gradients without crashing', () => {
|
||||||
|
for (const accent of ['gold', 'purple', 'blue', 'red']) {
|
||||||
|
cleanup();
|
||||||
|
const { container } = render(
|
||||||
|
<StatCard accent={accent} label="Test" value="1" />
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[style*="linear-gradient"]')).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders subtitle when provided', () => {
|
||||||
|
render(
|
||||||
|
<StatCard
|
||||||
|
accent="gold"
|
||||||
|
label="Rare Cards"
|
||||||
|
value="317"
|
||||||
|
subtitle="12.9% of collection"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('12.9% of collection')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue