deckhearth/pages/dashboard.js
varutasu 7acee45dae
feat(design-system): redesign v2 #7 + #8 — dashboard rebuild + right-rail Card Spotlight (#107)
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>
2026-06-04 11:21:58 -05:00

255 lines
8.8 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout';
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';
// 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(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
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}>
<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"
style={{ color: 'var(--text-primary)' }}
>
Welcome back
{user?.username ? `, ${user.username}` : ''}
</h1>
<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 gap-2">
<Link href="/scanner">
<Button variant="secondary" size="sm">
Scan Card
</Button>
</Link>
<Link href="/collections">
<Button variant="primary" size="sm">
Create List
</Button>
</Link>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div
className="motion-essential animate-spin rounded-full h-12 w-12 border-b-2"
style={{ borderColor: 'var(--accent-ember)' }}
/>
</div>
) : (
<>
{/* 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={totalCards.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>
}
/>
{/* TODO(rarity-aggregation convoy): swap placeholder for
real count once user_cards.rarity column is populated. */}
<StatCard
accent="purple"
label="Rare Cards"
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="M12 2l3 7h7l-5.5 4.5L18 21l-6-4-6 4 1.5-7.5L2 9h7l3-7z"
/>
</svg>
}
/>
<StatCard
accent="gold"
label="Collection Value"
value={`$${collectionValue.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="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>
}
/>
{/* TODO(wishlist-feature convoy): real wishlist count
ships when the wishlist table + API land. */}
<StatCard
accent="red"
label="Wishlist Items"
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>
{/* 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 className="lg:col-span-1">
<DashboardCardSpotlight />
</div>
</div>
</>
)}
</div>
</Layout>
);
}