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:
parent
ea21b01ed6
commit
05f40ffd60
4 changed files with 829 additions and 164 deletions
408
components/DashboardCardSpotlight.js
Normal file
408
components/DashboardCardSpotlight.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
components/DashboardFeaturedCollection.js
Normal file
140
components/DashboardFeaturedCollection.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
components/DashboardRecentActivity.js
Normal file
155
components/DashboardRecentActivity.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,36 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import Link from 'next/link';
|
||||||
import Layout from '../components/Layout';
|
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 { Button, StatCard } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
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() {
|
export default function Dashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [collections, setCollections] = useState([]);
|
const [collections, setCollections] = useState([]);
|
||||||
|
const [recentCards, setRecentCards] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [cardsLoading, setCardsLoading] = useState(true);
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -21,113 +39,117 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
}, [authLoading, user, router]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (!user) return;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- load dashboard lists when user is available
|
let cancelled = false;
|
||||||
fetchCollections();
|
|
||||||
}
|
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]);
|
}, [user]);
|
||||||
|
|
||||||
;
|
const totalCards = collections.reduce(
|
||||||
|
(total, col) => total + (col.cardCount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const collectionValue = collections.reduce(
|
||||||
|
(total, col) => total + (col.value || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
{/* Header */}
|
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||||||
<div className="px-4 sm:px-6 pt-6 pb-2">
|
{/* 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 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
<h1
|
||||||
{VOCAB.MY_COLLECTION}
|
className="text-2xl sm:text-3xl font-bold"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
Welcome back
|
||||||
|
{user?.username ? `, ${user.username}` : ''}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
|
<p
|
||||||
Overview of your lists and owned cards
|
className="text-sm"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Here's what's happening with your collection today.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<Link href="/collections">
|
||||||
<Button
|
<Button variant="primary" size="sm">
|
||||||
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>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Create List
|
Create List
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-4 sm:p-6">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-7xl mx-auto">
|
<>
|
||||||
{/* Stats Cards — 4-up grid from operator mockup
|
{/* Stats row — 4-up grid (umbrella § 7.1 locked metrics) */}
|
||||||
(.convoys/redesign-v2-from-mockups.md § 7.1).
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
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">
|
|
||||||
<StatCard
|
<StatCard
|
||||||
accent="blue"
|
accent="blue"
|
||||||
label="Total Cards"
|
label="Total Cards"
|
||||||
value={collections
|
value={totalCards.toLocaleString()}
|
||||||
.reduce((total, col) => total + (col.cardCount || 0), 0)
|
|
||||||
.toLocaleString()}
|
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<svg
|
||||||
className="h-6 w-6"
|
className="h-6 w-6"
|
||||||
|
|
@ -144,11 +166,8 @@ export default function Dashboard() {
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* TODO(rarity-aggregation convoy): swap placeholder 0
|
{/* TODO(rarity-aggregation convoy): swap placeholder for
|
||||||
for a real count once the user_cards.rarity column is
|
real count once user_cards.rarity column is populated. */}
|
||||||
populated by the import jobs. Operator-approved
|
|
||||||
placeholder per .convoys/redesign-v2-from-mockups.md
|
|
||||||
§ 7.1 ("placeholders for what we don't have"). */}
|
|
||||||
<StatCard
|
<StatCard
|
||||||
accent="purple"
|
accent="purple"
|
||||||
label="Rare Cards"
|
label="Rare Cards"
|
||||||
|
|
@ -173,9 +192,7 @@ export default function Dashboard() {
|
||||||
<StatCard
|
<StatCard
|
||||||
accent="gold"
|
accent="gold"
|
||||||
label="Collection Value"
|
label="Collection Value"
|
||||||
value={`$${collections
|
value={`$${collectionValue.toLocaleString()}`}
|
||||||
.reduce((total, col) => total + (col.value || 0), 0)
|
|
||||||
.toLocaleString()}`}
|
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<svg
|
||||||
className="h-6 w-6"
|
className="h-6 w-6"
|
||||||
|
|
@ -193,8 +210,7 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* TODO(wishlist-feature convoy): real wishlist count
|
{/* TODO(wishlist-feature convoy): real wishlist count
|
||||||
ships when the wishlist table + API land. Operator-
|
ships when the wishlist table + API land. */}
|
||||||
approved placeholder for now. */}
|
|
||||||
<StatCard
|
<StatCard
|
||||||
accent="red"
|
accent="red"
|
||||||
label="Wishlist Items"
|
label="Wishlist Items"
|
||||||
|
|
@ -218,74 +234,20 @@ export default function Dashboard() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collections Grid */}
|
{/* Main content + right rail */}
|
||||||
<div className="mb-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>Recent Lists</h2>
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{collections.length === 0 ? (
|
<DashboardFeaturedCollection
|
||||||
<div className="text-center py-12">
|
cards={recentCards}
|
||||||
<div className="glass-panel w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center">
|
loading={cardsLoading}
|
||||||
<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" />
|
<DashboardRecentActivity />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="lg:col-span-1">
|
||||||
</div>
|
<DashboardCardSpotlight />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue