deckhearth/pages/deck/[id].js
varutasu ca302a89c1
fix(layout+pages): default user=null + page audit sweep (P0 #7) (#15)
* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak)

The last remaining P0 ship-blocker from .convoys/ship-readiness.md.
components/Layout.js line 562 defaults the user prop to a real email
address (me@randallstillwell.com); any page that renders Layout without
passing user explicitly impersonates the maintainer.

Scope: components/Layout.js + audit of 17 pages that import Layout
(grep-confirmed list in convoy file). Single PR likely. Auditor cohort
skipped (no design-system, IA, or browser-smoke surface).

Architect to address:
  - Q1: logged-out rendering branch design (navbar, mobile-nav,
        auth-only items treatment)
  - Q2: page audit triage into always-auth / public-or-auth /
        anonymous-allowed buckets
  - Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out)
  - Q4: whether to add vitest coverage for the logged-out branch
        (recommend yes — small surface, high regression protection)

Hard out-of-scope: branding (pick-a-name), auth-provider collapse
(single-auth-provider), Layout god-component split (god-component-split).

depends_on: bump-next-js (shipped), fix-auth-bypass (shipped),
            drop-public-setup (shipped)
addresses: P0 #7 from .convoys/ship-readiness.md
parent: ship-readiness

Co-authored-by: Cursor <cursoragent@cursor.com>

* architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit)

2 briefs, single PR. ~12 files net (down from the 18 in the original scope —
10 of the 17 Layout-importing pages already pass user explicitly).

Brief 1: components/Layout.js default user=null + Sign-in CTA branch in
  UserProfileDropdown when logged out. Adds first jsdom test in the repo
  at test/components/Layout.test.js (Decision D2) with 5 regression-lock
  assertions. devDeps: jsdom@^29, @testing-library/react@^16.

Brief 2: page audit sweep — 7 pages need code changes:
  - Pass user={user} to Layout: scanner.js, deck-builder.js (×4),
    deck/[id].js (×3), decks.js (×3)
  - Replace page-level useState({email: 'me@...'}) → useState(null) +
    null-guards: profile.js, settings.js
  - Replace hardcoded const user = {email: 'me@...'} with useAuth():
    card/[id].js

Discovered second anti-pattern: profile.js, settings.js, card/[id].js
seed page-level state with the maintainer email. Folded into Brief 2 since
success metric "no real email address remains in any component default-prop"
reads naturally to include page-level seed values.

Decisions:
  A1 — Sign-in CTA replaces avatar+email+dropdown when user===null;
       hides auth-only dropdown (Profile/Settings/Logout/Admin);
       keeps public + community nav visible
  B  — Per-page bucket assignment (10 already correct, 7 need fix);
       full per-page table with justification in convoy file
  C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page
       sweep depends on Brief 1). C1 buries the conceptual change under
       mechanical edits; C3 is over-orchestrated for this scope
  D2 — vitest lock-in; first jsdom test in repo; same negative-regression
       style as test/lib/permission-middleware.test.js (synthetic-admin
       shape). devDeps jsdom + @testing-library/react

Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky
pages — mitigated by audit-pass mandate + manual smoke).

MobileNavigation deliberately NOT folded in: its user prop is dead code
(never reads user.*); different bug class; cleanup queued separately to
avoid scope expansion.

Flagged-but-deferred:
  - 4 pages still import useAuth from lib/auth-context.js
    → single-auth-provider (queued P1 #9)
  - Layout headers still render "Deck Hearth" / "DH" branding
    → pick-a-name (queued P1 #12)
  - MobileNavigation dead user prop → cleanup-mobile-nav-dead-props
    or fold into god-component-split

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user)

Closes the source-side half of P0 #7 from .convoys/ship-readiness.md.
The page-side sweep (Brief 2) follows in a separate commit.

components/Layout.js:
  - Default user prop is now null (was hardcoded to
    { email: 'me@randallstillwell.com', role: 'user' })
  - UserProfileDropdown renders a "Sign in" link to /login when
    user === null instead of the maintainer's email + auth-only menu
    items (Decision A1)
  - All user.* accesses guarded with optional chaining or null checks
  - useState hook stays above the new null-user early return to satisfy
    rules-of-hooks (boot-the-brief caught this on the first try;
    see AGENTS.md Gotcha #11.5)

test/components/Layout.test.js (new):
  - First jsdom test in the repo (Decision D2)
  - 5 regression-lock assertions: no maintainer email ever rendered
    (prop omitted, prop=null), Sign-in link exists with href=/login,
    supplied email renders when prop is set, no "Guest" placeholder
    (locks A1 copy choice)
  - Mocks next/link, next/router (prefetch, replace, events, query),
    and theme-context.useTheme for jsdom safety under Next 16

package.json + package-lock.json:
  - Add jsdom@^29 and @testing-library/react@^16 to devDependencies
  - @testing-library/dom@^10 added explicitly (peer auto-install
    skipped it under npm 11; brief anticipated this fallback)

vitest.config.js (deviation from brief — see PR description):
  - Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can
    parse JSX in .js files. Required to import any React component
    written in the repo's Next.js pages-router .js convention
    (AGENTS.md Gotcha #9). The brief said "no change" to this file,
    but JSX-in-.js parsing is a hard prerequisite for the new test
    to import components/Layout.js — the alternatives (rename test
    to .test.jsx; rewrite test in React.createElement) either break
    the test glob or still hit the same Layout.js parse failure.
    Other tests are unaffected (they import non-JSX modules).

Smoke output: see PR description.

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user)

Closes the page-side half of P0 #7 from .convoys/ship-readiness.md.
Brief 1 (commit ddf8fd2) handled the Layout-side fix.

Per the architect's per-page bucket table (Decision B in
.convoys/fix-layout-default-user.md), 7 pages needed code changes;
the other 10 of 17 Layout-importing pages already pass `user` correctly.

Pass user={user} to Layout (4 pages, 11 call sites):
  - pages/scanner.js (1 call)
  - pages/decks.js (3 calls)
  - pages/deck-builder.js (4 calls)
  - pages/deck/[id].js (3 calls)
  (All four still import useAuth from lib/auth-context.js — that's
   intentional and stays as-is until the single-auth-provider convoy
   collapses the three parallel auth surfaces.)

Replace leaky page-level seed values with useState(null) + null guards
(2 pages, R2 mitigation):
  - pages/profile.js: useState({email: 'me@...', role: 'user', ...})
                     → useState(null) + ?. on every sync user.* read
                     + early-return guards in getDisplayName/getInitials
                     + conditional render around the "Member since" block
                       so formatDate(undefined) never runs
  - pages/settings.js: same pattern (single user.email reader guarded)

Replace hardcoded const with useAuth from lib/use-auth.js (1 page):
  - pages/card/[id].js: const user = {email: 'me@...'}
                       → const { user } = useAuth() (called unconditionally
                       at the top of the component; rules-of-hooks safe)

Verification:
  - grep 'me@randallstillwell.com' pages/ → 0 hits
  - 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1)
  - npm run lint matches baseline (128 problems pre, 128 post; verified
    via git stash before/after)
  - Manual static read-through of every diff; ReadLints clean on the 7
    files
  - Dev-server smoke: /cards anonymous returned HTTP 200 with 0
    'me@randallstillwell' matches before the user's shared dev server
    became unresponsive mid-session (same dev-server-shared-by-user
    constraint flagged in Brief 1); interactive logged-in smoke is
    parent/operator gated

Flagged-but-deferred (untouched per scope):
  - 4 pages still import useAuth from lib/auth-context.js
    → single-auth-provider (queued P1 #9)
  - components/MobileNavigation.js still receives dead user prop
    → cleanup-mobile-nav-dead-props (or fold into god-component-split)

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:31:37 -05:00

341 lines
No EOL
13 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../../components/Layout';
import { ManaCost, ColorIdentity } from '../../components/ManaSymbols';
import { useAuth } from '../../lib/auth-context';
import { getColorIdentity } from '../../lib/mana-symbols';
export default function DeckDetail() {
const { user } = useAuth();
const router = useRouter();
const { id: deckId } = router.query;
const [deck, setDeck] = useState(null);
const [loading, setLoading] = useState(true);
const [groupBy, setGroupBy] = useState('type');
useEffect(() => {
if (deckId) {
fetchDeck();
}
}, [deckId]);
const fetchDeck = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch(`/api/decks/${deckId}`, { headers });
if (response.ok) {
const data = await response.json();
setDeck(data);
} else {
console.error('Failed to fetch deck');
router.push('/decks');
}
} catch (error) {
console.error('Error fetching deck:', error);
router.push('/decks');
} finally {
setLoading(false);
}
};
const getDeckStats = () => {
if (!deck?.cards) return { totalCards: 0, avgCmc: 0, colorCounts: {}, typeCounts: {} };
const totalCards = deck.cards.reduce((sum, card) => sum + card.quantity, 0);
const avgCmc = deck.cards.length > 0
? (deck.cards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / totalCards).toFixed(1)
: 0;
const colorCounts = deck.cards.reduce((counts, card) => {
if (card.colors) {
try {
const colors = JSON.parse(card.colors);
colors.forEach(color => {
counts[color] = (counts[color] || 0) + card.quantity;
});
} catch (e) {
// Handle non-JSON color format
}
}
return counts;
}, {});
const typeCounts = deck.cards.reduce((counts, card) => {
if (card.card_type) {
const types = card.card_type.split(' — ')[0].split(' ');
types.forEach(type => {
counts[type] = (counts[type] || 0) + card.quantity;
});
}
return counts;
}, {});
return { totalCards, avgCmc, colorCounts, typeCounts };
};
const getGroupedCards = () => {
if (!deck?.cards) return {};
return deck.cards.reduce((groups, card) => {
let key;
switch (groupBy) {
case 'type':
key = card.card_type ? card.card_type.split(' — ')[0] : 'Unknown';
break;
case 'cmc':
key = `${card.cmc || 0} Mana`;
break;
case 'color':
try {
const colors = card.colors ? JSON.parse(card.colors) : [];
key = colors.length === 0 ? 'Colorless' : colors.map(c => c).join('');
} catch (e) {
key = 'Colorless';
}
break;
case 'rarity':
key = card.rarity || 'Unknown';
break;
default:
key = 'All Cards';
}
if (!groups[key]) groups[key] = [];
groups[key].push(card);
return groups;
}, {});
};
const getFormatIcon = (format) => {
switch (format) {
case 'Commander':
return '⚔️';
case 'Standard':
return '🏆';
case 'Modern':
return '🔥';
case 'Legacy':
return '💎';
default:
return '🃏';
}
};
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember"></div>
</div>
</Layout>
);
}
if (!deck) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Deck not found</h1>
<Link href="/decks" className="text-accent-ember hover:underline">
Back to Decks
</Link>
</div>
</div>
</Layout>
);
}
const stats = getDeckStats();
const groupedCards = getGroupedCards();
const isOwner = user && deck.user_id === user.userId;
return (
<Layout user={user}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex justify-between items-start mb-8">
<div>
<div className="flex items-center space-x-3 mb-2">
<Link href="/decks" className="text-accent-ember hover:underline">
Back to Decks
</Link>
</div>
<div className="flex items-center space-x-3 mb-2">
<span className="text-3xl">{getFormatIcon(deck.format)}</span>
<h1 className="text-3xl font-bold text-text-primary">{deck.name}</h1>
{deck.is_public && (
<span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-medium">
Public
</span>
)}
</div>
<p className="text-text-secondary mb-2">
by {deck.creator_username} {deck.format} {stats.totalCards} cards
</p>
{deck.description && (
<p className="text-text-secondary max-w-2xl">{deck.description}</p>
)}
</div>
{isOwner && (
<div className="flex space-x-3">
<Link
href={`/deck-builder?deck=${deck.id}`}
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Edit Deck
</Link>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Stats Sidebar */}
<div className="lg:col-span-1">
<div className="bg-bg-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">Statistics</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-text-secondary">Total Cards:</span>
<span className="text-text-primary font-medium">{stats.totalCards}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">Avg. CMC:</span>
<span className="text-text-primary font-medium">{stats.avgCmc}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">Format:</span>
<span className="text-text-primary font-medium">{deck.format}</span>
</div>
</div>
{/* Color Distribution */}
{Object.keys(stats.colorCounts).length > 0 && (
<div className="mt-6">
<h4 className="text-text-secondary text-sm font-medium mb-3">Color Distribution</h4>
<div className="space-y-2">
{Object.entries(stats.colorCounts)
.sort(([,a], [,b]) => b - a)
.map(([color, count]) => (
<div key={color} className="flex justify-between items-center">
<div className="flex items-center space-x-2">
<span className="text-lg">{color}</span>
<span className="text-text-secondary text-sm">{color}</span>
</div>
<span className="text-text-primary font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
{/* Type Distribution */}
{Object.keys(stats.typeCounts).length > 0 && (
<div className="mt-6">
<h4 className="text-text-secondary text-sm font-medium mb-3">Card Types</h4>
<div className="space-y-2">
{Object.entries(stats.typeCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 8)
.map(([type, count]) => (
<div key={type} className="flex justify-between">
<span className="text-text-secondary text-sm">{type}</span>
<span className="text-text-primary font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Group By Controls */}
<div className="bg-bg-secondary rounded-lg p-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">Group Cards By</h3>
<div className="space-y-2">
{[
{ value: 'type', label: 'Card Type' },
{ value: 'cmc', label: 'Mana Cost' },
{ value: 'color', label: 'Color' },
{ value: 'rarity', label: 'Rarity' }
].map(option => (
<button
key={option.value}
onClick={() => setGroupBy(option.value)}
className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
groupBy === option.value
? 'bg-accent-ember text-white'
: 'text-text-secondary hover:bg-bg-tertiary'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
{/* Card List */}
<div className="lg:col-span-3">
<div className="bg-bg-secondary rounded-lg p-6">
{deck.cards && deck.cards.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🃏</div>
<h3 className="text-xl font-semibold text-text-primary mb-2">Empty Deck</h3>
<p className="text-text-secondary">This deck doesn't have any cards yet</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedCards)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, cards]) => (
<div key={group}>
<h3 className="text-lg font-semibold text-text-primary mb-3 border-b border-border pb-2">
{group} ({cards.reduce((sum, card) => sum + card.quantity, 0)})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{cards
.sort((a, b) => a.name.localeCompare(b.name))
.map((card) => (
<div key={`${card.card_id}-${card.id}`} className="flex items-center space-x-3 p-3 bg-bg-primary rounded-lg hover:bg-bg-tertiary transition-colors">
{card.image_url && (
<img
src={card.image_url}
alt={card.name}
className="w-12 h-16 object-cover rounded"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h4 className="font-medium text-text-primary truncate">{card.name}</h4>
<span className="text-text-primary font-medium ml-2">{card.quantity}x</span>
</div>
<p className="text-text-secondary text-sm">{card.set_name}</p>
<div className="flex items-center space-x-2 text-xs text-text-secondary">
{card.mana_cost && (
<ManaCost cost={card.mana_cost} size="xs" />
)}
{card.rarity && <span className="capitalize">{card.rarity}</span>}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
);
}