deckhearth/pages/admin/card-import.js
Randall Stillwell ef08398149 Auto-link pending scan submissions after catalog sync imports.
When a set lands via runCatalogSync, match pending card_submissions by set/name/number to catalog rows and approve them with promoted_card_id instead of leaving them in the admin queue.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 09:46:11 -05:00

427 lines
No EOL
16 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';
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 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')}`,
},
});
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="mb-8 p-4 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<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
onClick={() => router.push('/admin/card-editor')}
className="px-4 py-2 rounded-lg font-medium transition-all duration-200 border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
🖊 Card Editor
</button>
<button
onClick={() => router.push('/admin/card-import')}
className="px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white"
>
📥 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="mb-8 p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<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)' }}>
Import up to three newest missing MTG and Pokémon sets the same job the weekly Vercel cron runs.
Pending scan submissions for those sets are auto-linked to the catalog when a unique match exists.
</p>
</div>
<button
type="button"
onClick={handleCatalogSync}
disabled={isSyncing}
className={`shrink-0 py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isSyncing
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-purple text-white hover:shadow-lg'
}`}
>
{isSyncing ? 'Syncing catalog…' : 'Run catalog 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">
<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.game}/{entry.setCode}: {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="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<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
onClick={handleImport}
disabled={isImporting || !setCode.trim()}
className={`w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isImporting || !setCode.trim()
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-purple text-white hover:shadow-lg'
}`}
>
{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="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<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="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<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 });