deckhearth/pages/admin/card-import.js
Randall Stillwell ec9bb2b93e feat(catalog): unified multi-game bulk sync + schema map update
Ship Pokemon and Lorcana bulk import libs/scripts, unified weekly cron
sync across MTG/Pokemon/Lorcana with per-game error isolation and
catalog_sync_log telemetry. Admin UI adds Unified/Incremental/Bulk MTG modes.

- Rename reconcile migrations to 1781442330* timestamps so they apply
  after bulk-data migrations without node-pg-migrate ordering conflicts
- Add Lorcana set-code normalization + orphan cleanup migrations
- Drop stricter user_cards_user_card_unique (keep 3-column foil unique)
- Update docs/SCHEMA_MAP.md for tags, card_tags, catalog_sync_log, bulk columns

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:22:19 -05:00

502 lines
No EOL
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import dynamic from 'next/dynamic';
import Layout from '../../components/Layout';
import AdminProtected from '../../components/AdminProtected';
import { Button } from '../../components/ui';
const CardImport = () => {
const router = useRouter();
const [importType, setImportType] = useState('mtg');
const [setCode, setSetCode] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const [syncMode, setSyncMode] = useState('unified');
const handleCatalogSync = async () => {
setIsSyncing(true);
setSyncResult(null);
try {
const response = await fetch('/api/admin/sync-catalog', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({ mode: syncMode }),
});
const result = await response.json();
if (response.ok) {
setSyncResult({ success: true, ...result });
} else {
setSyncResult({
success: false,
message: result.error || 'Catalog sync failed',
});
}
} catch (error) {
setSyncResult({
success: false,
message: `Network error: ${error.message}`,
});
} finally {
setIsSyncing(false);
}
};
const handleImport = async () => {
if (!setCode.trim()) {
alert('Please enter a set code');
return;
}
setIsImporting(true);
setImportResult(null);
try {
const endpoint = importType === 'mtg'
? '/api/cards/import-mtg'
: '/api/cards/import-pokemon';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({ setCode: setCode.trim() }),
});
const result = await response.json();
if (response.ok) {
setImportResult({
success: true,
message: result.message,
imported: result.imported,
skipped: result.skipped,
total: result.total
});
} else {
setImportResult({
success: false,
message: result.error || 'Import failed'
});
}
} catch (error) {
setImportResult({
success: false,
message: 'Network error: ' + error.message
});
} finally {
setIsImporting(false);
}
};
const popularSets = {
mtg: [
{ code: 'neo', name: 'Kamigawa: Neon Dynasty' },
{ code: 'vow', name: 'Innistrad: Crimson Vow' },
{ code: 'mid', name: 'Innistrad: Midnight Hunt' },
{ code: 'afr', name: 'Adventures in the Forgotten Realms' },
{ code: 'stx', name: 'Strixhaven: School of Mages' },
{ code: 'khm', name: 'Kaldheim' },
{ code: 'znr', name: 'Zendikar Rising' },
{ code: 'iko', name: 'Ikoria: Lair of Behemoths' },
{ code: 'thb', name: 'Theros Beyond Death' },
{ code: 'eld', name: 'Throne of Eldraine' }
],
pokemon: [
{ code: 'swsh1', name: 'Sword & Shield' },
{ code: 'swsh2', name: 'Rebel Clash' },
{ code: 'swsh3', name: 'Darkness Ablaze' },
{ code: 'swsh4', name: 'Vivid Voltage' },
{ code: 'swsh5', name: 'Battle Styles' },
{ code: 'swsh6', name: 'Chilling Reign' },
{ code: 'swsh7', name: 'Evolving Skies' },
{ code: 'swsh8', name: 'Fusion Strike' },
{ code: 'swsh9', name: 'Brilliant Stars' },
{ code: 'swsh10', name: 'Astral Radiance' }
]
};
return (
<AdminProtected>
{(user) => (
<Layout user={user}>
<div className="container mx-auto px-6 py-8">
{/* Admin Navigation */}
<div className="glass-panel mb-8 p-4 rounded-xl">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
Admin Tools
</h1>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => router.push('/admin/card-editor')}
leadingIcon={<span aria-hidden="true">🖊</span>}
>
Card Editor
</Button>
<Button
variant="primary"
onClick={() => router.push('/admin/card-import')}
leadingIcon={<span aria-hidden="true">📥</span>}
>
Card Import
</Button>
</div>
</div>
</div>
{/* Card Import Section */}
<div className="mb-6">
<h2 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Card Import Manager
</h2>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Import cards from external APIs into your database
</p>
</div>
{/* Catalog sync */}
<div className="glass-panel mb-8 p-6 rounded-xl">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Catalog sync
</h2>
<p className="text-sm max-w-2xl" style={{ color: 'var(--text-secondary)' }}>
{syncMode === 'unified'
? 'Run bulk sync for MTG (Scryfall), Pokémon (GitHub), and Lorcana (lorcana-api.com) in one job — the same weekly Vercel cron path. Per-game failures are isolated.'
: syncMode === 'bulk'
? 'Download Scryfall\'s full Oracle Cards bulk file and upsert all 36k+ MTG cards with rich metadata (legalities, keywords, color identity, tags). Takes ~30s.'
: 'Import up to three newest missing MTG and Pokémon sets. Pending scan submissions are auto-linked when a unique match exists.'}
</p>
<div className="flex flex-wrap gap-2 mt-3">
<button
onClick={() => setSyncMode('unified')}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
backgroundColor: syncMode === 'unified' ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: syncMode === 'unified' ? '#fff' : 'var(--text-secondary)',
}}
>
Unified (all games)
</button>
<button
onClick={() => setSyncMode('incremental')}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
backgroundColor: syncMode === 'incremental' ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: syncMode === 'incremental' ? '#fff' : 'var(--text-secondary)',
}}
>
Incremental (new sets)
</button>
<button
onClick={() => setSyncMode('bulk')}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
backgroundColor: syncMode === 'bulk' ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: syncMode === 'bulk' ? '#fff' : 'var(--text-secondary)',
}}
>
Bulk MTG only
</button>
</div>
</div>
<Button
variant="primary"
size="lg"
onClick={handleCatalogSync}
disabled={isSyncing}
loading={isSyncing}
className="shrink-0"
>
{isSyncing ? 'Syncing catalog…' : syncMode === 'unified' ? 'Run unified sync' : syncMode === 'bulk' ? 'Run bulk MTG sync' : 'Run incremental sync'}
</Button>
</div>
{syncResult && (
<div
className={`mt-4 p-4 rounded-xl ${
syncResult.success
? 'bg-green-100 border border-green-300'
: 'bg-red-100 border border-red-300'
}`}
>
<h3
className={`font-semibold mb-2 ${
syncResult.success ? 'text-green-800' : 'text-red-800'
}`}
>
{syncResult.success ? 'Catalog sync complete' : 'Catalog sync failed'}
</h3>
{syncResult.success ? (
<div className="text-sm space-y-2 text-green-900">
{syncResult.mode === 'unified' ? (
<>
<p>
Unified sync finished in {(syncResult.durationMs / 1000).toFixed(1)}s.
</p>
<ul className="list-disc pl-5 space-y-1">
{syncResult.mtg && (
<li>
MTG:{' '}
{syncResult.mtg.error
? `failed — ${syncResult.mtg.error}`
: `${syncResult.mtg.upserted ?? 0} upserted${syncResult.mtg.errors ? ` (${syncResult.mtg.errors} row errors)` : ''}`}
</li>
)}
{syncResult.pokemon && (
<li>
Pokémon:{' '}
{syncResult.pokemon.error
? `failed — ${syncResult.pokemon.error}`
: `${syncResult.pokemon.upserted ?? 0} upserted across ${syncResult.pokemon.setsProcessed ?? 0} sets${syncResult.pokemon.errors ? ` (${syncResult.pokemon.errors} set errors)` : ''}`}
</li>
)}
{syncResult.lorcana && (
<li>
Lorcana:{' '}
{syncResult.lorcana.error
? `failed — ${syncResult.lorcana.error}`
: `${syncResult.lorcana.upserted ?? 0} upserted${syncResult.lorcana.errors ? ` (${syncResult.lorcana.errors} row errors)` : ''}`}
</li>
)}
</ul>
</>
) : syncResult.mode === 'bulk' ? (
<p>
Upserted {syncResult.upserted} MTG cards from Scryfall bulk data.
{syncResult.backfilledOracleId > 0 && ` Backfilled oracle_id on ${syncResult.backfilledOracleId} existing rows.`}
{syncResult.errors > 0 && ` (${syncResult.errors} errors)`}
</p>
) : (
<p>
Imported {syncResult.imported} cards ({syncResult.skipped} skipped).
</p>
)}
{syncResult.submissionsReconciled > 0 && (
<p>
Linked {syncResult.submissionsReconciled} pending scan submission
{syncResult.submissionsReconciled === 1 ? '' : 's'} to catalog cards.
</p>
)}
{(syncResult.pendingMtgSets != null || syncResult.pendingPokemonSets != null) && (
<p>
Still pending: {syncResult.pendingMtgSets} MTG sets,{' '}
{syncResult.pendingPokemonSets} Pokémon sets.
</p>
)}
{syncResult.lorcana && <p>{syncResult.lorcana}</p>}
{Array.isArray(syncResult.setsProcessed) && syncResult.setsProcessed.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{syncResult.setsProcessed.map((set) => (
<li key={`${set.game}-${set.setCode}`}>
{set.name} ({set.game}/{set.setCode}) {set.imported} imported
</li>
))}
</ul>
)}
{Array.isArray(syncResult.reconciliation) && syncResult.reconciliation.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{syncResult.reconciliation.flatMap((entry) =>
entry.details.map((detail) => (
<li key={`submission-${detail.submissionId}`}>
Submission #{detail.submissionId}: {detail.name}
{detail.cardNumber ? ` (${detail.cardNumber})` : ''} card #{detail.cardId}
</li>
))
)}
</ul>
)}
{Array.isArray(syncResult.errors) && syncResult.errors.length > 0 && (
<ul className="list-disc pl-5 space-y-1 text-red-800">
{syncResult.errors.map((entry) => (
<li key={`${entry.game}-${entry.setCode || entry.message}`}>
{entry.setCode
? `${entry.game}/${entry.setCode}: ${entry.message}`
: `${entry.game}: ${entry.message}`}
</li>
))}
</ul>
)}
</div>
) : (
<p className="text-sm text-red-800">{syncResult.message}</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Import Form */}
<div className="space-y-6">
<div className="glass-panel p-6 rounded-xl">
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Import Settings
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
TCG Type
</label>
<select
value={importType}
onChange={(e) => setImportType(e.target.value)}
className="input-field w-full"
>
<option value="mtg">Magic: The Gathering</option>
<option value="pokemon">Pokemon TCG</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Set Code
</label>
<input
type="text"
value={setCode}
onChange={(e) => setSetCode(e.target.value)}
placeholder="e.g., neo, vow, swsh1"
className="input-field w-full"
/>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Enter the set code (usually 3-4 characters)
</p>
</div>
<Button
variant="primary"
size="lg"
onClick={handleImport}
disabled={isImporting || !setCode.trim()}
loading={isImporting}
fullWidth
>
{isImporting ? 'Importing...' : 'Import Cards'}
</Button>
</div>
</div>
{/* Import Result */}
{importResult && (
<div className={`p-6 rounded-xl ${
importResult.success
? 'bg-green-100 border border-green-300'
: 'bg-red-100 border border-red-300'
}`}>
<h3 className={`font-semibold mb-2 ${
importResult.success ? 'text-green-800' : 'text-red-800'
}`}>
{importResult.success ? 'Import Successful' : 'Import Failed'}
</h3>
<p className="text-sm mb-2">
{importResult.message}
</p>
{importResult.success && (
<div className="text-sm space-y-1">
<p>Imported: {importResult.imported}</p>
<p>Skipped: {importResult.skipped}</p>
<p>Total: {importResult.total}</p>
</div>
)}
</div>
)}
</div>
{/* Popular Sets */}
<div className="space-y-6">
<div className="glass-panel p-6 rounded-xl">
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Popular Sets
</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Magic: The Gathering
</h3>
<div className="grid grid-cols-2 gap-2">
{popularSets.mtg.map((set) => (
<button
key={set.code}
onClick={() => {
setImportType('mtg');
setSetCode(set.code);
}}
className="text-left p-2 rounded-lg text-sm hover:bg-opacity-20 transition-all duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)'
}}
>
<div className="font-medium">{set.code.toUpperCase()}</div>
<div className="text-xs opacity-75">{set.name}</div>
</button>
))}
</div>
</div>
<div>
<h3 className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Pokemon TCG
</h3>
<div className="grid grid-cols-2 gap-2">
{popularSets.pokemon.map((set) => (
<button
key={set.code}
onClick={() => {
setImportType('pokemon');
setSetCode(set.code);
}}
className="text-left p-2 rounded-lg text-sm hover:bg-opacity-20 transition-all duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)'
}}
>
<div className="font-medium">{set.code.toUpperCase()}</div>
<div className="text-xs opacity-75">{set.name}</div>
</button>
))}
</div>
</div>
</div>
</div>
{/* Import Tips */}
<div className="glass-panel p-6 rounded-xl">
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Import Tips
</h2>
<ul className="space-y-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<li> Set codes are case-insensitive</li>
<li> Duplicate cards will be skipped automatically</li>
<li> Import may take several minutes for large sets</li>
<li> Prices are fetched from TCGPlayer when available</li>
<li> Images are stored as URLs to external sources</li>
</ul>
</div>
</div>
</div>
</div>
</Layout>
)}
</AdminProtected>
);
};
// Export with dynamic import to disable SSR
export default dynamic(() => Promise.resolve(CardImport), { ssr: false });