deckhearth/components/DashboardFeaturedCollection.js

141 lines
5.2 KiB
JavaScript
Raw Normal View History

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>
2026-06-04 12:19:06 -04:00
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>
);
}