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 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