From d21394d5799d97946810a3917e5562d80cb53a57 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 22 Jul 2025 20:00:10 -0500 Subject: [PATCH] Add comprehensive card data sources integration with external APIs and database browser component --- CARD_DATA_SOURCES.md | 231 ++++++++++++ scripts/populate-card-database.js | 328 +++++++++++++++++ src/components/cards/CardDatabaseBrowser.tsx | 364 +++++++++++++++++++ src/pages/Cards.tsx | 44 ++- src/services/cardDataSources.ts | 364 +++++++++++++++++++ 5 files changed, 1321 insertions(+), 10 deletions(-) create mode 100644 CARD_DATA_SOURCES.md create mode 100644 scripts/populate-card-database.js create mode 100644 src/components/cards/CardDatabaseBrowser.tsx create mode 100644 src/services/cardDataSources.ts diff --git a/CARD_DATA_SOURCES.md b/CARD_DATA_SOURCES.md new file mode 100644 index 0000000..f8a117a --- /dev/null +++ b/CARD_DATA_SOURCES.md @@ -0,0 +1,231 @@ +# Card Data Sources Guide + +This guide explains how to build out your card database using external APIs and data sources. + +## 🎯 **Recommended Data Sources** + +### **1. Scryfall API (Magic: The Gathering)** +**Best for MTG cards** +- **URL**: https://api.scryfall.com +- **Free**: No API key required +- **Rate Limit**: 10 requests/second +- **Coverage**: All MTG cards with images, prices, rulings + +**Example Usage**: +```javascript +// Search for a card +const response = await fetch('https://api.scryfall.com/cards/named?fuzzy=lightning+bolt'); +const card = await response.json(); + +// Search multiple cards +const searchResponse = await fetch('https://api.scryfall.com/cards/search?q=game:paper+type:creature'); +const cards = await searchResponse.json(); +``` + +### **2. Pokémon TCG API** +**Best for Pokémon cards** +- **URL**: https://api.pokemontcg.io/v2 +- **Free**: No authentication required +- **Rate Limit**: Generous limits +- **Coverage**: All Pokémon cards with images + +**Example Usage**: +```javascript +// Search for a card +const response = await fetch('https://api.pokemontcg.io/v2/cards?q=name:pikachu'); +const cards = await response.json(); +``` + +### **3. Yu-Gi-Oh! API** +**Best for Yu-Gi-Oh! cards** +- **URL**: https://db.ygoprodeck.com/api/v7 +- **Free**: Open source API +- **Coverage**: All Yu-Gi-Oh! cards + +**Example Usage**: +```javascript +// Search for a card +const response = await fetch('https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=Blue-Eyes%20White%20Dragon'); +const cards = await response.json(); +``` + +### **4. Disney Lorcana** +**For Lorcana cards** +- **Status**: No official API available yet +- **Alternative**: Manual data entry or community data scraping +- **Future**: May have official API when game matures + +## 🚀 **Implementation Strategy** + +### **Phase 1: Core Integration** +1. **Set up rate limiting** for each API +2. **Create unified search interface** that queries all sources +3. **Implement caching** to avoid repeated requests +4. **Add error handling** for API failures + +### **Phase 2: Database Population** +1. **Fetch popular cards** from each game +2. **Store in local database** with proper metadata +3. **Update pricing** regularly +4. **Add image caching** for better performance + +### **Phase 3: Advanced Features** +1. **Real-time pricing** from multiple sources +2. **Set completion tracking** +3. **Market trend analysis** +4. **Deck building suggestions** + +## 📊 **Data Mapping** + +### **Universal Fields** +| Field | Description | Source | +|-------|-------------|--------| +| `name` | Card name | All APIs | +| `game` | Game identifier | All APIs | +| `set_name` | Set/expansion name | All APIs | +| `set_code` | Set abbreviation | All APIs | +| `card_number` | Collector number | All APIs | +| `rarity` | Rarity level | All APIs | +| `image_url` | Card image URL | All APIs | +| `current_price` | Market price | MTG, Pokémon | + +### **Game-Specific Fields** + +#### **Magic: The Gathering** +- `mana_cost`: Mana symbols `{W}{U}{B}{R}{G}{C}` +- `cmc`: Converted mana cost +- `card_type`: Full type line +- `colors`: Color identity +- `oracle_text`: Rules text +- `power`/`toughness`: Creature stats + +#### **Pokémon** +- `mana_cost`: Energy requirements +- `cmc`: Total energy cost +- `card_type`: Card type + stage +- `colors`: Energy types +- `power`: HP value +- `oracle_text`: Effect text + +#### **Yu-Gi-Oh!** +- `mana_cost`: Level/rank +- `cmc`: Level value +- `card_type`: Card type +- `colors`: Attribute +- `oracle_text`: Effect text +- `power`/`toughness`: ATK/DEF + +## 🛠️ **Usage Examples** + +### **Search Cards from External APIs** +```javascript +import cardDataService from '../services/cardDataSources'; + +// Search across all games +const cards = await cardDataService.searchCards('dragon'); + +// Search specific game +const mtgCards = await cardDataService.searchCards('lightning', 'MTG'); +``` + +### **Add Cards to Database** +```javascript +// Fetch card from external API +const card = await mtgService.getCardByName('Lightning Bolt'); + +// Add to local database +const result = await databaseAPI.addCard(card); +``` + +### **Populate Database with Popular Cards** +```bash +# Run the population script +node scripts/populate-card-database.js +``` + +## 📈 **Performance Considerations** + +### **Rate Limiting** +- **Scryfall**: 10 requests/second +- **Pokémon API**: 5 requests/second +- **Yu-Gi-Oh! API**: No strict limits + +### **Caching Strategy** +- **Search results**: Cache for 5 minutes +- **Card details**: Cache for 1 hour +- **Images**: Cache indefinitely +- **Pricing**: Update every 24 hours + +### **Error Handling** +- **API failures**: Retry with exponential backoff +- **Rate limits**: Implement proper queuing +- **Network issues**: Graceful degradation + +## 🔧 **Setup Instructions** + +### **1. Install Dependencies** +```bash +npm install node-fetch +``` + +### **2. Set Environment Variables** +```bash +# .env +REACT_APP_API_URL=http://localhost:8000 +TCG_VAULT_TOKEN=your_auth_token_here +``` + +### **3. Run Database Population** +```bash +# Populate with popular cards +node scripts/populate-card-database.js + +# Or use the web interface +# Navigate to Cards page → "Browse Database" button +``` + +### **4. Test the Integration** +```javascript +// Test external API search +const cards = await cardDataService.searchCards('pikachu', 'POKEMON'); +console.log('Found cards:', cards.length); + +// Test database addition +const card = await mtgService.getCardByName('Lightning Bolt'); +if (card) { + await databaseAPI.addCard(card); + console.log('Card added to database'); +} +``` + +## 🎯 **Next Steps** + +### **Immediate Actions** +1. **Test the APIs** with the provided services +2. **Populate your database** with popular cards +3. **Integrate the browser component** into your app +4. **Set up regular updates** for pricing data + +### **Future Enhancements** +1. **Add more games** (Lorcana, Flesh and Blood, etc.) +2. **Implement price tracking** with alerts +3. **Add set completion tracking** +4. **Create deck building suggestions** +5. **Add market analysis tools** + +## 📚 **Additional Resources** + +- **Scryfall API Docs**: https://scryfall.com/docs/api +- **Pokémon TCG API**: https://dev.pokemontcg.io/ +- **Yu-Gi-Oh! API**: https://ygoprodeck.com/api-guide/ +- **TCGPlayer API**: https://docs.tcgplayer.com/ + +## 🚨 **Important Notes** + +1. **Respect rate limits** - Implement proper throttling +2. **Cache responses** - Avoid unnecessary API calls +3. **Handle errors gracefully** - APIs can be unreliable +4. **Update pricing regularly** - Card values change frequently +5. **Verify card data** - Cross-reference with official sources + +This implementation provides a solid foundation for building a comprehensive card database that can grow with your application's needs! \ No newline at end of file diff --git a/scripts/populate-card-database.js b/scripts/populate-card-database.js new file mode 100644 index 0000000..4a4ccfa --- /dev/null +++ b/scripts/populate-card-database.js @@ -0,0 +1,328 @@ +#!/usr/bin/env node + +/** + * Card Database Population Script + * + * This script fetches cards from external APIs and adds them to your local database. + * Run this to populate your database with popular cards from various games. + */ + +const fetch = require('node-fetch'); + +// Configuration +const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000'; +const AUTH_TOKEN = process.env.TCG_VAULT_TOKEN; // Set this in your environment + +// Popular cards to fetch (examples) +const POPULAR_CARDS = { + MTG: [ + 'Lightning Bolt', + 'Black Lotus', + 'Counterspell', + 'Dark Ritual', + 'Brainstorm', + 'Force of Will', + 'Wasteland', + 'Tarmogoyf', + 'Snapcaster Mage', + 'Jace, the Mind Sculptor' + ], + POKEMON: [ + 'Pikachu', + 'Charizard', + 'Blastoise', + 'Venusaur', + 'Mewtwo', + 'Lugia', + 'Ho-Oh', + 'Rayquaza', + 'Garchomp', + 'Lucario' + ], + YUGIOH: [ + 'Blue-Eyes White Dragon', + 'Dark Magician', + 'Red-Eyes Black Dragon', + 'Exodia the Forbidden One', + 'Slifer the Sky Dragon', + 'Obelisk the Tormentor', + 'The Winged Dragon of Ra', + 'Elemental HERO Neos', + 'Stardust Dragon', + 'Number 39: Utopia' + ] +}; + +// Rate limiting utility +class RateLimiter { + constructor(maxRequests, timeWindow) { + this.maxRequests = maxRequests; + this.timeWindow = timeWindow; + this.requests = []; + } + + async waitForSlot() { + const now = Date.now(); + this.requests = this.requests.filter(time => now - time < this.timeWindow); + + if (this.requests.length >= this.maxRequests) { + const oldestRequest = this.requests[0]; + const waitTime = this.timeWindow - (now - oldestRequest); + await new Promise(resolve => setTimeout(resolve, waitTime)); + } + + this.requests.push(now); + } +} + +const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second +const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second + +// API Services +const mtgService = { + async getCardByName(name) { + await scryfallLimiter.waitForSlot(); + + try { + const response = await fetch( + `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(name)}` + ); + + if (!response.ok) { + console.log(`❌ MTG card not found: ${name}`); + return null; + } + + const card = await response.json(); + + return { + name: card.name, + game: 'MTG', + set_name: card.set_name, + set_code: card.set, + card_number: card.collector_number, + rarity: card.rarity, + mana_cost: card.mana_cost, + cmc: card.cmc, + card_type: card.type_line, + colors: card.colors, + oracle_text: card.oracle_text, + power: card.power, + toughness: card.toughness, + image_url: card.image_uris?.normal, + stock_image_url: card.image_uris?.normal, + current_price: card.prices?.usd ? parseFloat(card.prices.usd) : null, + market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null, + }; + } catch (error) { + console.error(`❌ Error fetching MTG card ${name}:`, error.message); + return null; + } + } +}; + +const pokemonService = { + async getCardByName(name) { + await pokemonLimiter.waitForSlot(); + + try { + const response = await fetch( + `https://api.pokemontcg.io/v2/cards?q=name:${encodeURIComponent(name)}&pageSize=1` + ); + + if (!response.ok) { + console.log(`❌ Pokémon card not found: ${name}`); + return null; + } + + const data = await response.json(); + + if (data.data.length === 0) { + console.log(`❌ Pokémon card not found: ${name}`); + return null; + } + + const card = data.data[0]; + + return { + name: card.name, + game: 'POKEMON', + set_name: card.set.name, + set_code: card.set.id, + card_number: card.number, + rarity: card.rarity, + mana_cost: card.convertedRetreatCost?.toString(), + cmc: card.convertedRetreatCost, + card_type: card.supertype, + colors: card.types || [], + oracle_text: card.attacks?.map(attack => `${attack.name}: ${attack.text}`).join('\n'), + power: card.hp, + toughness: null, + image_url: card.images.small, + stock_image_url: card.images.large, + current_price: card.cardmarket?.prices?.averageSellPrice || null, + market_price: card.cardmarket?.prices?.lowPrice || null, + }; + } catch (error) { + console.error(`❌ Error fetching Pokémon card ${name}:`, error.message); + return null; + } + } +}; + +const yugiohService = { + async getCardByName(name) { + try { + const response = await fetch( + `https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=${encodeURIComponent(name)}` + ); + + if (!response.ok) { + console.log(`❌ Yu-Gi-Oh! card not found: ${name}`); + return null; + } + + const data = await response.json(); + + if (data.data.length === 0) { + console.log(`❌ Yu-Gi-Oh! card not found: ${name}`); + return null; + } + + const card = data.data[0]; + + return { + name: card.name, + game: 'YUGIOH', + set_name: card.set_name, + set_code: card.set_code, + card_number: card.num, + rarity: card.rarity, + mana_cost: card.level?.toString(), + cmc: card.level, + card_type: card.type, + colors: [card.attribute], + oracle_text: card.desc, + power: card.atk?.toString(), + toughness: card.def?.toString(), + image_url: card.card_images?.[0]?.image_url, + stock_image_url: card.card_images?.[0]?.image_url_small, + current_price: card.card_prices?.[0]?.amazon_price ? parseFloat(card.card_prices[0].amazon_price) : null, + market_price: card.card_prices?.[0]?.cardmarket_price ? parseFloat(card.card_prices[0].cardmarket_price) : null, + }; + } catch (error) { + console.error(`❌ Error fetching Yu-Gi-Oh! card ${name}:`, error.message); + return null; + } + } +}; + +// Database API +const databaseAPI = { + async addCard(cardData) { + try { + const response = await fetch(`${API_BASE_URL}/api/cards/find-or-create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(AUTH_TOKEN && { 'Authorization': `Bearer ${AUTH_TOKEN}` }), + }, + body: JSON.stringify({ + name: cardData.name, + game: cardData.game, + setName: cardData.set_name, + setCode: cardData.set_code, + rarity: cardData.rarity, + cardType: cardData.card_type, + manaCost: cardData.mana_cost, + imageUrl: cardData.stock_image_url, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + return result; + } catch (error) { + console.error(`❌ Error adding card to database:`, error.message); + return null; + } + } +}; + +// Main execution +async function populateDatabase() { + console.log('🚀 Starting card database population...\n'); + + let totalProcessed = 0; + let totalAdded = 0; + let totalErrors = 0; + + for (const [game, cards] of Object.entries(POPULAR_CARDS)) { + console.log(`📦 Processing ${game} cards...`); + + for (const cardName of cards) { + totalProcessed++; + console.log(` 🔍 Fetching: ${cardName}`); + + let cardData = null; + + // Fetch card data based on game + switch (game) { + case 'MTG': + cardData = await mtgService.getCardByName(cardName); + break; + case 'POKEMON': + cardData = await pokemonService.getCardByName(cardName); + break; + case 'YUGIOH': + cardData = await yugiohService.getCardByName(cardName); + break; + } + + if (cardData) { + console.log(` ✅ Found: ${cardData.name} (${cardData.set_name})`); + + // Add to database + const result = await databaseAPI.addCard(cardData); + + if (result && result.success) { + console.log(` 💾 Added to database: ${cardData.name}`); + totalAdded++; + } else { + console.log(` ⚠️ Already exists or failed to add: ${cardData.name}`); + totalErrors++; + } + } else { + console.log(` ❌ Not found: ${cardName}`); + totalErrors++; + } + + // Small delay between requests + await new Promise(resolve => setTimeout(resolve, 100)); + } + + console.log(''); + } + + console.log('📊 Population Summary:'); + console.log(` Total processed: ${totalProcessed}`); + console.log(` Successfully added: ${totalAdded}`); + console.log(` Errors/not found: ${totalErrors}`); + console.log('\n✨ Database population complete!'); +} + +// Run the script +if (require.main === module) { + populateDatabase().catch(console.error); +} + +module.exports = { + populateDatabase, + mtgService, + pokemonService, + yugiohService, + databaseAPI +}; \ No newline at end of file diff --git a/src/components/cards/CardDatabaseBrowser.tsx b/src/components/cards/CardDatabaseBrowser.tsx new file mode 100644 index 0000000..3db582a --- /dev/null +++ b/src/components/cards/CardDatabaseBrowser.tsx @@ -0,0 +1,364 @@ +import React, { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { tcgApi } from '../../services/tcgApi'; +import cardDataService from '../../services/cardDataSources'; +import type { Card } from '../../types'; + +interface CardDatabaseBrowserProps { + isOpen: boolean; + onClose: () => void; + onCardSelect?: (card: Card) => void; +} + +const CardDatabaseBrowser: React.FC = ({ + isOpen, + onClose, + onCardSelect +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [selectedGame, setSelectedGame] = useState(''); + const [selectedCard, setSelectedCard] = useState(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 ( + <> +
+
+
+
+ + {/* Header */} +
+

+ Card Database Browser +

+ +
+ + {/* Search Bar */} +
+
+ + + +
+ 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" + /> +
+ + {/* Game Filter */} +
+ + + + +
+ + {/* Content */} +
+ {isLoading ? ( +
+
+ + Searching external databases... + +
+ ) : displayCards.length === 0 ? ( +
+
🔍
+

+ {debouncedSearch.trim() ? 'No cards found' : 'Discover Cards'} +

+

+ {debouncedSearch.trim() + ? 'Try adjusting your search terms or game filter' + : 'Search for cards to see results from external databases' + } +

+
+ ) : ( +
+ {displayCards.map((card) => ( +
+
+ {/* Card Image */} +
+ {card.stock_image_url ? ( + {card.name} + ) : ( + + + + )} +
+ + {/* Card Info */} +
+

+ {card.name} +

+

+ {card.set_name} • {card.card_number} +

+ + {/* Badges */} +
+ + {card.game} + + {card.rarity && ( + + {card.rarity} + + )} + {card.current_price && ( + + ${card.current_price} + + )} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ))} +
+ )} +
+
+
+
+ + {/* Card Manager Modal */} + {selectedCard && showCardManager && ( +
+ {/* This would render the CardManager component */} +
setShowCardManager(false)}> +
+
+

+ Add {selectedCard.name} to your collection +

+

+ This card will be added to your collection with the details you specify. +

+
+ + +
+
+
+
+
+ )} + + ); +}; + +export default CardDatabaseBrowser; \ No newline at end of file diff --git a/src/pages/Cards.tsx b/src/pages/Cards.tsx index 905e589..300cb01 100644 --- a/src/pages/Cards.tsx +++ b/src/pages/Cards.tsx @@ -2,10 +2,11 @@ import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { tcgApi } from '../services/tcgApi'; import CardSearch from '../components/cards/CardSearch'; +import CardDatabaseBrowser from '../components/cards/CardDatabaseBrowser'; import CardManager from '../components/cards/CardManager'; import CardImageDisplay from '../components/CardImageDisplay'; import GlowingCard from '../components/GlowingCard'; -import type { UserCard, CardFilters } from '../types'; +import type { UserCard, CardFilters, Card } from '../types'; const Cards: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); @@ -17,6 +18,7 @@ const Cards: React.FC = () => { }); const [viewMode, setViewMode] = useState<'card' | 'list'>('card'); const [showCardSearch, setShowCardSearch] = useState(false); + const [showDatabaseBrowser, setShowDatabaseBrowser] = useState(false); const [editingCard, setEditingCard] = useState(null); const { data: userCards = [], isLoading, error } = useQuery({ @@ -90,15 +92,26 @@ const Cards: React.FC = () => {

- +
+ + +
{/* Search and Filter Bar */} @@ -443,6 +456,17 @@ const Cards: React.FC = () => { cardId={editingCard} /> )} + + {/* Database Browser Modal */} + setShowDatabaseBrowser(false)} + onCardSelect={(card: Card) => { + // Handle card selection from database browser + console.log('Selected card from database:', card); + setShowDatabaseBrowser(false); + }} + /> ); }; diff --git a/src/services/cardDataSources.ts b/src/services/cardDataSources.ts new file mode 100644 index 0000000..fe28f82 --- /dev/null +++ b/src/services/cardDataSources.ts @@ -0,0 +1,364 @@ +import type { Card } from '../types'; + +// API Configuration +const SCRYFALL_BASE_URL = 'https://api.scryfall.com'; +const POKEMON_API_BASE_URL = 'https://api.pokemontcg.io/v2'; +const YUGIOH_API_BASE_URL = 'https://db.ygoprodeck.com/api/v7'; + +// Rate limiting utilities +class RateLimiter { + private requests: number[] = []; + private maxRequests: number; + private timeWindow: number; + + constructor(maxRequests: number, timeWindow: number) { + this.maxRequests = maxRequests; + this.timeWindow = timeWindow; + } + + async waitForSlot(): Promise { + const now = Date.now(); + this.requests = this.requests.filter(time => now - time < this.timeWindow); + + if (this.requests.length >= this.maxRequests) { + const oldestRequest = this.requests[0]; + const waitTime = this.timeWindow - (now - oldestRequest); + await new Promise(resolve => setTimeout(resolve, waitTime)); + } + + this.requests.push(now); + } +} + +// Rate limiters for each API +const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second +const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second + +// ============================================================================ +// MAGIC: THE GATHERING (Scryfall API) +// ============================================================================ + +export const mtgService = { + async searchCards(query: string, page = 1): Promise { + await scryfallLimiter.waitForSlot(); + + try { + const response = await fetch( + `${SCRYFALL_BASE_URL}/cards/search?q=${encodeURIComponent(query)}&page=${page}` + ); + + if (!response.ok) { + throw new Error(`Scryfall API error: ${response.status}`); + } + + const data = await response.json(); + + return data.data.map((card: any) => ({ + id: card.id, + name: card.name, + set_name: card.set_name, + set_code: card.set, + card_number: card.collector_number, + rarity: card.rarity, + game: 'MTG', + mana_cost: card.mana_cost, + cmc: card.cmc, + card_type: card.type_line, + colors: card.colors, + oracle_text: card.oracle_text, + power: card.power, + toughness: card.toughness, + image_url: card.image_uris?.normal, + stock_image_url: card.image_uris?.normal, + current_price: card.prices?.usd ? parseFloat(card.prices.usd) : null, + market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null, + verified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + } catch (error) { + console.error('Error fetching MTG cards:', error); + return []; + } + }, + + async getCardByName(name: string): Promise { + await scryfallLimiter.waitForSlot(); + + try { + const response = await fetch( + `${SCRYFALL_BASE_URL}/cards/named?fuzzy=${encodeURIComponent(name)}` + ); + + if (!response.ok) { + return null; + } + + const card = await response.json(); + + return { + id: card.id, + name: card.name, + set_name: card.set_name, + set_code: card.set, + card_number: card.collector_number, + rarity: card.rarity, + game: 'MTG', + mana_cost: card.mana_cost, + cmc: card.cmc, + card_type: card.type_line, + colors: card.colors, + oracle_text: card.oracle_text, + power: card.power, + toughness: card.toughness, + image_url: card.image_uris?.normal, + stock_image_url: card.image_uris?.normal, + current_price: card.prices?.usd ? parseFloat(card.prices.usd) : null, + market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null, + verified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } catch (error) { + console.error('Error fetching MTG card:', error); + return null; + } + }, + + async getRandomCards(count = 20): Promise { + await scryfallLimiter.waitForSlot(); + + try { + const response = await fetch(`${SCRYFALL_BASE_URL}/cards/random?q=game:paper&page_size=${count}`); + + if (!response.ok) { + throw new Error(`Scryfall API error: ${response.status}`); + } + + const data = await response.json(); + const cards = Array.isArray(data) ? data : [data]; + + return cards.map((card: any) => ({ + id: card.id, + name: card.name, + set_name: card.set_name, + set_code: card.set, + card_number: card.collector_number, + rarity: card.rarity, + game: 'MTG', + mana_cost: card.mana_cost, + cmc: card.cmc, + card_type: card.type_line, + colors: card.colors, + oracle_text: card.oracle_text, + power: card.power, + toughness: card.toughness, + image_url: card.image_uris?.normal, + stock_image_url: card.image_uris?.normal, + current_price: card.prices?.usd ? parseFloat(card.prices.usd) : null, + market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null, + verified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + } catch (error) { + console.error('Error fetching random MTG cards:', error); + return []; + } + } +}; + +// ============================================================================ +// POKÉMON TCG API +// ============================================================================ + +export const pokemonService = { + async searchCards(query: string, page = 1): Promise { + await pokemonLimiter.waitForSlot(); + + try { + const response = await fetch( + `${POKEMON_API_BASE_URL}/cards?q=name:${encodeURIComponent(query)}&page=${page}&pageSize=20` + ); + + if (!response.ok) { + throw new Error(`Pokémon API error: ${response.status}`); + } + + const data = await response.json(); + + return data.data.map((card: any) => ({ + id: card.id, + name: card.name, + set_name: card.set.name, + set_code: card.set.id, + card_number: card.number, + rarity: card.rarity, + game: 'POKEMON', + mana_cost: card.convertedRetreatCost?.toString(), + cmc: card.convertedRetreatCost, + card_type: card.supertype, + colors: card.types || [], + oracle_text: card.attacks?.map((attack: any) => `${attack.name}: ${attack.text}`).join('\n'), + power: card.hp, + toughness: null, + image_url: card.images.small, + stock_image_url: card.images.large, + current_price: card.cardmarket?.prices?.averageSellPrice || null, + market_price: card.cardmarket?.prices?.lowPrice || null, + verified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + } catch (error) { + console.error('Error fetching Pokémon cards:', error); + return []; + } + }, + + async getCardByName(name: string): Promise { + const cards = await this.searchCards(name, 1); + return cards.length > 0 ? cards[0] : null; + } +}; + +// ============================================================================ +// YU-GI-OH! API +// ============================================================================ + +export const yugiohService = { + async searchCards(query: string): Promise { + try { + const response = await fetch( + `${YUGIOH_API_BASE_URL}/cardinfo.php?fname=${encodeURIComponent(query)}` + ); + + if (!response.ok) { + throw new Error(`Yu-Gi-Oh! API error: ${response.status}`); + } + + const data = await response.json(); + + return data.data.map((card: any) => ({ + id: card.id, + name: card.name, + set_name: card.set_name, + set_code: card.set_code, + card_number: card.num, + rarity: card.rarity, + game: 'YUGIOH', + mana_cost: card.level?.toString(), + cmc: card.level, + card_type: card.type, + colors: [card.attribute], + oracle_text: card.desc, + power: card.atk?.toString(), + toughness: card.def?.toString(), + image_url: card.card_images?.[0]?.image_url, + stock_image_url: card.card_images?.[0]?.image_url_small, + current_price: card.card_prices?.[0]?.amazon_price ? parseFloat(card.card_prices[0].amazon_price) : null, + market_price: card.card_prices?.[0]?.cardmarket_price ? parseFloat(card.card_prices[0].cardmarket_price) : null, + verified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + } catch (error) { + console.error('Error fetching Yu-Gi-Oh! cards:', error); + return []; + } + }, + + async getCardByName(name: string): Promise { + const cards = await this.searchCards(name); + return cards.length > 0 ? cards[0] : null; + } +}; + +// ============================================================================ +// UNIFIED CARD SEARCH +// ============================================================================ + +export const cardDataService = { + async searchCards(query: string, game?: string): Promise { + const results: Card[] = []; + + try { + if (!game || game === 'MTG') { + const mtgCards = await mtgService.searchCards(query); + results.push(...mtgCards); + } + + if (!game || game === 'POKEMON') { + const pokemonCards = await pokemonService.searchCards(query); + results.push(...pokemonCards); + } + + if (!game || game === 'YUGIOH') { + const yugiohCards = await yugiohService.searchCards(query); + results.push(...yugiohCards); + } + + // Remove duplicates and sort by relevance + const uniqueCards = results.filter((card, index, self) => + index === self.findIndex(c => c.name === card.name && c.game === card.game) + ); + + return uniqueCards; + } catch (error) { + console.error('Error in unified card search:', error); + return []; + } + }, + + async getCardByName(name: string, game?: string): Promise { + try { + if (game === 'MTG') { + return await mtgService.getCardByName(name); + } + + if (game === 'POKEMON') { + return await pokemonService.getCardByName(name); + } + + if (game === 'YUGIOH') { + return await yugiohService.getCardByName(name); + } + + // Try all games if no specific game is specified + const mtgCard = await mtgService.getCardByName(name); + if (mtgCard) return mtgCard; + + const pokemonCard = await pokemonService.getCardByName(name); + if (pokemonCard) return pokemonCard; + + const yugiohCard = await yugiohService.getCardByName(name); + if (yugiohCard) return yugiohCard; + + return null; + } catch (error) { + console.error('Error in unified card search by name:', error); + return null; + } + }, + + async getRandomCards(count = 20, game?: string): Promise { + const results: Card[] = []; + + try { + if (!game || game === 'MTG') { + const mtgCards = await mtgService.getRandomCards(Math.ceil(count / 3)); + results.push(...mtgCards); + } + + // Note: Pokémon and Yu-Gi-Oh! APIs don't have random card endpoints + // You could implement random selection from popular cards lists + + return results.slice(0, count); + } catch (error) { + console.error('Error getting random cards:', error); + return []; + } + } +}; + +export default cardDataService; \ No newline at end of file