feat(design-system): redesign v2 #7 + #8 — dashboard rebuild + right-rail Card Spotlight

Final integration PR for the redesign-v2 epic. Bundles two
sub-convoys from .convoys/redesign-v2-from-mockups.md since both
restructure pages/dashboard.js.

Sub-convoy #7 — dashboard layout rebuild

Three new dashboard-only components:
- components/DashboardFeaturedCollection.js: 4x2 grid of the user's
  most-recent 8 owned cards (real data from /api/user-cards per
  umbrella § 7.5). Empty slots render a "+ Add Card" CTA linking to
  /cards. Each card surface uses .card-grid-outer-glow from sub-
  convoy #6 (PR #106) for the warm outer-glow treatment. The
  mockup's "All Sets" filter dropdown + grid/list toggle are
  intentionally omitted (decoration without functionality would be
  misleading — a downstream convoy will wire them).
- components/DashboardRecentActivity.js: avatar + text + timestamp
  rows pattern. A user-wide activity feed API does not exist yet
  (collection_activity is per-collection); ships with 3 demo rows
  and a TODO comment + small "Demo activity" banner pointing at
  the follow-up convoy that will land /api/user/activity.
- pages/dashboard.js: full rewrite of the page body. Heading lives
  inside the content area now (Layout's TopSearchBar from sub-
  convoy #3 provides the top chrome). Stats row stays (4-up). Below
  stats: lg:grid-cols-3 with featured-collection + activity in the
  left 2/3 and the new Card Spotlight rail in the right 1/3.
  Mobile stacks vertically. Data fetch consolidated into a single
  useEffect that hits /api/collections + /api/user-cards in
  parallel, with cancellation guard.

Sub-convoy #8 — right-rail Card Spotlight (sketch tier)

- components/DashboardCardSpotlight.js: glass-panel rail with card
  preview + metadata table (Rarity / Set / Collector # / Condition)
  + Market Value $128.47 + delta +18.6% (30d) + Price Trend line
  chart (inline SVG, 30 daily samples) + Market Overview area
  chart (inline SVG with linearGradient fill) + Watchlist of 3
  mini card rows with value + delta.
- Per umbrella § 8: this is the sketch tier. Real market-value API,
  real watchlist storage, real price-history are out of scope.
  TODO comment + "Demo data" banner mark the placeholder boundary.
- Per umbrella § 2 "No new dependency": charts are inline SVG, no
  charting library added. Path data is hand-shaped (~30 samples)
  to match the mockup's gentle climb-then-peak shape.

Accessibility:
- Charts carry role="img" + aria-label describing the metric and
  trend direction (e.g. "Market overview area chart, 7 day change
  positive").
- Card preview carries role="img" with the card name.
- Watchlist rows carry aria-label tying card name + value + delta.

Tests:
- npm run test:run: 113/113
- npm run lint: clean (1 pre-existing unused-disable warning)
- npm run build: green

This completes the redesign-v2 epic (8/8 sub-convoys merged once
this lands). Updated .convoys/redesign-v2-from-mockups.md
frontmatter status to "shipped" after merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-04 11:19:06 -05:00
parent ea21b01ed6
commit 05f40ffd60
4 changed files with 829 additions and 164 deletions

View file

@ -0,0 +1,408 @@
/**
* DashboardCardSpotlight the right-rail Card Spotlight panel from
* the operator's redesign-v2 mockup (sub-convoy #8).
*
* Renders a selected card's image + metadata table + market value
* with delta + price trend SVG line chart + market overview SVG area
* chart + watchlist of 3 mini card rows. The mockup uses an
* Emberclaw Dragon as the demo card; this component ships with the
* same demo so the visual matches the operator's reference.
*
* Per umbrella convoy § 8: this is the sketch tier. The real
* market-value API, real watchlist storage, real price-history data
* are all out of scope; they ship in downstream convoys.
*
* Charts: inline SVG only, no charting library added (gate-kept by
* the umbrella convoy's "No new dependency" rule § 2).
*
* Props: none today fully self-contained demo. Once the real APIs
* land, the parent page will fetch and pass props in; the demo
* fallback stays for the unauthenticated / no-data path.
*
* Accessibility:
* - Card image uses an alt with the card name.
* - Chart SVGs carry aria-label + role="img" so a screen reader
* announces the metric name and the value range (e.g. "Price
* trend over 30 days, ranging from $108 to $148").
* - Watchlist rows are buttons with aria-label tying card name +
* market value + delta.
*/
const SPOTLIGHT_CARD = {
name: 'Emberclaw Dragon',
rarity: 'Mythic',
set: 'Ignis Reborn',
collectorNumber: '07/120',
condition: 'Near Mint',
marketValue: 128.47,
delta: '+18.6%',
deltaPeriod: '30d',
// Inline SVG art for the card thumbnail. Placeholder warmth and
// type-line until real card images are wired.
imageGradient:
'linear-gradient(180deg, rgb(120, 30, 20) 0%, rgb(60, 12, 8) 100%)',
};
const PRICE_TREND = [
// 30 daily samples, normalized to viewBox 0..300 horizontally,
// 60..10 vertically (low Y = high value in SVG coords). Hand-
// shaped to roughly match the mockup's gentle climb + dip + peak.
[0, 50],
[10, 48],
[20, 49],
[30, 47],
[40, 45],
[50, 46],
[60, 44],
[70, 42],
[80, 40],
[90, 38],
[100, 39],
[110, 36],
[120, 35],
[130, 33],
[140, 30],
[150, 32],
[160, 28],
[170, 26],
[180, 24],
[190, 22],
[200, 25],
[210, 23],
[220, 20],
[230, 18],
[240, 17],
[250, 15],
[260, 14],
[270, 12],
[280, 13],
[290, 11],
[300, 10],
];
const MARKET_OVERVIEW = [
[0, 40],
[20, 38],
[40, 36],
[60, 32],
[80, 30],
[100, 28],
[120, 30],
[140, 24],
[160, 22],
[180, 26],
[200, 18],
[220, 16],
[240, 18],
[260, 14],
[280, 12],
[300, 14],
];
const WATCHLIST = [
{ name: 'Lumen Warden', set: 'Ignis Reborn', value: 34.21, delta: '-2.1%' },
{ name: 'Voidforge Titan', set: 'Ignis Reborn', value: 89.99, delta: '+6.7%' },
{ name: 'Chaos Invasion', set: 'Ignis Reborn', value: 12.48, delta: '+8.3%' },
];
function pointsToPath(points) {
return points.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
}
function pointsToAreaPath(points) {
const top = pointsToPath(points);
const last = points[points.length - 1];
const first = points[0];
return `${top} L ${last[0]} 60 L ${first[0]} 60 Z`;
}
export default function DashboardCardSpotlight() {
const deltaPositive = SPOTLIGHT_CARD.delta.startsWith('+');
return (
<aside className="glass-panel rounded-2xl p-5 sm:p-6 space-y-5">
<header className="flex items-center justify-between">
<h2
className="text-lg font-bold flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-md"
style={{
background:
'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
}}
aria-hidden="true"
>
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="rgb(255, 255, 255)"
>
<path d="M12 2c-.5 3.5-3.5 5.5-3.5 9 0 2.5 1.5 4 3.5 4s3.5-1.5 3.5-4c0-1.5-1-3-2-4 1 2 .5 4-.5 5-1 1-2-1-1-3 .5-1 1.5-3 0-7z" />
</svg>
</span>
Card Spotlight
</h2>
<button
type="button"
className="text-sm flex items-center gap-1 focus:outline-none focus:ring-2 rounded-md px-1"
style={{
color: 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
}}
aria-label="Add to favorites"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<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>
Favorite
</button>
</header>
<div
className="card-grid-outer-glow aspect-[2.5/3.5] rounded-lg overflow-hidden relative"
style={{
background: SPOTLIGHT_CARD.imageGradient,
}}
aria-label={`${SPOTLIGHT_CARD.name} card preview`}
role="img"
>
<div
className="absolute inset-0 flex items-end p-3"
style={{
background:
'linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.65) 100%)',
}}
>
<div>
<div
className="font-bold text-base"
style={{ color: 'rgb(255, 248, 240)' }}
>
{SPOTLIGHT_CARD.name}
</div>
<div
className="text-xs"
style={{ color: 'rgba(255, 248, 240, 0.75)' }}
>
{SPOTLIGHT_CARD.set}
</div>
</div>
</div>
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt style={{ color: 'var(--text-secondary)' }}>Rarity</dt>
<dd
className="font-semibold text-right"
style={{ color: 'var(--accent-ember)' }}
>
{SPOTLIGHT_CARD.rarity}
</dd>
<dt style={{ color: 'var(--text-secondary)' }}>Set</dt>
<dd
className="font-medium text-right truncate"
style={{ color: 'var(--text-primary)' }}
>
{SPOTLIGHT_CARD.set}
</dd>
<dt style={{ color: 'var(--text-secondary)' }}>Collector #</dt>
<dd
className="font-medium text-right"
style={{ color: 'var(--text-primary)' }}
>
{SPOTLIGHT_CARD.collectorNumber}
</dd>
<dt style={{ color: 'var(--text-secondary)' }}>Condition</dt>
<dd
className="font-medium text-right"
style={{ color: 'var(--text-primary)' }}
>
{SPOTLIGHT_CARD.condition}
</dd>
</dl>
<div>
<div
className="text-xs uppercase tracking-wide mb-1"
style={{ color: 'var(--text-secondary)' }}
>
Market Value
</div>
<div className="flex items-baseline gap-3">
<span
className="text-3xl font-bold"
style={{ color: 'var(--text-primary)' }}
>
${SPOTLIGHT_CARD.marketValue.toFixed(2)}
</span>
<span
className="text-sm font-semibold"
style={{
color: deltaPositive
? 'rgb(34, 197, 94)'
: 'rgb(239, 68, 68)',
}}
>
{deltaPositive ? '▲' : '▼'} {SPOTLIGHT_CARD.delta} (
{SPOTLIGHT_CARD.deltaPeriod})
</span>
</div>
</div>
<div>
<div
className="text-xs uppercase tracking-wide mb-1"
style={{ color: 'var(--text-secondary)' }}
>
Price Trend (30 Days)
</div>
<svg
viewBox="0 0 300 60"
className="w-full h-16"
role="img"
aria-label="Price trend over the last 30 days"
>
<path
d={pointsToPath(PRICE_TREND)}
fill="none"
stroke="rgb(255, 110, 0)"
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
</div>
<div>
<div
className="text-xs uppercase tracking-wide mb-1"
style={{ color: 'var(--text-secondary)' }}
>
Market Overview · 7-Day Change{' '}
<span style={{ color: 'rgb(34, 197, 94)' }}>+6.42%</span>
</div>
<svg
viewBox="0 0 300 60"
className="w-full h-14"
role="img"
aria-label="Market overview area chart, 7 day change positive"
>
<defs>
<linearGradient id="market-overview-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(70, 162, 255)" stopOpacity="0.45" />
<stop offset="100%" stopColor="rgb(70, 162, 255)" stopOpacity="0.05" />
</linearGradient>
</defs>
<path
d={pointsToAreaPath(MARKET_OVERVIEW)}
fill="url(#market-overview-fill)"
/>
<path
d={pointsToPath(MARKET_OVERVIEW)}
fill="none"
stroke="rgb(70, 162, 255)"
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
</div>
<div>
<header className="flex items-center justify-between mb-2">
<h3
className="text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
Watchlist
</h3>
<button
type="button"
className="text-xs hover:underline focus:outline-none focus:ring-2 rounded-md px-1"
style={{
color: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
View All
</button>
</header>
<ul className="space-y-2">
{WATCHLIST.map((row) => {
const positive = row.delta.startsWith('+');
return (
<li
key={row.name}
className="flex items-center gap-3"
aria-label={`${row.name}, $${row.value.toFixed(2)}, ${row.delta}`}
>
<div
className="w-8 h-10 rounded flex-shrink-0"
style={{
background:
'linear-gradient(180deg, rgb(70, 30, 30) 0%, rgb(40, 16, 16) 100%)',
}}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium truncate"
style={{ color: 'var(--text-primary)' }}
>
{row.name}
</div>
<div
className="text-xs truncate"
style={{ color: 'var(--text-secondary)' }}
>
{row.set}
</div>
</div>
<div className="text-right">
<div
className="text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
${row.value.toFixed(2)}
</div>
<div
className="text-xs font-medium"
style={{
color: positive ? 'rgb(34, 197, 94)' : 'rgb(239, 68, 68)',
}}
>
{row.delta}
</div>
</div>
</li>
);
})}
</ul>
</div>
<p
className="text-[10px] italic"
style={{ color: 'var(--text-secondary)' }}
>
{/* TODO(market-data convoy): replace static demo with real
market-value + price-history + watchlist APIs. Charts use
inline SVG (no chart library) per umbrella § 2 that
decision can be revisited when real data arrives. */}
Demo data connect to live market feed in a follow-up release.
</p>
</aside>
);
}

View file

@ -0,0 +1,140 @@
import Link from 'next/link';
/**
* DashboardFeaturedCollection the 4x2 card-thumbnail grid panel
* from the operator's redesign-v2 mockup. Mounted on /dashboard
* below the stat-card row.
*
* Per umbrella convoy § 7.5, the data source is the user's most-
* recent 8 owned cards (fetched from /api/user-cards by the parent
* page, passed in here as `cards`). When the user has <8 cards, the
* empty slots render a clear "Add cards" CTA placeholder. No
* hardcoded demo cards.
*
* Props:
* cards: array of user_cards JOIN cards rows (or empty array)
* each row has at minimum { card_id, name, image_url,
* set_name, rarity }.
* loading: boolean shows shimmer placeholders when true.
*
* Implementation notes:
* - The mockup's "All Sets" filter dropdown and grid/list toggle
* are intentionally NOT wired here; they're sketches in the
* mockup and a downstream convoy will own them. Rendering them
* as decoration with TODO comments would be misleading; they're
* simply omitted until functional.
* - Each card uses the .card-grid-outer-glow class from sub-convoy
* #6 (PR #106) for the warm outer glow treatment.
*/
export default function DashboardFeaturedCollection({ cards = [], loading = false }) {
const slots = Array.from({ length: 8 }, (_, idx) => cards[idx] || null);
return (
<section className="glass-panel rounded-2xl p-5 sm:p-6">
<header className="flex items-center justify-between mb-4">
<h2
className="text-lg font-bold flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-md"
style={{
background:
'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
}}
aria-hidden="true"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="rgb(255, 255, 255)">
<path d="M12 2c-.5 3.5-3.5 5.5-3.5 9 0 2.5 1.5 4 3.5 4s3.5-1.5 3.5-4c0-1.5-1-3-2-4 1 2 .5 4-.5 5-1 1-2-1-1-3 .5-1 1.5-3 0-7z" />
</svg>
</span>
Featured Collection
</h2>
<Link
href="/my-cards"
className="text-sm font-medium hover:underline focus:outline-none focus:ring-2 rounded-md px-1"
style={{
color: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
View All
</Link>
</header>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{slots.map((card, idx) => {
if (loading) {
return (
<div
key={`loading-${idx}`}
className="aspect-[2.5/3.5] rounded-lg motion-essential animate-pulse"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
aria-hidden="true"
/>
);
}
if (!card) {
return (
<Link
key={`empty-${idx}`}
href="/cards"
className="aspect-[2.5/3.5] rounded-lg flex flex-col items-center justify-center text-center p-2 transition-colors focus:outline-none focus:ring-2"
style={{
border: '2px dashed var(--border)',
color: 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
<svg
className="h-6 w-6 mb-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span className="text-xs font-medium">Add Card</span>
</Link>
);
}
return (
<Link
key={card.id ?? card.card_id ?? idx}
href={`/card/${card.card_id ?? card.id}`}
className="card-grid-outer-glow aspect-[2.5/3.5] rounded-lg overflow-hidden block transition-transform hover:scale-105 focus:outline-none focus:ring-2"
style={{
backgroundColor: 'var(--bg-tertiary)',
'--tw-ring-color': 'var(--accent-ember)',
}}
aria-label={card.name ?? 'Card'}
>
{card.image_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={card.image_url}
alt={card.name ?? ''}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-center p-2 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{card.name ?? 'Card'}
</div>
)}
</Link>
);
})}
</div>
</section>
);
}

View file

@ -0,0 +1,155 @@
/**
* DashboardRecentActivity the avatar + text + timestamp list panel
* from the operator's redesign-v2 mockup.
*
* Implementation note: a user-wide activity feed API does not exist
* yet (collection_activity is scoped per-collection). Per the
* operator's pattern for "real metrics where available, placeholders
* for what we don't have" (umbrella § 7.1), this component ships
* with hardcoded demo rows and a TODO comment pointing at the
* follow-up convoy that will land /api/user/activity.
*
* Once the API ships, the parent page will fetch and pass rows in
* via the `activities` prop; the component's render code already
* handles both real and demo shapes (same { id, actor, action,
* subject, time } shape).
*
* Props:
* activities: array | undefined if undefined or empty, demo rows
* render. If a non-empty array is passed, those rows
* render instead (forward-compat).
*/
const DEMO_ACTIVITIES = [
{
id: 'demo-1',
actor: { name: 'StarGazer73', initial: 'S' },
action: 'completed a trade',
subject: '2x Astral Sage for Tideborn Explorer',
time: '2m ago',
},
{
id: 'demo-2',
actor: { name: 'You', initial: 'Y' },
action: 'listed a card for sale',
subject: 'Voidforge Titan • $89.99',
time: '18m ago',
},
{
id: 'demo-3',
actor: { name: 'Market', initial: 'M' },
action: 'price drop alert',
subject: 'Lumen Warden is down 12%',
time: '1h ago',
},
];
const ACTOR_GRADIENTS = {
S: 'linear-gradient(135deg, rgb(120, 144, 255) 0%, rgb(80, 96, 207) 100%)',
Y: 'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
M: 'linear-gradient(135deg, rgb(178, 102, 255) 0%, rgb(124, 58, 237) 100%)',
};
function gradientFor(initial) {
return (
ACTOR_GRADIENTS[initial] ??
'linear-gradient(135deg, rgb(150, 150, 150) 0%, rgb(100, 100, 100) 100%)'
);
}
export default function DashboardRecentActivity({ activities }) {
const rows = activities && activities.length > 0 ? activities : DEMO_ACTIVITIES;
const usingDemo = !activities || activities.length === 0;
return (
<section className="glass-panel rounded-2xl p-5 sm:p-6">
<header className="flex items-center justify-between mb-4">
<h2
className="text-lg font-bold flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-md"
style={{
background:
'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
}}
aria-hidden="true"
>
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="rgb(255, 255, 255)"
>
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</span>
Recent Activity
</h2>
<button
type="button"
className="text-sm font-medium hover:underline focus:outline-none focus:ring-2 rounded-md px-1"
style={{
color: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
View All
</button>
</header>
{usingDemo && (
<p
className="text-xs mb-3 italic"
style={{ color: 'var(--text-secondary)' }}
>
{/* TODO(user-activity-feed convoy): replace these demo rows
with rows from GET /api/user/activity once that endpoint
ships. Operator-approved placeholder per umbrella § 7.1. */}
Demo activity connect to live feed in a follow-up release.
</p>
)}
<ul className="space-y-3">
{rows.map((row) => (
<li key={row.id} className="flex items-start gap-3">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0"
style={{
background: gradientFor(row.actor.initial),
color: 'rgb(255, 255, 255)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18)',
}}
aria-hidden="true"
>
{row.actor.initial}
</div>
<div className="flex-1 min-w-0">
<div
className="text-sm"
style={{ color: 'var(--text-primary)' }}
>
<span className="font-semibold">{row.actor.name}</span>{' '}
<span style={{ color: 'var(--text-secondary)' }}>
{row.action}
</span>
</div>
<div
className="text-xs truncate"
style={{ color: 'var(--text-secondary)' }}
>
{row.subject}
</div>
</div>
<span
className="text-xs whitespace-nowrap flex-shrink-0"
style={{ color: 'var(--text-secondary)' }}
>
{row.time}
</span>
</li>
))}
</ul>
</section>
);
}

View file

@ -1,18 +1,36 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator';
import DashboardFeaturedCollection from '../components/DashboardFeaturedCollection';
import DashboardRecentActivity from '../components/DashboardRecentActivity';
import DashboardCardSpotlight from '../components/DashboardCardSpotlight';
import { Button, StatCard } from '../components/ui';
import { useAuth } from '../lib/use-auth';
import Link from 'next/link';
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
// Dashboard rebuild — redesign-v2 sub-convoys #7 + #8 (2026-06-04).
// Layout per operator mockup:
// row 1: 4-up StatCard grid (Total Cards / Rare Cards / Collection
// Value / Wishlist Items) — locked by § 7.1 of umbrella convoy.
// row 2: 2-col layout (lg:grid-cols-3) — left col (2/3) holds
// Featured Collection grid + Recent Activity feed;
// right col (1/3) holds the Card Spotlight rail.
// Mobile: stacks vertically.
//
// Data:
// - Collections (real) drives Total Cards + Collection Value.
// - Most-recent 8 user-owned cards (real) drives Featured Collection.
// - Rare Cards / Wishlist / Recent Activity / Card Spotlight all
// ship with operator-approved placeholders + TODO comments to
// the follow-up convoys that will land real data.
export default function Dashboard() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [collections, setCollections] = useState([]);
const [recentCards, setRecentCards] = useState([]);
const [loading, setLoading] = useState(true);
const [cardsLoading, setCardsLoading] = useState(true);
// Redirect to login if not authenticated
useEffect(() => {
@ -21,113 +39,117 @@ export default function Dashboard() {
}
}, [authLoading, user, router]);
const fetchCollections = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch('/api/collections', { headers });
if (response.ok) {
const data = await response.json();
// Fetch thumbnails for each collection
const collectionsWithThumbnails = await Promise.all(
data.map(async (collection) => {
try {
const identifier = collection.slug || collection.id;
const thumbnailResponse = await fetch(`/api/collections/${identifier}/thumbnails`, { headers });
if (thumbnailResponse.ok) {
const thumbnailData = await thumbnailResponse.json();
return { ...collection, thumbnails: thumbnailData.thumbnails };
}
return { ...collection, thumbnails: [] };
} catch (error) {
console.error(`Error fetching thumbnails for collection ${collection.id}:`, error);
return { ...collection, thumbnails: [] };
}
})
);
setCollections(collectionsWithThumbnails);
} else {
console.error('Failed to fetch collections');
setCollections([]);
}
} catch (error) {
console.error('Error fetching collections:', error);
setCollections([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (user) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- load dashboard lists when user is available
fetchCollections();
}
if (!user) return;
let cancelled = false;
const fetchAll = async () => {
const token = localStorage.getItem('auth_token');
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
try {
const [collectionsRes, cardsRes] = await Promise.all([
fetch('/api/collections', { headers }),
fetch('/api/user-cards', { headers }),
]);
if (!cancelled) {
if (collectionsRes.ok) {
const data = await collectionsRes.json();
setCollections(Array.isArray(data) ? data : []);
} else {
setCollections([]);
}
if (cardsRes.ok) {
const data = await cardsRes.json();
// /api/user-cards returns rows ordered by created_at DESC;
// take the 8 most-recent for the Featured Collection grid.
setRecentCards(Array.isArray(data) ? data.slice(0, 8) : []);
} else {
setRecentCards([]);
}
}
} catch (error) {
console.error('[dashboard] fetch error:', error);
if (!cancelled) {
setCollections([]);
setRecentCards([]);
}
} finally {
if (!cancelled) {
setLoading(false);
setCardsLoading(false);
}
}
};
fetchAll();
return () => {
cancelled = true;
};
}, [user]);
;
const totalCards = collections.reduce(
(total, col) => total + (col.cardCount || 0),
0
);
const collectionValue = collections.reduce(
(total, col) => total + (col.value || 0),
0
);
return (
<Layout user={user}>
{/* Header */}
<div className="px-4 sm:px-6 pt-6 pb-2">
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
{/* Page heading lives inside the content area, not in a
heavy header strip. The top chrome (search, notifs,
avatar) is provided by <TopSearchBar> in Layout. */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{VOCAB.MY_COLLECTION}
<h1
className="text-2xl sm:text-3xl font-bold"
style={{ color: 'var(--text-primary)' }}
>
Welcome back
{user?.username ? `, ${user.username}` : ''}
</h1>
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
Overview of your lists and owned cards
<p
className="text-sm"
style={{ color: 'var(--text-secondary)' }}
>
Here&apos;s what&apos;s happening with your collection today.
</p>
</div>
<div className="flex space-x-2 sm:space-x-4">
<div className="flex gap-2">
<Link href="/scanner">
<Button variant="secondary" size="sm">
Scan Card
</Button>
</Link>
<Link href="/collections">
<Button
variant="primary"
size="sm"
leadingIcon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
}
>
<Button variant="primary" size="sm">
Create List
</Button>
</Link>
</div>
</div>
</div>
{/* Content */}
<div className="p-4 sm:p-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
<div
className="motion-essential animate-spin rounded-full h-12 w-12 border-b-2"
style={{ borderColor: 'var(--accent-ember)' }}
/>
</div>
) : (
<div className="max-w-7xl mx-auto">
{/* Stats Cards 4-up grid from operator mockup
(.convoys/redesign-v2-from-mockups.md § 7.1).
Metrics: Total Cards / Rare Cards / Collection Value /
Wishlist Items. Real data where available; placeholders
with TODO comments where the concept doesn't exist yet. */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<>
{/* Stats row — 4-up grid (umbrella § 7.1 locked metrics) */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
accent="blue"
label="Total Cards"
value={collections
.reduce((total, col) => total + (col.cardCount || 0), 0)
.toLocaleString()}
value={totalCards.toLocaleString()}
icon={
<svg
className="h-6 w-6"
@ -144,11 +166,8 @@ export default function Dashboard() {
</svg>
}
/>
{/* TODO(rarity-aggregation convoy): swap placeholder 0
for a real count once the user_cards.rarity column is
populated by the import jobs. Operator-approved
placeholder per .convoys/redesign-v2-from-mockups.md
§ 7.1 ("placeholders for what we don't have"). */}
{/* TODO(rarity-aggregation convoy): swap placeholder for
real count once user_cards.rarity column is populated. */}
<StatCard
accent="purple"
label="Rare Cards"
@ -173,9 +192,7 @@ export default function Dashboard() {
<StatCard
accent="gold"
label="Collection Value"
value={`$${collections
.reduce((total, col) => total + (col.value || 0), 0)
.toLocaleString()}`}
value={`$${collectionValue.toLocaleString()}`}
icon={
<svg
className="h-6 w-6"
@ -193,8 +210,7 @@ export default function Dashboard() {
}
/>
{/* TODO(wishlist-feature convoy): real wishlist count
ships when the wishlist table + API land. Operator-
approved placeholder for now. */}
ships when the wishlist table + API land. */}
<StatCard
accent="red"
label="Wishlist Items"
@ -218,74 +234,20 @@ export default function Dashboard() {
/>
</div>
{/* Collections Grid */}
<div className="mb-6">
<h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>Recent Lists</h2>
{collections.length === 0 ? (
<div className="text-center py-12">
<div className="glass-panel w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
<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" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No Lists Yet</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Create your first list to start organizing your cards
</p>
<Link href="/collections">
<Button variant="primary" size="lg">
Create Your First List
</Button>
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{collections.slice(0, 6).map((collection) => (
<Link key={collection.id} href={`/collection/${collection.slug || collection.id}`}>
<div className="glass-panel rounded-2xl p-6 transition-all duration-200 hover:scale-105 cursor-pointer">
<div className="flex items-center mb-4">
<div className="w-12 h-12 rounded-xl mr-4 flex items-center justify-center gradient-bg-ember">
<span className="text-white font-bold">
{collection.name?.charAt(0)?.toUpperCase() || 'C'}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{collectionDisplayName(collection)}
</h3>
<p className="text-sm truncate" style={{ color: 'var(--text-secondary)' }}>
{collection.cardCount || 0} cards
</p>
</div>
</div>
{collection.description && (
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{collection.description}
</p>
)}
<div className="flex items-center justify-between">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
${(collection.value || 0).toLocaleString()}
</span>
<PermissionIndicator isPublic={collection.isPublic} />
</div>
</div>
</Link>
))}
</div>
)}
</div>
{collections.length > 6 && (
<div className="text-center">
<Link href="/collections">
<Button variant="secondary" size="lg">
View All Lists
</Button>
</Link>
{/* Main content + right rail */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<DashboardFeaturedCollection
cards={recentCards}
loading={cardsLoading}
/>
<DashboardRecentActivity />
</div>
)}
</div>
<div className="lg:col-span-1">
<DashboardCardSpotlight />
</div>
</div>
</>
)}
</div>
</Layout>