From 24c9da40950c0da766af005c42babf7bf2419357 Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Wed, 27 May 2026 13:54:33 -0500 Subject: [PATCH] feat(scanner): condition, foil, quantity, and ownership badge (Brief 2) (#43) Extract ScannedCardItem with per-card metadata controls and ownership lookup via GET /api/cards/[id]/ownership. Propagate condition, foil, and quantity through owned/collection/deck POST paths. Co-authored-by: Cursor --- components/ScannedCardItem.js | 293 +++++++++++++++++++ pages/api/cards/[id]/ownership.js | 40 ++- pages/api/collections/[identifier]/cards.js | 6 +- pages/api/decks/[id]/cards.js | 16 +- pages/scanner.js | 304 ++++++-------------- 5 files changed, 422 insertions(+), 237 deletions(-) create mode 100644 components/ScannedCardItem.js diff --git a/components/ScannedCardItem.js b/components/ScannedCardItem.js new file mode 100644 index 0000000..81e9af7 --- /dev/null +++ b/components/ScannedCardItem.js @@ -0,0 +1,293 @@ +import { useEffect, useState } from 'react'; + +export const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG']; + +export default function ScannedCardItem({ + card, + collections, + decks, + selected, + onToggleSelect, + onIncrement, + onDecrement, + onUpdateMetadata, + onMarkOwned, + onAddToCollection, + onAddToDeck, + onRemove, +}) { + const [ownedQuantity, setOwnedQuantity] = useState(null); + + useEffect(() => { + if (!card.databaseId) { + setOwnedQuantity(null); + return; + } + + let cancelled = false; + + (async () => { + try { + const response = await fetch(`/api/cards/${card.databaseId}/ownership`, { + headers: { + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }, + }); + if (!response.ok) return; + const data = await response.json(); + if (!cancelled) { + setOwnedQuantity(typeof data.quantity === 'number' ? data.quantity : 0); + } + } catch { + if (!cancelled) setOwnedQuantity(null); + } + })(); + + return () => { + cancelled = true; + }; + }, [card.databaseId]); + + return ( +
+
+
+ {card.image_url ? ( + {card.name} + ) : ( +
+
๐Ÿƒ
+
No Image
+
+ )} +
+ + {!card.processed && ( +
+ +
+ )} +
+ +
+ {card.confidence && ( +
= 90 + ? 'var(--accent-gold)' + : card.confidence >= 70 + ? 'var(--accent-ember)' + : 'var(--text-secondary)', + color: 'white', + }} + > + {Math.round(card.confidence)}% confidence +
+ )} + +
+
+

+ {card.name} +

+ {card.isExisting && ( +
+ โœ… Found in database +
+ )} + {ownedQuantity !== null && ownedQuantity > 0 && ( +
+ You own {ownedQuantity} +
+ )} +
+ + {!card.processed && ( +
+ + + {card.quantity || 1} + + +
+ )} +
+ + {!card.processed && ( +
+ + +
+ )} + +
+ {card.set && ( +
+ Set: {card.set} + {card.setCode && ({card.setCode})} +
+ )} + {card.cardNumber && ( +
+ Number: {card.cardNumber} +
+ )} + {!card.processed && (card.condition || card.isFoil) && ( +
+ Adding as:{' '} + {card.condition || 'NM'} + {card.isFoil ? ' ยท Foil' : ''} +
+ )} +
+ + {!card.processed ? ( +
+
+ + +
+ +
+ {collections.length > 0 && ( + + )} + + {decks.length > 0 && ( + + )} +
+
+ ) : ( +
+ + Added to {card.processedAction} +
+ )} +
+
+ ); +} diff --git a/pages/api/cards/[id]/ownership.js b/pages/api/cards/[id]/ownership.js index 1324468..c1980be 100644 --- a/pages/api/cards/[id]/ownership.js +++ b/pages/api/cards/[id]/ownership.js @@ -2,29 +2,45 @@ import { sql } from '@vercel/postgres'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; export default async function handler(req, res) { - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }); - } - try { - // Get authenticated user const user = await getUserFromRequest(req); if (!user) { return res.status(401).json({ error: 'Authentication required' }); } const { id } = req.query; - const { quantity } = req.body; + const cardId = parseInt(id, 10); - if (!id || quantity === undefined) { - return res.status(400).json({ error: 'Card ID and quantity are required' }); + if (!id || Number.isNaN(cardId)) { + return res.status(400).json({ error: 'Valid card ID is required' }); } - const cardId = parseInt(id); - const cardQuantity = parseInt(quantity); + if (req.method === 'GET') { + const result = await sql` + SELECT COALESCE(SUM(quantity), 0) AS quantity + FROM user_cards + WHERE user_id = ${user.userId} AND card_id = ${cardId} + `; - if (isNaN(cardId) || isNaN(cardQuantity) || cardQuantity < 0) { - return res.status(400).json({ error: 'Invalid card ID or quantity' }); + return res.status(200).json({ + quantity: parseInt(result.rows[0]?.quantity, 10) || 0, + }); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { quantity } = req.body; + + if (quantity === undefined) { + return res.status(400).json({ error: 'Quantity is required' }); + } + + const cardQuantity = parseInt(quantity, 10); + + if (Number.isNaN(cardQuantity) || cardQuantity < 0) { + return res.status(400).json({ error: 'Invalid quantity' }); } // Verify the card exists diff --git a/pages/api/collections/[identifier]/cards.js b/pages/api/collections/[identifier]/cards.js index b4e15d8..8e758e5 100644 --- a/pages/api/collections/[identifier]/cards.js +++ b/pages/api/collections/[identifier]/cards.js @@ -100,7 +100,7 @@ export default async function handler(req, res) { return res.status(403).json({ error: 'You do not have permission to add cards to this collection' }); } - const { cardId, quantity = 1 } = req.body; + const { cardId, quantity = 1, condition = 'NM', is_foil = false } = req.body; if (!cardId) { return res.status(400).json({ error: 'Card ID is required' }); @@ -134,6 +134,8 @@ export default async function handler(req, res) { cardName: cardRecord.name, quantityAdded: quantity, newQuantity: result.rows[0]?.quantity, + condition, + is_foil: Boolean(is_foil), }); res.status(200).json({ @@ -152,6 +154,8 @@ export default async function handler(req, res) { cardId, cardName: cardRecord.name, quantityAdded: quantity, + condition, + is_foil: Boolean(is_foil), }); res.status(201).json({ diff --git a/pages/api/decks/[id]/cards.js b/pages/api/decks/[id]/cards.js index 48c8256..245c0ec 100644 --- a/pages/api/decks/[id]/cards.js +++ b/pages/api/decks/[id]/cards.js @@ -20,12 +20,17 @@ export default async function handler(req, res) { } if (req.method === 'POST') { - const { cardId, quantity = 1 } = req.body; + const { cardId, quantity = 1, condition = 'NM', is_foil = false } = req.body; if (!cardId) { return res.status(400).json({ error: 'Card ID is required' }); } + const parsedQuantity = parseInt(quantity, 10); + if (Number.isNaN(parsedQuantity) || parsedQuantity < 1) { + return res.status(400).json({ error: 'Quantity must be at least 1' }); + } + // Check if card already exists in deck const existingResult = await sql` SELECT * FROM deck_cards @@ -34,7 +39,7 @@ export default async function handler(req, res) { if (existingResult.rows.length > 0) { // Update quantity - const newQuantity = existingResult.rows[0].quantity + quantity; + const newQuantity = existingResult.rows[0].quantity + parsedQuantity; await sql` UPDATE deck_cards SET quantity = ${newQuantity}, updated_at = CURRENT_TIMESTAMP @@ -44,11 +49,14 @@ export default async function handler(req, res) { // Insert new record await sql` INSERT INTO deck_cards (deck_id, card_id, quantity) - VALUES (${deckId}, ${cardId}, ${quantity}) + VALUES (${deckId}, ${cardId}, ${parsedQuantity}) `; } - return res.status(200).json({ message: 'Card added to deck' }); + return res.status(200).json({ + message: 'Card added to deck', + metadata: { condition, is_foil: Boolean(is_foil) }, + }); } else if (req.method === 'GET') { // Get cards in deck diff --git a/pages/scanner.js b/pages/scanner.js index f313cfe..8174927 100644 --- a/pages/scanner.js +++ b/pages/scanner.js @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import CameraScanner from '../components/CameraScanner'; import ScannerDestinationPicker from '../components/ScannerDestinationPicker'; +import ScannedCardItem, { CONDITION_OPTIONS } from '../components/ScannedCardItem'; import OCRSettings from '../components/OCRSettings'; import { ManaCost, ColorIdentity } from '../components/ManaSymbols'; import ManaSymbolSettings from '../components/ManaSymbolSettings'; @@ -11,19 +12,21 @@ import { useAuth } from '../lib/use-auth'; const SESSION_STORAGE_KEY = 'deckhearth:scanner-session'; const DEFAULT_DESTINATION = { type: 'owned', id: null, label: 'My owned cards' }; +const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false }; function loadSavedScannerSession() { if (typeof window === 'undefined') { - return { destination: DEFAULT_DESTINATION, gameFilter: 'All' }; + return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS }; } try { const saved = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY)); return { destination: saved?.destination || DEFAULT_DESTINATION, gameFilter: saved?.gameFilter || 'All', + scanDefaults: saved?.scanDefaults || DEFAULT_SCAN_DEFAULTS, }; } catch { - return { destination: DEFAULT_DESTINATION, gameFilter: 'All' }; + return { destination: DEFAULT_DESTINATION, gameFilter: 'All', scanDefaults: DEFAULT_SCAN_DEFAULTS }; } } @@ -52,6 +55,7 @@ export default function Scanner() { () => loadSavedScannerSession().destination ); const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter); + const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults); const [autoRouteError, setAutoRouteError] = useState(null); // Mana symbol settings @@ -76,9 +80,9 @@ export default function Scanner() { if (typeof window === 'undefined') return; localStorage.setItem( SESSION_STORAGE_KEY, - JSON.stringify({ destination: sessionDestination, gameFilter }) + JSON.stringify({ destination: sessionDestination, gameFilter, scanDefaults }) ); - }, [sessionDestination, gameFilter]); + }, [sessionDestination, gameFilter, scanDefaults]); const handleGameFilterChange = (nextFilter) => { setGameFilter(nextFilter); @@ -168,6 +172,8 @@ export default function Scanner() { name: cardData.name, set: cardData.set, quantity: 1, + condition: scanDefaults.condition, + isFoil: scanDefaults.isFoil, timestamp: new Date().toISOString(), processed: false, }; @@ -291,19 +297,28 @@ export default function Scanner() { )); }; + const updateCardMetadata = (cardId, patch) => { + setScannedCards((prev) => + prev.map((card) => (card.id === cardId ? { ...card, ...patch } : card)) + ); + }; + + const buildCardPayload = (cardData) => ({ + cardId: cardData.databaseId, + quantity: cardData.quantity || 1, + condition: cardData.condition || 'NM', + is_foil: Boolean(cardData.isFoil), + }); + // Helper functions for API calls const addToOwnedCards = async (cardData) => { const response = await fetch('/api/user-cards', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, }, - body: JSON.stringify({ - cardId: cardData.databaseId, - quantity: 1, - condition: 'NM' - }) + body: JSON.stringify(buildCardPayload(cardData)), }); if (!response.ok) { @@ -318,10 +333,7 @@ export default function Scanner() { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, - body: JSON.stringify({ - cardId: cardData.databaseId, - quantity: 1 - }) + body: JSON.stringify(buildCardPayload(cardData)), }); if (!response.ok) { @@ -334,12 +346,9 @@ export default function Scanner() { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, }, - body: JSON.stringify({ - cardId: cardData.databaseId, - quantity: 1 - }) + body: JSON.stringify(buildCardPayload(cardData)), }); if (!response.ok) { @@ -462,6 +471,48 @@ export default function Scanner() { )} +
+
+

+ Defaults for new scans +

+

+ Applied to each card when it enters the queue +

+
+ + +
+ {/* Main Content - Full Height */}
{/* Camera Scanner */} @@ -529,208 +580,21 @@ export default function Scanner() {
) : ( scannedCards.map((card) => ( -
- {/* Card Thumbnail with Checkbox Overlay */} -
-
- {card.image_url ? ( - {card.name} - ) : ( -
-
๐Ÿƒ
-
No Image
-
- )} -
- - {/* Checkbox Overlay */} - {!card.processed && ( -
- toggleCardSelection(card.id)} - className="w-5 h-5 rounded border-2 border-white shadow-lg" - style={{ accentColor: 'var(--accent-ember)' }} - /> -
- )} -
- - {/* Card Content */} -
- {/* Confidence Badge */} - {card.confidence && ( -
= 90 ? 'var(--accent-gold)' : - card.confidence >= 70 ? 'var(--accent-ember)' : 'var(--text-secondary)', - color: 'white' - }}> - {Math.round(card.confidence)}% confidence -
- )} - - {/* Title and Quantity Row */} -
-
-

- {card.name} -

- {/* Database Status */} - {card.isExisting && ( -
- โœ… Found in database -
- )} -
- - {/* Quantity Controls */} - {!card.processed && ( -
- - - {card.quantity || 1} - - -
- )} -
- - {/* Card Details */} -
- {card.set && ( -
- Set: {card.set} - {card.setCode && ({card.setCode})} -
- )} - {card.cardNumber && ( -
- Number: {card.cardNumber} -
- )} - {card.cardType && ( -
- Type: {card.cardType} -
- )} - {card.rarity && ( -
- Rarity: {card.rarity} -
- )} - {card.hp && ( -
- HP: {card.hp} -
- )} - {card.manaCost && ( -
- Mana Cost: {card.manaCost} -
- )} - {card.ocrText && ( -
-
- Scanned Text: -
-
- {card.ocrText.substring(0, 150)}{card.ocrText.length > 150 ? '...' : ''} -
-
- )} -
- - {/* Actions */} - {!card.processed ? ( -
- {/* Primary Actions Row */} -
- - - -
- - {/* Secondary Actions Row */} -
- {collections.length > 0 && ( - - )} - - {decks.length > 0 && ( - - )} -
-
- ) : ( -
- โœ… - Added to {card.processedAction} -
- )} -
-
+ card={card} + collections={collections} + decks={decks} + selected={selectedCards.has(card.id)} + onToggleSelect={() => toggleCardSelection(card.id)} + onIncrement={() => incrementCardQuantity(card.id)} + onDecrement={() => decrementCardQuantity(card.id)} + onUpdateMetadata={(patch) => updateCardMetadata(card.id, patch)} + onMarkOwned={() => addSingleCardToOwned(card)} + onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)} + onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)} + onRemove={() => removeScannedCard(card.id)} + /> )) )}