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 +}