Implement complete card scanning and collection flow - ready for local testing

This commit is contained in:
Randall Stillwell 2025-07-22 08:01:20 -05:00
parent 08e2664a9a
commit e9b2da09a5
4 changed files with 702 additions and 9 deletions

169
api/cards/find-or-create.js Normal file
View file

@ -0,0 +1,169 @@
const { Pool } = require('pg');
const jwt = require('jsonwebtoken');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
// Middleware to verify user authentication
function verifyAuth(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('No token provided');
}
const token = authHeader.substring(7);
const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
try {
const decoded = jwt.verify(token, jwtSecret);
return decoded;
} catch (error) {
throw new Error('Invalid token');
}
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const client = await pool.connect();
try {
// Verify authentication
const user = verifyAuth(req);
const {
name,
game,
setName = null,
setCode = null,
rarity = null,
cardType = null,
manaCost = null,
ocrConfidence = null,
ocrRawText = null,
imageUrl = null
} = req.body;
// Validate required fields
if (!name || !game) {
return res.status(400).json({
error: 'Card name and game are required'
});
}
// Try to find existing card first
let searchQuery = `
SELECT id, name, set_name, set_code, rarity, game, card_type, mana_cost,
current_price, stock_image_url, image_url, verified
FROM cards
WHERE LOWER(name) = LOWER($1) AND UPPER(game) = UPPER($2)
`;
let searchParams = [name.trim(), game.trim()];
// If set name is provided, try to match more specifically
if (setName) {
searchQuery += ` AND (set_name IS NULL OR LOWER(set_name) = LOWER($3))`;
searchParams.push(setName.trim());
}
searchQuery += ` ORDER BY verified DESC, created_at DESC LIMIT 1`;
const existingCard = await client.query(searchQuery, searchParams);
if (existingCard.rows.length > 0) {
// Card found, return it
const card = existingCard.rows[0];
res.status(200).json({
success: true,
found: true,
message: 'Card found in database',
card: {
id: card.id,
name: card.name,
setName: card.set_name,
setCode: card.set_code,
rarity: card.rarity,
game: card.game,
cardType: card.card_type,
manaCost: card.mana_cost,
currentPrice: card.current_price,
stockImageUrl: card.stock_image_url,
imageUrl: card.image_url,
verified: card.verified
}
});
} else {
// Card not found, create new one
const insertQuery = `
INSERT INTO cards (
name, game, set_name, set_code, rarity, card_type, mana_cost,
ocr_confidence, ocr_raw_text, image_url, verified, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
RETURNING id, name, set_name, set_code, rarity, game, card_type, mana_cost,
ocr_confidence, ocr_raw_text, image_url, verified, created_at
`;
const insertParams = [
name.trim(),
game.trim().toUpperCase(),
setName?.trim() || null,
setCode?.trim() || null,
rarity?.trim() || null,
cardType?.trim() || null,
manaCost?.trim() || null,
ocrConfidence || null,
ocrRawText?.trim() || null,
imageUrl?.trim() || null,
false // New cards from OCR are unverified by default
];
const newCard = await client.query(insertQuery, insertParams);
const card = newCard.rows[0];
res.status(201).json({
success: true,
found: false,
message: 'New card created from scan data',
card: {
id: card.id,
name: card.name,
setName: card.set_name,
setCode: card.set_code,
rarity: card.rarity,
game: card.game,
cardType: card.card_type,
manaCost: card.mana_cost,
currentPrice: null,
stockImageUrl: null,
imageUrl: card.image_url,
verified: card.verified,
ocrConfidence: card.ocr_confidence,
ocrRawText: card.ocr_raw_text,
createdAt: card.created_at
}
});
}
} catch (error) {
console.error('Find or create card error:', error);
if (error.message === 'No token provided' || error.message === 'Invalid token') {
res.status(401).json({ error: error.message });
} else {
res.status(500).json({
error: 'Internal server error',
details: error.message
});
}
} finally {
client.release();
}
}

174
api/collections/add-card.js Normal file
View file

@ -0,0 +1,174 @@
const { Pool } = require('pg');
const jwt = require('jsonwebtoken');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
// Middleware to verify user authentication
function verifyAuth(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('No token provided');
}
const token = authHeader.substring(7);
const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
try {
const decoded = jwt.verify(token, jwtSecret);
return decoded;
} catch (error) {
throw new Error('Invalid token');
}
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const client = await pool.connect();
try {
// Verify authentication
const user = verifyAuth(req);
const {
collectionId,
cardId,
quantity = 1,
condition = 'near-mint',
notes = null,
purchasePrice = null
} = req.body;
// Validate required fields
if (!collectionId || !cardId) {
return res.status(400).json({
error: 'Collection ID and Card ID are required'
});
}
// Verify collection ownership
const collectionCheck = await client.query(
'SELECT id, name FROM user_collections WHERE id = $1 AND user_id = $2',
[collectionId, user.userId]
);
if (collectionCheck.rows.length === 0) {
return res.status(404).json({
error: 'Collection not found or access denied'
});
}
// Verify card exists
const cardCheck = await client.query(
'SELECT id, name, game, rarity, current_price FROM cards WHERE id = $1',
[cardId]
);
if (cardCheck.rows.length === 0) {
return res.status(404).json({
error: 'Card not found'
});
}
const card = cardCheck.rows[0];
const collection = collectionCheck.rows[0];
// Check if card already exists in collection with same condition
const existingCard = await client.query(`
SELECT id, quantity
FROM collection_cards
WHERE collection_id = $1 AND card_id = $2 AND condition = $3
`, [collectionId, cardId, condition]);
let result;
if (existingCard.rows.length > 0) {
// Update existing entry - increase quantity
const newQuantity = existingCard.rows[0].quantity + quantity;
await client.query(`
UPDATE collection_cards
SET quantity = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`, [newQuantity, existingCard.rows[0].id]);
result = {
action: 'updated',
previousQuantity: existingCard.rows[0].quantity,
newQuantity: newQuantity
};
} else {
// Create new collection card entry
const insertResult = await client.query(`
INSERT INTO collection_cards (
collection_id, card_id, quantity, condition, notes, purchase_price
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, added_at
`, [collectionId, cardId, quantity, condition, notes, purchasePrice]);
result = {
action: 'added',
collectionCardId: insertResult.rows[0].id,
addedAt: insertResult.rows[0].added_at
};
}
// Get updated collection stats
const statsQuery = await client.query(`
SELECT
COUNT(cc.id) as total_entries,
COALESCE(SUM(cc.quantity), 0) as total_cards,
COALESCE(SUM(cc.quantity * COALESCE(c.current_price, 0)), 0) as total_value
FROM collection_cards cc
LEFT JOIN cards c ON cc.card_id = c.id
WHERE cc.collection_id = $1
`, [collectionId]);
const stats = statsQuery.rows[0];
res.status(200).json({
success: true,
message: `Card ${result.action} successfully`,
result: {
...result,
card: {
id: card.id,
name: card.name,
game: card.game,
rarity: card.rarity,
currentPrice: card.current_price
},
collection: {
id: collection.id,
name: collection.name
},
quantity: quantity,
condition: condition,
collectionStats: {
totalEntries: parseInt(stats.total_entries),
totalCards: parseInt(stats.total_cards),
totalValue: parseFloat(stats.total_value)
}
}
});
} catch (error) {
console.error('Add card to collection error:', error);
if (error.message === 'No token provided' || error.message === 'Invalid token') {
res.status(401).json({ error: error.message });
} else {
res.status(500).json({
error: 'Internal server error',
details: error.message
});
}
} finally {
client.release();
}
}

121
api/collections/index.js Normal file
View file

@ -0,0 +1,121 @@
const { Pool } = require('pg');
const jwt = require('jsonwebtoken');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
// Middleware to verify user authentication
function verifyAuth(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('No token provided');
}
const token = authHeader.substring(7);
const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
try {
const decoded = jwt.verify(token, jwtSecret);
return decoded;
} catch (error) {
throw new Error('Invalid token');
}
}
export default async function handler(req, res) {
const client = await pool.connect();
try {
// Verify authentication
const user = verifyAuth(req);
if (req.method === 'GET') {
// Get user's collections
const collectionsQuery = `
SELECT
uc.id, uc.name, uc.description, uc.is_public, uc.created_at, uc.updated_at,
COUNT(cc.id) as total_cards,
COALESCE(SUM(cc.quantity * COALESCE(c.current_price, 0)), 0) as total_value
FROM user_collections uc
LEFT JOIN collection_cards cc ON uc.id = cc.collection_id
LEFT JOIN cards c ON cc.card_id = c.id
WHERE uc.user_id = $1
GROUP BY uc.id, uc.name, uc.description, uc.is_public, uc.created_at, uc.updated_at
ORDER BY uc.created_at DESC
`;
const collections = await client.query(collectionsQuery, [user.userId]);
res.status(200).json({
success: true,
collections: collections.rows.map(collection => ({
id: collection.id,
name: collection.name,
description: collection.description,
isPublic: collection.is_public,
totalCards: parseInt(collection.total_cards) || 0,
totalValue: parseFloat(collection.total_value) || 0,
createdAt: collection.created_at,
updatedAt: collection.updated_at
}))
});
} else if (req.method === 'POST') {
// Create new collection
const { name, description, isPublic = false } = req.body;
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Collection name is required' });
}
const insertQuery = `
INSERT INTO user_collections (user_id, name, description, is_public)
VALUES ($1, $2, $3, $4)
RETURNING id, name, description, is_public, created_at, updated_at
`;
const result = await client.query(insertQuery, [
user.userId,
name.trim(),
description?.trim() || null,
isPublic
]);
const newCollection = result.rows[0];
res.status(201).json({
success: true,
message: 'Collection created successfully',
collection: {
id: newCollection.id,
name: newCollection.name,
description: newCollection.description,
isPublic: newCollection.is_public,
totalCards: 0,
totalValue: 0,
createdAt: newCollection.created_at,
updatedAt: newCollection.updated_at
}
});
} else {
res.status(405).json({ error: 'Method not allowed' });
}
} catch (error) {
console.error('Collections API error:', error);
if (error.message === 'No token provided' || error.message === 'Invalid token') {
res.status(401).json({ error: error.message });
} else {
res.status(500).json({
error: 'Internal server error',
details: error.message
});
}
} finally {
client.release();
}
}

View file

@ -1,8 +1,9 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import CameraScanner from '../components/CameraScanner'; import CameraScanner from '../components/CameraScanner';
import GlowingCard from '../components/GlowingCard'; import GlowingCard from '../components/GlowingCard';
import CardImageDisplay from '../components/CardImageDisplay'; import CardImageDisplay from '../components/CardImageDisplay';
import { cardMatcher } from '../services/cardMatcher'; import { cardMatcher } from '../services/cardMatcher';
import { useAuth } from '../contexts/AuthContext';
interface ScannedCard { interface ScannedCard {
id: string; id: string;
@ -14,11 +15,91 @@ interface ScannedCard {
timestamp: number; timestamp: number;
} }
interface Collection {
id: string;
name: string;
description?: string;
totalCards: number;
totalValue: number;
}
const Scanner: React.FC = () => { const Scanner: React.FC = () => {
const { user } = useAuth();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [scannedCards, setScannedCards] = useState<ScannedCard[]>([]); const [scannedCards, setScannedCards] = useState<ScannedCard[]>([]);
const [isProcessingMatch, setIsProcessingMatch] = useState(false); const [isProcessingMatch, setIsProcessingMatch] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollectionId, setSelectedCollectionId] = useState<string | null>(null);
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
const [isAddingToCollection, setIsAddingToCollection] = useState(false);
// Load user's collections on component mount
useEffect(() => {
if (user) {
loadCollections();
}
}, [user]);
const loadCollections = async () => {
setIsLoadingCollections(true);
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/collections', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setCollections(data.collections || []);
// Auto-select first collection if available
if (data.collections && data.collections.length > 0) {
setSelectedCollectionId(data.collections[0].id);
}
} else {
console.error('Failed to load collections');
}
} catch (error) {
console.error('Error loading collections:', error);
} finally {
setIsLoadingCollections(false);
}
};
const createNewCollection = async (name: string, description?: string) => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/collections', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name.trim(),
description: description?.trim() || null,
isPublic: false
})
});
if (response.ok) {
const data = await response.json();
const newCollection = data.collection;
setCollections(prev => [newCollection, ...prev]);
setSelectedCollectionId(newCollection.id);
return newCollection;
} else {
throw new Error('Failed to create collection');
}
} catch (error) {
console.error('Error creating collection:', error);
throw error;
}
};
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -90,14 +171,92 @@ const Scanner: React.FC = () => {
const scannedCard = scannedCards.find(c => c.id === scannedCardId); const scannedCard = scannedCards.find(c => c.id === scannedCardId);
if (!scannedCard || !scannedCard.selectedMatch) return; if (!scannedCard || !scannedCard.selectedMatch) return;
if (!selectedCollectionId) {
setError('Please select a collection first.');
return;
}
setIsAddingToCollection(true);
try { try {
// TODO: Implement collection service const token = localStorage.getItem('token');
console.log('Adding to collection:', scannedCard.selectedMatch);
// For now, just remove from scan results // First, ensure the card exists in our database
removeScannedCard(scannedCardId); let cardId = scannedCard.selectedMatch.id;
} catch (err) {
if (!cardId) {
// Need to find or create the card
const findOrCreateResponse = await fetch('/api/cards/find-or-create', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: scannedCard.selectedMatch.name,
game: scannedCard.selectedMatch.game || 'MTG',
setName: scannedCard.selectedMatch.set_name,
setCode: scannedCard.selectedMatch.set_code,
rarity: scannedCard.selectedMatch.rarity,
cardType: scannedCard.selectedMatch.card_type,
manaCost: scannedCard.selectedMatch.mana_cost,
ocrConfidence: scannedCard.ocrConfidence,
ocrRawText: scannedCard.ocrText
})
});
if (findOrCreateResponse.ok) {
const cardData = await findOrCreateResponse.json();
cardId = cardData.card.id;
} else {
throw new Error('Failed to find or create card in database');
}
}
// Now add the card to the collection
const addResponse = await fetch('/api/collections/add-card', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
collectionId: selectedCollectionId,
cardId: cardId,
quantity: 1,
condition: 'near-mint',
notes: `Added via OCR scan (${Math.round(scannedCard.ocrConfidence)}% confidence)`
})
});
if (addResponse.ok) {
const result = await addResponse.json();
// Update collection stats
setCollections(prev => prev.map(collection =>
collection.id === selectedCollectionId
? {
...collection,
totalCards: result.result.collectionStats.totalCards,
totalValue: result.result.collectionStats.totalValue
}
: collection
));
// Show success message and remove from scan results
setError(null);
removeScannedCard(scannedCardId);
// You could show a success toast here instead
console.log(`${result.message}:`, result.result.card.name);
} else {
const errorData = await addResponse.json();
throw new Error(errorData.error || 'Failed to add card to collection');
}
} catch (err: any) {
console.error('Failed to add to collection:', err); console.error('Failed to add to collection:', err);
setError('Failed to add card to collection.'); setError(`Failed to add card to collection: ${err.message}`);
} finally {
setIsAddingToCollection(false);
} }
}; };
@ -135,6 +294,66 @@ const Scanner: React.FC = () => {
</div> </div>
)} )}
{/* Collection Selection */}
{user && (
<div className="mb-6 bg-white rounded-lg shadow border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">📚 Collection Selection</h2>
{isLoadingCollections ? (
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-600"></div>
<span className="text-gray-600">Loading collections...</span>
</div>
) : collections.length > 0 ? (
<div className="space-y-4">
<div>
<label htmlFor="collection-select" className="block text-sm font-medium text-gray-700 mb-2">
Select collection to add scanned cards to:
</label>
<select
id="collection-select"
value={selectedCollectionId || ''}
onChange={(e) => setSelectedCollectionId(e.target.value || null)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Select a collection...</option>
{collections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name} ({collection.totalCards} cards - ${collection.totalValue.toFixed(2)})
</option>
))}
</select>
</div>
{selectedCollectionId && (
<div className="text-sm text-gray-600">
Cards will be added to: <strong>{collections.find(c => c.id === selectedCollectionId)?.name}</strong>
</div>
)}
</div>
) : (
<div className="text-center py-4">
<div className="text-gray-600 mb-4">
You don't have any collections yet. Create one to start adding scanned cards!
</div>
<button
onClick={() => {
const name = prompt('Enter collection name:');
if (name) {
createNewCollection(name).catch(err => {
setError('Failed to create collection. Please try again.');
});
}
}}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
>
Create First Collection
</button>
</div>
)}
</div>
)}
{/* Camera Scanner */} {/* Camera Scanner */}
<div className="mb-8 bg-white rounded-lg shadow border border-gray-200 p-6"> <div className="mb-8 bg-white rounded-lg shadow border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">📸 Live Camera Scanner</h2> <h2 className="text-xl font-semibold text-gray-900 mb-4">📸 Live Camera Scanner</h2>
@ -286,9 +505,19 @@ const Scanner: React.FC = () => {
</div> </div>
<button <button
onClick={() => addToCollection(scannedCard.id)} onClick={() => addToCollection(scannedCard.id)}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2" disabled={isAddingToCollection || !selectedCollectionId}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2"
> >
<span></span> Add to Collection {isAddingToCollection ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Adding...
</>
) : (
<>
<span></span> Add to Collection
</>
)}
</button> </button>
</div> </div>
</div> </div>