Compare commits
7 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3fc8079c6 | ||
|
|
6d8e7b3224 | ||
|
|
8bb80dac2e | ||
|
|
247f2205b8 | ||
|
|
e96289de03 | ||
|
|
ad7ae17985 | ||
| 96ba4c2809 |
@@ -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,20 +606,109 @@ 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">
|
||||
<MagicCard card={card} />
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
||||
<p className="text-gray-400 text-sm">{card.type_line}</p>
|
||||
{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">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardSearch;
|
||||
export default CardSearch;
|
||||
|
||||
@@ -1,121 +1,498 @@
|
||||
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: '' });
|
||||
|
||||
// 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 {
|
||||
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('Error loading collection:', error);
|
||||
setSnackbar({ message: 'Failed to load collection', type: 'error' });
|
||||
} finally {
|
||||
setIsLoadingCollection(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCollection();
|
||||
}, [user]);
|
||||
|
||||
// Filter collection based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredCollection(collection);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
try {
|
||||
const cards = await searchCards(searchQuery);
|
||||
setSearchResults(cards);
|
||||
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('Failed to search cards:', error);
|
||||
console.error('Error updating card quantity:', error);
|
||||
setSnackbar({ message: 'Failed to update quantity', type: 'error' });
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
setTimeout(() => setSnackbar(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
return [...prev, { card, quantity: 1 }];
|
||||
});
|
||||
// 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 (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<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>
|
||||
)}
|
||||
</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 && (
|
||||
<img
|
||||
src={card.image_uris.normal}
|
||||
alt={card.name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
<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={getCardImageUri(card, currentFaceIndex)}
|
||||
alt={displayName}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{/* 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}
|
||||
</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 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">
|
||||
x{quantity}
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
105
src/components/ConfirmModal.tsx
Normal file
105
src/components/ConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
|
||||
>
|
||||
<MagicCard card={card} />
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
||||
<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"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add to Deck
|
||||
</button>
|
||||
{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">
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
@@ -492,42 +678,114 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-xl mb-4">
|
||||
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
|
||||
</h3>
|
||||
{selectedCards.map(({ card, quantity }) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex items-center gap-4 bg-gray-700 p-2 rounded-lg"
|
||||
>
|
||||
<img
|
||||
src={card.image_uris?.art_crop}
|
||||
alt={card.name}
|
||||
className="w-12 h-12 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{card.name}</h4>
|
||||
{card.prices?.usd && (
|
||||
<div className="text-sm text-gray-400">${card.prices.usd}</div>
|
||||
)}
|
||||
<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>
|
||||
{!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>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={e =>
|
||||
updateCardQuantity(card.id, parseInt(e.target.value))
|
||||
}
|
||||
min="1"
|
||||
className="w-16 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeCardFromDeck(card.id)}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
)}
|
||||
</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 p-2 rounded-lg ${
|
||||
isMissing
|
||||
? 'bg-yellow-900/20 border border-yellow-700/50'
|
||||
: 'bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<img
|
||||
src={card.image_uris?.art_crop}
|
||||
alt={card.name}
|
||||
className="w-12 h-12 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<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}
|
||||
onChange={e =>
|
||||
updateCardQuantity(card.id, parseInt(e.target.value))
|
||||
}
|
||||
min="1"
|
||||
className="w-16 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeCardFromDeck(card.id)}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="font-bold text-xl">
|
||||
@@ -601,4 +859,4 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
80
src/components/Modal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
412
src/examples/CollectionIntegrationExample.tsx
Normal file
412
src/examples/CollectionIntegrationExample.tsx
Normal 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
233
src/hooks/useCollection.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
546
src/services/collectionService.ts
Normal file
546
src/services/collectionService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
@@ -34,4 +48,4 @@ export interface CardEntity {
|
||||
card_id: string;
|
||||
quantity: number;
|
||||
is_commander: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user