Implement complete card scanning and collection flow - ready for local testing
This commit is contained in:
parent
08e2664a9a
commit
e9b2da09a5
4 changed files with 702 additions and 9 deletions
169
api/cards/find-or-create.js
Normal file
169
api/cards/find-or-create.js
Normal 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
174
api/collections/add-card.js
Normal 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
121
api/collections/index.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue