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 { useQuery } from '@tanstack/react-query';
|
||||||
import { tcgApi } from '../services/tcgApi';
|
import { tcgApi } from '../services/tcgApi';
|
||||||
import CardSearch from '../components/cards/CardSearch';
|
import CardSearch from '../components/cards/CardSearch';
|
||||||
|
import CardDatabaseBrowser from '../components/cards/CardDatabaseBrowser';
|
||||||
import CardManager from '../components/cards/CardManager';
|
import CardManager from '../components/cards/CardManager';
|
||||||
import CardImageDisplay from '../components/CardImageDisplay';
|
import CardImageDisplay from '../components/CardImageDisplay';
|
||||||
import GlowingCard from '../components/GlowingCard';
|
import GlowingCard from '../components/GlowingCard';
|
||||||
import type { UserCard, CardFilters } from '../types';
|
import type { UserCard, CardFilters, Card } from '../types';
|
||||||
|
|
||||||
const Cards: React.FC = () => {
|
const Cards: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -17,6 +18,7 @@ const Cards: React.FC = () => {
|
||||||
});
|
});
|
||||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
const [showCardSearch, setShowCardSearch] = useState(false);
|
const [showCardSearch, setShowCardSearch] = useState(false);
|
||||||
|
const [showDatabaseBrowser, setShowDatabaseBrowser] = useState(false);
|
||||||
const [editingCard, setEditingCard] = useState<string | null>(null);
|
const [editingCard, setEditingCard] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: userCards = [], isLoading, error } = useQuery({
|
const { data: userCards = [], isLoading, error } = useQuery({
|
||||||
|
|
@ -90,15 +92,26 @@ const Cards: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex space-x-3">
|
||||||
onClick={() => setShowCardSearch(true)}
|
<button
|
||||||
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"
|
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 className="w-4 h-4 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
Add Cards
|
</svg>
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter Bar */}
|
{/* Search and Filter Bar */}
|
||||||
|
|
@ -443,6 +456,17 @@ const Cards: React.FC = () => {
|
||||||
cardId={editingCard}
|
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>
|
</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