Extract useCardDetail hook and CardDetailView (Brief 3).
Completes card detail god-component split: thin page composer with loading/not-found branches. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8e2c0e47a8
commit
4a1adb6bcc
3 changed files with 905 additions and 779 deletions
494
components/CardDetailView.js
Normal file
494
components/CardDetailView.js
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
|
||||||
|
import CollectionSelectionModal from './CollectionSelectionModal';
|
||||||
|
import CardDetailQuantityModal from './CardDetailQuantityModal';
|
||||||
|
import CardDetailDeckModal from './CardDetailDeckModal';
|
||||||
|
import { ManaCost, ColorIdentity, AdvancedManaCost } from './ManaSymbols';
|
||||||
|
import ManaSymbolSettings from './ManaSymbolSettings';
|
||||||
|
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
|
||||||
|
export default function CardDetailView(props) {
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
adminLoading,
|
||||||
|
card,
|
||||||
|
cardCollections,
|
||||||
|
cardDecks,
|
||||||
|
collections,
|
||||||
|
decks,
|
||||||
|
formatCurrency,
|
||||||
|
getParticleColor,
|
||||||
|
getParticleCount,
|
||||||
|
getRarityColor,
|
||||||
|
getRarityGradient,
|
||||||
|
getRarityLabel,
|
||||||
|
getTCGIcon,
|
||||||
|
handleAddToCollection,
|
||||||
|
handleAddToDeck,
|
||||||
|
handleOwnershipUpdate,
|
||||||
|
handleToggleFavorite,
|
||||||
|
id,
|
||||||
|
isAdmin,
|
||||||
|
isFavorited,
|
||||||
|
loading,
|
||||||
|
manaSymbolSettings,
|
||||||
|
ownedQuantity,
|
||||||
|
particleStyles,
|
||||||
|
quantity,
|
||||||
|
router,
|
||||||
|
selectedCollection,
|
||||||
|
selectedDeck,
|
||||||
|
setActiveTab,
|
||||||
|
setCard,
|
||||||
|
setCardCollections,
|
||||||
|
setCardDecks,
|
||||||
|
setCollections,
|
||||||
|
setDecks,
|
||||||
|
setIsFavorited,
|
||||||
|
setLoading,
|
||||||
|
setManaSymbolSettings,
|
||||||
|
setOwnedQuantity,
|
||||||
|
setQuantity,
|
||||||
|
setSelectedCollection,
|
||||||
|
setSelectedDeck,
|
||||||
|
setShowCollectionModal,
|
||||||
|
setShowDeckModal,
|
||||||
|
setShowQuantityModal,
|
||||||
|
showCollectionModal,
|
||||||
|
showDeckModal,
|
||||||
|
showInitialLoading,
|
||||||
|
showQuantityModal
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
{particleStyles.map((style, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute animate-float"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
In {VOCAB.MY_COLLECTION} ({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' : VOCAB.ADD_TO_MY_COLLECTION}
|
||||||
|
</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>
|
||||||
|
{VOCAB.ADD_TO_LIST}
|
||||||
|
</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>{collectionDisplayName(collection)}</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>
|
||||||
|
|
||||||
|
<CardDetailQuantityModal
|
||||||
|
isOpen={showQuantityModal}
|
||||||
|
ownedQuantity={ownedQuantity}
|
||||||
|
quantity={quantity}
|
||||||
|
onQuantityChange={setQuantity}
|
||||||
|
onConfirm={handleOwnershipUpdate}
|
||||||
|
onClose={() => setShowQuantityModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardDetailDeckModal
|
||||||
|
isOpen={showDeckModal}
|
||||||
|
card={card}
|
||||||
|
decks={decks}
|
||||||
|
selectedDeck={selectedDeck}
|
||||||
|
onSelectedDeckChange={setSelectedDeck}
|
||||||
|
onAdd={handleAddToDeck}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDeckModal(false);
|
||||||
|
setSelectedDeck('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
400
lib/use-card-detail.js
Normal file
400
lib/use-card-detail.js
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { VOCAB, collectionDisplayName } from './collection-vocabulary.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card/collection page state and handlers (god-component split).
|
||||||
|
*/
|
||||||
|
export function useCardDetail({ user, authLoading }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
|
const adminLoading = authLoading;
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// 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 particleStyles = useMemo(() => {
|
||||||
|
if (!card) return [];
|
||||||
|
const count = getParticleCount(card.rarity);
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
left: `${((i * 37) % 100)}%`,
|
||||||
|
top: `${((i * 53) % 100)}%`,
|
||||||
|
animationDelay: `${i % 3}s`,
|
||||||
|
animationDuration: `${3 + (i % 2)}s`,
|
||||||
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable decorative layout per card id/rarity
|
||||||
|
}, [card?.id, card?.rarity]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showInitialLoading = authLoading || loading;
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
card,
|
||||||
|
cardCollections,
|
||||||
|
cardDecks,
|
||||||
|
collections,
|
||||||
|
decks,
|
||||||
|
formatCurrency,
|
||||||
|
getParticleColor,
|
||||||
|
getParticleCount,
|
||||||
|
getRarityColor,
|
||||||
|
getRarityGradient,
|
||||||
|
getRarityLabel,
|
||||||
|
getTCGIcon,
|
||||||
|
handleAddToCollection,
|
||||||
|
handleAddToDeck,
|
||||||
|
handleOwnershipUpdate,
|
||||||
|
handleToggleFavorite,
|
||||||
|
id,
|
||||||
|
isAdmin,
|
||||||
|
adminLoading,
|
||||||
|
isFavorited,
|
||||||
|
loading,
|
||||||
|
manaSymbolSettings,
|
||||||
|
ownedQuantity,
|
||||||
|
particleStyles,
|
||||||
|
getParticleCount,
|
||||||
|
quantity,
|
||||||
|
router,
|
||||||
|
selectedCollection,
|
||||||
|
selectedDeck,
|
||||||
|
setActiveTab,
|
||||||
|
setCard,
|
||||||
|
setCardCollections,
|
||||||
|
setCardDecks,
|
||||||
|
setCollections,
|
||||||
|
setDecks,
|
||||||
|
setIsFavorited,
|
||||||
|
setLoading,
|
||||||
|
setManaSymbolSettings,
|
||||||
|
setOwnedQuantity,
|
||||||
|
setQuantity,
|
||||||
|
setSelectedCollection,
|
||||||
|
setSelectedDeck,
|
||||||
|
setShowCollectionModal,
|
||||||
|
setShowDeckModal,
|
||||||
|
setShowQuantityModal,
|
||||||
|
showCollectionModal,
|
||||||
|
showDeckModal,
|
||||||
|
showInitialLoading,
|
||||||
|
showQuantityModal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,378 +1,38 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
|
import CardDetailView from '../../components/CardDetailView';
|
||||||
import { useAuth } from '../../lib/use-auth';
|
import { useAuth } from '../../lib/use-auth';
|
||||||
import CollectionSelectionModal from '../../components/CollectionSelectionModal';
|
import { useCardDetail } from '../../lib/use-card-detail.js';
|
||||||
import CardDetailQuantityModal from '../../components/CardDetailQuantityModal';
|
|
||||||
import CardDetailDeckModal from '../../components/CardDetailDeckModal';
|
|
||||||
import { ManaCost, ColorIdentity, AdvancedManaCost } from '../../components/ManaSymbols';
|
|
||||||
import ManaSymbolSettings from '../../components/ManaSymbolSettings';
|
|
||||||
import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
|
|
||||||
|
|
||||||
export default function CardDetail() {
|
export default function CardDetail() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id } = router.query;
|
const { user } = useAuth();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const detail = useCardDetail();
|
||||||
|
|
||||||
const [card, setCard] = useState(null);
|
if (detail.loading) {
|
||||||
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 });
|
|
||||||
|
|
||||||
const isAdmin = user?.role === 'admin';
|
|
||||||
const adminLoading = authLoading;
|
|
||||||
|
|
||||||
// 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 particleStyles = useMemo(() => {
|
|
||||||
if (!card) return [];
|
|
||||||
const count = getParticleCount(card.rarity);
|
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
|
||||||
left: `${((i * 37) % 100)}%`,
|
|
||||||
top: `${((i * 53) % 100)}%`,
|
|
||||||
animationDelay: `${i % 3}s`,
|
|
||||||
animationDuration: `${3 + (i % 2)}s`,
|
|
||||||
}));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable decorative layout per card id/rarity
|
|
||||||
}, [card?.id, card?.rarity]);
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<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 className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--text-accent-light)' }} />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!card) {
|
if (!detail.card) {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-6xl mb-4">🃏</div>
|
<div className="text-6xl mb-4">🃏</div>
|
||||||
<h2 className="text-2xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
Card Not Found
|
Card Not Found
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
The card you're looking for doesn't exist.
|
The card you're looking for doesn't exist.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => router.push('/cards')}
|
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"
|
className="px-6 py-3 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
|
||||||
>
|
>
|
||||||
|
|
@ -386,435 +46,7 @@ export default function CardDetail() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div style={{ backgroundColor: 'var(--bg-primary)', minHeight: '100vh' }}>
|
<CardDetailView {...detail} />
|
||||||
{/* 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">
|
|
||||||
{particleStyles.map((style, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="absolute animate-float"
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
In {VOCAB.MY_COLLECTION} ({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' : VOCAB.ADD_TO_MY_COLLECTION}
|
|
||||||
</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>
|
|
||||||
{VOCAB.ADD_TO_LIST}
|
|
||||||
</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>{collectionDisplayName(collection)}</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>
|
|
||||||
|
|
||||||
<CardDetailQuantityModal
|
|
||||||
isOpen={showQuantityModal}
|
|
||||||
ownedQuantity={ownedQuantity}
|
|
||||||
quantity={quantity}
|
|
||||||
onQuantityChange={setQuantity}
|
|
||||||
onConfirm={handleOwnershipUpdate}
|
|
||||||
onClose={() => setShowQuantityModal(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 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();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CardDetailDeckModal
|
|
||||||
isOpen={showDeckModal}
|
|
||||||
card={card}
|
|
||||||
decks={decks}
|
|
||||||
selectedDeck={selectedDeck}
|
|
||||||
onSelectedDeckChange={setSelectedDeck}
|
|
||||||
onAdd={handleAddToDeck}
|
|
||||||
onClose={() => {
|
|
||||||
setShowDeckModal(false);
|
|
||||||
setSelectedDeck('');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue