6 Commits

Author SHA1 Message Date
Matthieu
b3fc8079c6 Add confirmation modal for card removal in collection 2025-11-21 15:25:33 +01:00
Matthieu
6d8e7b3224 Implement card quantity management in collection with increment and decrement functionality 2025-11-21 15:19:34 +01:00
Matthieu
8bb80dac2e Add user collection management and card face toggling in CardSearch and Collection components 2025-11-21 15:14:37 +01:00
Matthieu
247f2205b8 Refactor card collection component to implement search filtering and hover preview 2025-11-21 14:51:39 +01:00
Matthieu
e96289de03 Implement user collection loading and card addition functionality 2025-11-21 14:43:04 +01:00
Matthieu
ad7ae17985 [ISSUE-10] Add card collection integration to deck manager
Implemented comprehensive collection management features:

Backend:
- Created collectionService with 9 API functions
- Added useCollection React hook for state management
- Implemented batch processing for performance
- Added full authentication and authorization

Frontend:
- Enhanced DeckManager with collection status indicators
- Added "Add All Missing Cards" bulk operation button
- Added individual "Add Card" buttons for missing cards
- Implemented loading states and error handling
- Added responsive design with visual badges

Features:
- Visual indicators (yellow for missing, green for owned)
- Bulk add all missing cards functionality
- Individual card addition with quantity tracking
- Real-time collection synchronization
- Success/error notifications

Tests: Build passing (5.98s), linting passing, TypeScript passing

Resolves: #ISSUE-10

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 14:53:42 +01:00
11 changed files with 2509 additions and 185 deletions

View File

@@ -1,9 +1,12 @@
import React, { useState } from 'react';
import { searchCards } from '../services/api';
import React, { useState, useEffect } from 'react';
import { RefreshCw, PackagePlus, Loader2, CheckCircle, XCircle, Trash2 } from 'lucide-react';
import { searchCards, getUserCollection, addCardToCollection } from '../services/api';
import { Card } from '../types';
import { useAuth } from '../contexts/AuthContext';
import MagicCard from './MagicCard';
const CardSearch = () => {
const { user } = useAuth();
const [cardName, setCardName] = useState('');
const [text, setText] = useState('');
const [rulesText, setRulesText] = useState('');
@@ -40,6 +43,85 @@ const CardSearch = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Collection state
const [userCollection, setUserCollection] = useState<Map<string, number>>(new Map());
const [addingCardId, setAddingCardId] = useState<string | null>(null);
const [cardFaceIndex, setCardFaceIndex] = useState<Map<string, number>>(new Map());
const [snackbar, setSnackbar] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// Load user collection
useEffect(() => {
const loadUserCollection = async () => {
if (!user) return;
try {
const collection = await getUserCollection(user.id);
setUserCollection(collection);
} catch (error) {
console.error('Error loading user collection:', error);
}
};
loadUserCollection();
}, [user]);
// Helper function to check if a card has an actual back face
const isDoubleFaced = (card: Card) => {
const backFaceLayouts = ['transform', 'modal_dfc', 'double_faced_token', 'reversible_card'];
return card.card_faces && card.card_faces.length > 1 && backFaceLayouts.includes(card.layout);
};
// Get current face index for a card
const getCurrentFaceIndex = (cardId: string) => {
return cardFaceIndex.get(cardId) || 0;
};
// Toggle card face
const toggleCardFace = (cardId: string, totalFaces: number) => {
setCardFaceIndex(prev => {
const newMap = new Map(prev);
const currentIndex = prev.get(cardId) || 0;
const nextIndex = (currentIndex + 1) % totalFaces;
newMap.set(cardId, nextIndex);
return newMap;
});
};
// Get card image for current face
const getCardImageUri = (card: Card, faceIndex: number = 0) => {
if (isDoubleFaced(card) && card.card_faces) {
return card.card_faces[faceIndex]?.image_uris?.normal || card.card_faces[faceIndex]?.image_uris?.small;
}
return card.image_uris?.normal || card.image_uris?.small || card.card_faces?.[0]?.image_uris?.normal;
};
// Add card to collection
const handleAddCardToCollection = async (cardId: string) => {
if (!user) {
setSnackbar({ message: 'Please log in to add cards to your collection', type: 'error' });
setTimeout(() => setSnackbar(null), 3000);
return;
}
try {
setAddingCardId(cardId);
await addCardToCollection(user.id, cardId, 1);
setUserCollection(prev => {
const newMap = new Map(prev);
const currentQty = newMap.get(cardId) || 0;
newMap.set(cardId, currentQty + 1);
return newMap;
});
setSnackbar({ message: 'Card added to collection!', type: 'success' });
} catch (error) {
console.error('Error adding card to collection:', error);
setSnackbar({ message: 'Failed to add card to collection', type: 'error' });
} finally {
setAddingCardId(null);
setTimeout(() => setSnackbar(null), 3000);
}
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -524,18 +606,107 @@ const CardSearch = () => {
{searchResults && searchResults.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{searchResults.map((card) => (
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
{searchResults.map((card) => {
const currentFaceIndex = getCurrentFaceIndex(card.id);
const isMultiFaced = isDoubleFaced(card);
const inCollection = userCollection.get(card.id) || 0;
const isAddingThisCard = addingCardId === card.id;
const displayName = isMultiFaced && card.card_faces
? card.card_faces[currentFaceIndex]?.name || card.name
: card.name;
return (
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all">
<div className="relative">
{getCardImageUri(card, currentFaceIndex) ? (
<img
src={getCardImageUri(card, currentFaceIndex)}
alt={displayName}
className="w-full h-auto"
/>
) : (
<MagicCard card={card} />
)}
{isMultiFaced && (
<button
onClick={(e) => {
e.stopPropagation();
toggleCardFace(card.id, card.card_faces!.length);
}}
className="absolute bottom-2 right-2 bg-purple-600 hover:bg-purple-700 text-white p-2 rounded-full shadow-lg transition-all"
title="Flip card"
>
<RefreshCw size={16} />
</button>
)}
</div>
<div className="p-4">
<h3 className="font-bold mb-2">{card.name}</h3>
<p className="text-gray-400 text-sm">{card.type_line}</p>
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold">{displayName}</h3>
{inCollection > 0 && (
<span className="text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
<CheckCircle size={12} />
x{inCollection}
</span>
)}
</div>
<p className="text-gray-400 text-sm mb-3">
{isMultiFaced && card.card_faces
? card.card_faces[currentFaceIndex]?.type_line || card.type_line
: card.type_line}
</p>
{card.prices?.usd && (
<div className="text-sm text-gray-400 mb-2">${card.prices.usd}</div>
)}
<button
onClick={() => handleAddCardToCollection(card.id)}
disabled={isAddingThisCard}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
title="Add to collection"
>
{isAddingThisCard ? (
<>
<Loader2 className="animate-spin" size={20} />
Adding...
</>
) : (
<>
<PackagePlus size={20} />
Add to Collection
</>
)}
</button>
</div>
</div>
))}
);
})}
</div>
)}
</div>
{/* Snackbar */}
{snackbar && (
<div
className={`fixed bottom-4 right-4 p-4 rounded-lg shadow-lg transition-all duration-300 ${
snackbar.type === 'success' ? 'bg-green-500' : 'bg-red-500'
} text-white z-50`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
{snackbar.type === 'success' ? (
<CheckCircle className="mr-2" size={20} />
) : (
<XCircle className="mr-2" size={20} />
)}
<span>{snackbar.message}</span>
</div>
<button onClick={() => setSnackbar(null)} className="ml-4 text-gray-200 hover:text-white focus:outline-none">
<Trash2 size={16} />
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,37 +1,191 @@
import React, { useState } from 'react';
import { Search, Plus } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Search, Loader2, Trash2, CheckCircle, XCircle, RefreshCw, Plus, Minus, X } from 'lucide-react';
import { Card } from '../types';
import { searchCards } from '../services/api';
import { getUserCollection, getCardsByIds, addCardToCollection } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../lib/supabase';
import ConfirmModal from './ConfirmModal';
export default function Collection() {
const { user } = useAuth();
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Card[]>([]);
const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]);
const [filteredCollection, setFilteredCollection] = useState<{ card: Card; quantity: number }[]>([]);
const [isLoadingCollection, setIsLoadingCollection] = useState(true);
const [hoveredCard, setHoveredCard] = useState<Card | null>(null);
const [selectedCard, setSelectedCard] = useState<{ card: Card; quantity: number } | null>(null);
const [cardFaceIndex, setCardFaceIndex] = useState<Map<string, number>>(new Map());
const [snackbar, setSnackbar] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
cardId: string;
cardName: string;
}>({ isOpen: false, cardId: '', cardName: '' });
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
// Helper function to check if a card has an actual back face (not adventure/split/etc)
const isDoubleFaced = (card: Card) => {
// Only show flip for cards with physical back sides
const backFaceLayouts = ['transform', 'modal_dfc', 'double_faced_token', 'reversible_card'];
return card.card_faces && card.card_faces.length > 1 && backFaceLayouts.includes(card.layout);
};
// Helper function to get the current face index for a card
const getCurrentFaceIndex = (cardId: string) => {
return cardFaceIndex.get(cardId) || 0;
};
// Helper function to get the image URI for a card (handling both single and double-faced)
const getCardImageUri = (card: Card, faceIndex: number = 0) => {
if (isDoubleFaced(card) && card.card_faces) {
return card.card_faces[faceIndex]?.image_uris?.normal || card.card_faces[faceIndex]?.image_uris?.small;
}
return card.image_uris?.normal || card.image_uris?.small;
};
// Helper function to get the large image URI for hover preview
const getCardLargeImageUri = (card: Card, faceIndex: number = 0) => {
if (isDoubleFaced(card) && card.card_faces) {
return card.card_faces[faceIndex]?.image_uris?.large || card.card_faces[faceIndex]?.image_uris?.normal;
}
return card.image_uris?.large || card.image_uris?.normal;
};
// Toggle card face
const toggleCardFace = (cardId: string, totalFaces: number) => {
setCardFaceIndex(prev => {
const newMap = new Map(prev);
const currentIndex = prev.get(cardId) || 0;
const nextIndex = (currentIndex + 1) % totalFaces;
newMap.set(cardId, nextIndex);
return newMap;
});
};
// Load user's collection from Supabase on mount
useEffect(() => {
const loadCollection = async () => {
if (!user) {
setIsLoadingCollection(false);
return;
}
try {
const cards = await searchCards(searchQuery);
setSearchResults(cards);
setIsLoadingCollection(true);
// Get collection from Supabase (returns Map<card_id, quantity>)
const collectionMap = await getUserCollection(user.id);
if (collectionMap.size === 0) {
setCollection([]);
return;
}
// Get the actual card data from Scryfall for all cards in collection
const cardIds = Array.from(collectionMap.keys());
const cards = await getCardsByIds(cardIds);
// Combine card data with quantities
const collectionWithCards = cards.map(card => ({
card,
quantity: collectionMap.get(card.id) || 0,
}));
setCollection(collectionWithCards);
setFilteredCollection(collectionWithCards);
} catch (error) {
console.error('Failed to search cards:', error);
console.error('Error loading collection:', error);
setSnackbar({ message: 'Failed to load collection', type: 'error' });
} finally {
setIsLoadingCollection(false);
}
};
const addToCollection = (card: Card) => {
setCollection(prev => {
const existing = prev.find(c => c.card.id === card.id);
if (existing) {
return prev.map(c =>
c.card.id === card.id
? { ...c, quantity: c.quantity + 1 }
: c
);
loadCollection();
}, [user]);
// Filter collection based on search query
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredCollection(collection);
return;
}
return [...prev, { card, quantity: 1 }];
const query = searchQuery.toLowerCase();
const filtered = collection.filter(({ card }) => {
return (
card.name.toLowerCase().includes(query) ||
card.type_line?.toLowerCase().includes(query) ||
card.oracle_text?.toLowerCase().includes(query) ||
card.colors?.some(color => color.toLowerCase().includes(query))
);
});
setFilteredCollection(filtered);
}, [searchQuery, collection]);
// Update card quantity in collection
const updateCardQuantity = async (cardId: string, newQuantity: number) => {
if (!user || newQuantity < 0) return;
try {
setIsUpdating(true);
if (newQuantity === 0) {
// Remove card from collection
const { error } = await supabase
.from('collections')
.delete()
.eq('user_id', user.id)
.eq('card_id', cardId);
if (error) throw error;
// Update local state
setCollection(prev => prev.filter(item => item.card.id !== cardId));
setSelectedCard(null);
setSnackbar({ message: 'Card removed from collection', type: 'success' });
} else {
// Update quantity
const { error } = await supabase
.from('collections')
.update({ quantity: newQuantity, updated_at: new Date().toISOString() })
.eq('user_id', user.id)
.eq('card_id', cardId);
if (error) throw error;
// Update local state
setCollection(prev =>
prev.map(item =>
item.card.id === cardId ? { ...item, quantity: newQuantity } : item
)
);
if (selectedCard && selectedCard.card.id === cardId) {
setSelectedCard({ ...selectedCard, quantity: newQuantity });
}
setSnackbar({ message: 'Quantity updated', type: 'success' });
}
} catch (error) {
console.error('Error updating card quantity:', error);
setSnackbar({ message: 'Failed to update quantity', type: 'error' });
} finally {
setIsUpdating(false);
setTimeout(() => setSnackbar(null), 3000);
}
};
// Add one to quantity
const incrementQuantity = async (cardId: string, currentQuantity: number) => {
await updateCardQuantity(cardId, currentQuantity + 1);
};
// Remove one from quantity
const decrementQuantity = async (cardId: string, currentQuantity: number) => {
if (currentQuantity > 0) {
await updateCardQuantity(cardId, currentQuantity - 1);
}
};
return (
@@ -39,83 +193,306 @@ export default function Collection() {
<div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
<div className="relative flex-1">
{/* Search within collection */}
<div className="mb-8">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search cards to add..."
placeholder="Search your collection by name, type, or text..."
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
>
<Search size={20} />
Search
</button>
</form>
{/* Search Results */}
{searchResults.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Search Results</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{searchResults.map(card => (
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
{card.image_uris?.normal && (
<img
src={card.image_uris.normal}
alt={card.name}
className="w-full h-auto"
/>
)}
<div className="p-4">
<h3 className="font-bold mb-2">{card.name}</h3>
<button
onClick={() => addToCollection(card)}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
>
<Plus size={20} />
Add to Collection
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Collection */}
<div>
<h2 className="text-xl font-semibold mb-4">My Cards</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{collection.map(({ card, quantity }) => (
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
{card.image_uris?.normal && (
<h2 className="text-xl font-semibold mb-4">
{searchQuery ? `Found ${filteredCollection.length} card(s)` : `My Cards (${collection.length} unique, ${collection.reduce((acc, c) => acc + c.quantity, 0)} total)`}
</h2>
{isLoadingCollection ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-blue-500" size={48} />
</div>
) : collection.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<p className="text-lg mb-2">Your collection is empty</p>
<p className="text-sm">Add cards from the Deck Manager to build your collection</p>
</div>
) : filteredCollection.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<p className="text-lg mb-2">No cards found</p>
<p className="text-sm">Try a different search term</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-3">
{filteredCollection.map(({ card, quantity }) => {
const currentFaceIndex = getCurrentFaceIndex(card.id);
const isMultiFaced = isDoubleFaced(card);
const displayName = isMultiFaced && card.card_faces
? card.card_faces[currentFaceIndex]?.name || card.name
: card.name;
return (
<div
key={card.id}
className="relative group cursor-pointer"
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
onClick={() => setSelectedCard({ card, quantity })}
>
{/* Small card thumbnail */}
<div className="relative rounded-lg overflow-hidden shadow-lg transition-all group-hover:ring-2 group-hover:ring-blue-500">
<img
src={card.image_uris.normal}
alt={card.name}
src={getCardImageUri(card, currentFaceIndex)}
alt={displayName}
className="w-full h-auto"
/>
)}
<div className="p-4">
<div className="flex justify-between items-center mb-2">
<h3 className="font-bold">{card.name}</h3>
<span className="text-sm bg-blue-600 px-2 py-1 rounded">
{/* Quantity badge */}
<div className="absolute top-1 right-1 bg-blue-600 text-white text-xs font-bold px-2 py-1 rounded-full shadow-lg">
x{quantity}
</span>
</div>
{/* Flip button for double-faced cards */}
{isMultiFaced && (
<button
onClick={(e) => {
e.stopPropagation();
toggleCardFace(card.id, card.card_faces!.length);
}}
className="absolute bottom-1 right-1 bg-purple-600 hover:bg-purple-700 text-white p-1 rounded-full shadow-lg transition-all"
title="Flip card"
>
<RefreshCw size={12} />
</button>
)}
</div>
{/* Card name below thumbnail */}
<div className="mt-1 text-xs text-center truncate px-1">
{displayName}
</div>
</div>
);
})}
</div>
))}
)}
</div>
</div>
{/* Hover Card Preview - only show if no card is selected */}
{hoveredCard && !selectedCard && (() => {
const currentFaceIndex = getCurrentFaceIndex(hoveredCard.id);
const isMultiFaced = isDoubleFaced(hoveredCard);
const currentFace = isMultiFaced && hoveredCard.card_faces
? hoveredCard.card_faces[currentFaceIndex]
: null;
const displayName = currentFace?.name || hoveredCard.name;
const displayTypeLine = currentFace?.type_line || hoveredCard.type_line;
const displayOracleText = currentFace?.oracle_text || hoveredCard.oracle_text;
return (
<div className="fixed top-1/2 right-8 transform -translate-y-1/2 z-40 pointer-events-none">
<div className="bg-gray-800 rounded-lg shadow-2xl p-4 max-w-md">
<div className="relative">
<img
src={getCardLargeImageUri(hoveredCard, currentFaceIndex)}
alt={displayName}
className="w-full h-auto rounded-lg shadow-lg"
/>
{isMultiFaced && (
<div className="absolute top-2 right-2 bg-purple-600 text-white text-xs font-bold px-2 py-1 rounded-full shadow-lg">
Face {currentFaceIndex + 1}/{hoveredCard.card_faces!.length}
</div>
)}
</div>
<div className="mt-3 space-y-2">
<h3 className="text-xl font-bold">{displayName}</h3>
<p className="text-sm text-gray-400">{displayTypeLine}</p>
{displayOracleText && (
<p className="text-sm text-gray-300 border-t border-gray-700 pt-2">
{displayOracleText}
</p>
)}
{hoveredCard.prices?.usd && (
<div className="text-sm text-green-400 font-semibold border-t border-gray-700 pt-2">
${hoveredCard.prices.usd}
</div>
)}
</div>
</div>
</div>
);
})()}
{/* Card Detail Panel - slides in from right */}
{selectedCard && (() => {
const currentFaceIndex = getCurrentFaceIndex(selectedCard.card.id);
const isMultiFaced = isDoubleFaced(selectedCard.card);
const currentFace = isMultiFaced && selectedCard.card.card_faces
? selectedCard.card.card_faces[currentFaceIndex]
: null;
const displayName = currentFace?.name || selectedCard.card.name;
const displayTypeLine = currentFace?.type_line || selectedCard.card.type_line;
const displayOracleText = currentFace?.oracle_text || selectedCard.card.oracle_text;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
onClick={() => setSelectedCard(null)}
/>
{/* Sliding Panel */}
<div className="fixed top-0 right-0 h-full w-full md:w-96 bg-gray-800 shadow-2xl z-50 overflow-y-auto animate-slide-in-right">
<div className="p-6">
{/* Close button */}
<button
onClick={() => setSelectedCard(null)}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<X size={24} />
</button>
{/* Card Image */}
<div className="relative mb-4">
<img
src={getCardLargeImageUri(selectedCard.card, currentFaceIndex)}
alt={displayName}
className="w-full h-auto rounded-lg shadow-lg"
/>
{isMultiFaced && (
<>
<div className="absolute top-2 right-2 bg-purple-600 text-white text-xs font-bold px-2 py-1 rounded-full shadow-lg">
Face {currentFaceIndex + 1}/{selectedCard.card.card_faces!.length}
</div>
<button
onClick={() => toggleCardFace(selectedCard.card.id, selectedCard.card.card_faces!.length)}
className="absolute bottom-2 right-2 bg-purple-600 hover:bg-purple-700 text-white p-2 rounded-full shadow-lg transition-all"
title="Flip card"
>
<RefreshCw size={20} />
</button>
</>
)}
</div>
{/* Card Info */}
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{displayName}</h2>
<p className="text-sm text-gray-400">{displayTypeLine}</p>
</div>
{displayOracleText && (
<div className="border-t border-gray-700 pt-3">
<p className="text-sm text-gray-300">{displayOracleText}</p>
</div>
)}
{selectedCard.card.prices?.usd && (
<div className="border-t border-gray-700 pt-3">
<div className="text-lg text-green-400 font-semibold">
${selectedCard.card.prices.usd} each
</div>
<div className="text-sm text-gray-400">
Total value: ${(parseFloat(selectedCard.card.prices.usd) * selectedCard.quantity).toFixed(2)}
</div>
</div>
)}
{/* Quantity Management */}
<div className="border-t border-gray-700 pt-3">
<h3 className="text-lg font-semibold mb-3">Quantity in Collection</h3>
<div className="flex items-center justify-between bg-gray-900 rounded-lg p-4">
<button
onClick={() => decrementQuantity(selectedCard.card.id, selectedCard.quantity)}
disabled={isUpdating || selectedCard.quantity === 0}
className="bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white p-2 rounded-lg transition-colors"
>
<Minus size={20} />
</button>
<div className="text-center">
<div className="text-3xl font-bold">{selectedCard.quantity}</div>
<div className="text-xs text-gray-400">copies</div>
</div>
<button
onClick={() => incrementQuantity(selectedCard.card.id, selectedCard.quantity)}
disabled={isUpdating}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white p-2 rounded-lg transition-colors"
>
<Plus size={20} />
</button>
</div>
{/* Remove from collection button */}
<button
onClick={() => {
setConfirmModal({
isOpen: true,
cardId: selectedCard.card.id,
cardName: displayName,
});
}}
disabled={isUpdating}
className="w-full mt-3 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<Trash2 size={20} />
Remove from Collection
</button>
</div>
</div>
</div>
</div>
</>
);
})()}
{/* Confirm Modal */}
<ConfirmModal
isOpen={confirmModal.isOpen}
onClose={() => setConfirmModal({ isOpen: false, cardId: '', cardName: '' })}
onConfirm={() => {
updateCardQuantity(confirmModal.cardId, 0);
setConfirmModal({ isOpen: false, cardId: '', cardName: '' });
}}
title="Remove from Collection"
message={`Are you sure you want to remove "${confirmModal.cardName}" from your collection? This action cannot be undone.`}
confirmText="Remove"
cancelText="Cancel"
variant="danger"
isLoading={isUpdating}
/>
{/* Snackbar */}
{snackbar && (
<div
className={`fixed bottom-4 right-4 p-4 rounded-lg shadow-lg transition-all duration-300 ${
snackbar.type === 'success' ? 'bg-green-500' : 'bg-red-500'
} text-white z-50`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
{snackbar.type === 'success' ? (
<CheckCircle className="mr-2" size={20} />
) : (
<XCircle className="mr-2" size={20} />
)}
<span>{snackbar.message}</span>
</div>
<button onClick={() => setSnackbar(null)} className="ml-4 text-gray-200 hover:text-white focus:outline-none">
<Trash2 size={16} />
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { AlertCircle, CheckCircle, Trash2, AlertTriangle } from 'lucide-react';
import Modal from './Modal';
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info' | 'success';
isLoading?: boolean;
}
export default function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'danger',
isLoading = false,
}: ConfirmModalProps) {
const handleConfirm = () => {
onConfirm();
if (!isLoading) {
onClose();
}
};
const variantConfig = {
danger: {
icon: Trash2,
iconColor: 'text-red-500',
iconBg: 'bg-red-500/10',
buttonColor: 'bg-red-600 hover:bg-red-700',
},
warning: {
icon: AlertTriangle,
iconColor: 'text-yellow-500',
iconBg: 'bg-yellow-500/10',
buttonColor: 'bg-yellow-600 hover:bg-yellow-700',
},
info: {
icon: AlertCircle,
iconColor: 'text-blue-500',
iconBg: 'bg-blue-500/10',
buttonColor: 'bg-blue-600 hover:bg-blue-700',
},
success: {
icon: CheckCircle,
iconColor: 'text-green-500',
iconBg: 'bg-green-500/10',
buttonColor: 'bg-green-600 hover:bg-green-700',
},
};
const config = variantConfig[variant];
const Icon = config.icon;
return (
<Modal isOpen={isOpen} onClose={onClose} size="sm" showCloseButton={false}>
<div className="p-6">
{/* Icon */}
<div className="flex items-center justify-center mb-4">
<div className={`${config.iconBg} p-3 rounded-full`}>
<Icon className={config.iconColor} size={32} />
</div>
</div>
{/* Title */}
<h2 className="text-xl font-bold text-white text-center mb-2">
{title}
</h2>
{/* Message */}
<p className="text-gray-400 text-center mb-6">
{message}
</p>
{/* Buttons */}
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{cancelText}
</button>
<button
onClick={handleConfirm}
disabled={isLoading}
className={`flex-1 px-4 py-2 ${config.buttonColor} disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg transition-colors`}
>
{isLoading ? 'Loading...' : confirmText}
</button>
</div>
</div>
</Modal>
);
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Plus, Search, Save, Trash2, Upload, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { Plus, Search, Save, Trash2, Loader2, CheckCircle, XCircle, AlertCircle, PackagePlus, RefreshCw } from 'lucide-react';
import { Card, Deck } from '../types';
import { searchCards } from '../services/api';
import { searchCards, getUserCollection, addCardToCollection, addMultipleCardsToCollection } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../lib/supabase';
import { validateDeck } from '../utils/deckValidation';
@@ -12,38 +12,33 @@ interface DeckManagerProps {
onSave?: () => void;
}
const calculateManaCurve = (cards: { card; quantity: number }[]) => {
const manaValues = cards.map(({ card }) => {
if (!card.mana_cost) return 0;
// Basic heuristic: count mana symbols
return (card.mana_cost.match(/\{WUBRG0-9]\}/g) || []).length;
});
// const calculateManaCurve = (cards: { card; quantity: number }[]) => {
// const manaValues = cards.map(({ card }) => {
// if (!card.mana_cost) return 0;
// // Basic heuristic: count mana symbols
// return (card.mana_cost.match(/\{WUBRG0-9]\}/g) || []).length;
// });
const averageManaValue = manaValues.reduce((a, b) => a + b, 0) / manaValues.length;
return averageManaValue;
};
// const averageManaValue = manaValues.reduce((a, b) => a + b, 0) / manaValues.length;
// return averageManaValue;
// };
const suggestLandCountAndDistribution = (
cards: { card; quantity: number }[],
format: string
) => {
const formatRules = {
standard: { minCards: 60, targetLands: 24.5 },
modern: { minCards: 60, targetLands: 24.5 },
commander: { minCards: 100, targetLands: 36.5 },
legacy: { minCards: 60, targetLands: 24.5 },
vintage: { minCards: 60, targetLands: 24.5 },
pauper: { minCards: 60, targetLands: 24.5 },
standard: { minCards: 60 },
modern: { minCards: 60 },
commander: { minCards: 100 },
legacy: { minCards: 60 },
vintage: { minCards: 60 },
pauper: { minCards: 60 },
};
const { minCards, targetLands } =
const { minCards } =
formatRules[format as keyof typeof formatRules] || formatRules.standard;
const deckSize = cards.reduce((acc, { quantity }) => acc + quantity, 0);
const nonLandCards = cards.reduce(
(acc, { card, quantity }) =>
card.type_line?.toLowerCase().includes('land') ? acc : acc + quantity,
0
);
const landsToAdd = Math.max(0, minCards - deckSize);
const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0 };
@@ -77,7 +72,7 @@ const suggestLandCountAndDistribution = (
landDistribution[color] = Math.round(landsToAdd * proportion);
}
let totalDistributed = Object.values(landDistribution).reduce(
const totalDistributed = Object.values(landDistribution).reduce(
(acc, count) => acc + count,
0
);
@@ -120,6 +115,147 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
const [isSaving, setIsSaving] = useState(false);
const [snackbar, setSnackbar] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// Collection management state
const [userCollection, setUserCollection] = useState<Map<string, number>>(new Map());
const [isLoadingCollection, setIsLoadingCollection] = useState(true);
const [addingCardId, setAddingCardId] = useState<string | null>(null);
const [isAddingAll, setIsAddingAll] = useState(false);
const [cardFaceIndex, setCardFaceIndex] = useState<Map<string, number>>(new Map());
// Load user collection on component mount
useEffect(() => {
const loadUserCollection = async () => {
if (!user) return;
try {
setIsLoadingCollection(true);
const collection = await getUserCollection(user.id);
setUserCollection(collection);
} catch (error) {
console.error('Error loading user collection:', error);
setSnackbar({ message: 'Failed to load collection', type: 'error' });
} finally {
setIsLoadingCollection(false);
}
};
loadUserCollection();
}, [user]);
// Helper functions for double-faced cards
const isDoubleFaced = (card: Card) => {
const backFaceLayouts = ['transform', 'modal_dfc', 'double_faced_token', 'reversible_card'];
return card.card_faces && card.card_faces.length > 1 && backFaceLayouts.includes(card.layout);
};
const getCurrentFaceIndex = (cardId: string) => {
return cardFaceIndex.get(cardId) || 0;
};
const toggleCardFace = (cardId: string, totalFaces: number) => {
setCardFaceIndex(prev => {
const newMap = new Map(prev);
const currentIndex = prev.get(cardId) || 0;
const nextIndex = (currentIndex + 1) % totalFaces;
newMap.set(cardId, nextIndex);
return newMap;
});
};
const getCardImageUri = (card: Card, faceIndex: number = 0) => {
if (isDoubleFaced(card) && card.card_faces) {
return card.card_faces[faceIndex]?.image_uris?.normal || card.card_faces[faceIndex]?.image_uris?.small;
}
return card.image_uris?.normal || card.image_uris?.small || card.card_faces?.[0]?.image_uris?.normal;
};
// Helper function to check if a card is in the collection
const isCardInCollection = (cardId: string, requiredQuantity: number = 1): boolean => {
const ownedQuantity = userCollection.get(cardId) || 0;
return ownedQuantity >= requiredQuantity;
};
// Helper function to get missing cards
const getMissingCards = () => {
return selectedCards.filter(({ card, quantity }) => {
return !isCardInCollection(card.id, quantity);
});
};
// Add single card to collection
const handleAddCardToCollection = async (cardId: string, quantity: number) => {
if (!user) return;
try {
setAddingCardId(cardId);
await addCardToCollection(user.id, cardId, quantity);
// Update local collection state
setUserCollection(prev => {
const newMap = new Map(prev);
const currentQty = newMap.get(cardId) || 0;
newMap.set(cardId, currentQty + quantity);
return newMap;
});
setSnackbar({ message: 'Card added to collection!', type: 'success' });
} catch (error) {
console.error('Error adding card to collection:', error);
setSnackbar({ message: 'Failed to add card to collection', type: 'error' });
} finally {
setAddingCardId(null);
setTimeout(() => setSnackbar(null), 3000);
}
};
// Add all missing cards to collection
const handleAddAllMissingCards = async () => {
if (!user) return;
const missingCards = getMissingCards();
if (missingCards.length === 0) {
setSnackbar({ message: 'All cards are already in your collection!', type: 'success' });
setTimeout(() => setSnackbar(null), 3000);
return;
}
try {
setIsAddingAll(true);
const cardsToAdd = missingCards.map(({ card, quantity }) => {
const ownedQuantity = userCollection.get(card.id) || 0;
const neededQuantity = Math.max(0, quantity - ownedQuantity);
return {
cardId: card.id,
quantity: neededQuantity,
};
}).filter(c => c.quantity > 0);
await addMultipleCardsToCollection(user.id, cardsToAdd);
// Update local collection state
setUserCollection(prev => {
const newMap = new Map(prev);
cardsToAdd.forEach(({ cardId, quantity }) => {
const currentQty = newMap.get(cardId) || 0;
newMap.set(cardId, currentQty + quantity);
});
return newMap;
});
setSnackbar({
message: `Successfully added ${cardsToAdd.length} card(s) to collection!`,
type: 'success'
});
} catch (error) {
console.error('Error adding cards to collection:', error);
setSnackbar({ message: 'Failed to add cards to collection', type: 'error' });
} finally {
setIsAddingAll(false);
setTimeout(() => setSnackbar(null), 3000);
}
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
@@ -162,12 +298,6 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
setSelectedCards(prev => {
return prev.map(c => {
if (c.card.id === cardId) {
const isBasicLand =
c.card.name === 'Plains' ||
c.card.name === 'Island' ||
c.card.name === 'Swamp' ||
c.card.name === 'Mountain' ||
c.card.name === 'Forest';
return { ...c, quantity: quantity };
}
return c;
@@ -190,8 +320,6 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
updatedAt: new Date(),
};
const validation = validateDeck(deckToSave);
const deckData = {
id: deckToSave.id,
name: deckToSave.name,
@@ -328,11 +456,11 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
cardsToAdd.push({ card, quantity });
} else {
console.warn(`Card not found: ${cardName}`);
alert(`Card not found: ${cardName}`);
setSnackbar({ message: `Card not found: ${cardName}`, type: 'error' });
}
} catch (error) {
console.error(`Failed to search card ${cardName}:`, error);
alert(`Failed to search card ${cardName}: ${error}`);
setSnackbar({ message: `Failed to import card: ${cardName}`, type: 'error' });
}
}
@@ -391,24 +519,82 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
</form>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{searchResults.map(card => (
{searchResults.map(card => {
const currentFaceIndex = getCurrentFaceIndex(card.id);
const isMultiFaced = isDoubleFaced(card);
const inCollection = userCollection.get(card.id) || 0;
const isAddingThisCard = addingCardId === card.id;
const displayName = isMultiFaced && card.card_faces
? card.card_faces[currentFaceIndex]?.name || card.name
: card.name;
return (
<div
key={card.id}
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
>
<div className="relative">
{getCardImageUri(card, currentFaceIndex) ? (
<img
src={getCardImageUri(card, currentFaceIndex)}
alt={displayName}
className="w-full h-auto"
/>
) : (
<MagicCard card={card} />
)}
{isMultiFaced && (
<button
onClick={(e) => {
e.stopPropagation();
toggleCardFace(card.id, card.card_faces!.length);
}}
className="absolute bottom-2 right-2 bg-purple-600 hover:bg-purple-700 text-white p-2 rounded-full shadow-lg transition-all"
title="Flip card"
>
<RefreshCw size={16} />
</button>
)}
</div>
<div className="p-4">
<h3 className="font-bold mb-2">{card.name}</h3>
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold">{displayName}</h3>
{inCollection > 0 && (
<span className="text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
<CheckCircle size={12} />
x{inCollection}
</span>
)}
</div>
{card.prices?.usd && (
<div className="text-sm text-gray-400 mb-2">${card.prices.usd}</div>
)}
<div className="flex gap-2">
<button
onClick={() => addCardToDeck(card)}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
>
<Plus size={20} />
Add to Deck
</button>
<button
onClick={() => handleAddCardToCollection(card.id, 1)}
disabled={isAddingThisCard}
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
title="Add to collection"
>
{isAddingThisCard ? (
<Loader2 className="animate-spin" size={20} />
) : (
<PackagePlus size={20} />
)}
</button>
</div>
</div>
))}
</div>
);
})}
</div>
</div>
@@ -492,13 +678,51 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
)}
<div className="space-y-2">
<h3 className="font-bold text-xl mb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-xl">
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
</h3>
{selectedCards.map(({ card, quantity }) => (
{!isLoadingCollection && getMissingCards().length > 0 && (
<div className="flex items-center gap-2 text-sm text-yellow-500">
<AlertCircle size={16} />
<span>{getMissingCards().length} missing</span>
</div>
)}
</div>
{!isLoadingCollection && getMissingCards().length > 0 && (
<button
onClick={handleAddAllMissingCards}
disabled={isAddingAll}
className="w-full px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2 mb-3 relative"
>
{isAddingAll ? (
<>
<Loader2 className="animate-spin" size={20} />
<span>Adding to collection...</span>
</>
) : (
<>
<PackagePlus size={20} />
<span>Add All Missing Cards to Collection</span>
</>
)}
</button>
)}
{selectedCards.map(({ card, quantity }) => {
const ownedQuantity = userCollection.get(card.id) || 0;
const isMissing = !isCardInCollection(card.id, quantity);
const neededQuantity = Math.max(0, quantity - ownedQuantity);
return (
<div
key={card.id}
className="flex items-center gap-4 bg-gray-700 p-2 rounded-lg"
className={`flex items-center gap-4 p-2 rounded-lg ${
isMissing
? 'bg-yellow-900/20 border border-yellow-700/50'
: 'bg-gray-700'
}`}
>
<img
src={card.image_uris?.art_crop}
@@ -506,11 +730,43 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
className="w-12 h-12 rounded"
/>
<div className="flex-1">
<h4 className="font-medium">{card.name}</h4>
<h4 className="font-medium flex items-center gap-2">
{card.name}
{isMissing && (
<span className="text-xs bg-yellow-600 px-2 py-0.5 rounded-full flex items-center gap-1">
<AlertCircle size={12} />
Missing {neededQuantity}
</span>
)}
{!isMissing && ownedQuantity > 0 && (
<span className="text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
<CheckCircle size={12} />
Owned ({ownedQuantity})
</span>
)}
</h4>
{card.prices?.usd && (
<div className="text-sm text-gray-400">${card.prices.usd}</div>
)}
</div>
<div className="flex items-center gap-2">
{isMissing && (
<button
onClick={() => handleAddCardToCollection(card.id, neededQuantity)}
disabled={addingCardId === card.id}
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded text-sm flex items-center gap-1"
title={`Add ${neededQuantity} to collection`}
>
{addingCardId === card.id ? (
<Loader2 className="animate-spin" size={16} />
) : (
<>
<Plus size={16} />
<span className="hidden sm:inline">Add</span>
</>
)}
</button>
)}
<input
type="number"
value={quantity}
@@ -527,7 +783,9 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
<Trash2 size={20} />
</button>
</div>
))}
</div>
);
})}
</div>
<div className="font-bold text-xl">

View File

@@ -6,11 +6,14 @@ interface MagicCardProps {
}
const MagicCard = ({ card }: MagicCardProps) => {
// Handle both regular cards and double-faced cards (transform, modal_dfc, etc)
const imageUri = card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal;
return (
<div className="relative card-hover animate-fade-in">
{card.image_uris?.normal ? (
{imageUri ? (
<img
src={card.image_uris.normal}
src={imageUri}
alt={card.name}
className="w-full h-auto rounded-lg transition-smooth"
/>

80
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,80 @@
import React, { useEffect } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
showCloseButton?: boolean;
}
export default function Modal({
isOpen,
onClose,
children,
size = 'md',
showCloseButton = true
}: ModalProps) {
// Close modal on ESC key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-50 transition-opacity duration-300 animate-fade-in"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div
className={`${sizeClasses[size]} w-full bg-gray-800 rounded-lg shadow-2xl pointer-events-auto animate-scale-in`}
onClick={(e) => e.stopPropagation()}
>
{showCloseButton && (
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors z-10"
>
<X size={24} />
</button>
)}
{children}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,412 @@
/**
* EXAMPLE FILE - Collection Integration Examples
*
* This file demonstrates how to integrate the collection service
* into your components. These are complete, working examples that
* can be used as templates for implementing the collection features.
*
* DO NOT DELETE - Reference for frontend integration
*/
import React, { useState, useEffect } from 'react';
import { useCollection } from '../hooks/useCollection';
import { CardOwnershipInfo, MissingCardInfo } from '../services/collectionService';
import { Card } from '../types';
import { Plus, CheckCircle, AlertCircle } from 'lucide-react';
/**
* Example 1: Display Missing Cards Badge
* Shows a badge indicating how many cards are missing from collection
*/
export function MissingCardsBadge({ deckId }: { deckId: string }) {
const { getMissingCards, loading } = useCollection();
const [missingCount, setMissingCount] = useState(0);
useEffect(() => {
const fetchMissing = async () => {
const missing = await getMissingCards(deckId);
if (missing) {
setMissingCount(missing.length);
}
};
fetchMissing();
}, [deckId, getMissingCards]);
if (loading) return <span className="text-gray-400">...</span>;
return missingCount > 0 ? (
<span className="bg-red-500 text-white px-2 py-1 rounded text-sm">
{missingCount} cards missing
</span>
) : (
<span className="bg-green-500 text-white px-2 py-1 rounded text-sm flex items-center gap-1">
<CheckCircle size={16} />
Complete
</span>
);
}
/**
* Example 2: Card Ownership Indicator
* Shows whether a specific card is owned and in what quantity
*/
interface CardOwnershipIndicatorProps {
cardId: string;
quantityNeeded: number;
}
export function CardOwnershipIndicator({ cardId, quantityNeeded }: CardOwnershipIndicatorProps) {
const { checkCardOwnership } = useCollection();
const [owned, setOwned] = useState(0);
useEffect(() => {
const check = async () => {
const card = await checkCardOwnership(cardId);
setOwned(card?.quantity || 0);
};
check();
}, [cardId, checkCardOwnership]);
const hasEnough = owned >= quantityNeeded;
return (
<div className={`flex items-center gap-2 ${hasEnough ? 'text-green-500' : 'text-red-500'}`}>
{hasEnough ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
<span>
{owned} / {quantityNeeded} owned
</span>
</div>
);
}
/**
* Example 3: Add Single Card Button
* Button to add a specific card to collection
*/
interface AddCardButtonProps {
cardId: string;
cardName: string;
quantity?: number;
onSuccess?: () => void;
}
export function AddCardButton({ cardId, cardName, quantity = 1, onSuccess }: AddCardButtonProps) {
const { addCard, loading, error, clearError } = useCollection();
const handleAdd = async () => {
clearError();
const success = await addCard(cardId, quantity);
if (success) {
alert(`Added ${quantity}x ${cardName} to collection!`);
onSuccess?.();
}
};
return (
<div>
<button
onClick={handleAdd}
disabled={loading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center gap-2"
>
<Plus size={16} />
{loading ? 'Adding...' : `Add ${quantity > 1 ? `${quantity}x ` : ''}to Collection`}
</button>
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
</div>
);
}
/**
* Example 4: Add All Missing Cards Button
* Button to add all missing cards from a deck to collection
*/
export function AddAllMissingCardsButton({ deckId, onSuccess }: { deckId: string; onSuccess?: () => void }) {
const { addMissingDeckCards, loading, error, clearError } = useCollection();
const [lastResult, setLastResult] = useState<string | null>(null);
const handleAddAll = async () => {
clearError();
setLastResult(null);
const results = await addMissingDeckCards(deckId);
if (results) {
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
let message = `Added ${successCount} cards to collection`;
if (failCount > 0) {
message += `, ${failCount} failed`;
}
setLastResult(message);
if (successCount > 0) {
onSuccess?.();
}
}
};
return (
<div>
<button
onClick={handleAddAll}
disabled={loading}
className="px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center gap-2 font-semibold"
>
{loading ? 'Adding Cards...' : 'Add All Missing Cards to Collection'}
</button>
{lastResult && <p className="text-green-400 text-sm mt-2">{lastResult}</p>}
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
</div>
);
}
/**
* Example 5: Deck Card List with Collection Status
* Shows all cards in a deck with their collection status
*/
interface DeckCardWithStatusProps {
deckId: string;
cards: Array<{ card: Card; quantity: number }>;
}
export function DeckCardListWithStatus({ deckId, cards }: DeckCardWithStatusProps) {
const { getDeckOwnership, loading } = useCollection();
const [ownership, setOwnership] = useState<CardOwnershipInfo[]>([]);
useEffect(() => {
const fetchOwnership = async () => {
const data = await getDeckOwnership(deckId);
if (data) setOwnership(data);
};
fetchOwnership();
}, [deckId, getDeckOwnership]);
if (loading) {
return <div className="text-gray-400">Loading collection status...</div>;
}
const ownershipMap = new Map(ownership.map(o => [o.card_id, o]));
return (
<div className="space-y-2">
{cards.map(({ card, quantity }) => {
const status = ownershipMap.get(card.id);
const isOwned = status?.owned || false;
const quantityOwned = status?.quantity_in_collection || 0;
const quantityNeeded = status?.quantity_needed || 0;
return (
<div key={card.id} className="flex items-center gap-4 bg-gray-800 p-4 rounded-lg">
<img
src={card.image_uris?.art_crop}
alt={card.name}
className="w-16 h-16 rounded object-cover"
/>
<div className="flex-1">
<h3 className="font-semibold">{card.name}</h3>
<p className="text-sm text-gray-400">
Need: {quantity} | Owned: {quantityOwned}
</p>
</div>
<div className="flex items-center gap-2">
{isOwned ? (
<span className="text-green-500 flex items-center gap-1">
<CheckCircle size={20} />
Complete
</span>
) : (
<>
<span className="text-red-500 flex items-center gap-1">
<AlertCircle size={20} />
Need {quantityNeeded} more
</span>
<AddCardButton
cardId={card.id}
cardName={card.name}
quantity={quantityNeeded}
/>
</>
)}
</div>
</div>
);
})}
</div>
);
}
/**
* Example 6: Bulk Add with Preview
* Shows missing cards and allows bulk add with preview
*/
export function BulkAddWithPreview({ deckId }: { deckId: string }) {
const { getMissingCards, addCardsBulk, loading, error } = useCollection();
const [missingCards, setMissingCards] = useState<MissingCardInfo[]>([]);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
const fetchMissing = async () => {
const missing = await getMissingCards(deckId);
if (missing) setMissingCards(missing);
};
fetchMissing();
}, [deckId, getMissingCards]);
const handleBulkAdd = async () => {
const cardsToAdd = missingCards.map(card => ({
card_id: card.card_id,
quantity: card.quantity_needed,
}));
const results = await addCardsBulk(cardsToAdd);
if (results) {
const successCount = results.filter(r => r.success).length;
alert(`Successfully added ${successCount} cards to collection!`);
setMissingCards([]);
setShowPreview(false);
}
};
if (missingCards.length === 0) {
return (
<div className="bg-green-500/10 border border-green-500 rounded-lg p-4 text-green-400">
All cards from this deck are in your collection!
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold">Missing Cards: {missingCards.length}</h3>
<button
onClick={() => setShowPreview(!showPreview)}
className="text-blue-400 hover:text-blue-300"
>
{showPreview ? 'Hide' : 'Show'} Preview
</button>
</div>
{showPreview && (
<div className="bg-gray-800 rounded-lg p-4 space-y-2">
{missingCards.map(card => (
<div key={card.card_id} className="flex justify-between text-sm">
<span>{card.card_id}</span>
<span className="text-gray-400">
Need {card.quantity_needed} (have {card.quantity_in_collection})
</span>
</div>
))}
</div>
)}
<button
onClick={handleBulkAdd}
disabled={loading}
className="w-full px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg font-semibold"
>
{loading ? 'Adding...' : `Add All ${missingCards.length} Missing Cards`}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4 text-red-400">
{error}
</div>
)}
</div>
);
}
/**
* Example 7: Complete Deck Editor Integration
* Full example of a deck editor with collection integration
*/
interface CompleteDeckEditorExampleProps {
deckId: string;
deckName: string;
cards: Array<{ card: Card; quantity: number }>;
}
export function CompleteDeckEditorExample({ deckId, deckName, cards }: CompleteDeckEditorExampleProps) {
const { getDeckOwnership, addMissingDeckCards, loading } = useCollection();
const [ownership, setOwnership] = useState<CardOwnershipInfo[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
const fetchOwnership = async () => {
const data = await getDeckOwnership(deckId);
if (data) setOwnership(data);
};
fetchOwnership();
}, [deckId, getDeckOwnership, refreshKey]);
const handleRefresh = () => setRefreshKey(prev => prev + 1);
const missingCount = ownership.filter(o => !o.owned).length;
const totalCards = cards.length;
const ownedCount = totalCards - missingCount;
const completionPercent = totalCards > 0 ? Math.round((ownedCount / totalCards) * 100) : 0;
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
{/* Header with stats */}
<div className="bg-gray-800 rounded-lg p-6">
<h1 className="text-3xl font-bold mb-4">{deckName}</h1>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-gray-700 rounded p-3">
<div className="text-sm text-gray-400">Total Cards</div>
<div className="text-2xl font-bold">{totalCards}</div>
</div>
<div className="bg-gray-700 rounded p-3">
<div className="text-sm text-gray-400">Owned</div>
<div className="text-2xl font-bold text-green-500">{ownedCount}</div>
</div>
<div className="bg-gray-700 rounded p-3">
<div className="text-sm text-gray-400">Missing</div>
<div className="text-2xl font-bold text-red-500">{missingCount}</div>
</div>
</div>
{/* Progress bar */}
<div className="w-full bg-gray-700 rounded-full h-4 mb-4">
<div
className="bg-green-500 h-4 rounded-full transition-all"
style={{ width: `${completionPercent}%` }}
/>
</div>
{/* Action buttons */}
<div className="flex gap-4">
{missingCount > 0 && (
<button
onClick={async () => {
await addMissingDeckCards(deckId);
handleRefresh();
}}
disabled={loading}
className="flex-1 px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 rounded-lg font-semibold"
>
{loading ? 'Adding...' : `Add All ${missingCount} Missing Cards`}
</button>
)}
<button
onClick={handleRefresh}
className="px-6 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg"
>
Refresh
</button>
</div>
</div>
{/* Card list */}
<DeckCardListWithStatus deckId={deckId} cards={cards} />
</div>
);
}

233
src/hooks/useCollection.ts Normal file
View File

@@ -0,0 +1,233 @@
import { useState, useCallback } from 'react';
import {
getUserCollection,
getCardInCollection,
checkCardsOwnership,
getDeckCardOwnership,
getMissingCardsFromDeck,
addCardToCollection,
addCardsToCollectionBulk,
addMissingDeckCardsToCollection,
removeCardFromCollection,
CollectionCard,
CardOwnershipInfo,
MissingCardInfo,
} from '../services/collectionService';
/**
* Custom React hook for managing card collections
* Provides state management and loading/error handling for collection operations
*/
export const useCollection = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Clear any existing error
*/
const clearError = useCallback(() => {
setError(null);
}, []);
/**
* Get user's entire collection
*/
const getCollection = useCallback(async (): Promise<CollectionCard[] | null> => {
setLoading(true);
setError(null);
try {
const collection = await getUserCollection();
return collection;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch collection';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
}, []);
/**
* Check if a single card is in the collection
*/
const checkCardOwnership = useCallback(async (cardId: string): Promise<CollectionCard | null> => {
setLoading(true);
setError(null);
try {
const card = await getCardInCollection(cardId);
return card;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to check card ownership';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
}, []);
/**
* Check ownership for multiple cards
*/
const checkMultipleCardsOwnership = useCallback(
async (cardIds: string[]): Promise<Map<string, number> | null> => {
setLoading(true);
setError(null);
try {
const ownershipMap = await checkCardsOwnership(cardIds);
return ownershipMap;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to check cards ownership';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
},
[]
);
/**
* Get detailed ownership info for all cards in a deck
*/
const getDeckOwnership = useCallback(
async (deckId: string): Promise<CardOwnershipInfo[] | null> => {
setLoading(true);
setError(null);
try {
const ownershipInfo = await getDeckCardOwnership(deckId);
return ownershipInfo;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to get deck ownership info';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
},
[]
);
/**
* Get list of missing cards from a deck
*/
const getMissingCards = useCallback(
async (deckId: string): Promise<MissingCardInfo[] | null> => {
setLoading(true);
setError(null);
try {
const missingCards = await getMissingCardsFromDeck(deckId);
return missingCards;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to get missing cards';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
},
[]
);
/**
* Add a single card to collection
*/
const addCard = useCallback(
async (cardId: string, quantity: number = 1): Promise<boolean> => {
setLoading(true);
setError(null);
try {
await addCardToCollection(cardId, quantity);
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add card to collection';
setError(errorMessage);
return false;
} finally {
setLoading(false);
}
},
[]
);
/**
* Add multiple cards to collection in bulk
*/
const addCardsBulk = useCallback(
async (
cards: Array<{ card_id: string; quantity: number }>
): Promise<Array<{ card_id: string; success: boolean; error?: string }> | null> => {
setLoading(true);
setError(null);
try {
const results = await addCardsToCollectionBulk(cards);
return results;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add cards to collection';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
},
[]
);
/**
* Add all missing cards from a deck to collection
*/
const addMissingDeckCards = useCallback(
async (
deckId: string
): Promise<Array<{ card_id: string; success: boolean; error?: string }> | null> => {
setLoading(true);
setError(null);
try {
const results = await addMissingDeckCardsToCollection(deckId);
return results;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add missing cards';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
},
[]
);
/**
* Remove a card from collection
*/
const removeCard = useCallback(
async (cardId: string, quantity?: number): Promise<boolean> => {
setLoading(true);
setError(null);
try {
await removeCardFromCollection(cardId, quantity);
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to remove card from collection';
setError(errorMessage);
return false;
} finally {
setLoading(false);
}
},
[]
);
return {
loading,
error,
clearError,
getCollection,
checkCardOwnership,
checkMultipleCardsOwnership,
getDeckOwnership,
getMissingCards,
addCard,
addCardsBulk,
addMissingDeckCards,
removeCard,
};
};

View File

@@ -1,4 +1,5 @@
import { Card } from '../types';
import { supabase } from '../lib/supabase';
const SCRYFALL_API = 'https://api.scryfall.com';
@@ -52,3 +53,127 @@ export const getCardsByIds = async (cardIds: string[]): Promise<Card[]> => {
return allCards;
};
// Collection API functions
export const getUserCollection = async (userId: string): Promise<Map<string, number>> => {
const { data, error } = await supabase
.from('collections')
.select('card_id, quantity')
.eq('user_id', userId);
if (error) {
console.error('Error fetching user collection:', error);
throw error;
}
// Create a map of card_id to quantity for easy lookup
const collectionMap = new Map<string, number>();
data?.forEach((item) => {
collectionMap.set(item.card_id, item.quantity);
});
return collectionMap;
};
export const addCardToCollection = async (
userId: string,
cardId: string,
quantity: number = 1
): Promise<void> => {
// Check if card already exists in collection
const { data: existing, error: fetchError } = await supabase
.from('collections')
.select('id, quantity')
.eq('user_id', userId)
.eq('card_id', cardId)
.single();
if (fetchError && fetchError.code !== 'PGRST116') {
// PGRST116 is "not found" error, which is expected for new cards
throw fetchError;
}
if (existing) {
// Update existing card quantity
const { error: updateError } = await supabase
.from('collections')
.update({
quantity: existing.quantity + quantity,
updated_at: new Date().toISOString()
})
.eq('id', existing.id);
if (updateError) throw updateError;
} else {
// Insert new card
const { error: insertError } = await supabase
.from('collections')
.insert({
user_id: userId,
card_id: cardId,
quantity: quantity,
});
if (insertError) throw insertError;
}
};
export const addMultipleCardsToCollection = async (
userId: string,
cards: { cardId: string; quantity: number }[]
): Promise<void> => {
// Fetch existing cards in collection
const cardIds = cards.map(c => c.cardId);
const { data: existingCards, error: fetchError } = await supabase
.from('collections')
.select('card_id, quantity, id')
.eq('user_id', userId)
.in('card_id', cardIds);
if (fetchError) throw fetchError;
const existingMap = new Map<string, { id: string; quantity: number }>();
existingCards?.forEach((item) => {
existingMap.set(item.card_id, { id: item.id, quantity: item.quantity });
});
const toInsert = [];
const toUpdate = [];
for (const card of cards) {
const existing = existingMap.get(card.cardId);
if (existing) {
toUpdate.push({
id: existing.id,
quantity: existing.quantity + card.quantity,
updated_at: new Date().toISOString(),
});
} else {
toInsert.push({
user_id: userId,
card_id: card.cardId,
quantity: card.quantity,
});
}
}
// Perform bulk operations
if (toInsert.length > 0) {
const { error: insertError } = await supabase
.from('collections')
.insert(toInsert);
if (insertError) throw insertError;
}
if (toUpdate.length > 0) {
for (const update of toUpdate) {
const { error: updateError } = await supabase
.from('collections')
.update({ quantity: update.quantity, updated_at: update.updated_at })
.eq('id', update.id);
if (updateError) throw updateError;
}
}
};

View File

@@ -0,0 +1,546 @@
import { supabase } from '../lib/supabase';
/**
* Collection Service
* Handles all backend operations related to user card collections
*/
export interface CollectionCard {
id: string;
user_id: string;
card_id: string;
quantity: number;
created_at: string;
updated_at: string;
}
export interface MissingCardInfo {
card_id: string;
quantity_needed: number;
quantity_in_collection: number;
}
export interface CardOwnershipInfo {
card_id: string;
owned: boolean;
quantity_in_collection: number;
quantity_in_deck: number;
quantity_needed: number;
}
/**
* Get the current authenticated user's ID
* @throws Error if user is not authenticated
*/
const getCurrentUserId = async (): Promise<string> => {
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
throw new Error('User not authenticated');
}
return user.id;
};
/**
* Get all cards in the user's collection
* @returns Array of collection cards with user's ownership info
*/
export const getUserCollection = async (): Promise<CollectionCard[]> => {
try {
const userId = await getCurrentUserId();
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', userId);
if (error) {
throw new Error(`Failed to fetch collection: ${error.message}`);
}
return data || [];
} catch (error) {
console.error('Error in getUserCollection:', error);
throw error;
}
};
/**
* Check if a single card exists in the user's collection
* @param cardId - The Scryfall card ID
* @returns CollectionCard or null if not found
*/
export const getCardInCollection = async (cardId: string): Promise<CollectionCard | null> => {
try {
const userId = await getCurrentUserId();
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', userId)
.eq('card_id', cardId)
.maybeSingle();
if (error) {
throw new Error(`Failed to check card ownership: ${error.message}`);
}
return data;
} catch (error) {
console.error('Error in getCardInCollection:', error);
throw error;
}
};
/**
* Check ownership status for multiple cards
* @param cardIds - Array of Scryfall card IDs
* @returns Map of card_id to quantity owned
*/
export const checkCardsOwnership = async (
cardIds: string[]
): Promise<Map<string, number>> => {
try {
const userId = await getCurrentUserId();
// Remove duplicates
const uniqueCardIds = [...new Set(cardIds)];
if (uniqueCardIds.length === 0) {
return new Map();
}
const { data, error } = await supabase
.from('collections')
.select('card_id, quantity')
.eq('user_id', userId)
.in('card_id', uniqueCardIds);
if (error) {
throw new Error(`Failed to check cards ownership: ${error.message}`);
}
const ownershipMap = new Map<string, number>();
if (data) {
data.forEach(item => {
ownershipMap.set(item.card_id, item.quantity || 0);
});
}
return ownershipMap;
} catch (error) {
console.error('Error in checkCardsOwnership:', error);
throw error;
}
};
/**
* Get detailed card ownership information for a deck
* @param deckId - The deck ID to check
* @returns Array of CardOwnershipInfo for each card in the deck
*/
export const getDeckCardOwnership = async (
deckId: string
): Promise<CardOwnershipInfo[]> => {
try {
const userId = await getCurrentUserId();
// First verify the user owns this deck
const { data: deck, error: deckError } = await supabase
.from('decks')
.select('id, user_id')
.eq('id', deckId)
.eq('user_id', userId)
.single();
if (deckError || !deck) {
throw new Error('Deck not found or access denied');
}
// Get all cards in the deck
const { data: deckCards, error: deckCardsError } = await supabase
.from('deck_cards')
.select('card_id, quantity')
.eq('deck_id', deckId);
if (deckCardsError) {
throw new Error(`Failed to fetch deck cards: ${deckCardsError.message}`);
}
if (!deckCards || deckCards.length === 0) {
return [];
}
// Create a map of card quantities in deck
const deckCardMap = new Map<string, number>();
deckCards.forEach(card => {
const currentQty = deckCardMap.get(card.card_id) || 0;
deckCardMap.set(card.card_id, currentQty + (card.quantity || 1));
});
// Get ownership info for all cards
const cardIds = Array.from(deckCardMap.keys());
const ownershipMap = await checkCardsOwnership(cardIds);
// Build the result
const ownershipInfo: CardOwnershipInfo[] = [];
deckCardMap.forEach((quantityInDeck, cardId) => {
const quantityInCollection = ownershipMap.get(cardId) || 0;
const quantityNeeded = Math.max(0, quantityInDeck - quantityInCollection);
ownershipInfo.push({
card_id: cardId,
owned: quantityInCollection >= quantityInDeck,
quantity_in_collection: quantityInCollection,
quantity_in_deck: quantityInDeck,
quantity_needed: quantityNeeded,
});
});
return ownershipInfo;
} catch (error) {
console.error('Error in getDeckCardOwnership:', error);
throw error;
}
};
/**
* Get list of missing cards from a deck
* @param deckId - The deck ID to check
* @returns Array of missing cards with quantity needed
*/
export const getMissingCardsFromDeck = async (
deckId: string
): Promise<MissingCardInfo[]> => {
try {
const ownershipInfo = await getDeckCardOwnership(deckId);
return ownershipInfo
.filter(info => !info.owned)
.map(info => ({
card_id: info.card_id,
quantity_needed: info.quantity_needed,
quantity_in_collection: info.quantity_in_collection,
}));
} catch (error) {
console.error('Error in getMissingCardsFromDeck:', error);
throw error;
}
};
/**
* Add a single card to the user's collection
* If the card already exists, increment its quantity
* @param cardId - The Scryfall card ID
* @param quantity - The quantity to add (default: 1)
* @returns The updated or created collection card
*/
export const addCardToCollection = async (
cardId: string,
quantity: number = 1
): Promise<CollectionCard> => {
try {
if (!cardId || cardId.trim() === '') {
throw new Error('Invalid card ID');
}
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
const userId = await getCurrentUserId();
// Check if card already exists in collection
const existingCard = await getCardInCollection(cardId);
if (existingCard) {
// Update existing card quantity
const newQuantity = existingCard.quantity + quantity;
const { data, error } = await supabase
.from('collections')
.update({
quantity: newQuantity,
updated_at: new Date().toISOString(),
})
.eq('id', existingCard.id)
.select()
.single();
if (error) {
throw new Error(`Failed to update card quantity: ${error.message}`);
}
return data;
} else {
// Insert new card
const { data, error } = await supabase
.from('collections')
.insert({
user_id: userId,
card_id: cardId,
quantity: quantity,
})
.select()
.single();
if (error) {
throw new Error(`Failed to add card to collection: ${error.message}`);
}
return data;
}
} catch (error) {
console.error('Error in addCardToCollection:', error);
throw error;
}
};
/**
* Add multiple cards to the user's collection in bulk
* @param cards - Array of {card_id, quantity} objects
* @returns Array of results with success/failure status for each card
*/
export const addCardsToCollectionBulk = async (
cards: Array<{ card_id: string; quantity: number }>
): Promise<Array<{ card_id: string; success: boolean; error?: string }>> => {
try {
if (!cards || cards.length === 0) {
throw new Error('No cards provided');
}
// Validate all cards first
const validationErrors: string[] = [];
cards.forEach((card, index) => {
if (!card.card_id || card.card_id.trim() === '') {
validationErrors.push(`Card at index ${index} has invalid ID`);
}
if (card.quantity < 1) {
validationErrors.push(`Card at index ${index} has invalid quantity`);
}
});
if (validationErrors.length > 0) {
throw new Error(`Validation failed: ${validationErrors.join(', ')}`);
}
const userId = await getCurrentUserId();
// Get current collection state for all cards
const cardIds = cards.map(c => c.card_id);
// Prepare updates and inserts
const toUpdate: Array<{ id: string; card_id: string; quantity: number }> = [];
const toInsert: Array<{ user_id: string; card_id: string; quantity: number }> = [];
// First, get existing collection entries for update
const { data: existingCards, error: fetchError } = await supabase
.from('collections')
.select('id, card_id, quantity')
.eq('user_id', userId)
.in('card_id', cardIds);
if (fetchError) {
throw new Error(`Failed to fetch existing cards: ${fetchError.message}`);
}
const existingCardsMap = new Map(
(existingCards || []).map(card => [card.card_id, card])
);
// Categorize cards for update or insert
cards.forEach(card => {
const existing = existingCardsMap.get(card.card_id);
if (existing) {
toUpdate.push({
id: existing.id,
card_id: card.card_id,
quantity: existing.quantity + card.quantity,
});
} else {
toInsert.push({
user_id: userId,
card_id: card.card_id,
quantity: card.quantity,
});
}
});
const results: Array<{ card_id: string; success: boolean; error?: string }> = [];
// Process updates
for (const updateCard of toUpdate) {
try {
const { error } = await supabase
.from('collections')
.update({
quantity: updateCard.quantity,
updated_at: new Date().toISOString(),
})
.eq('id', updateCard.id);
if (error) {
results.push({
card_id: updateCard.card_id,
success: false,
error: error.message,
});
} else {
results.push({
card_id: updateCard.card_id,
success: true,
});
}
} catch (err) {
results.push({
card_id: updateCard.card_id,
success: false,
error: err instanceof Error ? err.message : 'Unknown error',
});
}
}
// Process inserts in batches (Supabase recommends batches of 1000)
const batchSize = 1000;
for (let i = 0; i < toInsert.length; i += batchSize) {
const batch = toInsert.slice(i, i + batchSize);
try {
const { error } = await supabase
.from('collections')
.insert(batch);
if (error) {
// If batch fails, mark all as failed
batch.forEach(card => {
results.push({
card_id: card.card_id,
success: false,
error: error.message,
});
});
} else {
// Mark all as success
batch.forEach(card => {
results.push({
card_id: card.card_id,
success: true,
});
});
}
} catch (err) {
// If batch fails with exception, mark all as failed
batch.forEach(card => {
results.push({
card_id: card.card_id,
success: false,
error: err instanceof Error ? err.message : 'Unknown error',
});
});
}
}
return results;
} catch (error) {
console.error('Error in addCardsToCollectionBulk:', error);
throw error;
}
};
/**
* Add all missing cards from a deck to the user's collection
* @param deckId - The deck ID
* @returns Array of results with success/failure status for each card
*/
export const addMissingDeckCardsToCollection = async (
deckId: string
): Promise<Array<{ card_id: string; success: boolean; error?: string }>> => {
try {
const userId = await getCurrentUserId();
// Verify deck ownership
const { data: deck, error: deckError } = await supabase
.from('decks')
.select('id')
.eq('id', deckId)
.eq('user_id', userId)
.single();
if (deckError || !deck) {
throw new Error('Deck not found or access denied');
}
// Get missing cards
const missingCards = await getMissingCardsFromDeck(deckId);
if (missingCards.length === 0) {
return [];
}
// Convert to format for bulk add
const cardsToAdd = missingCards.map(card => ({
card_id: card.card_id,
quantity: card.quantity_needed,
}));
return await addCardsToCollectionBulk(cardsToAdd);
} catch (error) {
console.error('Error in addMissingDeckCardsToCollection:', error);
throw error;
}
};
/**
* Remove a card from the user's collection
* @param cardId - The Scryfall card ID
* @param quantity - The quantity to remove (default: all)
* @returns true if successful
*/
export const removeCardFromCollection = async (
cardId: string,
quantity?: number
): Promise<boolean> => {
try {
const existingCard = await getCardInCollection(cardId);
if (!existingCard) {
throw new Error('Card not found in collection');
}
// If no quantity specified or quantity >= existing, delete the entry
if (!quantity || quantity >= existingCard.quantity) {
const { error } = await supabase
.from('collections')
.delete()
.eq('id', existingCard.id);
if (error) {
throw new Error(`Failed to remove card: ${error.message}`);
}
} else {
// Otherwise, decrease the quantity
const newQuantity = existingCard.quantity - quantity;
const { error } = await supabase
.from('collections')
.update({
quantity: newQuantity,
updated_at: new Date().toISOString(),
})
.eq('id', existingCard.id);
if (error) {
throw new Error(`Failed to update card quantity: ${error.message}`);
}
}
return true;
} catch (error) {
console.error('Error in removeCardFromCollection:', error);
throw error;
}
};

View File

@@ -16,6 +16,20 @@ export interface Card {
type_line?: string;
oracle_text?: string;
colors?: string[];
prices?: {
usd?: string;
usd_foil?: string;
eur?: string;
};
}
export interface Collection {
id: string;
user_id: string;
card_id: string;
quantity: number;
created_at: string;
updated_at: string;
}
export interface Deck {