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>
427 lines
No EOL
16 KiB
JavaScript
427 lines
No EOL
16 KiB
JavaScript
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 }); |