From 7acee45dae1990cf8f7e56de152a68b8b0415821 Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:21:58 -0500 Subject: [PATCH] =?UTF-8?q?feat(design-system):=20redesign=20v2=20#7=20+?= =?UTF-8?q?=20#8=20=E2=80=94=20dashboard=20rebuild=20+=20right-rail=20Card?= =?UTF-8?q?=20Spotlight=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/DashboardCardSpotlight.js | 408 ++++++++++++++++++++++ components/DashboardFeaturedCollection.js | 140 ++++++++ components/DashboardRecentActivity.js | 155 ++++++++ pages/dashboard.js | 290 +++++++-------- 4 files changed, 829 insertions(+), 164 deletions(-) create mode 100644 components/DashboardCardSpotlight.js create mode 100644 components/DashboardFeaturedCollection.js create mode 100644 components/DashboardRecentActivity.js diff --git a/components/DashboardCardSpotlight.js b/components/DashboardCardSpotlight.js new file mode 100644 index 0000000..81b895f --- /dev/null +++ b/components/DashboardCardSpotlight.js @@ -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 ( + + ); +} diff --git a/components/DashboardFeaturedCollection.js b/components/DashboardFeaturedCollection.js new file mode 100644 index 0000000..370a88a --- /dev/null +++ b/components/DashboardFeaturedCollection.js @@ -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 ( +
+
+

+ + Featured Collection +

+ + View All + +
+ +
+ {slots.map((card, idx) => { + if (loading) { + return ( + +
+ ); +} diff --git a/components/DashboardRecentActivity.js b/components/DashboardRecentActivity.js new file mode 100644 index 0000000..634268f --- /dev/null +++ b/components/DashboardRecentActivity.js @@ -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 ( +
+
+

+ + Recent Activity +

+ +
+ + {usingDemo && ( +

+ {/* 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. +

+ )} + + +
+ ); +} diff --git a/pages/dashboard.js b/pages/dashboard.js index 6b2b3ab..345e866 100644 --- a/pages/dashboard.js +++ b/pages/dashboard.js @@ -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 ( - {/* Header */} -
+
+ {/* Page heading lives inside the content area, not in a + heavy header strip. The top chrome (search, notifs, + avatar) is provided by in Layout. */}
-

- {VOCAB.MY_COLLECTION} +

+ Welcome back + {user?.username ? `, ${user.username}` : ''}

-

- Overview of your lists and owned cards +

+ Here's what's happening with your collection today.

-
+
+ + + -
-
- {/* Content */} -
{loading ? (
-
+
) : ( -
- {/* 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. */} -
+ <> + {/* Stats row — 4-up grid (umbrella § 7.1 locked metrics) */} +
total + (col.cardCount || 0), 0) - .toLocaleString()} + value={totalCards.toLocaleString()} icon={ } /> - {/* 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. */} total + (col.value || 0), 0) - .toLocaleString()}`} + value={`$${collectionValue.toLocaleString()}`} icon={ {/* 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. */}
- {/* Collections Grid */} -
-

Recent Lists

- {collections.length === 0 ? ( -
-
- - - -
-

No Lists Yet

-

- Create your first list to start organizing your cards -

- - - -
- ) : ( -
- {collections.slice(0, 6).map((collection) => ( - -
-
-
- - {collection.name?.charAt(0)?.toUpperCase() || 'C'} - -
-
-

- {collectionDisplayName(collection)} -

-

- {collection.cardCount || 0} cards -

-
-
- {collection.description && ( -

- {collection.description} -

- )} -
- - ${(collection.value || 0).toLocaleString()} - - -
-
- - ))} -
- )} -
- - {collections.length > 6 && ( -
- - - + {/* Main content + right rail */} +
+
+ +
- )} -
+
+ +
+
+ )}
); -} \ No newline at end of file +}