feat(scanner): stack-destination picker and auto-route (Brief 1) #42

Merged
varutasu merged 1 commit from feat/redesign-scanner-flow-brief-1 into main 2026-05-27 14:50:54 -04:00
2 changed files with 350 additions and 39 deletions

View file

@ -0,0 +1,214 @@
const GAME_OPTIONS = [
{ value: 'All', label: 'All games' },
{ value: 'MTG', label: 'Magic' },
{ value: 'Pokemon', label: 'Pokémon' },
{ value: 'Lorcana', label: 'Lorcana' },
];
function matchesGameFilter(itemGame, filter) {
if (filter === 'All') return true;
const normalized = (itemGame || '').toLowerCase();
if (filter === 'MTG') return normalized === 'mtg' || normalized.includes('magic');
if (filter === 'Pokemon') return normalized.includes('pokemon') || normalized.includes('pokémon');
if (filter === 'Lorcana') return normalized.includes('lorcana');
return itemGame === filter;
}
export default function ScannerDestinationPicker({
gameFilter,
onGameFilterChange,
destination,
onDestinationChange,
collections = [],
decks = [],
disabled = false,
}) {
const filteredCollections = collections.filter(
(collection) => matchesGameFilter(collection.tcg, gameFilter) || collection.tcg === 'All'
);
const filteredDecks = decks.filter((deck) => matchesGameFilter(deck.game, gameFilter));
const handleTypeChange = (type) => {
if (type === 'owned') {
onDestinationChange({ type: 'owned', id: null, label: 'My owned cards' });
return;
}
if (type === 'collection' && filteredCollections.length > 0) {
const first = filteredCollections[0];
onDestinationChange({
type: 'collection',
id: first.id,
label: first.name,
});
return;
}
if (type === 'deck' && filteredDecks.length > 0) {
const first = filteredDecks[0];
onDestinationChange({
type: 'deck',
id: first.id,
label: first.name,
});
}
};
const handleTargetChange = (event) => {
const value = event.target.value;
if (!value || !destination) return;
if (destination.type === 'collection') {
const collection = filteredCollections.find((item) => String(item.id) === value);
if (collection) {
onDestinationChange({
type: 'collection',
id: collection.id,
label: collection.name,
});
}
}
if (destination.type === 'deck') {
const deck = filteredDecks.find((item) => String(item.id) === value);
if (deck) {
onDestinationChange({
type: 'deck',
id: deck.id,
label: deck.name,
});
}
}
};
return (
<section
className="mx-6 mb-4 rounded-xl border p-4"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
aria-label="Scan destination"
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
Scan destination
</h2>
<p className="text-sm" style={{ color: 'var(--text-primary)' }}>
{destination
? `Every scan goes to: ${destination.label}`
: 'Choose where scanned cards should land'}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
<label className="flex flex-col gap-1 text-sm">
<span style={{ color: 'var(--text-secondary)' }}>Game filter</span>
<select
value={gameFilter}
onChange={(e) => onGameFilterChange(e.target.value)}
disabled={disabled}
className="px-3 py-2 rounded-lg border min-w-[10rem]"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
{GAME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<fieldset className="flex flex-wrap gap-2" disabled={disabled}>
<legend className="sr-only">Destination type</legend>
{[
{ type: 'owned', label: '💎 Owned' },
{ type: 'collection', label: '📚 Collection' },
{ type: 'deck', label: '🃏 Deck' },
].map((option) => {
const isActive = destination?.type === option.type;
const isDisabled =
(option.type === 'collection' && filteredCollections.length === 0) ||
(option.type === 'deck' && filteredDecks.length === 0);
return (
<button
key={option.type}
type="button"
onClick={() => handleTypeChange(option.type)}
disabled={isDisabled}
aria-pressed={isActive}
className="px-3 py-2 rounded-lg text-sm font-medium border disabled:opacity-40"
style={{
backgroundColor: isActive ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
borderColor: isActive ? 'var(--accent-ember)' : 'var(--border)',
color: isActive ? 'white' : 'var(--text-primary)',
}}
>
{option.label}
</button>
);
})}
</fieldset>
</div>
</div>
{destination?.type === 'collection' && filteredCollections.length > 0 && (
<label className="flex flex-col gap-1 mt-4 text-sm">
<span style={{ color: 'var(--text-secondary)' }}>Collection</span>
<select
value={String(destination.id)}
onChange={handleTargetChange}
disabled={disabled}
className="px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
{filteredCollections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
</label>
)}
{destination?.type === 'deck' && filteredDecks.length > 0 && (
<label className="flex flex-col gap-1 mt-4 text-sm">
<span style={{ color: 'var(--text-secondary)' }}>Deck</span>
<select
value={String(destination.id)}
onChange={handleTargetChange}
disabled={disabled}
className="px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
{filteredDecks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name} ({deck.game})
</option>
))}
</select>
</label>
)}
{destination?.type === 'collection' && filteredCollections.length === 0 && (
<p className="mt-3 text-sm" style={{ color: 'var(--text-secondary)' }}>
No collections match this game filter.
</p>
)}
{destination?.type === 'deck' && filteredDecks.length === 0 && (
<p className="mt-3 text-sm" style={{ color: 'var(--text-secondary)' }}>
No decks match this game filter.
</p>
)}
</section>
);
}

View file

@ -2,11 +2,36 @@ import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import CameraScanner from '../components/CameraScanner';
import ScannerDestinationPicker from '../components/ScannerDestinationPicker';
import OCRSettings from '../components/OCRSettings';
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/use-auth';
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: 'My owned cards' };
function loadSavedScannerSession() {
if (typeof window === 'undefined') {
return { destination: DEFAULT_DESTINATION, gameFilter: 'All' };
}
try {
const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY));
return {
destination: saved?.destination || DEFAULT_DESTINATION,
gameFilter: saved?.gameFilter || 'All',
};
} catch {
return { destination: DEFAULT_DESTINATION, gameFilter: 'All' };
}
}
function destinationActionKey(destination) {
if (!destination) return 'pending';
return destination.type === 'owned' ? 'owned' : destination.type;
}
export default function Scanner() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
@ -23,6 +48,11 @@ export default function Scanner() {
const [bulkTarget, setBulkTarget] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const addingInFlightRef = useRef(new Set());
const [sessionDestination, setSessionDestination] = useState(
() => loadSavedScannerSession().destination
);
const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
const [autoRouteError, setAutoRouteError] = useState(null);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
@ -42,6 +72,38 @@ export default function Scanner() {
}
}, [user]);
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({ destination: sessionDestination, gameFilter })
);
}, [sessionDestination, gameFilter]);
const handleGameFilterChange = (nextFilter) => {
setGameFilter(nextFilter);
setSessionDestination((current) => {
if (!current || current.type === 'owned') return current;
return DEFAULT_DESTINATION;
});
};
const routeCardToDestination = async (card, destination) => {
if (!destination || !card.databaseId) return false;
if (destination.type === 'owned') {
await addToOwnedCards(card);
} else if (destination.type === 'collection') {
await addToCollection(card, destination.id);
} else if (destination.type === 'deck') {
await addToDeck(card, destination.id);
} else {
return false;
}
return true;
};
const loadCollections = async () => {
try {
const response = await fetch('/api/collections', {
@ -77,44 +139,55 @@ export default function Scanner() {
};
const handleCardScanned = async (cardData) => {
console.log('Card scanned:', cardData);
console.log('Looking for existing card with name:', cardData.name, 'set:', cardData.set);
setAutoRouteError(null);
// Check if this card already exists in the queue
setScannedCards(prev => {
console.log('Current queue:', prev.map(c => ({ name: c.name, set: c.set, processed: c.processed })));
const existingCardIndex = prev.findIndex(existing =>
const existingCardIndex = scannedCards.findIndex((existing) =>
existing.name === cardData.name &&
existing.set === cardData.set &&
!existing.processed
);
let cardEntry;
if (existingCardIndex !== -1) {
console.log(`📈 Incrementing quantity for existing card: ${cardData.name}`);
// Increment quantity of existing card
const updatedCards = [...prev];
updatedCards[existingCardIndex] = {
...updatedCards[existingCardIndex],
quantity: (updatedCards[existingCardIndex].quantity || 1) + 1,
timestamp: new Date().toISOString() // Update timestamp
const existing = scannedCards[existingCardIndex];
cardEntry = {
...existing,
quantity: (existing.quantity || 1) + 1,
timestamp: new Date().toISOString(),
};
return updatedCards;
setScannedCards((prev) => {
const updated = [...prev];
updated[existingCardIndex] = cardEntry;
return updated;
});
} else {
console.log(`🆕 Adding new card to queue: ${cardData.name}`);
// Add new card to queue
const scannedCard = {
cardEntry = {
...cardData,
id: Date.now() + Math.random(), // More unique ID for the queue
id: Date.now() + Math.random(),
name: cardData.name,
set: cardData.set,
quantity: 1,
timestamp: new Date().toISOString(),
processed: false
processed: false,
};
return [scannedCard, ...prev];
setScannedCards((prev) => [cardEntry, ...prev]);
}
if (!sessionDestination) return;
if (!cardEntry.databaseId) {
setAutoRouteError(`"${cardEntry.name}" was queued but is not in the catalog yet — add it manually after review.`);
return;
}
try {
await routeCardToDestination(cardEntry, sessionDestination);
markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination));
} catch (error) {
console.error('Auto-route failed:', error);
setAutoRouteError(`Could not add "${cardEntry.name}" to ${sessionDestination.label}. Use the card actions below.`);
}
});
};
// Quantity management functions
@ -361,10 +434,34 @@ export default function Scanner() {
🃏 Card Scanner
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Scan cards to identify them, then choose what to do with your collection
Pick a destination once every scan lands there until you change it
</p>
</div>
<ScannerDestinationPicker
gameFilter={gameFilter}
onGameFilterChange={handleGameFilterChange}
destination={sessionDestination}
onDestinationChange={setSessionDestination}
collections={collections}
decks={decks}
disabled={isProcessing}
/>
{autoRouteError && (
<div
className="mx-6 mb-4 px-4 py-3 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--accent-flame)',
color: 'var(--text-primary)',
}}
role="alert"
>
{autoRouteError}
</div>
)}
{/* Main Content - Full Height */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-5 gap-6 px-6 pb-6">
{/* Camera Scanner */}