feat(scanner): add debug instrumentation for vision pipeline timing #155
16 changed files with 889 additions and 971 deletions
|
|
@ -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"}
|
||||||
|
|
|
||||||
265
.convoys/dashboard-home-realignment.md
Normal file
265
.convoys/dashboard-home-realignment.md
Normal 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.
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,31 +77,18 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
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"
|
||||||
|
|
@ -95,86 +97,80 @@ export default function MobileNavigation({ onMenuOpen }) {
|
||||||
elevation="flat"
|
elevation="flat"
|
||||||
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={{
|
||||||
background: item.active
|
background: item.active
|
||||||
? '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>
|
||||||
|
<span
|
||||||
{/* Label below raised button */}
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
26
lib/format-relative-time.js
Normal file
26
lib/format-relative-time.js
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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's what'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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
14
test/lib/format-relative-time.test.js
Normal file
14
test/lib/format-relative-time.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue