Completes card detail god-component split: thin page composer with loading/not-found branches. Co-authored-by: Cursor <cursoragent@cursor.com>
494 lines
21 KiB
JavaScript
494 lines
21 KiB
JavaScript
/* 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>
|
||
</>
|
||
);
|
||
}
|