2025-07-22 21:00:10 -04:00
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
2025-07-22 21:07:28 -04:00
|
|
|
|
2025-07-22 21:00:10 -04:00
|
|
|
import cardDataService from '../../services/cardDataSources';
|
|
|
|
|
import type { Card } from '../../types';
|
|
|
|
|
|
|
|
|
|
interface CardDatabaseBrowserProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onCardSelect?: (card: Card) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CardDatabaseBrowser: React.FC<CardDatabaseBrowserProps> = ({
|
|
|
|
|
isOpen,
|
|
|
|
|
onClose,
|
|
|
|
|
onCardSelect
|
|
|
|
|
}) => {
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
|
|
|
const [selectedGame, setSelectedGame] = useState<string>('');
|
|
|
|
|
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
|
|
|
|
|
const [showCardManager, setShowCardManager] = useState(false);
|
|
|
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
|
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
|
|
// Debounce search input
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
setDebouncedSearch(searchTerm);
|
|
|
|
|
}, 500);
|
|
|
|
|
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}, [searchTerm]);
|
|
|
|
|
|
|
|
|
|
// Search external APIs
|
|
|
|
|
const { data: externalCards = [], isLoading: isSearchingExternal } = useQuery({
|
|
|
|
|
queryKey: ['external-cards', debouncedSearch, selectedGame],
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
if (!debouncedSearch.trim()) return [];
|
|
|
|
|
setIsSearching(true);
|
|
|
|
|
try {
|
|
|
|
|
const cards = await cardDataService.searchCards(debouncedSearch, selectedGame || undefined);
|
|
|
|
|
return cards;
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSearching(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
enabled: !!debouncedSearch.trim() && isOpen,
|
|
|
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Get random cards for discovery
|
|
|
|
|
const { data: randomCards = [] } = useQuery({
|
|
|
|
|
queryKey: ['random-cards', selectedGame],
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
return await cardDataService.getRandomCards(12, selectedGame || undefined);
|
|
|
|
|
},
|
|
|
|
|
enabled: isOpen && !debouncedSearch.trim(),
|
|
|
|
|
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add card to database mutation
|
|
|
|
|
const addToDatabaseMutation = useMutation({
|
|
|
|
|
mutationFn: async (card: Card) => {
|
|
|
|
|
// First, try to add the card to our database
|
|
|
|
|
const response = await fetch('/api/cards/find-or-create', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Authorization': `Bearer ${localStorage.getItem('tcg-vault-token')}`,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
name: card.name,
|
|
|
|
|
game: card.game,
|
|
|
|
|
setName: card.set_name,
|
|
|
|
|
setCode: card.set_code,
|
|
|
|
|
rarity: card.rarity,
|
|
|
|
|
cardType: card.card_type,
|
|
|
|
|
manaCost: card.mana_cost,
|
|
|
|
|
imageUrl: card.stock_image_url,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('Failed to add card to database');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
return result.card;
|
|
|
|
|
},
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleCardClick = async (card: Card) => {
|
|
|
|
|
if (onCardSelect) {
|
|
|
|
|
onCardSelect(card);
|
|
|
|
|
onClose();
|
|
|
|
|
} else {
|
|
|
|
|
setSelectedCard(card);
|
|
|
|
|
setShowCardManager(true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAddToDatabase = async (card: Card) => {
|
|
|
|
|
try {
|
|
|
|
|
await addToDatabaseMutation.mutateAsync(card);
|
|
|
|
|
// Show success message
|
|
|
|
|
alert(`${card.name} has been added to the database!`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error adding card to database:', error);
|
|
|
|
|
alert('Failed to add card to database. Please try again.');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getGameBadgeColor = (game: string) => {
|
|
|
|
|
switch (game) {
|
|
|
|
|
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
|
|
|
|
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
|
|
|
|
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
|
|
|
|
|
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
|
|
|
|
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getRarityBadgeColor = (rarity: string) => {
|
|
|
|
|
switch (rarity?.toLowerCase()) {
|
|
|
|
|
case 'common': return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
|
|
|
|
|
case 'uncommon': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
|
|
|
|
case 'rare': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
|
|
|
|
case 'super rare': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
|
|
|
|
|
case 'legendary': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
|
|
|
|
case 'mythic': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
|
|
|
|
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!isOpen) return null;
|
|
|
|
|
|
|
|
|
|
const displayCards = debouncedSearch.trim() ? externalCards : randomCards;
|
|
|
|
|
const isLoading = isSearchingExternal || isSearching;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose}>
|
|
|
|
|
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl h-[90vh] overflow-hidden animate-slide-up">
|
|
|
|
|
<div className="p-4 pb-0">
|
|
|
|
|
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-4"></div>
|
|
|
|
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h2 className="text-xl font-bold text-surface-900 dark:text-white">
|
|
|
|
|
Card Database Browser
|
|
|
|
|
</h2>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
className="p-2 rounded-xl text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Search Bar */}
|
|
|
|
|
<div className="relative mb-4">
|
|
|
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
|
|
|
<svg className="h-5 w-5 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
placeholder="Search cards from external databases..."
|
|
|
|
|
className="w-full pl-10 pr-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Game Filter */}
|
|
|
|
|
<div className="flex space-x-2 mb-4 overflow-x-auto pb-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSelectedGame('')}
|
|
|
|
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
|
|
|
|
!selectedGame
|
|
|
|
|
? 'bg-primary-500 text-white'
|
|
|
|
|
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
All Games
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSelectedGame('MTG')}
|
|
|
|
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
|
|
|
|
selectedGame === 'MTG'
|
|
|
|
|
? 'bg-orange-500 text-white'
|
|
|
|
|
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Magic: The Gathering
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSelectedGame('POKEMON')}
|
|
|
|
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
|
|
|
|
selectedGame === 'POKEMON'
|
|
|
|
|
? 'bg-yellow-500 text-white'
|
|
|
|
|
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Pokémon
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSelectedGame('YUGIOH')}
|
|
|
|
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
|
|
|
|
selectedGame === 'YUGIOH'
|
|
|
|
|
? 'bg-blue-500 text-white'
|
|
|
|
|
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Yu-Gi-Oh!
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
<div className="flex-1 overflow-y-auto pb-20">
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-8">
|
|
|
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
|
|
|
|
<span className="ml-3 text-surface-600 dark:text-surface-400">
|
|
|
|
|
Searching external databases...
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : displayCards.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
<div className="text-6xl mb-4">🔍</div>
|
|
|
|
|
<h3 className="text-lg font-semibold text-surface-900 dark:text-white mb-2">
|
|
|
|
|
{debouncedSearch.trim() ? 'No cards found' : 'Discover Cards'}
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="text-surface-600 dark:text-surface-400">
|
|
|
|
|
{debouncedSearch.trim()
|
|
|
|
|
? 'Try adjusting your search terms or game filter'
|
|
|
|
|
: 'Search for cards to see results from external databases'
|
|
|
|
|
}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-1 gap-4">
|
|
|
|
|
{displayCards.map((card) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${card.game}-${card.id}`}
|
|
|
|
|
className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 transition-all duration-200 hover:shadow-md"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start space-x-4">
|
|
|
|
|
{/* Card Image */}
|
|
|
|
|
<div className="w-16 h-20 bg-surface-200 dark:bg-surface-700 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
|
|
|
{card.stock_image_url ? (
|
|
|
|
|
<img
|
|
|
|
|
src={card.stock_image_url}
|
|
|
|
|
alt={card.name}
|
|
|
|
|
className="w-full h-full object-cover rounded-lg"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<svg className="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Card Info */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<h3 className="font-semibold text-surface-900 dark:text-white truncate">
|
|
|
|
|
{card.name}
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="text-sm text-surface-600 dark:text-surface-400">
|
|
|
|
|
{card.set_name} • {card.card_number}
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{/* Badges */}
|
|
|
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
|
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(card.game)}`}>
|
|
|
|
|
{card.game}
|
|
|
|
|
</span>
|
|
|
|
|
{card.rarity && (
|
|
|
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getRarityBadgeColor(card.rarity)}`}>
|
|
|
|
|
{card.rarity}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{card.current_price && (
|
|
|
|
|
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded-full text-xs font-medium">
|
|
|
|
|
${card.current_price}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
<div className="flex flex-col space-y-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleCardClick(card)}
|
|
|
|
|
className="px-3 py-1 bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Add to Collection
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleAddToDatabase(card)}
|
|
|
|
|
disabled={addToDatabaseMutation.isPending}
|
|
|
|
|
className="px-3 py-1 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-300 text-xs font-medium rounded-lg transition-colors disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
{addToDatabaseMutation.isPending ? 'Adding...' : 'Add to DB'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Card Manager Modal */}
|
|
|
|
|
{selectedCard && showCardManager && (
|
|
|
|
|
<div className="fixed inset-0 z-60">
|
|
|
|
|
{/* This would render the CardManager component */}
|
|
|
|
|
<div className="fixed inset-0 bg-black/50" onClick={() => setShowCardManager(false)}>
|
|
|
|
|
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl p-6 max-h-[80vh] overflow-y-auto">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<h3 className="text-lg font-semibold text-surface-900 dark:text-white mb-4">
|
|
|
|
|
Add {selectedCard.name} to your collection
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="text-surface-600 dark:text-surface-400 mb-6">
|
|
|
|
|
This card will be added to your collection with the details you specify.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex space-x-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
handleCardClick(selectedCard);
|
|
|
|
|
setShowCardManager(false);
|
|
|
|
|
}}
|
|
|
|
|
className="flex-1 bg-primary-500 hover:bg-primary-600 text-white font-medium py-3 px-6 rounded-xl transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Continue
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowCardManager(false)}
|
|
|
|
|
className="flex-1 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-300 font-medium py-3 px-6 rounded-xl transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default CardDatabaseBrowser;
|