deckhearth/pages/card/[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

910 lines
No EOL
36 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout';
import { useIsAdmin } from '../../lib/admin-auth';
import { useAuth } from '../../lib/use-auth';
import CollectionSelectionModal from '../../components/CollectionSelectionModal';
import { ManaCost, ColorIdentity, AdvancedManaCost } from '../../components/ManaSymbols';
import ManaSymbolSettings from '../../components/ManaSymbolSettings';
export default function CardDetail() {
const router = useRouter();
const { id } = router.query;
const { user } = useAuth();
const [card, setCard] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('details');
const [ownedQuantity, setOwnedQuantity] = useState(0);
const [showQuantityModal, setShowQuantityModal] = useState(false);
const [showCollectionModal, setShowCollectionModal] = useState(false);
const [showDeckModal, setShowDeckModal] = useState(false);
const [selectedCollection, setSelectedCollection] = useState('');
const [selectedDeck, setSelectedDeck] = useState('');
const [quantity, setQuantity] = useState(1);
const [isFavorited, setIsFavorited] = useState(false);
const [collections, setCollections] = useState([]);
const [decks, setDecks] = useState([]);
const [cardCollections, setCardCollections] = useState([]);
const [cardDecks, setCardDecks] = useState([]);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
// Check admin status
const { isAdmin, loading: adminLoading } = useIsAdmin();
// Fetch card data from API
useEffect(() => {
const fetchCard = async () => {
if (!id) return;
try {
const response = await fetch(`/api/cards/${id}`);
if (response.ok) {
const cardData = await response.json();
setCard(cardData);
// Fetch user's ownership of this card
const token = localStorage.getItem('auth_token');
if (token) {
try {
const ownershipResponse = await fetch(`/api/cards/${id}/ownership`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (ownershipResponse.ok) {
const ownershipData = await ownershipResponse.json();
setOwnedQuantity(ownershipData.quantity || 0);
}
} catch (error) {
console.error('Error fetching ownership:', error);
}
// Check if card is favorited
try {
const favoritesResponse = await fetch(`/api/favorites?type=card`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (favoritesResponse.ok) {
const favoritesData = await favoritesResponse.json();
const isCardFavorited = favoritesData.favorites.some(fav => fav.item_id == id);
setIsFavorited(isCardFavorited);
}
} catch (error) {
console.error('Error checking favorites:', error);
}
}
} else {
console.error('Failed to fetch card');
}
} catch (error) {
console.error('Error fetching card:', error);
} finally {
setLoading(false);
}
};
fetchCard();
}, [id]);
// Fetch user's collections and decks
useEffect(() => {
const fetchUserData = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = {
'Authorization': `Bearer ${token}`
};
// Fetch collections
const collectionsResponse = await fetch('/api/collections', { headers });
if (collectionsResponse.ok) {
const collectionsData = await collectionsResponse.json();
setCollections(collectionsData);
}
// Fetch decks
const decksResponse = await fetch('/api/decks', { headers });
if (decksResponse.ok) {
const decksData = await decksResponse.json();
setDecks(decksData);
}
// Fetch card's current collections and decks
if (card) {
const cardCollectionsResponse = await fetch(`/api/cards/${id}/collections`, { headers });
if (cardCollectionsResponse.ok) {
const cardCollectionsData = await cardCollectionsResponse.json();
setCardCollections(cardCollectionsData);
}
const cardDecksResponse = await fetch(`/api/cards/${id}/decks`, { headers });
if (cardDecksResponse.ok) {
const cardDecksData = await cardDecksResponse.json();
setCardDecks(cardDecksData);
}
}
} catch (error) {
console.error('Error fetching user data:', error);
}
};
if (card) {
fetchUserData();
}
}, [card, id]);
const getRarityGradient = (rarity) => {
const rarityKey = rarity?.toLowerCase();
const gradients = {
'common': '#9ca3af, #6b7280, #4b5563', // Subtle gray glow
'uncommon': '#10b981, #059669, #047857', // Green glow
'rare': '#f59e0b, #d97706, #b45309', // Gold glow
'mythic': '#fbbf24, #f59e0b, #d97706', // Rich gold glow
'holographic': '#ec4899, #db2777, #be185d', // Pink glow
'enchanted': '#a855f7, #9333ea, #7c3aed', // Purple glow
'super rare': '#3b82f6, #2563eb, #1d4ed8', // Blue glow
'legendary': '#fbbf24, #f59e0b, #ea580c' // Vibrant gold-orange glow
};
return gradients[rarityKey] || '#6b7280, #4b5563, #374151';
};
const getParticleCount = (rarity) => {
const rarityKey = rarity?.toLowerCase();
const particleCounts = {
'common': 0,
'uncommon': 15,
'rare': 25,
'mythic': 40,
'holographic': 50,
'enchanted': 60,
'super rare': 45,
'legendary': 80
};
return particleCounts[rarityKey] || 0;
};
const getParticleColor = (rarity) => {
const rarityKey = rarity?.toLowerCase();
const colors = {
'common': '#ffffff',
'uncommon': '#10b981',
'rare': '#f59e0b',
'mythic': '#ffd700',
'holographic': '#ff6b6b',
'enchanted': '#a855f7',
'super rare': '#3b82f6',
'legendary': '#ffd700'
};
return colors[rarityKey] || '#ffffff';
};
const getTCGIcon = (game) => {
const icons = {
'MTG': '🔮',
'Pokemon': '⚡',
'Lorcana': '✨'
};
return icons[game] || '🃏';
};
const formatCurrency = (amount) => {
if (!amount) return '$0.00';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const getRarityLabel = (rarity) => {
const rarityMap = {
'common': 'Common',
'uncommon': 'Uncommon',
'rare': 'Rare',
'mythic': 'Mythic',
'holographic': 'Holographic',
'enchanted': 'Enchanted',
'super rare': 'Super Rare',
'legendary': 'Legendary'
};
return rarityMap[rarity?.toLowerCase()] || rarity;
};
const getRarityColor = (rarity) => {
const colors = {
'common': '#6B7280',
'uncommon': '#10B981',
'rare': '#F59E0B',
'mythic': '#FFD700',
'holographic': '#FF6B6B',
'enchanted': '#A855F7',
'super rare': '#3B82F6',
'legendary': '#FFD700'
};
return colors[rarity?.toLowerCase()] || '#6B7280';
};
const handleOwnershipUpdate = async (newQuantity) => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/cards/${id}/ownership`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ quantity: newQuantity })
});
if (response.ok) {
setOwnedQuantity(newQuantity);
setShowQuantityModal(false);
} else {
console.error('Failed to update ownership');
}
} catch (error) {
console.error('Error updating ownership:', error);
}
};
const handleAddToCollection = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
const response = await fetch(`/api/cards/${id}/collections`, {
method: 'POST',
headers,
body: JSON.stringify({ collectionId: selectedCollection })
});
if (response.ok) {
// Refresh card collections
const cardCollectionsResponse = await fetch(`/api/cards/${id}/collections`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (cardCollectionsResponse.ok) {
const cardCollectionsData = await cardCollectionsResponse.json();
setCardCollections(cardCollectionsData);
}
setShowCollectionModal(false);
setSelectedCollection('');
} else {
console.error('Failed to add to collection');
}
} catch (error) {
console.error('Error adding to collection:', error);
}
};
const handleAddToDeck = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
const response = await fetch(`/api/cards/${id}/decks`, {
method: 'POST',
headers,
body: JSON.stringify({ deckId: selectedDeck })
});
if (response.ok) {
// Refresh card decks
const cardDecksResponse = await fetch(`/api/cards/${id}/decks`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (cardDecksResponse.ok) {
const cardDecksData = await cardDecksResponse.json();
setCardDecks(cardDecksData);
}
setShowDeckModal(false);
setSelectedDeck('');
} else {
console.error('Failed to add to deck');
}
} catch (error) {
console.error('Error adding to deck:', error);
}
};
const handleToggleFavorite = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/cards/${id}/favorite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ favorited: !isFavorited })
});
if (response.ok) {
setIsFavorited(!isFavorited);
} else {
console.error('Failed to toggle favorite');
}
} catch (error) {
console.error('Error toggling favorite:', error);
}
};
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" style={{ borderColor: 'var(--text-accent-light)' }}></div>
</div>
</Layout>
);
}
if (!card) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="text-6xl mb-4">🃏</div>
<h2 className="text-2xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Card Not Found
</h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
The card you're looking for doesn't exist.
</p>
<button
onClick={() => router.push('/cards')}
className="px-6 py-3 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
>
Back to Cards
</button>
</div>
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div style={{ backgroundColor: 'var(--bg-primary)', minHeight: '100vh' }}>
{/* Hero Section with Card Image and Basic Info */}
<div
className="relative min-h-96 flex items-center justify-center overflow-hidden"
style={{
background: 'linear-gradient(135deg, var(--bg-secondary), var(--bg-primary))'
}}
>
{/* Animated Particles Background */}
{getParticleCount(card.rarity) > 0 && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{Array.from({ length: getParticleCount(card.rarity) }).map((_, i) => (
<div
key={i}
className="absolute animate-float"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 3}s`,
animationDuration: `${3 + Math.random() * 2}s`
}}
>
<div
className="w-1 h-1 rounded-full opacity-60"
style={{
backgroundColor: getParticleColor(card.rarity),
boxShadow: `0 0 6px ${getParticleColor(card.rarity)}`
}}
/>
</div>
))}
</div>
)}
{/* Background Pattern */}
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='0.1'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
</div>
<div className="relative z-10 container mx-auto px-6 py-12">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
{/* Card Image */}
<div className="flex justify-center">
<div className="relative group">
{/* Rarity Glow Effect */}
<div
className="absolute inset-0 rounded-2xl blur-xl opacity-60 animate-pulse"
style={{
background: `linear-gradient(135deg, ${getRarityGradient(card.rarity)})`,
transform: 'scale(1.1)'
}}
/>
<div
className="relative w-80 h-112 rounded-2xl overflow-hidden shadow-2xl transform transition-all duration-300 hover:scale-105"
style={{
background: `linear-gradient(135deg, ${getRarityColor(card.rarity)}15, ${getRarityColor(card.rarity)}08)`,
boxShadow: `0 20px 40px rgba(0, 0, 0, 0.3), 0 0 30px ${getRarityColor(card.rarity)}40`
}}
>
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-white">
<div className="text-center p-8">
<div className="text-6xl mb-4">{getTCGIcon(card.game)}</div>
<h1 className="text-2xl font-bold mb-2">{card.name}</h1>
<p className="text-sm opacity-90">{card.set_name}</p>
<div className="mt-4">
<span className="px-3 py-1 rounded-full text-xs font-medium bg-white bg-opacity-20 backdrop-blur-sm">
{getRarityLabel(card.rarity)}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Card Info */}
<div style={{ color: 'var(--text-primary)' }}>
<div className="mb-6">
<h1 className="text-4xl font-bold mb-2">{card.name}</h1>
<p className="text-xl opacity-90 mb-4">{card.oracle_text || card.card_type}</p>
{/* Ownership Status and Actions */}
<div className="mb-4 p-4 rounded-xl bg-white bg-opacity-10 backdrop-blur-sm">
<div className="flex items-center justify-between mb-3">
<span className="font-semibold">Ownership Status</span>
<div className="flex items-center gap-2">
{ownedQuantity > 0 && (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-green-500 bg-opacity-80">
Owned ({ownedQuantity})
</span>
)}
<button
onClick={handleToggleFavorite}
className={`p-2 rounded-full transition-all duration-200 ${
isFavorited
? 'bg-red-500 bg-opacity-80 text-white'
: 'bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30'
}`}
>
{isFavorited ? '❤️' : '🤍'}
</button>
</div>
</div>
<div className="flex items-center gap-4 flex-wrap">
<button
onClick={() => setShowQuantityModal(true)}
className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2"
>
<span>📦</span>
{ownedQuantity > 0 ? 'Update Quantity' : 'Mark as Owned'}
</button>
<button
onClick={() => setShowCollectionModal(true)}
className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2"
>
<span>📁</span>
Add to Collection
</button>
<button
onClick={() => setShowDeckModal(true)}
className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2"
>
<span>🎴</span>
Add to Deck
</button>
</div>
</div>
<div className="flex items-center gap-4 mb-6">
<span className="text-3xl">{getTCGIcon(card.game)}</span>
<span className="text-lg">{card.game}</span>
<span className="px-3 py-1 rounded-full text-sm font-medium bg-white bg-opacity-20 backdrop-blur-sm">
{getRarityLabel(card.rarity)}
</span>
</div>
<div className="text-3xl font-bold gradient-text-gold">
{formatCurrency(card.current_price || 0)}
</div>
{/* Admin Edit Button */}
{isAdmin && !adminLoading && (
<div className="mt-4">
<button
onClick={() => router.push(`/admin/card-editor?id=${card.id}`)}
className="px-4 py-2 rounded-xl font-medium bg-orange-500 hover:bg-orange-600 text-white transition-all duration-200 flex items-center gap-2 shadow-lg hover:shadow-xl"
>
<span></span>
Edit Card (Admin)
</button>
</div>
)}
</div>
{/* Current Collections and Decks */}
{(cardCollections.length > 0 || cardDecks.length > 0) && (
<div className="mb-6 p-4 rounded-xl bg-white bg-opacity-10 backdrop-blur-sm">
<h4 className="font-semibold mb-3">Currently In:</h4>
<div className="space-y-2">
{cardCollections.map(collection => (
<div key={collection.id} className="flex items-center gap-2 text-sm">
<span>📁</span>
<span>{collection.name}</span>
</div>
))}
{cardDecks.map(deck => (
<div key={deck.id} className="flex items-center gap-2 text-sm">
<span>🎴</span>
<span>{deck.name}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Content Tabs */}
<div
className="container mx-auto px-6 py-8"
style={{ backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }}
>
<div className="flex border-b" style={{ borderColor: 'var(--border)' }}>
{['details', 'price-history', 'purchase'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-6 py-3 font-medium transition-all duration-200 ${
activeTab === tab
? 'border-b-2 font-semibold'
: 'hover:opacity-70'
}`}
style={{
color: activeTab === tab ? 'var(--text-accent)' : 'var(--text-secondary)',
borderColor: activeTab === tab ? 'var(--text-accent)' : 'transparent'
}}
>
{tab === 'details' && 'Card Details'}
{tab === 'price-history' && 'Price History'}
{tab === 'purchase' && 'Purchase Options'}
</button>
))}
</div>
{/* Tab Content */}
<div className="mt-8">
{activeTab === 'details' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Card Metadata */}
<div className="space-y-6">
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Card Information
</h3>
<div className="space-y-4">
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>TCG</span>
<span style={{ color: 'var(--text-primary)' }}>{card.game}</span>
</div>
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Set</span>
<span style={{ color: 'var(--text-primary)' }}>{card.set_name}</span>
</div>
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Card Number</span>
<span style={{ color: 'var(--text-primary)' }}>{card.card_number}</span>
</div>
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Type</span>
<span style={{ color: 'var(--text-primary)' }}>{card.card_type}</span>
</div>
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Rarity</span>
<span style={{ color: 'var(--text-primary)' }}>{getRarityLabel(card.rarity)}</span>
</div>
{card.mana_cost && (
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Cost to Play</span>
<ManaCost cost={card.mana_cost} size="md" useSVG={manaSymbolSettings.useSVG} />
</div>
)}
{card.power && (
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Power</span>
<span style={{ color: 'var(--text-primary)' }}>{card.power}</span>
</div>
)}
{card.toughness && (
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Toughness</span>
<span style={{ color: 'var(--text-primary)' }}>{card.toughness}</span>
</div>
)}
{card.current_price && (
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Current Price</span>
<span style={{ color: 'var(--text-primary)' }}>{formatCurrency(card.current_price)}</span>
</div>
)}
</div>
</div>
{/* Card Text */}
<div className="space-y-6">
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Card Text
</h3>
<div className="space-y-4">
{card.oracle_text && (
<div className="p-4 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<p style={{ color: 'var(--text-primary)' }}>{card.oracle_text}</p>
</div>
)}
</div>
</div>
</div>
)}
{activeTab === 'price-history' && (
<div className="space-y-6">
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Price History
</h3>
{/* Price Graph Placeholder */}
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h4 className="text-lg font-semibold mb-6" style={{ color: 'var(--text-primary)' }}>
Price Trend
</h4>
<div className="h-64 flex items-center justify-center">
<p style={{ color: 'var(--text-secondary)' }}>
Price history data will be available soon
</p>
</div>
</div>
{/* Price Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Current Price */}
<div className="p-6 rounded-xl border transition-all duration-200 hover:shadow-lg hover:scale-105"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)'
}}>
<div className="text-center">
<div className="text-2xl mb-2">💰</div>
<h5 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Current Price
</h5>
<div className="text-xl font-bold gradient-text-gold">
{formatCurrency(card.current_price || 0)}
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'purchase' && (
<div className="space-y-6">
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Where to Buy
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a
href={`https://www.tcgplayer.com/search/${card.game.toLowerCase()}/product?productName=${encodeURIComponent(card.name)}`}
target="_blank"
rel="noopener noreferrer"
className="p-6 rounded-xl border transition-all duration-200 hover:shadow-lg hover:scale-105"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)'
}}
>
<div className="flex items-center gap-3">
<span className="text-2xl">🃏</span>
<div>
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
TCGPlayer
</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
View on TCGPlayer
</p>
</div>
</div>
</a>
<a
href={`https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card.name + ' ' + card.game)}`}
target="_blank"
rel="noopener noreferrer"
className="p-6 rounded-xl border transition-all duration-200 hover:shadow-lg hover:scale-105"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)'
}}
>
<div className="flex items-center gap-3">
<span className="text-2xl">🛒</span>
<div>
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
eBay
</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
View on eBay
</p>
</div>
</div>
</a>
</div>
</div>
)}
</div>
</div>
{/* Quantity Modal */}
{showQuantityModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
{ownedQuantity > 0 ? 'Update Quantity' : 'Mark as Owned'}
</h3>
<div className="mb-6">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Quantity
</label>
<div className="flex items-center gap-4">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="w-10 h-10 rounded-lg flex items-center justify-center border transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
-
</button>
<span className="text-2xl font-bold px-4" style={{ color: 'var(--text-primary)' }}>
{quantity}
</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="w-10 h-10 rounded-lg flex items-center justify-center border transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
+
</button>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => handleOwnershipUpdate(quantity)}
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
>
{ownedQuantity > 0 ? 'Update' : 'Mark as Owned'}
</button>
<button
onClick={() => setShowQuantityModal(false)}
className="flex-1 px-4 py-2 rounded-xl font-medium border transition-all duration-200"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)'
}}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Collection Selection Modal */}
<CollectionSelectionModal
isOpen={showCollectionModal}
onClose={() => setShowCollectionModal(false)}
cards={card ? [card] : []}
onAddToCollections={(results, selectedCollectionIds, cards) => {
const successCount = results.filter(r => r.success).length;
if (successCount > 0) {
// Refresh card collections
const fetchCardCollections = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/cards/${id}/collections`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
setCardCollections(data);
}
} catch (error) {
console.error('Error refreshing card collections:', error);
}
};
fetchCardCollections();
}
}}
/>
{/* Deck Modal */}
{showDeckModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Add to Deck
</h3>
<div className="mb-6">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Select Deck
</label>
<select
value={selectedDeck}
onChange={(e) => setSelectedDeck(e.target.value)}
className="w-full p-3 rounded-xl border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
<option value="">Choose a deck...</option>
{decks
.filter(deck => deck.game === card.game)
.map(deck => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
</div>
<div className="flex gap-3">
<button
onClick={handleAddToDeck}
disabled={!selectedDeck}
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-all duration-200 ${
selectedDeck
? 'gradient-bg-purple text-white hover:shadow-lg'
: 'opacity-50 cursor-not-allowed'
}`}
>
Add to Deck
</button>
<button
onClick={() => {
setShowDeckModal(false);
setSelectedDeck('');
}}
className="flex-1 px-4 py-2 rounded-xl font-medium border transition-all duration-200"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)'
}}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
</Layout>
);
}