diff --git a/api/admin/load-cards.js b/api/admin/load-cards.js new file mode 100644 index 0000000..a9077e1 --- /dev/null +++ b/api/admin/load-cards.js @@ -0,0 +1,370 @@ +import { NextResponse } from 'next/server'; +import { sql } from '@vercel/postgres'; +import { verifyToken } from '../setup-auth.js'; + +// Rate limiting for external APIs +const rateLimiters = { + mtg: { lastCall: 0, minInterval: 50 }, // 50ms between calls + pokemon: { lastCall: 0, minInterval: 100 }, // 100ms between calls + lorcana: { lastCall: 0, minInterval: 100 } // 100ms between calls +}; + +async function waitForRateLimit(api) { + const now = Date.now(); + const limiter = rateLimiters[api]; + const timeSinceLastCall = now - limiter.lastCall; + + if (timeSinceLastCall < limiter.minInterval) { + await new Promise(resolve => + setTimeout(resolve, limiter.minInterval - timeSinceLastCall) + ); + } + + limiter.lastCall = Date.now(); +} + +// Load MTG cards from Scryfall +async function loadMTGCards() { + console.log('🃏 Loading MTG cards from Scryfall...'); + + try { + // Get total count first + const countResponse = await fetch('https://api.scryfall.com/cards/search?q=game:paper'); + const countData = await countResponse.json(); + const totalCards = countData.total_cards; + + console.log(`📊 Found ${totalCards} MTG cards to load`); + + let loadedCount = 0; + let page = 1; + + while (loadedCount < Math.min(totalCards, 1000)) { // Limit to 1000 for now + await waitForRateLimit('mtg'); + + const response = await fetch(`https://api.scryfall.com/cards/search?q=game:paper&page=${page}`); + const data = await response.json(); + + if (!data.data || data.data.length === 0) break; + + for (const card of data.data) { + try { + await sql.query(` + INSERT INTO cards ( + name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, + card_type, colors, oracle_text, power, toughness, image_url, + stock_image_url, current_price, market_price, scryfall_id, verified + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (scryfall_id) DO NOTHING + `, [ + card.name, + card.set_name, + card.set, + card.collector_number, + card.rarity, + 'MTG', + card.mana_cost, + card.cmc, + card.type_line, + JSON.stringify(card.colors), + card.oracle_text, + card.power, + card.toughness, + card.image_uris?.normal || card.image_uris?.small, + card.image_uris?.small, + card.prices?.usd ? parseFloat(card.prices.usd) : null, + card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null, + card.id, + true + ]); + + loadedCount++; + } catch (error) { + console.error(`❌ Error loading MTG card ${card.name}:`, error); + } + } + + page++; + console.log(`✅ Loaded ${loadedCount} MTG cards so far...`); + } + + console.log(`🎉 Successfully loaded ${loadedCount} MTG cards`); + return loadedCount; + } catch (error) { + console.error('❌ Error loading MTG cards:', error); + return 0; + } +} + +// Load Pokémon cards from Pokémon TCG API +async function loadPokemonCards() { + console.log('⚡ Loading Pokémon cards from Pokémon TCG API...'); + + try { + let loadedCount = 0; + let page = 1; + const pageSize = 250; // Max allowed by API + + while (loadedCount < 1000) { // Limit to 1000 for now + await waitForRateLimit('pokemon'); + + const response = await fetch(`https://api.pokemontcg.io/v2/cards?page=${page}&pageSize=${pageSize}`, { + headers: { + 'X-Api-Key': process.env.POKEMON_API_KEY || '' + } + }); + + const data = await response.json(); + + if (!data.data || data.data.length === 0) break; + + for (const card of data.data) { + try { + await sql.query(` + INSERT INTO cards ( + name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, + card_type, colors, oracle_text, power, toughness, image_url, + stock_image_url, current_price, market_price, tcg_player_id, verified + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (tcg_player_id) DO NOTHING + `, [ + card.name, + card.set.name, + card.set.id, + card.number, + card.rarity, + 'POKEMON', + card.convertedRetreatCost?.toString() || null, + card.convertedRetreatCost, + card.supertype + (card.subtypes ? ' - ' + card.subtypes.join(', ') : ''), + JSON.stringify(card.types || []), + card.flavorText || card.rules?.join(' ') || '', + card.attacks?.[0]?.damage || null, + card.hp || null, + card.images?.large, + card.images?.small, + card.cardmarket?.prices?.averageSellPrice ? parseFloat(card.cardmarket.prices.averageSellPrice) : null, + card.cardmarket?.prices?.lowPrice ? parseFloat(card.cardmarket.prices.lowPrice) : null, + card.id, + true + ]); + + loadedCount++; + } catch (error) { + console.error(`❌ Error loading Pokémon card ${card.name}:`, error); + } + } + + page++; + console.log(`✅ Loaded ${loadedCount} Pokémon cards so far...`); + } + + console.log(`🎉 Successfully loaded ${loadedCount} Pokémon cards`); + return loadedCount; + } catch (error) { + console.error('❌ Error loading Pokémon cards:', error); + return 0; + } +} + +// Load Lorcana cards from multiple sources +async function loadLorcanaCards() { + console.log('🏰 Loading Lorcana cards from multiple sources...'); + + try { + let loadedCount = 0; + + // Try Lorcana API first + try { + await waitForRateLimit('lorcana'); + const response = await fetch('https://api.lorcana-api.com/cards/fetch?pagesize=1000'); + const data = await response.json(); + + if (data.cards && data.cards.length > 0) { + console.log(`📊 Found ${data.cards.length} cards from Lorcana API`); + + for (const card of data.cards) { + try { + await sql.query(` + INSERT INTO cards ( + name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, + card_type, colors, oracle_text, power, toughness, image_url, + stock_image_url, current_price, market_price, verified + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + ON CONFLICT (name, set_code, card_number) DO NOTHING + `, [ + card.name || card.card_name || card.title || '', + card.set?.name || card.set_name || '', + card.set?.code || card.set_code || '', + card.number || card.card_number || card.card_num || '', + card.rarity || card.rarity_name || '', + 'LORCANA', + card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '', + card.cost || card.cmc || card.ink_cost || 0, + card.type || card.card_type || card.type_name || '', + JSON.stringify(card.colors || card.ink || []), + card.text || card.oracle_text || card.description || card.effect || '', + card.strength?.toString() || card.power || card.attack?.toString() || '', + card.willpower?.toString() || card.toughness || card.defense?.toString() || '', + card.image_url || card.images?.small || card.images?.png || card.image || '', + card.image_url || card.images?.small || card.images?.png || card.image || '', + card.price?.market || card.current_price || null, + card.price?.low || card.market_price || null, + true + ]); + + loadedCount++; + } catch (error) { + console.error(`❌ Error loading Lorcana card ${card.name}:`, error); + } + } + } + } catch (error) { + console.log('❌ Lorcana API failed, trying Lorcast...'); + } + + // Try Lorcast API as fallback + if (loadedCount === 0) { + try { + await waitForRateLimit('lorcana'); + const response = await fetch('https://api.lorcast.com/v0/cards'); + const data = await response.json(); + + if (data.cards && data.cards.length > 0) { + console.log(`📊 Found ${data.cards.length} cards from Lorcast API`); + + for (const card of data.cards) { + try { + await sql.query(` + INSERT INTO cards ( + name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, + card_type, colors, oracle_text, power, toughness, image_url, + stock_image_url, current_price, market_price, verified + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + ON CONFLICT (name, set_code, card_number) DO NOTHING + `, [ + card.name || card.card_name || card.title || '', + card.set?.name || card.set_name || '', + card.set?.code || card.set_code || '', + card.number || card.card_number || card.card_num || '', + card.rarity || card.rarity_name || '', + 'LORCANA', + card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '', + card.cost || card.cmc || card.ink_cost || 0, + card.type || card.card_type || card.type_name || '', + JSON.stringify(card.colors || card.ink || []), + card.text || card.oracle_text || card.description || card.effect || '', + card.strength?.toString() || card.power || card.attack?.toString() || '', + card.willpower?.toString() || card.toughness || card.defense?.toString() || '', + card.image_url || card.images?.small || card.images?.png || card.image || '', + card.image_url || card.images?.small || card.images?.png || card.image || '', + card.price?.market || card.current_price || null, + card.price?.low || card.market_price || null, + true + ]); + + loadedCount++; + } catch (error) { + console.error(`❌ Error loading Lorcana card ${card.name}:`, error); + } + } + } + } catch (error) { + console.error('❌ Lorcast API also failed:', error); + } + } + + console.log(`🎉 Successfully loaded ${loadedCount} Lorcana cards`); + return loadedCount; + } catch (error) { + console.error('❌ Error loading Lorcana cards:', error); + return 0; + } +} + +// POST /api/admin/load-cards - Load cards from external APIs +export async function POST(request) { + try { + const token = request.headers.get('authorization')?.replace('Bearer ', ''); + const user = await verifyToken(token); + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user is admin (you can implement your own admin check) + const { searchParams } = new URL(request.url); + const game = searchParams.get('game'); // 'MTG', 'POKEMON', 'LORCANA', or 'ALL' + + console.log(`🚀 Starting card loading process for game: ${game}`); + + let results = {}; + + if (game === 'MTG' || game === 'ALL') { + results.mtg = await loadMTGCards(); + } + + if (game === 'POKEMON' || game === 'ALL') { + results.pokemon = await loadPokemonCards(); + } + + if (game === 'LORCANA' || game === 'ALL') { + results.lorcana = await loadLorcanaCards(); + } + + const totalLoaded = Object.values(results).reduce((sum, count) => sum + count, 0); + + console.log(`🎉 Card loading completed! Total loaded: ${totalLoaded}`); + + return NextResponse.json({ + success: true, + message: `Successfully loaded ${totalLoaded} cards`, + results + }); + + } catch (error) { + console.error('❌ Error in card loading API:', error); + return NextResponse.json( + { error: 'Failed to load cards', details: error.message }, + { status: 500 } + ); + } +} + +// GET /api/admin/load-cards - Get loading status +export async function GET(request) { + try { + const token = request.headers.get('authorization')?.replace('Bearer ', ''); + const user = await verifyToken(token); + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get card counts from database + const result = await sql.query(` + SELECT + game, + COUNT(*) as count + FROM cards + GROUP BY game + `); + + const counts = {}; + result.rows.forEach(row => { + counts[row.game] = parseInt(row.count); + }); + + return NextResponse.json({ + success: true, + counts, + total: Object.values(counts).reduce((sum, count) => sum + count, 0) + }); + + } catch (error) { + console.error('❌ Error getting card counts:', error); + return NextResponse.json( + { error: 'Failed to get card counts', details: error.message }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/api/cards/search.js b/api/cards/search.js new file mode 100644 index 0000000..679648a --- /dev/null +++ b/api/cards/search.js @@ -0,0 +1,158 @@ +import { NextResponse } from 'next/server'; +import { sql } from '@vercel/postgres'; + +// GET /api/cards/search - Search cards from database +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q') || searchParams.get('search') || ''; + const game = searchParams.get('game'); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '20'); + const offset = (page - 1) * limit; + + console.log(`🔍 Searching cards: "${query}" game: "${game}" page: ${page}`); + + // Build the SQL query + let sqlQuery = ` + SELECT + id, + name, + set_name, + set_code, + card_number, + rarity, + game, + mana_cost, + cmc, + card_type, + colors, + oracle_text, + power, + toughness, + image_url, + stock_image_url, + current_price, + market_price, + verified, + created_at, + updated_at + FROM cards + WHERE 1=1 + `; + + const params = []; + let paramIndex = 1; + + // Add search filter + if (query.trim()) { + sqlQuery += ` AND ( + name ILIKE $${paramIndex} OR + oracle_text ILIKE $${paramIndex} OR + card_type ILIKE $${paramIndex} OR + set_name ILIKE $${paramIndex} + )`; + params.push(`%${query}%`); + paramIndex++; + } + + // Add game filter + if (game && game !== 'ALL') { + sqlQuery += ` AND game = $${paramIndex}`; + params.push(game); + paramIndex++; + } + + // Add ordering and pagination + sqlQuery += ` ORDER BY name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; + params.push(limit, offset); + + console.log(`📝 SQL Query: ${sqlQuery}`); + console.log(`📝 Parameters:`, params); + + // Execute the query + const result = await sql.query(sqlQuery, params); + + // Get total count for pagination + let countQuery = ` + SELECT COUNT(*) as total + FROM cards + WHERE 1=1 + `; + + const countParams = []; + let countParamIndex = 1; + + if (query.trim()) { + countQuery += ` AND ( + name ILIKE $${countParamIndex} OR + oracle_text ILIKE $${countParamIndex} OR + card_type ILIKE $${countParamIndex} OR + set_name ILIKE $${countParamIndex} + )`; + countParams.push(`%${query}%`); + countParamIndex++; + } + + if (game && game !== 'ALL') { + countQuery += ` AND game = $${countParamIndex}`; + countParams.push(game); + countParamIndex++; + } + + const countResult = await sql.query(countQuery, countParams); + const total = parseInt(countResult.rows[0].total); + + // Transform the results + const cards = result.rows.map(row => ({ + id: row.id, + name: row.name, + set_name: row.set_name, + set_code: row.set_code, + card_number: row.card_number, + rarity: row.rarity, + game: row.game, + mana_cost: row.mana_cost, + cmc: row.cmc, + card_type: row.card_type, + colors: row.colors ? JSON.parse(row.colors) : [], + oracle_text: row.oracle_text, + power: row.power, + toughness: row.toughness, + image_url: row.image_url, + stock_image_url: row.stock_image_url, + current_price: row.current_price, + market_price: row.market_price, + verified: row.verified, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + + console.log(`✅ Found ${cards.length} cards (total: ${total})`); + + return NextResponse.json({ + success: true, + data: cards, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasNext: page * limit < total, + hasPrev: page > 1 + }, + search: { + query, + game, + results: cards.length + } + }); + + } catch (error) { + console.error('❌ Error searching cards:', error); + return NextResponse.json( + { error: 'Failed to search cards', details: error.message }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/components/admin/AdminPanel.tsx b/src/components/admin/AdminPanel.tsx index 007ad15..fc6ceb7 100644 --- a/src/components/admin/AdminPanel.tsx +++ b/src/components/admin/AdminPanel.tsx @@ -4,6 +4,7 @@ import { Navigate } from 'react-router-dom'; import UserManagement from './UserManagement'; import CardManagement from './CardManagement'; import SystemStats from './SystemStats'; +import CardLoader from './CardLoader'; type AdminTab = 'dashboard' | 'users' | 'cards' | 'decks' | 'settings'; @@ -31,7 +32,7 @@ const AdminPanel: React.FC = () => { case 'users': return ; case 'cards': - return ; + return ; case 'decks': return
Deck Management - Coming Soon
; case 'settings': diff --git a/src/components/admin/CardLoader.tsx b/src/components/admin/CardLoader.tsx new file mode 100644 index 0000000..02753ca --- /dev/null +++ b/src/components/admin/CardLoader.tsx @@ -0,0 +1,185 @@ +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../../contexts/AuthContext'; + +interface CardCounts { + MTG?: number; + POKEMON?: number; + LORCANA?: number; + total?: number; +} + +interface LoadingResults { + mtg?: number; + pokemon?: number; + lorcana?: number; +} + +const CardLoader: React.FC = () => { + const { user, token } = useAuth(); + const [cardCounts, setCardCounts] = useState({}); + const [loading, setLoading] = useState(false); + const [loadingResults, setLoadingResults] = useState({}); + const [selectedGame, setSelectedGame] = useState('ALL'); + const [message, setMessage] = useState(''); + + // Fetch current card counts + const fetchCardCounts = async () => { + try { + const response = await fetch('https://tcg-vault.vercel.app/api/admin/load-cards', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + setCardCounts(data.counts || {}); + } + } catch (error) { + console.error('Error fetching card counts:', error); + } + }; + + useEffect(() => { + fetchCardCounts(); + }, []); + + // Load cards from external APIs + const loadCards = async (game: string) => { + setLoading(true); + setMessage(`Loading ${game} cards...`); + setLoadingResults({}); + + try { + const response = await fetch(`https://tcg-vault.vercel.app/api/admin/load-cards?game=${game}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const data = await response.json(); + setLoadingResults(data.results || {}); + setMessage(`Successfully loaded cards! ${data.message}`); + + // Refresh card counts + setTimeout(() => { + fetchCardCounts(); + }, 1000); + } else { + const errorData = await response.json(); + setMessage(`Error loading cards: ${errorData.error}`); + } + } catch (error) { + console.error('Error loading cards:', error); + setMessage('Error loading cards. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleLoadCards = () => { + loadCards(selectedGame); + }; + + return ( +
+

Card Database Loader

+ + {/* Current Card Counts */} +
+

Current Database Status

+
+
+
{cardCounts.MTG || 0}
+
MTG Cards
+
+
+
{cardCounts.POKEMON || 0}
+
Pokémon Cards
+
+
+
{cardCounts.LORCANA || 0}
+
Lorcana Cards
+
+
+
{cardCounts.total || 0}
+
Total Cards
+
+
+
+ + {/* Load Cards Section */} +
+

Load Cards from External APIs

+ +
+ + + +
+ + {/* Loading Results */} + {Object.keys(loadingResults).length > 0 && ( +
+

Loading Results:

+
+ {loadingResults.mtg !== undefined && ( +
MTG: {loadingResults.mtg} cards loaded
+ )} + {loadingResults.pokemon !== undefined && ( +
Pokémon: {loadingResults.pokemon} cards loaded
+ )} + {loadingResults.lorcana !== undefined && ( +
Lorcana: {loadingResults.lorcana} cards loaded
+ )} +
+
+ )} + + {/* Message */} + {message && ( +
+ {message} +
+ )} +
+ + {/* Instructions */} +
+

Instructions

+
    +
  • • This will load cards from external APIs into your database
  • +
  • • MTG cards come from Scryfall API
  • +
  • • Pokémon cards come from Pokémon TCG API
  • +
  • • Lorcana cards come from Lorcana API and Lorcast API
  • +
  • • Loading may take several minutes for large datasets
  • +
  • • Cards are deduplicated automatically
  • +
+
+
+ ); +}; + +export default CardLoader; \ No newline at end of file diff --git a/src/services/cardDataSources.ts b/src/services/cardDataSources.ts index 3d62c85..0fc2db1 100644 --- a/src/services/cardDataSources.ts +++ b/src/services/cardDataSources.ts @@ -744,48 +744,56 @@ export const lorcanaService = { export const cardDataService = { async searchCards(query: string, game?: string): Promise { - const results: Card[] = []; - - console.log(`🔍 Unified search: "${query}" for game: "${game}"`); + console.log(`🔍 Database search: "${query}" for game: "${game}"`); try { - if (!game || game === 'MTG') { - console.log('🔍 Searching MTG...'); - const mtgCards = await mtgService.searchCards(query); - console.log(`✅ Found ${mtgCards.length} MTG cards`); - results.push(...mtgCards); + const params = new URLSearchParams({ + q: query, + limit: '50' + }); + + if (game && game !== 'ALL') { + params.append('game', game); } - if (!game || game === 'POKEMON') { - console.log('🔍 Searching Pokémon...'); - const pokemonCards = await pokemonService.searchCards(query); - console.log(`✅ Found ${pokemonCards.length} Pokémon cards`); - results.push(...pokemonCards); + const response = await fetch(`https://tcg-vault.vercel.app/api/cards/search?${params}`); + + if (!response.ok) { + throw new Error(`Database search failed: ${response.status}`); } - if (!game || game === 'YUGIOH') { - console.log('🔍 Searching Yu-Gi-Oh!...'); - const yugiohCards = await yugiohService.searchCards(query); - console.log(`✅ Found ${yugiohCards.length} Yu-Gi-Oh! cards`); - results.push(...yugiohCards); + const data = await response.json(); + + if (data.success && data.data) { + console.log(`✅ Found ${data.data.length} cards from database`); + 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.card_number, + rarity: card.rarity, + game: card.game, + mana_cost: card.mana_cost, + cmc: card.cmc, + card_type: card.card_type, + colors: card.colors || [], + oracle_text: card.oracle_text, + power: card.power, + toughness: card.toughness, + image_url: card.image_url, + stock_image_url: card.stock_image_url, + current_price: card.current_price, + market_price: card.market_price, + verified: card.verified, + createdAt: card.createdAt, + updatedAt: card.updatedAt, + })); } - if (!game || game === 'LORCANA') { - console.log('🔍 Searching Lorcana...'); - const lorcanaCards = await lorcanaService.searchCards(query); - console.log(`✅ Found ${lorcanaCards.length} Lorcana cards`); - results.push(...lorcanaCards); - } - - // 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) - ); - - console.log(`🎯 Total results: ${results.length}, Unique: ${uniqueCards.length}`); - return uniqueCards; + return []; } catch (error) { - console.error('Error in unified card search:', error); + console.error('Error in database card search:', error); return []; } },