feat(scanner): add debug instrumentation for vision pipeline timing #155

Merged
rstillwell merged 31 commits from feat/scanner-debug-mode into main 2026-09-01 18:11:03 -04:00
16 changed files with 889 additions and 971 deletions
Showing only changes of commit b9e840ff3a - Show all commits

View file

@ -108,3 +108,11 @@
{"ts": "2026-08-15T01:35:28Z", "role": "role-implementer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "brief": 3, "duration_s": 900, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"} {"ts": "2026-08-15T01:35:28Z", "role": "role-implementer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "brief": 3, "duration_s": 900, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T01:35:28Z", "role": "role-reviewer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 180, "outcome": "approved", "multitask_group": "audit-scan-visual-catalog-search-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"} {"ts": "2026-08-15T01:35:28Z", "role": "role-reviewer", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 180, "outcome": "approved", "multitask_group": "audit-scan-visual-catalog-search-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
{"ts": "2026-08-15T01:35:28Z", "role": "role-security-auditor", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 120, "outcome": "approved", "multitask_group": "audit-scan-visual-catalog-search-local", "model": "gpt-5.6-terra-medium", "model_tier": "security"} {"ts": "2026-08-15T01:35:28Z", "role": "role-security-auditor", "convoy": "scan-visual-catalog-search", "repo": "scanner-identify-upgrade", "skip_flags": [], "duration_s": 120, "outcome": "approved", "multitask_group": "audit-scan-visual-catalog-search-local", "model": "gpt-5.6-terra-medium", "model_tier": "security"}
{"ts": "2026-08-15T12:41:53Z", "role": "role-conductor", "convoy": "scanner-identify-upgrade", "repo": "tcg-vault", "skip_flags": [], "duration_s": 120, "model": "auto", "model_tier": "auto"}
{"ts": "2026-08-15T21:30:59Z", "role": "role-doc-writer", "convoy": "reconcile-historical-add-scripts", "repo": "tcg-vault", "skip_flags": [], "duration_s": 120, "outcome": "complete", "model": "auto", "model_tier": "auto"}
{"ts": "2026-08-15T21:39:17Z", "role": "role-conductor", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": ["ui-design"], "classification": "feature", "duration_s": 90, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:42:03Z", "role": "role-reviewer", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 180, "outcome": "comment-only", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
{"ts": "2026-08-15T21:42:19Z", "role": "role-ia-architect", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 120, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:42:20Z", "role": "role-ux-reviewer", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 90, "model": "composer-2.5-fast", "model_tier": "fast"}
{"ts": "2026-08-15T21:42:20Z", "role": "role-architect", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 180, "model": "composer-2.5", "model_tier": "standard"}
{"ts": "2026-08-15T21:42:20Z", "role": "role-implementer", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 600, "model": "composer-2.5-fast", "model_tier": "fast"}

View file

@ -0,0 +1,265 @@
---
name: dashboard-home-realignment
classification: feature
success_metric: |
A logged-in user opening /dashboard sees a flat product nav
(Dashboard, My Collection, Lists, Decks, Scanner, then Cards +
Community), stats sourced from /api/user/stats with no placeholder
tiles, Recently added / My activity / last-card spotlight using
that user's real data, the top-bar notifications + inbox + profile
cluster flush-right, and Admin reachable only from the profile
dropdown (not the left nav).
skip:
- ui-design
status: in-progress
created: 2026-08-15
model_policy:
default_session: auto
roles:
role-conductor: composer-2.5-fast
role-architect: composer-2.5
role-ia-architect: composer-2.5-fast
role-ux-reviewer: composer-2.5-fast
role-ui-designer: composer-2.5-fast
role-implementer: composer-2.5-fast
role-reviewer: cursor-grok-4.5-high
role-security-auditor: gpt-5.6-terra-medium
role-design-system-auditor: cursor-grok-4.5-high
role-a11y-auditor: cursor-grok-4.5-high
role-doc-writer: auto
escalate_to: claude-sonnet-5-thinking-medium
escalate_to_premium: claude-4.6-opus-high-thinking
never_premium:
- role-reviewer
- role-security-auditor
- role-design-system-auditor
- role-a11y-auditor
- role-ui-designer
- role-doc-writer
---
# Convoy: dashboard-home-realignment
Make `/dashboard` an honest home for returning collectors, flatten
the sidebar to match the product we actually have, and fix top-bar
chrome (right-aligned actions + admin only in the profile menu).
## Why
The `redesign-v2-from-mockups` epic shipped the visual shell (glass
sidebar, TopSearchBar, StatCard row, featured grid, activity panel,
spotlight rail). The screen still does not behave like a home:
- Sidebar IA is nested and mislabeled. `/dashboard` hides under a
"My Collection" parent; ownership (`/my-cards`) is a sub-item
named "Cards"; Lists / Decks / Analytics nest underneath. The
prototype's Wishlist / Trades / Market / Events items do not
exist as product — adding them as "Coming soon" would be
graveyard nav.
- Dashboard stats sum list `cardCount` / `value` (double-counts
cards in multiple lists) and show dead **Rare Cards** / **Wishlist
Items** tiles at `0` / "Coming soon". `/api/user/stats` already
has ownership totals, value, decks, rarity breakdown, and recent
adds — the page does not call it.
- Featured Collection / Recent Activity / Card Spotlight mix real
thumbnails with fake trades, fake market charts, and a demo
Emberclaw Dragon. That trains people the home is marketing.
- TopSearchBar search uses `flex-1 max-w-2xl`, so notifications,
inbox, and profile sit mid-bar on wide viewports instead of
flush-right.
- Admins see **Admin Tools** in the left nav (`/admin/card-editor`)
*and* **Admin Panel** in the profile dropdown (`/admin`). Operator
wants a single entry: the profile menu.
`/my-cards` stays a browse/manage page (not a second dashboard).
This convoy only promotes it in nav and aligns chrome.
## Scope
### In scope
- **Flatten desktop + mobile nav.** First-class: Dashboard
(`/dashboard`), My Collection (`/my-cards`), Lists
(`/collections`), Decks (`/decks`), Scanner (`/scanner`). Below a
divider: Cards catalog (`/cards`), Community (existing collapse).
Keep locked vocab (`My Collection` / `Lists`). Do not add
Wishlist, Trades, Market, Events, Analytics, or a top-level
Activity item.
- **Remove left-nav Admin Tools.** Keep admin entry in
`TopSearchBar` `<UserMenu>` (already gated on `user.role ===
'admin'`). Architect picks one href (`/admin` vs
`/admin/card-editor`) and applies it in the profile menu + any
leftover mobile-drawer copy of `UserProfileDropdown`. Do not
resurrect admin in `NavigationContent`.
- **Right-align top-bar actions.** Notifications, inbox, and
profile cluster flush to the right edge of `TopSearchBar`. Search
stays left / grows; leftover width after `max-w-2xl` must not
sit to the right of the cluster. Hide the notification badge
when count is `0` (do not invent a fake `3`).
- **Honest dashboard stats.** Fetch `/api/user/stats`. Ship three
real tiles: Total Cards (from `user_cards`, not list sums),
Collection Value, and a third computable metric (This month /
Lists / Decks — IA + Architect lock). Hide Rare Cards and
Wishlist until those features exist. Use `delta` / `subtitle`
only when the number is real.
- **Honest home panels.** Rename Featured Collection → Recently
added (same 8-up + empty-slot CTAs, real `/api/user-cards` or
stats recent rows). Wire Recent Activity to the signed-in user's
adds (stats `recentActivity` is an acceptable v1). Spotlight the
last added / last scanned card with real metadata; hide
price-trend, market-overview, and watchlist until a market-data
convoy.
- **Home CTAs.** Keep Scan / Create List only for empty or new
collections, or drop the welcome header in favor of stats —
UX locks this. Do not keep both a long greeting *and* two
always-on buttons.
- **Collection page chrome only.** Same max-width / padding rhythm
as dashboard; optional one-line summary from stats. No second
stat row, no spotlight rail on `/my-cards`.
### Out of scope
- Wishlist / Trades / Market / Events product work (queued
follow-ups from `redesign-v2-from-mockups`, still queued).
- Daily Ember backend, Level / XP, federated ⌘K search.
- Renaming Lists → Binders.
- Real notification / inbox backends.
- Market-price history or a charting library.
- Redesigning `CardItem`, scanner, or community pages.
- Changing `/api/user/stats` auth or adding new tables.
## Roles invoked
1. `role-ia-architect` — lock the flat nav map (desktop + mobile),
dashboard information hierarchy, and Collection-page-as-browse
(not a second home).
2. `role-ux-reviewer` — empty-state CTAs vs greeting, third stat
tile, activity/spotlight honesty, top-bar alignment + badge
rules. `ui-design` skipped: incremental inside Liquid Glass;
no new visual language.
3. `role-architect` — briefs for nav + admin relocation, TopSearchBar
alignment, dashboard data wiring (reuse `/api/user/stats`, no
new tables).
4. `role-implementer` — per brief.
5. Audit fan-out after PR draft: reviewer + security-auditor +
design-system-auditor + a11y-auditor.
6. `role-doc-writer` — AGENTS.md / Layout convention note if nav
map changes.
## Todos
- [x] IA: publish the locked nav map and dashboard outline
- [x] UX: lock greeting/CTA rule, third stat, badge-at-zero, spotlight contents
- [x] Architect: split briefs (nav+admin, top-bar, dashboard data)
- [x] Flatten `NavigationContent` + `MobileNavigation`; drop left-nav Admin Tools
- [x] Right-align TopSearchBar action cluster; badge hidden at 0
- [x] Dashboard consumes `/api/user/stats`; hide placeholder tiles
- [x] Recently added / My activity / last-card spotlight use real user data
- [x] `/my-cards` padding / max-width aligned with dashboard
- [x] Audits + docs pass
## IA
**Affected routes**
- `[modified]` `/dashboard` — honest home; stats from `/api/user/stats`
- `[modified]` `/my-cards` — summary line + shared max-width container
- `[impacted]` `/collections`, `/collection/[id]`, `/decks`, `/deck/*`, `/scanner`, `/cards`, `/community/*` — nav active states only
**User flow**
```mermaid
flowchart LR
Login --> Dashboard
Dashboard --> MyCards["My Collection"]
Dashboard --> Scanner
Dashboard --> Lists
MyCards --> CardDetail["/card/:id"]
```
**Screen inventory**
| Screen | Path | Change | Notes |
| --- | --- | --- | --- |
| Dashboard home | `/dashboard` | Modified | 3 stat tiles, recently added, activity, latest card |
| My Collection | `/my-cards` | Modified | Browse surface; optional stats summary |
| Layout chrome | all auth pages | Modified | Flat nav; admin in profile menu only |
**Content / data model deltas**
- No schema changes. Reuse `GET /api/user/stats` and `GET /api/user-cards`.
- `user-cards` GET adds `market_price` to SELECT for spotlight display.
**Open IA questions**
- None — third stat locked to **Decks** (`totalDecks`).
## UX
**Decisions (locked)**
1. **Empty vs returning home:** Welcome + Scan/Browse CTAs only when stats load successfully *and* both `totalCards === 0` and no recent cards. Returning users see stats row only (no greeting strip).
2. **Stat row:** Three tiles — Total cards, Collection value, Decks. No Rare/Wishlist placeholders.
3. **Panels:** Featured → **Recently added**; activity from user's `recentActivity`; spotlight shows latest owned card with real fields only (no demo charts).
4. **Top bar:** Notifications badge hidden at `0`. Action cluster flush-right via `ml-auto`.
5. **Admin:** Single **Admin Tools** entry in profile dropdown (`/admin`); removed from sidebar.
## Architecture
**File plan**
| File | Action | Purpose |
| --- | --- | --- |
| `components/Layout.js` | Modified | Flat nav; remove sidebar admin |
| `components/MobileNavigation.js` | Modified | Dashboard hub + Collection/Scanner/Decks |
| `components/ui/TopSearchBar.js` | Modified | Right-align actions; Admin Tools label |
| `pages/dashboard.js` | Modified | Wire `/api/user/stats` |
| `components/Dashboard*.js` | Modified | Real data panels |
| `pages/my-cards.js` | Modified | Container + summary |
| `lib/format-relative-time.js` | New | Activity timestamps |
| `pages/api/user-cards.js` | Modified | Include `market_price` in GET |
**API surface**
- No new routes. Consumers: `GET /api/user/stats`, `GET /api/user-cards` (auth required).
**Schema diff**
- None.
**Test plan**
- `test/lib/format-relative-time.test.js` (new)
- Existing `Layout.test.js`, `StatCard.test.js` regression
**Decomposition**
| Brief # | Title | Files | Depends on |
| --- | --- | --- | --- |
| 1 | Flat nav + admin relocation | Layout, MobileNavigation, TopSearchBar | — |
| 2 | Honest dashboard home | dashboard.js, Dashboard*.js, format-relative-time | — |
| 3 | My Collection chrome | my-cards.js, user-cards API | — |
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files: [components/Layout.js, components/MobileNavigation.js, components/ui/TopSearchBar.js]
- brief: 2
depends_on: []
files: [pages/dashboard.js, components/DashboardFeaturedCollection.js, components/DashboardRecentActivity.js, components/DashboardCardSpotlight.js, lib/format-relative-time.js]
- brief: 3
depends_on: []
files: [pages/my-cards.js, pages/api/user-cards.js]
```
## Audits (2026-08-15)
- **Reviewer:** No blockers after fixes (stats empty-state gate, divider, Lists active on `/collection/*`, vocab, admin label).
- **Security:** No medium+ findings; auth boundaries unchanged.
- **Design-system / a11y:** Incremental Liquid Glass; nav items retain focus rings; activity list uses semantic text.
## Doc note
Authenticated sidebar nav (2026-08-15): Dashboard → My Collection → Lists → Decks → Scanner; divider; Cards catalog + Community. Admin only in TopSearchBar profile menu.

View file

@ -0,0 +1,25 @@
---
convoy: dashboard-home-realignment
brief_number: 1
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- components/Layout.js
- components/MobileNavigation.js
- components/ui/TopSearchBar.js
---
# Brief 1: Flat nav + top bar alignment
## Goal
Flatten authenticated navigation and right-align TopSearchBar actions; admin only in profile menu.
## Acceptance criteria
- [x] Flat primary nav with Dashboard, My Collection, Lists, Decks, Scanner
- [x] Secondary: Cards (+ Scanner when logged out), Community collapsible
- [x] No sidebar Admin Tools
- [x] TopSearchBar actions flush-right (`ml-auto`)
- [x] Admin Tools in UserMenu only

View file

@ -0,0 +1,27 @@
---
convoy: dashboard-home-realignment
brief_number: 2
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- pages/dashboard.js
- components/DashboardFeaturedCollection.js
- components/DashboardRecentActivity.js
- components/DashboardCardSpotlight.js
- lib/format-relative-time.js
- test/lib/format-relative-time.test.js
---
# Brief 2: Honest dashboard home
## Goal
Wire dashboard to `/api/user/stats` and remove demo panels/placeholder stat tiles.
## Acceptance criteria
- [x] Three real stat tiles (total cards, value, decks)
- [x] Recently added grid + real activity + latest card spotlight
- [x] Empty-state CTAs only when collection is truly empty
- [x] formatRelativeTime helper + unit test

View file

@ -0,0 +1,22 @@
---
convoy: dashboard-home-realignment
brief_number: 3
depends_on: []
recommended_model: composer-2.5-fast
model_tier: fast
files:
- pages/my-cards.js
- pages/api/user-cards.js
---
# Brief 3: My Collection chrome alignment
## Goal
Align `/my-cards` layout with dashboard container and show optional ownership summary.
## Acceptance criteria
- [x] `max-w-[1500px] mx-auto` container
- [x] One-line summary from `/api/user/stats`
- [x] `market_price` on user-cards GET for downstream spotlight

View file

@ -1,408 +1,154 @@
import Link from 'next/link';
import { VOCAB } from '../lib/collection-vocabulary.js';
/** /**
* DashboardCardSpotlight the right-rail Card Spotlight panel from * Last-added card spotlight on /dashboard real ownership data only.
* the operator's redesign-v2 mockup (sub-convoy #8). * Market charts and watchlist ship in a follow-up convoy.
*
* 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.
*/ */
export default function DashboardCardSpotlight({ card = null, loading = false }) {
if (loading) {
return (
<aside className="glass-panel rounded-2xl p-5 sm:p-6">
<div
className="aspect-[2.5/3.5] rounded-lg motion-essential animate-pulse mb-4"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
aria-hidden="true"
/>
<div
className="h-4 rounded motion-essential animate-pulse mb-2"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
/>
<div
className="h-4 rounded motion-essential animate-pulse w-2/3"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
/>
</aside>
);
}
const SPOTLIGHT_CARD = { if (!card) {
name: 'Emberclaw Dragon', return (
rarity: 'Mythic', <aside className="glass-panel rounded-2xl p-5 sm:p-6">
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 <h2
className="text-lg font-bold flex items-center gap-2" className="text-lg font-bold mb-3"
style={{ color: 'var(--text-primary)' }} style={{ color: 'var(--text-primary)' }}
> >
<span Latest card
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> </h2>
<button <p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
type="button" Add cards to {VOCAB.MY_COLLECTION} to see details here.
className="text-sm flex items-center gap-1 focus:outline-none focus:ring-2 rounded-md px-1" </p>
style={{ <Link href="/scanner">
color: 'var(--text-secondary)', <span
'--tw-ring-color': 'var(--accent-ember)', className="text-sm font-medium hover:underline"
}} style={{ 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 Open scanner
strokeLinecap="round" </span>
strokeLinejoin="round" </Link>
strokeWidth={2} </aside>
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 const cardId = card.card_id ?? card.id;
</button> const marketPrice = card.market_price ?? card.marketPrice;
const priceLabel =
marketPrice != null && !Number.isNaN(Number(marketPrice))
? `$${Number(marketPrice).toFixed(2)}`
: '—';
return (
<aside className="glass-panel rounded-2xl p-5 sm:p-6 space-y-4">
<header>
<h2
className="text-lg font-bold"
style={{ color: 'var(--text-primary)' }}
>
Latest card
</h2>
</header> </header>
<div <Link
className="card-grid-outer-glow aspect-[2.5/3.5] rounded-lg overflow-hidden relative" href={`/card/${cardId}`}
className="card-grid-outer-glow aspect-[2.5/3.5] rounded-lg overflow-hidden block focus:outline-none focus:ring-2"
style={{ style={{
background: SPOTLIGHT_CARD.imageGradient, backgroundColor: 'var(--bg-tertiary)',
'--tw-ring-color': 'var(--accent-ember)',
}} }}
aria-label={`${SPOTLIGHT_CARD.name} card preview`}
role="img"
> >
<div {card.image_url ? (
className="absolute inset-0 flex items-end p-3" // eslint-disable-next-line @next/next/no-img-element
style={{ <img
background: src={card.image_url}
'linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.65) 100%)', alt={card.name ?? 'Card'}
}} className="w-full h-full object-cover"
> />
<div> ) : (
<div <div
className="font-bold text-base" className="w-full h-full flex items-center justify-center p-4 text-center text-sm"
style={{ color: 'rgb(255, 248, 240)' }} style={{ color: 'var(--text-secondary)' }}
> >
{SPOTLIGHT_CARD.name} {card.name ?? 'Card'}
</div>
<div
className="text-xs"
style={{ color: 'rgba(255, 248, 240, 0.75)' }}
>
{SPOTLIGHT_CARD.set}
</div>
</div> </div>
)}
</Link>
<div>
<div
className="text-lg font-bold"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</div> </div>
{card.set_name && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{card.set_name}
</div>
)}
</div> </div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm"> <dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt style={{ color: 'var(--text-secondary)' }}>Rarity</dt> {card.rarity && (
<>
<dt style={{ color: 'var(--text-secondary)' }}>Rarity</dt>
<dd
className="font-semibold text-right"
style={{ color: 'var(--accent-ember)' }}
>
{card.rarity}
</dd>
</>
)}
{card.game && (
<>
<dt style={{ color: 'var(--text-secondary)' }}>Game</dt>
<dd
className="font-medium text-right"
style={{ color: 'var(--text-primary)' }}
>
{card.game}
</dd>
</>
)}
{card.quantity != null && (
<>
<dt style={{ color: 'var(--text-secondary)' }}>Owned</dt>
<dd
className="font-medium text-right"
style={{ color: 'var(--text-primary)' }}
>
{card.quantity}
</dd>
</>
)}
<dt style={{ color: 'var(--text-secondary)' }}>Market</dt>
<dd <dd
className="font-semibold text-right" 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)' }} style={{ color: 'var(--text-primary)' }}
> >
{SPOTLIGHT_CARD.set} {priceLabel}
</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> </dd>
</dl> </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> </aside>
); );
} }

View file

@ -1,30 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { VOCAB } from '../lib/collection-vocabulary.js';
/** /**
* DashboardFeaturedCollection the 4x2 card-thumbnail grid panel * Recently added cards grid on /dashboard (formerly Featured Collection).
* 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 }) { export default function DashboardFeaturedCollection({ cards = [], loading = false }) {
const slots = Array.from({ length: 8 }, (_, idx) => cards[idx] || null); const slots = Array.from({ length: 8 }, (_, idx) => cards[idx] || null);
@ -48,7 +26,7 @@ export default function DashboardFeaturedCollection({ cards = [], loading = fals
<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" /> <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> </svg>
</span> </span>
Featured Collection Recently added
</h2> </h2>
<Link <Link
href="/my-cards" href="/my-cards"
@ -58,7 +36,7 @@ export default function DashboardFeaturedCollection({ cards = [], loading = fals
'--tw-ring-color': 'var(--accent-ember)', '--tw-ring-color': 'var(--accent-ember)',
}} }}
> >
View All View all in {VOCAB.MY_COLLECTION}
</Link> </Link>
</header> </header>
@ -100,14 +78,15 @@ export default function DashboardFeaturedCollection({ cards = [], loading = fals
d="M12 6v6m0 0v6m0-6h6m-6 0H6" d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/> />
</svg> </svg>
<span className="text-xs font-medium">Add Card</span> <span className="text-xs font-medium">Add card</span>
</Link> </Link>
); );
} }
const cardId = card.card_id ?? card.id;
return ( return (
<Link <Link
key={card.id ?? card.card_id ?? idx} key={cardId ?? idx}
href={`/card/${card.card_id ?? card.id}`} href={`/card/${cardId}`}
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" 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={{ style={{
backgroundColor: 'var(--bg-tertiary)', backgroundColor: 'var(--bg-tertiary)',

View file

@ -1,66 +1,10 @@
import Link from 'next/link';
import { VOCAB } from '../lib/collection-vocabulary.js';
/** /**
* DashboardRecentActivity the avatar + text + timestamp list panel * Recent adds to My Collection on /dashboard.
* 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).
*/ */
export default function DashboardRecentActivity({ activities = [], loading = false }) {
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 ( return (
<section className="glass-panel rounded-2xl p-5 sm:p-6"> <section className="glass-panel rounded-2xl p-5 sm:p-6">
<header className="flex items-center justify-between mb-4"> <header className="flex items-center justify-between mb-4">
@ -84,72 +28,75 @@ export default function DashboardRecentActivity({ activities }) {
<path d="M13 10V3L4 14h7v7l9-11h-7z" /> <path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
</span> </span>
Recent Activity Recent activity
</h2> </h2>
<button <Link
type="button" href="/my-cards"
className="text-sm font-medium hover:underline focus:outline-none focus:ring-2 rounded-md px-1" className="text-sm font-medium hover:underline focus:outline-none focus:ring-2 rounded-md px-1"
style={{ style={{
color: 'var(--accent-ember)', color: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)', '--tw-ring-color': 'var(--accent-ember)',
}} }}
> >
View All View {VOCAB.MY_COLLECTION}
</button> </Link>
</header> </header>
{usingDemo && ( {loading ? (
<p <ul className="space-y-3" aria-busy="true">
className="text-xs mb-3 italic" {[0, 1, 2].map((idx) => (
style={{ color: 'var(--text-secondary)' }} <li
> key={idx}
{/* TODO(user-activity-feed convoy): replace these demo rows className="h-12 rounded-xl motion-essential animate-pulse"
with rows from GET /api/user/activity once that endpoint style={{ backgroundColor: 'var(--bg-tertiary)' }}
ships. Operator-approved placeholder per umbrella § 7.1. */} />
Demo activity connect to live feed in a follow-up release. ))}
</ul>
) : activities.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
No recent adds yet. Scan a card or browse the catalog to start
building {VOCAB.MY_COLLECTION}.
</p> </p>
)} ) : (
<ul className="space-y-3">
<ul className="space-y-3"> {activities.map((row) => (
{rows.map((row) => ( <li key={row.id} className="flex items-start gap-3">
<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 <div
className="text-sm" className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0"
style={{ color: 'var(--text-primary)' }} style={{
background:
'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
color: 'rgb(255, 255, 255)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18)',
}}
aria-hidden="true"
> >
<span className="font-semibold">{row.actor.name}</span>{' '} {row.actor.initial}
<span style={{ color: 'var(--text-secondary)' }}>
{row.action}
</span>
</div> </div>
<div <div className="flex-1 min-w-0">
className="text-xs truncate" <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)' }} style={{ color: 'var(--text-secondary)' }}
> >
{row.subject} {row.time}
</div> </span>
</div> </li>
<span ))}
className="text-xs whitespace-nowrap flex-shrink-0" </ul>
style={{ color: 'var(--text-secondary)' }} )}
>
{row.time}
</span>
</li>
))}
</ul>
</section> </section>
); );
} }

View file

@ -6,6 +6,7 @@ import MobileNavigation from './MobileNavigation';
import DailyEmberWidget from './DailyEmberWidget'; import DailyEmberWidget from './DailyEmberWidget';
import TopSearchBar from './ui/TopSearchBar'; import TopSearchBar from './ui/TopSearchBar';
import CommandPaletteModal from './ui/CommandPaletteModal'; import CommandPaletteModal from './ui/CommandPaletteModal';
import { VOCAB } from '../lib/collection-vocabulary.js';
// User Profile Dropdown Component // User Profile Dropdown Component
function UserProfileDropdown({ user, onMobileMenuClose }) { function UserProfileDropdown({ user, onMobileMenuClose }) {
@ -42,7 +43,7 @@ function UserProfileDropdown({ user, onMobileMenuClose }) {
{ name: 'Profile', href: '/profile', icon: 'user' }, { name: 'Profile', href: '/profile', icon: 'user' },
{ name: 'Settings', href: '/settings', icon: 'settings' }, { name: 'Settings', href: '/settings', icon: 'settings' },
...(user?.role === 'admin' ? [ ...(user?.role === 'admin' ? [
{ name: 'Admin Panel', href: '/admin', icon: 'admin' } { name: 'Admin Tools', href: '/admin', icon: 'admin' }
] : []), ] : []),
{ name: 'Logout', href: '/logout', icon: 'logout', isLogout: true } { name: 'Logout', href: '/logout', icon: 'logout', isLogout: true }
]; ];
@ -174,38 +175,68 @@ function NavigationContent({ user, router, onItemClick }) {
setLastCommunityPath(router.pathname); setLastCommunityPath(router.pathname);
} }
// Navigation structure for authenticated users // Flat product nav — dashboard-home-realignment convoy (2026-08-15).
const authenticatedNavigation = user ? [ // Primary destinations for signed-in users; secondary catalog +
{ name: 'Activity', href: '/activity', icon: 'activity', active: router.pathname === '/activity', isPlaceholder: true } // community below a divider. Admin lives in TopSearchBar UserMenu
] : []; // only — not duplicated here.
const primaryNavigation = user
? [
{
name: 'Dashboard',
href: '/dashboard',
icon: 'grid',
active: router.pathname === '/dashboard',
},
{
name: VOCAB.MY_COLLECTION,
href: '/my-cards',
icon: 'collection',
active: router.pathname === '/my-cards',
},
{
name: VOCAB.LISTS,
href: '/collections',
icon: 'lists',
active:
router.pathname === '/collections' ||
router.pathname.startsWith('/collection/'),
},
{
name: 'Decks',
href: '/decks',
icon: 'deck',
active:
router.pathname === '/decks' ||
router.pathname.startsWith('/deck/'),
},
{
name: 'Scanner',
href: '/scanner',
icon: 'scanner',
active: router.pathname === '/scanner',
},
]
: [];
// My Collection section (only for authenticated users) const secondaryNavigation = [
const myCollectionNavigation = user ? { {
name: 'My Collection', name: 'Cards',
href: '/dashboard', href: '/cards',
icon: 'collection', icon: 'card',
active: router.pathname === '/dashboard' || router.pathname === '/collections' || router.pathname === '/my-cards' || router.pathname === '/decks' || router.pathname === '/analytics', active: router.pathname === '/cards',
expanded: true, },
items: [ ...(user
{ name: 'Lists', href: '/collections', active: router.pathname === '/collections' }, ? []
{ name: 'Cards', href: '/my-cards', active: router.pathname === '/my-cards' }, : [
{ name: 'Decks', href: '/decks', active: router.pathname === '/decks' }, {
{ name: 'Analytics', href: '/analytics', active: router.pathname === '/analytics', isPlaceholder: true } name: 'Scanner',
] href: '/scanner',
} : null; icon: 'scanner',
active: router.pathname === '/scanner',
// Always visible navigation (public + authenticated) },
const publicNavigation = [ ]),
{ name: 'Cards', href: '/cards', icon: 'card', active: router.pathname === '/cards' },
{ name: 'Scanner', href: '/scanner', icon: 'scanner', active: router.pathname === '/scanner' },
{ name: 'Deck Builder', href: '/deck-builder', icon: 'deck', active: router.pathname === '/deck-builder', isPlaceholder: true }
]; ];
// Admin navigation (only for admin users)
const adminNavigation = (user?.role === 'admin') ? [
{ name: 'Admin Tools', href: '/admin/card-editor', icon: 'admin', active: router.pathname.startsWith('/admin'), badge: 'ADMIN' }
] : [];
const communityNavigation = { const communityNavigation = {
name: 'Community', name: 'Community',
icon: 'community', icon: 'community',
@ -230,6 +261,11 @@ function NavigationContent({ user, router, onItemClick }) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg> </svg>
), ),
lists: (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
),
card: ( card: (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6" fill="none" stroke="currentColor" 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" /> <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" />
@ -287,189 +323,45 @@ function NavigationContent({ user, router, onItemClick }) {
return icons[iconName] || icons.grid; return icons[iconName] || icons.grid;
}; };
const renderNavLink = (item) => (
<Link key={item.name} href={item.href}>
<div
className={`
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
transition-all duration-200 cursor-pointer
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${item.active ? 'shadow-lg nav-item-active' : 'hover:shadow-md nav-item-hover'}
`}
style={{
backgroundColor: item.active ? undefined : 'transparent',
color: item.active ? undefined : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)',
}}
onClick={onItemClick}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">
{getIcon(item.icon)}
</span>
<span className="font-medium">{item.name}</span>
</div>
</div>
</Link>
);
return ( return (
<> <>
{/* Authenticated User Navigation - Activity */} {primaryNavigation.map(renderNavLink)}
{authenticatedNavigation.map((item) => (
<div key={item.name}>
{item.isPlaceholder ? (
<div
className="nav-item flex items-center justify-between px-4 py-3 rounded-2xl opacity-50 cursor-not-allowed"
style={{
backgroundColor: 'transparent',
color: 'var(--text-secondary)'
}}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
Coming Soon
</span>
</div>
) : (
<Link href={item.href}>
<div
className={`
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
transition-all duration-200 cursor-pointer
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${item.active
? 'shadow-lg nav-item-active'
: 'hover:shadow-md nav-item-hover'
}
`}
style={{
backgroundColor: item.active ? undefined : 'transparent',
color: item.active ? undefined : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={onItemClick}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
</div>
</Link>
)}
</div>
))}
{/* My Collection Section (only for authenticated users) */} {primaryNavigation.length > 0 && (
{myCollectionNavigation && ( <div
<div className="space-y-1"> className="border-t pt-2 mt-2"
{/* My Collection Header - Clickable */} style={{ borderColor: 'var(--border)' }}
<Link href={myCollectionNavigation.href}> />
<div
className={`
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
transition-all duration-200 cursor-pointer
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${router.pathname === '/dashboard'
? 'shadow-lg nav-item-active'
: 'hover:shadow-md nav-item-hover'
}
`}
style={{
backgroundColor: router.pathname === '/dashboard' ? undefined : 'transparent',
color: router.pathname === '/dashboard' ? undefined : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={onItemClick}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(myCollectionNavigation.icon)}</span>
<span className="font-medium">{myCollectionNavigation.name}</span>
</div>
</div>
</Link>
{/* My Collection Sub-items */}
{myCollectionNavigation.expanded && (
<div className="ml-4 space-y-1 border-l-2 pl-4" style={{ borderColor: 'var(--border)' }}>
{myCollectionNavigation.items.map((subItem) => (
<div key={subItem.name}>
{subItem.isPlaceholder ? (
<div
className="nav-item flex items-center justify-between px-4 py-2 rounded-xl opacity-50 cursor-not-allowed text-sm"
style={{
backgroundColor: 'transparent',
color: 'var(--text-secondary)'
}}
>
<span className="font-medium">{subItem.name}</span>
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
Soon
</span>
</div>
) : (
<Link href={subItem.href}>
<div
className={`
nav-item flex items-center px-4 py-2 rounded-xl
transition-all duration-200 cursor-pointer text-sm
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${subItem.active
? 'shadow-md nav-item-active'
: 'hover:shadow-sm nav-item-hover'
}
`}
style={{
backgroundColor: subItem.active ? undefined : 'transparent',
color: subItem.active ? undefined : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={onItemClick}
>
<span className="font-medium">{subItem.name}</span>
</div>
</Link>
)}
</div>
))}
</div>
)}
</div>
)} )}
{/* Separator */} {secondaryNavigation.map(renderNavLink)}
<div className="border-t pt-2 mt-4" style={{ borderColor: 'var(--border)' }}></div>
{/* Public Navigation (always visible) */}
{publicNavigation.map((item) => (
<div key={item.name}>
{item.isPlaceholder ? (
<div
className="nav-item flex items-center justify-between px-4 py-3 rounded-2xl opacity-50 cursor-not-allowed"
style={{
backgroundColor: 'transparent',
color: 'var(--text-secondary)'
}}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
Coming Soon
</span>
</div>
) : (
<Link href={item.href}>
<div
className={`
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
transition-all duration-200 cursor-pointer
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${item.active
? 'shadow-lg nav-item-active'
: 'hover:shadow-md nav-item-hover'
}
`}
style={{
backgroundColor: item.active ? undefined : 'transparent',
color: item.active ? undefined : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={onItemClick}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
</div>
</Link>
)}
</div>
))}
{/* Community Section with Sub-items */}
<div className="space-y-1"> <div className="space-y-1">
<div <div
className={` className={`
@ -553,46 +445,6 @@ function NavigationContent({ user, router, onItemClick }) {
</div> </div>
)} )}
</div> </div>
{/* Admin Navigation (if admin user) */}
{adminNavigation.map((item) => (
<Link key={item.name} href={item.href}>
<div
className={`
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
transition-all duration-200 cursor-pointer
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${item.active
? 'shadow-lg nav-item-active'
: 'hover:shadow-md nav-item-hover'
}
`}
style={{
backgroundColor: item.active ? undefined : 'transparent',
color: item.active ? undefined : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={onItemClick}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
{item.badge && (
<span
className="px-2 py-1 text-xs rounded-full font-medium"
style={{
backgroundColor: item.badge === 'ADMIN' ? 'var(--accent-ember)' : 'var(--accent-flame)',
color: 'white'
}}
>
{item.badge}
</span>
)}
</div>
</Link>
))}
</> </>
); );
} }

View file

@ -1,52 +1,67 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react';
import { GlassSurface } from './ui'; import { GlassSurface } from './ui';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function MobileNavigation({ onMenuOpen }) { export default function MobileNavigation({ onMenuOpen }) {
const router = useRouter(); const router = useRouter();
// Navigation items for the bottom bar // Bottom bar — dashboard-home-realignment (2026-08-15). Primary hub
// is Dashboard only; Collection / Scanner / Decks are siblings.
// Lists + catalog + community live in the drawer via More.
const navigationItems = [ const navigationItems = [
{ {
name: 'Cards', name: VOCAB.MY_COLLECTION,
href: '/cards', href: '/my-cards',
icon: 'cards', icon: 'collection',
active: router.pathname === '/cards' active: router.pathname === '/my-cards',
}, },
{ {
name: 'Decks', name: 'Scanner',
href: '/decks', href: '/scanner',
icon: 'decks', icon: 'scanner',
active: router.pathname === '/decks' active: router.pathname === '/scanner',
}, },
{ {
name: 'Dashboard', name: 'Dashboard',
href: '/dashboard', href: '/dashboard',
icon: 'dashboard', icon: 'dashboard',
active: router.pathname === '/dashboard' || router.pathname === '/collections' || router.pathname === '/my-cards' || router.pathname === '/analytics', active: router.pathname === '/dashboard',
isPrimary: true // This will be the raised center button isPrimary: true,
}, },
{ {
name: 'Community', name: 'Decks',
href: '/community', href: '/decks',
icon: 'community', icon: 'decks',
active: router.pathname.startsWith('/community') active:
router.pathname === '/decks' ||
router.pathname.startsWith('/deck/'),
}, },
{ {
name: 'More', name: 'More',
href: '#', href: '#',
icon: 'more', icon: 'more',
onClick: onMenuOpen, onClick: onMenuOpen,
active: false active: false,
} },
]; ];
const getIcon = (iconName, isActive = false, isPrimary = false) => { const getIcon = (iconName, isActive = false, isPrimary = false) => {
const iconClass = isPrimary ? "h-7 w-7" : "h-6 w-6"; const iconClass = isPrimary ? 'h-7 w-7' : 'h-6 w-6';
const strokeWidth = isActive ? 2.5 : 2; const strokeWidth = isActive ? 2.5 : 2;
const icons = { const icons = {
collection: (
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
scanner: (
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
cards: ( cards: (
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} 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" />
@ -62,16 +77,11 @@ export default function MobileNavigation({ onMenuOpen }) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg> </svg>
), ),
community: (
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
),
more: ( more: (
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M4 6h16M4 12h16M4 18h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
) ),
}; };
return icons[iconName] || icons.dashboard; return icons[iconName] || icons.dashboard;
@ -79,14 +89,6 @@ export default function MobileNavigation({ onMenuOpen }) {
return ( return (
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50"> <div className="md:hidden fixed bottom-0 left-0 right-0 z-50">
{/* Liquid Glass background mid-tint surface with chrome-tier
corner-light bleed, matching the rest of the floating chrome
(sidebar nav-chip, TopSearchBar). Composed via <GlassSurface>
post-migrate-button-input-mobilenav-to-glass-primitive
convoy (2026-06-05) `rim="subtle"` reproduces the previous
rim-light-inner + rim-light-outer halo; `cornerLights="chrome"`
brings in the corner-light bleed that the convoy is
unifying across all chrome surfaces. */}
<GlassSurface <GlassSurface
className="absolute inset-0" className="absolute inset-0"
tint="mid" tint="mid"
@ -96,16 +98,15 @@ export default function MobileNavigation({ onMenuOpen }) {
cornerLights="chrome" cornerLights="chrome"
/> />
{/* Navigation Content */}
<div className="relative px-4 py-2"> <div className="relative px-4 py-2">
<div className="flex items-center justify-around"> <div className="flex items-center justify-around">
{navigationItems.map((item) => { {navigationItems.map((item) => {
const label = item.name;
if (item.isPrimary) { if (item.isPrimary) {
// Primary/Dashboard button - raised and prominent
return ( return (
<Link key={item.name} href={item.href}> <Link key={item.name} href={item.href}>
<div className="relative"> <div className="relative">
{/* Raised background circle */}
<div <div
className="absolute inset-0 rounded-full shadow-lg transform -translate-y-2" className="absolute inset-0 rounded-full shadow-lg transform -translate-y-2"
style={{ style={{
@ -113,67 +114,62 @@ export default function MobileNavigation({ onMenuOpen }) {
? 'var(--gradient-primary)' ? 'var(--gradient-primary)'
: 'var(--gradient-secondary)', : 'var(--gradient-secondary)',
width: '56px', width: '56px',
height: '56px' height: '56px',
}} }}
/> />
{/* Button content */}
<button <button
type="button"
className="relative flex flex-col items-center justify-center w-14 h-14 transform -translate-y-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2" className="relative flex flex-col items-center justify-center w-14 h-14 transform -translate-y-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{ style={{
color: 'white', color: 'white',
'--tw-ring-color': 'var(--accent-ember)', '--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-primary)' '--tw-ring-offset-color': 'var(--bg-primary)',
}} }}
aria-label={item.name} aria-label={item.name}
> >
{getIcon(item.icon, item.active, true)} {getIcon(item.icon, item.active, true)}
</button> </button>
{/* Label below raised button */}
<span <span
className="absolute top-12 left-1/2 transform -translate-x-1/2 text-xs font-medium whitespace-nowrap" className="absolute top-12 left-1/2 transform -translate-x-1/2 text-xs font-medium whitespace-nowrap"
style={{ style={{
color: item.active ? 'var(--accent-ember)' : 'var(--text-secondary)' color: item.active
? 'var(--accent-ember)'
: 'var(--text-secondary)',
}} }}
> >
{item.name} {label}
</span> </span>
</div> </div>
</Link> </Link>
); );
} else {
// Regular navigation buttons
const ButtonComponent = item.onClick ? 'button' : Link;
const buttonProps = item.onClick
? { onClick: item.onClick }
: { href: item.href };
return (
<ButtonComponent key={item.name} {...buttonProps}>
<div
className="flex flex-col items-center justify-center py-2 px-3 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
style={{
color: item.active ? 'var(--accent-ember)' : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
>
<div className="mb-1">
{getIcon(item.icon, item.active)}
</div>
<span className="text-xs font-medium">
{item.name}
</span>
</div>
</ButtonComponent>
);
} }
const ButtonComponent = item.onClick ? 'button' : Link;
const buttonProps = item.onClick
? { onClick: item.onClick, type: 'button' }
: { href: item.href };
return (
<ButtonComponent key={item.name} {...buttonProps}>
<div
className="flex flex-col items-center justify-center py-2 px-3 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
style={{
color: item.active
? 'var(--accent-ember)'
: 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)',
}}
>
<div className="mb-1">{getIcon(item.icon, item.active)}</div>
<span className="text-xs font-medium">{label}</span>
</div>
</ButtonComponent>
);
})} })}
</div> </div>
</div> </div>
{/* Safe area padding for devices with home indicator */}
<div className="h-safe-area-inset-bottom" /> <div className="h-safe-area-inset-bottom" />
</div> </div>
); );

View file

@ -93,7 +93,7 @@ function UserMenu({ user }) {
{ id: 'profile', label: 'Profile', href: '/profile', icon: 'user' }, { id: 'profile', label: 'Profile', href: '/profile', icon: 'user' },
{ id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' }, { id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' },
...(isAdmin ...(isAdmin
? [{ id: 'admin', label: 'Admin Panel', href: '/admin', icon: 'admin' }] ? [{ id: 'admin', label: 'Admin Tools', href: '/admin', icon: 'admin' }]
: []), : []),
{ id: 'support', label: 'Help & Support', href: '/support', icon: 'support' }, { id: 'support', label: 'Help & Support', href: '/support', icon: 'support' },
]; ];
@ -307,7 +307,7 @@ export default function TopSearchBar({
// pass applies to any visible 1px edge, and a hairline outer // pass applies to any visible 1px edge, and a hairline outer
// ring would re-introduce one on mobile where the bar abuts // ring would re-introduce one on mobile where the bar abuts
// content directly. // content directly.
className="px-4 sm:px-6 py-3 flex items-center gap-3 sm:gap-4 md:rounded-2xl" className="px-4 sm:px-6 py-3 flex items-center gap-3 sm:gap-4 md:rounded-2xl w-full"
style={{ style={{
// 2026-06-04 corner-border pass: same gradient-border // 2026-06-04 corner-border pass: same gradient-border
// technique as .glass-panel — the chip's fill clipped to // technique as .glass-panel — the chip's fill clipped to
@ -333,7 +333,7 @@ export default function TopSearchBar({
onClick={openPalette} onClick={openPalette}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
className="flex-1 max-w-2xl flex items-center gap-2 px-4 py-2 rounded-xl text-left transition-colors focus:outline-none focus:ring-2" className="w-full max-w-2xl flex items-center gap-2 px-4 py-2 rounded-xl text-left transition-colors focus:outline-none focus:ring-2"
style={{ style={{
backgroundColor: hovered backgroundColor: hovered
? 'var(--bg-tertiary)' ? 'var(--bg-tertiary)'
@ -374,7 +374,7 @@ export default function TopSearchBar({
</span> </span>
</button> </button>
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0"> <div className="flex items-center gap-2 sm:gap-3 flex-shrink-0 ml-auto">
<button <button
type="button" type="button"
className="relative p-2 rounded-xl transition-colors focus:outline-none focus:ring-2 nav-item-hover" className="relative p-2 rounded-xl transition-colors focus:outline-none focus:ring-2 nav-item-hover"

View file

@ -0,0 +1,26 @@
/**
* Human-readable relative time for activity feeds (e.g. "2m ago").
*/
export function formatRelativeTime(isoString) {
if (!isoString) return '';
const then = new Date(isoString).getTime();
if (Number.isNaN(then)) return '';
const diffMs = Date.now() - then;
if (diffMs < 0) return 'Just now';
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return new Date(isoString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}

View file

@ -60,7 +60,7 @@ export default async function handler(req, res) {
} else if (req.method === 'GET') { } else if (req.method === 'GET') {
// Get user's owned cards // Get user's owned cards
const result = await sql` const result = await sql`
SELECT uc.*, c.name, c.set_name, c.rarity, c.game, c.image_url SELECT uc.*, c.name, c.set_name, c.rarity, c.game, c.image_url, c.market_price
FROM user_cards uc FROM user_cards uc
JOIN cards c ON uc.card_id = c.id JOIN cards c ON uc.card_id = c.id
WHERE uc.user_id = ${user.userId} WHERE uc.user_id = ${user.userId}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
@ -7,32 +7,36 @@ import DashboardRecentActivity from '../components/DashboardRecentActivity';
import DashboardCardSpotlight from '../components/DashboardCardSpotlight'; 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 { VOCAB } from '../lib/collection-vocabulary.js';
import { formatRelativeTime } from '../lib/format-relative-time.js';
function authHeaders() {
const token =
typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
function mapRecentActivityRows(recentActivity) {
if (!Array.isArray(recentActivity)) return [];
return recentActivity.slice(0, 5).map((row, index) => ({
id: `${row.name}-${row.addedAt}-${index}`,
actor: { name: 'You', initial: 'Y' },
action: `added to ${VOCAB.MY_COLLECTION}`,
subject: [row.name, row.game].filter(Boolean).join(' · '),
time: formatRelativeTime(row.addedAt),
}));
}
// 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 [stats, setStats] = useState(null);
const [recentCards, setRecentCards] = 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
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
router.push('/login'); router.push('/login');
@ -44,27 +48,21 @@ export default function Dashboard() {
let cancelled = false; let cancelled = false;
const fetchAll = async () => { const fetchAll = async () => {
const token = localStorage.getItem('auth_token'); const headers = authHeaders();
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
try { try {
const [collectionsRes, cardsRes] = await Promise.all([ const [statsRes, cardsRes] = await Promise.all([
fetch('/api/collections', { headers }), fetch('/api/user/stats', { headers }),
fetch('/api/user-cards', { headers }), fetch('/api/user-cards', { headers }),
]); ]);
if (!cancelled) { if (!cancelled) {
if (collectionsRes.ok) { if (statsRes.ok) {
const data = await collectionsRes.json(); setStats(await statsRes.json());
setCollections(Array.isArray(data) ? data : []);
} else { } else {
setCollections([]); setStats(null);
} }
if (cardsRes.ok) { if (cardsRes.ok) {
const data = await cardsRes.json(); 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) : []); setRecentCards(Array.isArray(data) ? data.slice(0, 8) : []);
} else { } else {
setRecentCards([]); setRecentCards([]);
@ -73,13 +71,12 @@ export default function Dashboard() {
} catch (error) { } catch (error) {
console.error('[dashboard] fetch error:', error); console.error('[dashboard] fetch error:', error);
if (!cancelled) { if (!cancelled) {
setCollections([]); setStats(null);
setRecentCards([]); setRecentCards([]);
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setLoading(false); setLoading(false);
setCardsLoading(false);
} }
} }
}; };
@ -90,50 +87,49 @@ export default function Dashboard() {
}; };
}, [user]); }, [user]);
const totalCards = collections.reduce( const activityRows = useMemo(
(total, col) => total + (col.cardCount || 0), () => mapRecentActivityRows(stats?.recentActivity),
0 [stats?.recentActivity]
);
const collectionValue = collections.reduce(
(total, col) => total + (col.value || 0),
0
); );
const spotlightCard = recentCards[0] ?? null;
const totalCards = stats?.totalCards ?? 0;
const collectionValue = stats?.totalValue ?? 0;
const totalDecks = stats?.totalDecks ?? 0;
const statsLoaded = stats !== null;
const isEmptyCollection =
statsLoaded && totalCards === 0 && recentCards.length === 0;
return ( return (
<Layout user={user}> <Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6"> <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 {isEmptyCollection && (
heavy header strip. The top chrome (search, notifs, <div className="glass-panel rounded-2xl p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
avatar) is provided by <TopSearchBar> in Layout. */} <div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <h1
<div> className="text-2xl font-bold mb-1"
<h1 style={{ color: 'var(--text-primary)' }}
className="text-2xl sm:text-3xl font-bold" >
style={{ color: 'var(--text-primary)' }} Welcome{user?.username ? `, ${user.username}` : ''}
> </h1>
Welcome back <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{user?.username ? `, ${user.username}` : ''} Scan or browse cards to start {VOCAB.MY_COLLECTION}.
</h1> </p>
<p </div>
className="text-sm" <div className="flex gap-2">
style={{ color: 'var(--text-secondary)' }} <Link href="/scanner">
> <Button variant="secondary" size="sm">
Here&apos;s what&apos;s happening with your collection today. Scan card
</p> </Button>
</Link>
<Link href="/cards">
<Button variant="primary" size="sm">
Browse catalog
</Button>
</Link>
</div>
</div> </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 ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@ -144,11 +140,10 @@ export default function Dashboard() {
</div> </div>
) : ( ) : (
<> <>
{/* Stats row — 4-up grid (umbrella § 7.1 locked metrics) */} <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
accent="blue" accent="blue"
label="Total Cards" label="Total cards"
value={totalCards.toLocaleString()} value={totalCards.toLocaleString()}
icon={ icon={
<svg <svg
@ -166,33 +161,13 @@ export default function Dashboard() {
</svg> </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 <StatCard
accent="gold" accent="gold"
label="Collection Value" label="Collection value"
value={`$${collectionValue.toLocaleString()}`} value={`$${collectionValue.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`}
icon={ icon={
<svg <svg
className="h-6 w-6" className="h-6 w-6"
@ -209,13 +184,10 @@ export default function Dashboard() {
</svg> </svg>
} }
/> />
{/* TODO(wishlist-feature convoy): real wishlist count
ships when the wishlist table + API land. */}
<StatCard <StatCard
accent="red" accent="purple"
label="Wishlist Items" label="Decks"
value="0" value={totalDecks.toLocaleString()}
subtitle="Coming soon"
icon={ icon={
<svg <svg
className="h-6 w-6" className="h-6 w-6"
@ -227,24 +199,23 @@ export default function Dashboard() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} 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" 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 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/> />
</svg> </svg>
} }
/> />
</div> </div>
{/* Main content + right rail */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<DashboardFeaturedCollection <DashboardFeaturedCollection cards={recentCards} loading={false} />
cards={recentCards} <DashboardRecentActivity
loading={cardsLoading} activities={activityRows}
loading={false}
/> />
<DashboardRecentActivity />
</div> </div>
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<DashboardCardSpotlight /> <DashboardCardSpotlight card={spotlightCard} loading={false} />
</div> </div>
</div> </div>
</> </>

View file

@ -49,6 +49,7 @@ export default function MyCards() {
// Mana symbol settings // Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
const [collectionSummary, setCollectionSummary] = useState(null);
// Redirect to login if not authenticated // Redirect to login if not authenticated
useEffect(() => { useEffect(() => {
@ -150,6 +151,32 @@ export default function MyCards() {
} }
}; };
// Load collection summary for header line
useEffect(() => {
if (!user) return;
let cancelled = false;
const loadSummary = 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/user/stats', { headers });
if (response.ok && !cancelled) {
setCollectionSummary(await response.json());
}
} catch (error) {
console.error('Error loading collection summary:', error);
}
};
loadSummary();
return () => {
cancelled = true;
};
}, [user]);
// Initial load // Initial load
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@ -261,13 +288,25 @@ export default function MyCards() {
return ( return (
<Layout user={user}> <Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
{/* Header */} {/* Header */}
<div className="px-4 sm:px-6 pt-6 pb-2"> <div className="pt-2">
<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 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{VOCAB.MY_COLLECTION} {VOCAB.MY_COLLECTION}
</h1> </h1>
{collectionSummary && (
<p className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>
{collectionSummary.totalCards.toLocaleString()} cards
{' · '}
${collectionSummary.totalValue.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}{' '}
estimated value
</p>
)}
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}> <p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
Browse and manage cards in {VOCAB.MY_COLLECTION} Browse and manage cards in {VOCAB.MY_COLLECTION}
</p> </p>
@ -310,7 +349,7 @@ export default function MyCards() {
</div> </div>
{/* Filters */} {/* Filters */}
<div className="p-4 sm:p-6"> <div>
<div className="glass-panel rounded-2xl p-4 mb-6"> <div className="glass-panel rounded-2xl p-4 mb-6">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
@ -445,6 +484,7 @@ export default function MyCards() {
cards={cardsToAdd} cards={cardsToAdd}
onAddToCollections={handleAddToCollections} onAddToCollections={handleAddToCollections}
/> />
</div>
</Layout> </Layout>
); );
} }

View file

@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { formatRelativeTime } from '../../lib/format-relative-time.js';
describe('formatRelativeTime', () => {
it('returns empty string for missing input', () => {
expect(formatRelativeTime()).toBe('');
expect(formatRelativeTime('')).toBe('');
});
it('formats recent minutes', () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(formatRelativeTime(fiveMinutesAgo)).toBe('5m ago');
});
});