Add comprehensive card data sources integration with external APIs and database browser component
This commit is contained in:
parent
4e71e29922
commit
d21394d579
5 changed files with 1321 additions and 10 deletions
231
CARD_DATA_SOURCES.md
Normal file
231
CARD_DATA_SOURCES.md
Normal file
|
|
@ -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!
|
||||
328
scripts/populate-card-database.js
Normal file
328
scripts/populate-card-database.js
Normal file
|
|
@ -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
|
||||
};
|
||||
364
src/components/cards/CardDatabaseBrowser.tsx
Normal file
364
src/components/cards/CardDatabaseBrowser.tsx
Normal file
|
|
@ -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<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;
|
||||
|
|
@ -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<string | null>(null);
|
||||
|
||||
const { data: userCards = [], isLoading, error } = useQuery({
|
||||
|
|
@ -90,15 +92,26 @@ const Cards: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCardSearch(true)}
|
||||
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Cards
|
||||
</button>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowCardSearch(true)}
|
||||
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Cards
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDatabaseBrowser(true)}
|
||||
className="bg-gradient-to-r from-accent-500 to-primary-500 hover:from-accent-600 hover:to-primary-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 inline" 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>
|
||||
Browse Database
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
|
|
@ -443,6 +456,17 @@ const Cards: React.FC = () => {
|
|||
cardId={editingCard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Database Browser Modal */}
|
||||
<CardDatabaseBrowser
|
||||
isOpen={showDatabaseBrowser}
|
||||
onClose={() => setShowDatabaseBrowser(false)}
|
||||
onCardSelect={(card: Card) => {
|
||||
// Handle card selection from database browser
|
||||
console.log('Selected card from database:', card);
|
||||
setShowDatabaseBrowser(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
364
src/services/cardDataSources.ts
Normal file
364
src/services/cardDataSources.ts
Normal file
|
|
@ -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<void> {
|
||||
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<Card[]> {
|
||||
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<Card | null> {
|
||||
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<Card[]> {
|
||||
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<Card[]> {
|
||||
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<Card | null> {
|
||||
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<Card[]> {
|
||||
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<Card | null> {
|
||||
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<Card[]> {
|
||||
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<Card | null> {
|
||||
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<Card[]> {
|
||||
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;
|
||||
Loading…
Reference in a new issue