From 47a1abbe4df88a51fc7ef148197a1afd85818958 Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Wed, 27 May 2026 13:50:54 -0500 Subject: [PATCH] feat(scanner): stack-destination picker and auto-route (Brief 1) (#42) Add ScannerDestinationPicker with game filter and owned/collection/deck targets. Persist session destination in localStorage and auto-add each identified scan to the active destination. Co-authored-by: Cursor --- components/ScannerDestinationPicker.js | 214 +++++++++++++++++++++++++ pages/scanner.js | 175 +++++++++++++++----- 2 files changed, 350 insertions(+), 39 deletions(-) create mode 100644 components/ScannerDestinationPicker.js diff --git a/components/ScannerDestinationPicker.js b/components/ScannerDestinationPicker.js new file mode 100644 index 0000000..617b8a4 --- /dev/null +++ b/components/ScannerDestinationPicker.js @@ -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 ( +
+
+
+

+ Scan destination +

+

+ {destination + ? `Every scan goes to: ${destination.label}` + : 'Choose where scanned cards should land'} +

+
+ +
+ + +
+ Destination type + {[ + { 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 ( + + ); + })} +
+
+
+ + {destination?.type === 'collection' && filteredCollections.length > 0 && ( + + )} + + {destination?.type === 'deck' && filteredDecks.length > 0 && ( + + )} + + {destination?.type === 'collection' && filteredCollections.length === 0 && ( +

+ No collections match this game filter. +

+ )} + + {destination?.type === 'deck' && filteredDecks.length === 0 && ( +

+ No decks match this game filter. +

+ )} +
+ ); +} diff --git a/pages/scanner.js b/pages/scanner.js index 05146e7..f313cfe 100644 --- a/pages/scanner.js +++ b/pages/scanner.js @@ -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); - - // 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 => - existing.name === cardData.name && - existing.set === cardData.set && - !existing.processed - ); - - 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 - }; - return updatedCards; - } else { - console.log(`๐Ÿ†• Adding new card to queue: ${cardData.name}`); - // Add new card to queue - const scannedCard = { - ...cardData, - id: Date.now() + Math.random(), // More unique ID for the queue - name: cardData.name, - set: cardData.set, - quantity: 1, - timestamp: new Date().toISOString(), - processed: false - }; - return [scannedCard, ...prev]; - } - }); + setAutoRouteError(null); + + const existingCardIndex = scannedCards.findIndex((existing) => + existing.name === cardData.name && + existing.set === cardData.set && + !existing.processed + ); + + let cardEntry; + + if (existingCardIndex !== -1) { + const existing = scannedCards[existingCardIndex]; + cardEntry = { + ...existing, + quantity: (existing.quantity || 1) + 1, + timestamp: new Date().toISOString(), + }; + setScannedCards((prev) => { + const updated = [...prev]; + updated[existingCardIndex] = cardEntry; + return updated; + }); + } else { + cardEntry = { + ...cardData, + id: Date.now() + Math.random(), + name: cardData.name, + set: cardData.set, + quantity: 1, + timestamp: new Date().toISOString(), + processed: false, + }; + 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

- 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

+ + + {autoRouteError && ( +
+ {autoRouteError} +
+ )} + {/* Main Content - Full Height */}
{/* Camera Scanner */}