From 459cc0ecedf931e21b1fc36ff4ee8b83e8f63c72 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 24 Nov 2025 14:43:49 +0100 Subject: [PATCH] Add trading and friends features with UI components and services --- dev-dist/sw.js | 2 +- docker-compose.yml | 22 ++ src/App.tsx | 11 +- src/components/Community.tsx | 305 +++++++++++++++++++++++++ src/components/Friends.tsx | 312 ++++++++++++++++++++++++++ src/components/Navigation.tsx | 9 +- src/components/Profile.tsx | 44 +++- src/components/TradeCreator.tsx | 380 ++++++++++++++++++++++++++++++++ src/components/Trades.tsx | 326 +++++++++++++++++++++++++++ src/lib/Entities.ts | 134 ++++++++++- src/services/friendsService.ts | 202 +++++++++++++++++ src/services/tradesService.ts | 184 ++++++++++++++++ 12 files changed, 1923 insertions(+), 8 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/components/Community.tsx create mode 100644 src/components/Friends.tsx create mode 100644 src/components/TradeCreator.tsx create mode 100644 src/components/Trades.tsx create mode 100644 src/services/friendsService.ts create mode 100644 src/services/tradesService.ts diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 55a0a55..6122776 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-ca84f546'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.g74vi66e49" + "revision": "0.obrcsn1e2cs" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..83e3cc6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +# Simple deployment - Uses external Supabase (hosted or self-hosted separately) +# For full self-hosted setup with Supabase included, use docker-compose.selfhosted.yml + +services: + deckerr: + build: + context: . + dockerfile: Dockerfile + args: + - VITE_SUPABASE_URL=${VITE_SUPABASE_URL} + - VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY} + container_name: deckerr + ports: + - "${PORT:-3000}:80" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/src/App.tsx b/src/App.tsx index 4650848..4e714cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,10 +8,13 @@ import React, { useState } from 'react'; import Profile from './components/Profile'; import CardSearch from './components/CardSearch'; import LifeCounter from './components/LifeCounter'; + import Friends from './components/Friends'; + import Trades from './components/Trades'; + import Community from './components/Community'; import PWAInstallPrompt from './components/PWAInstallPrompt'; import { AuthProvider, useAuth } from './contexts/AuthContext'; - type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter'; + type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter' | 'friends' | 'trades' | 'community'; function AppContent() { const [currentPage, setCurrentPage] = useState('home'); @@ -69,6 +72,12 @@ import React, { useState } from 'react'; return ; case 'life-counter': return ; + case 'friends': + return ; + case 'trades': + return ; + case 'community': + return ; case 'login': return ; default: diff --git a/src/components/Community.tsx b/src/components/Community.tsx new file mode 100644 index 0000000..d6cd93a --- /dev/null +++ b/src/components/Community.tsx @@ -0,0 +1,305 @@ +import React, { useState, useEffect } from 'react'; +import { Search, Globe, Users, Eye, ArrowLeftRight, Loader2 } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import { supabase } from '../lib/supabase'; +import { getFriends, Friend } from '../services/friendsService'; +import { getUserCollection, getCardsByIds } from '../services/api'; +import { Card } from '../types'; +import TradeCreator from './TradeCreator'; + +interface UserProfile { + id: string; + username: string | null; + collection_visibility: 'public' | 'friends' | 'private' | null; +} + +interface CollectionItem { + card: Card; + quantity: number; +} + +type Tab = 'public' | 'friends'; + +export default function Community() { + const { user } = useAuth(); + const [activeTab, setActiveTab] = useState('public'); + const [searchQuery, setSearchQuery] = useState(''); + const [publicUsers, setPublicUsers] = useState([]); + const [friends, setFriends] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedUserCollection, setSelectedUserCollection] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingCollection, setLoadingCollection] = useState(false); + const [showTradeCreator, setShowTradeCreator] = useState(false); + + useEffect(() => { + if (user) { + loadData(); + } + }, [user]); + + const loadData = async () => { + if (!user) return; + setLoading(true); + try { + const [publicData, friendsData] = await Promise.all([ + loadPublicUsers(), + getFriends(user.id), + ]); + setPublicUsers(publicData); + setFriends(friendsData); + } catch (error) { + console.error('Error loading community data:', error); + } finally { + setLoading(false); + } + }; + + const loadPublicUsers = async (): Promise => { + const { data, error } = await supabase + .from('profiles') + .select('id, username, collection_visibility') + .eq('collection_visibility', 'public') + .neq('id', user?.id) + .order('username'); + + if (error) throw error; + return data || []; + }; + + const loadUserCollection = async (userId: string) => { + setLoadingCollection(true); + try { + const collectionMap = await getUserCollection(userId); + + if (collectionMap.size === 0) { + setSelectedUserCollection([]); + return; + } + + const cardIds = Array.from(collectionMap.keys()); + const cards = await getCardsByIds(cardIds); + + const collectionWithCards = cards.map((card) => ({ + card, + quantity: collectionMap.get(card.id) || 0, + })); + + setSelectedUserCollection(collectionWithCards); + } catch (error) { + console.error('Error loading collection:', error); + setSelectedUserCollection([]); + } finally { + setLoadingCollection(false); + } + }; + + const handleViewCollection = async (userProfile: UserProfile) => { + setSelectedUser(userProfile); + await loadUserCollection(userProfile.id); + }; + + const filteredPublicUsers = publicUsers.filter( + (u) => !searchQuery || u.username?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const friendProfiles: UserProfile[] = friends.map((f) => ({ + id: f.id, + username: f.username, + collection_visibility: 'friends' as const, + })); + + const filteredFriends = friendProfiles.filter( + (f) => !searchQuery || f.username?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + if (loading) { + return ( +
+
+
+ ); + } + + // If viewing a user's collection + if (selectedUser) { + return ( +
+
+ {/* Header */} +
+
+ +

{selectedUser.username}'s Collection

+
+ +
+ + {/* Collection Grid */} + {loadingCollection ? ( +
+ +
+ ) : selectedUserCollection.length === 0 ? ( +

This collection is empty

+ ) : ( +
+ {selectedUserCollection.map(({ card, quantity }) => ( +
+
+ {card.name} +
+ x{quantity} +
+
+
{card.name}
+
+ ))} +
+ )} + + {/* Trade Creator Modal */} + {showTradeCreator && ( + setShowTradeCreator(false)} + onTradeCreated={() => { + setShowTradeCreator(false); + alert('Trade proposal sent!'); + }} + /> + )} +
+
+ ); + } + + return ( +
+
+

Community

+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search users..." + 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" + /> +
+ + {/* Tabs */} +
+ + +
+ + {/* User List */} +
+ {activeTab === 'public' && ( + <> + {filteredPublicUsers.length === 0 ? ( +

+ No public collections found +

+ ) : ( + filteredPublicUsers.map((userProfile) => ( +
+
+ + {userProfile.username || 'Unknown'} +
+ +
+ )) + )} + + )} + + {activeTab === 'friends' && ( + <> + {filteredFriends.length === 0 ? ( +

+ {friends.length === 0 + ? 'Add friends to see their collections' + : 'No friends match your search'} +

+ ) : ( + filteredFriends.map((friend) => ( +
+
+ + {friend.username || 'Unknown'} +
+ +
+ )) + )} + + )} +
+
+
+ ); +} diff --git a/src/components/Friends.tsx b/src/components/Friends.tsx new file mode 100644 index 0000000..0b3a585 --- /dev/null +++ b/src/components/Friends.tsx @@ -0,0 +1,312 @@ +import React, { useState, useEffect } from 'react'; +import { Search, UserPlus, UserMinus, Check, X, Users, Clock, Send } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import { + getFriends, + getPendingRequests, + getSentRequests, + searchUsers, + sendFriendRequest, + acceptFriendRequest, + declineFriendRequest, + removeFriend, + Friend, +} from '../services/friendsService'; + +type Tab = 'friends' | 'requests' | 'search'; + +export default function Friends() { + const { user } = useAuth(); + const [activeTab, setActiveTab] = useState('friends'); + const [friends, setFriends] = useState([]); + const [pendingRequests, setPendingRequests] = useState([]); + const [sentRequests, setSentRequests] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState<{ id: string; username: string | null }[]>([]); + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + + useEffect(() => { + if (user) { + loadFriendsData(); + } + }, [user]); + + const loadFriendsData = async () => { + if (!user) return; + setLoading(true); + try { + const [friendsData, pendingData, sentData] = await Promise.all([ + getFriends(user.id), + getPendingRequests(user.id), + getSentRequests(user.id), + ]); + setFriends(friendsData); + setPendingRequests(pendingData); + setSentRequests(sentData); + } catch (error) { + console.error('Error loading friends:', error); + } finally { + setLoading(false); + } + }; + + const handleSearch = async () => { + if (!user || searchQuery.trim().length < 2) return; + setSearching(true); + try { + const results = await searchUsers(searchQuery, user.id); + setSearchResults(results || []); + } catch (error) { + console.error('Error searching users:', error); + } finally { + setSearching(false); + } + }; + + const handleSendRequest = async (addresseeId: string) => { + if (!user) return; + try { + await sendFriendRequest(user.id, addresseeId); + setSearchResults((prev) => prev.filter((u) => u.id !== addresseeId)); + await loadFriendsData(); + } catch (error) { + console.error('Error sending friend request:', error); + alert('Failed to send friend request'); + } + }; + + const handleAcceptRequest = async (friendshipId: string) => { + try { + await acceptFriendRequest(friendshipId); + await loadFriendsData(); + } catch (error) { + console.error('Error accepting request:', error); + } + }; + + const handleDeclineRequest = async (friendshipId: string) => { + try { + await declineFriendRequest(friendshipId); + await loadFriendsData(); + } catch (error) { + console.error('Error declining request:', error); + } + }; + + const handleRemoveFriend = async (friendshipId: string) => { + if (!confirm('Remove this friend?')) return; + try { + await removeFriend(friendshipId); + await loadFriendsData(); + } catch (error) { + console.error('Error removing friend:', error); + } + }; + + const isAlreadyFriendOrPending = (userId: string) => { + return ( + friends.some((f) => f.id === userId) || + pendingRequests.some((f) => f.id === userId) || + sentRequests.some((f) => f.id === userId) + ); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Friends

+ + {/* Tabs */} +
+ + + +
+ + {/* Friends List */} + {activeTab === 'friends' && ( +
+ {friends.length === 0 ? ( +

+ No friends yet. Search for users to add them! +

+ ) : ( + friends.map((friend) => ( +
+ {friend.username || 'Unknown'} + +
+ )) + )} +
+ )} + + {/* Requests Tab */} + {activeTab === 'requests' && ( +
+ {/* Received Requests */} +
+

Received Requests

+ {pendingRequests.length === 0 ? ( +

No pending requests

+ ) : ( +
+ {pendingRequests.map((request) => ( +
+ {request.username || 'Unknown'} +
+ + +
+
+ ))} +
+ )} +
+ + {/* Sent Requests */} +
+

Sent Requests

+ {sentRequests.length === 0 ? ( +

No sent requests

+ ) : ( +
+ {sentRequests.map((request) => ( +
+
+ + {request.username || 'Unknown'} +
+ Pending +
+ ))} +
+ )} +
+
+ )} + + {/* Search Tab */} + {activeTab === 'search' && ( +
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Search by username..." + className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+ + {searchResults.length > 0 && ( +
+ {searchResults.map((result) => ( +
+ {result.username || 'Unknown'} + {isAlreadyFriendOrPending(result.id) ? ( + Already connected + ) : ( + + )} +
+ ))} +
+ )} + + {searchQuery.length >= 2 && searchResults.length === 0 && !searching && ( +

No users found

+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 8524cf6..1c5b5c7 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -1,9 +1,9 @@ import React, { useState, useRef, useEffect } from 'react'; - import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown, Search, Heart, Menu } from 'lucide-react'; + import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown, Search, Heart, Menu, Users, ArrowLeftRight, Globe } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { supabase } from '../lib/supabase'; - type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile' | 'search' | 'life-counter'; + type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile' | 'search' | 'life-counter' | 'friends' | 'trades' | 'community'; interface NavigationProps { currentPage: Page; @@ -53,8 +53,11 @@ import React, { useState, useRef, useEffect } from 'react'; const navItems = [ { id: 'home' as const, label: 'Decks', icon: Library }, { id: 'collection' as const, label: 'Collection', icon: Library }, + { id: 'community' as const, label: 'Community', icon: Globe }, + { id: 'friends' as const, label: 'Friends', icon: Users }, + { id: 'trades' as const, label: 'Trades', icon: ArrowLeftRight }, { id: 'search' as const, label: 'Search', icon: Search }, - { id: 'life-counter' as const, label: 'Life Counter', icon: Heart }, + { id: 'life-counter' as const, label: 'Life', icon: Heart }, ]; const handleSignOut = async () => { diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index bea9094..3b29f0e 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -1,14 +1,23 @@ import React, { useState, useEffect } from 'react'; -import { Save } from 'lucide-react'; +import { Save, Globe, Users, Lock } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { supabase } from '../lib/supabase'; const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple']; +const VISIBILITY_OPTIONS = [ + { value: 'public', label: 'Public', icon: Globe, description: 'Anyone can view your collection' }, + { value: 'friends', label: 'Friends Only', icon: Users, description: 'Only friends can view your collection' }, + { value: 'private', label: 'Private', icon: Lock, description: 'Only you can view your collection' }, +] as const; + +type CollectionVisibility = 'public' | 'friends' | 'private'; + export default function Profile() { const { user } = useAuth(); const [username, setUsername] = useState(''); const [themeColor, setThemeColor] = useState('blue'); + const [collectionVisibility, setCollectionVisibility] = useState('private'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -17,13 +26,14 @@ export default function Profile() { if (user) { const { data, error } = await supabase .from('profiles') - .select('username, theme_color') + .select('username, theme_color, collection_visibility') .eq('id', user.id) .single(); if (data) { setUsername(data.username || ''); setThemeColor(data.theme_color || 'blue'); + setCollectionVisibility((data.collection_visibility as CollectionVisibility) || 'private'); } setLoading(false); } @@ -44,6 +54,7 @@ export default function Profile() { id: user.id, username, theme_color: themeColor, + collection_visibility: collectionVisibility, updated_at: new Date() }); @@ -107,6 +118,35 @@ export default function Profile() { +
+ +
+ {VISIBILITY_OPTIONS.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+
+ + + + {/* Content */} +
+ {/* My Collection (Left) */} +
+

+ My Collection (I give) +

+ {loading ? ( +
+
+
+ ) : myCollection.length === 0 ? ( +

Your collection is empty

+ ) : ( +
+ {myCollection.map(({ card, quantity }) => { + const offered = myOfferedCards.get(card.id); + const remainingQty = quantity - (offered?.quantity || 0); + return ( +
remainingQty > 0 && addToOffer(card, quantity)} + > + {card.name} +
+ {remainingQty}/{quantity} +
+ {offered && ( +
+ +{offered.quantity} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Their Collection (Right) */} +
+

+ {receiverUsername}'s Collection (I want) +

+ {receiverCollection.length === 0 ? ( +

Their collection is empty

+ ) : ( +
+ {receiverCollection.map(({ card, quantity }) => { + const wanted = wantedCards.get(card.id); + const remainingQty = quantity - (wanted?.quantity || 0); + return ( +
remainingQty > 0 && addToWanted(card, quantity)} + > + {card.name} +
+ {remainingQty}/{quantity} +
+ {wanted && ( +
+ +{wanted.quantity} +
+ )} +
+ ); + })} +
+ )} +
+
+ + {/* Trade Summary */} +
+
+ {/* I Give */} +
+

I Give:

+ {myOfferedCards.size === 0 ? ( +

Nothing selected (gift request)

+ ) : ( +
+ {Array.from(myOfferedCards.values()).map((item) => ( +
+ {item.card.name} + x{item.quantity} + +
+ ))} +
+ )} +
+ + {/* I Want */} +
+

I Want:

+ {wantedCards.size === 0 ? ( +

Nothing selected (gift)

+ ) : ( +
+ {Array.from(wantedCards.values()).map((item) => ( +
+ {item.card.name} + x{item.quantity} + +
+ ))} +
+ )} +
+
+ + {/* Message */} +
+ setMessage(e.target.value)} + placeholder="Add a message (optional)" + className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* Submit */} +
+ + +
+
+ + + ); +} diff --git a/src/components/Trades.tsx b/src/components/Trades.tsx new file mode 100644 index 0000000..7f6ca43 --- /dev/null +++ b/src/components/Trades.tsx @@ -0,0 +1,326 @@ +import React, { useState, useEffect } from 'react'; +import { ArrowLeftRight, Check, X, Clock, History, Plus, Package } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import { + getTrades, + getTradeHistory, + acceptTrade, + declineTrade, + cancelTrade, + Trade, + TradeItem, +} from '../services/tradesService'; +import { getCardsByIds } from '../services/api'; +import { Card } from '../types'; + +type Tab = 'pending' | 'history'; + +interface TradeWithCards extends Trade { + cardDetails?: Map; +} + +export default function Trades() { + const { user } = useAuth(); + const [activeTab, setActiveTab] = useState('pending'); + const [pendingTrades, setPendingTrades] = useState([]); + const [tradeHistory, setTradeHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [processingTradeId, setProcessingTradeId] = useState(null); + + useEffect(() => { + if (user) { + loadTrades(); + } + }, [user]); + + const loadTrades = async () => { + if (!user) return; + setLoading(true); + try { + const [pending, history] = await Promise.all([ + getTrades(user.id).then((trades) => trades.filter((t) => t.status === 'pending')), + getTradeHistory(user.id), + ]); + + // Load card details for all trades + const allCardIds = new Set(); + [...pending, ...history].forEach((trade) => { + trade.items?.forEach((item) => allCardIds.add(item.card_id)); + }); + + let cardDetails = new Map(); + if (allCardIds.size > 0) { + const cards = await getCardsByIds(Array.from(allCardIds)); + cards.forEach((card) => cardDetails.set(card.id, card)); + } + + setPendingTrades(pending.map((t) => ({ ...t, cardDetails }))); + setTradeHistory(history.map((t) => ({ ...t, cardDetails }))); + } catch (error) { + console.error('Error loading trades:', error); + } finally { + setLoading(false); + } + }; + + const handleAccept = async (tradeId: string) => { + setProcessingTradeId(tradeId); + try { + const success = await acceptTrade(tradeId); + if (success) { + await loadTrades(); + } else { + alert('Failed to execute trade. Please check your collection.'); + } + } catch (error) { + console.error('Error accepting trade:', error); + alert('Error accepting trade'); + } finally { + setProcessingTradeId(null); + } + }; + + const handleDecline = async (tradeId: string) => { + setProcessingTradeId(tradeId); + try { + await declineTrade(tradeId); + await loadTrades(); + } catch (error) { + console.error('Error declining trade:', error); + } finally { + setProcessingTradeId(null); + } + }; + + const handleCancel = async (tradeId: string) => { + if (!confirm('Cancel this trade offer?')) return; + setProcessingTradeId(tradeId); + try { + await cancelTrade(tradeId); + await loadTrades(); + } catch (error) { + console.error('Error cancelling trade:', error); + } finally { + setProcessingTradeId(null); + } + }; + + const getStatusColor = (status: Trade['status']) => { + switch (status) { + case 'accepted': + return 'text-green-400'; + case 'declined': + return 'text-red-400'; + case 'cancelled': + return 'text-gray-400'; + default: + return 'text-yellow-400'; + } + }; + + const renderTradeItems = ( + items: TradeItem[] | undefined, + ownerId: string, + cardDetails: Map | undefined, + label: string + ) => { + const ownerItems = items?.filter((i) => i.owner_id === ownerId) || []; + if (ownerItems.length === 0) { + return ( +
+ {label}: Nothing (gift) +
+ ); + } + + return ( +
+
{label}:
+
+ {ownerItems.map((item) => { + const card = cardDetails?.get(item.card_id); + return ( +
+ {card?.image_uris?.small && ( + {card.name} + )} + {card?.name || item.card_id} + {item.quantity > 1 && ( + x{item.quantity} + )} +
+ ); + })} +
+
+ ); + }; + + const renderTrade = (trade: TradeWithCards, showActions: boolean) => { + const isSender = trade.sender_id === user?.id; + const otherUser = isSender ? trade.receiver : trade.sender; + + return ( +
+ {/* Header */} +
+
+ + + {isSender ? `To: ${otherUser?.username}` : `From: ${otherUser?.username}`} + +
+ + {trade.status} + +
+ + {/* Trade Items */} +
+ {renderTradeItems( + trade.items, + trade.sender_id, + trade.cardDetails, + isSender ? 'You give' : 'They give' + )} + {renderTradeItems( + trade.items, + trade.receiver_id, + trade.cardDetails, + isSender ? 'You receive' : 'They receive' + )} +
+ + {/* Message */} + {trade.message && ( +
+ Message: {trade.message} +
+ )} + + {/* Actions */} + {showActions && trade.status === 'pending' && ( +
+ {isSender ? ( + + ) : ( + <> + + + + )} +
+ )} + + {/* Timestamp */} +
+ {new Date(trade.created_at || '').toLocaleDateString()} +
+
+ ); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

Trades

+
+ + {/* Tabs */} +
+ + +
+ + {/* Pending Trades */} + {activeTab === 'pending' && ( +
+ {pendingTrades.length === 0 ? ( +
+ +

No pending trades

+

+ Visit a friend's collection to propose a trade +

+
+ ) : ( + pendingTrades.map((trade) => renderTrade(trade, true)) + )} +
+ )} + + {/* Trade History */} + {activeTab === 'history' && ( +
+ {tradeHistory.length === 0 ? ( +

No trade history yet

+ ) : ( + tradeHistory.map((trade) => renderTrade(trade, false)) + )} +
+ )} +
+
+ ); +} diff --git a/src/lib/Entities.ts b/src/lib/Entities.ts index c121262..a83c31f 100644 --- a/src/lib/Entities.ts +++ b/src/lib/Entities.ts @@ -143,6 +143,7 @@ export type Database = { theme_color: string | null updated_at: string | null username: string | null + collection_visibility: 'public' | 'friends' | 'private' | null } Insert: { created_at?: string | null @@ -150,6 +151,7 @@ export type Database = { theme_color?: string | null updated_at?: string | null username?: string | null + collection_visibility?: 'public' | 'friends' | 'private' | null } Update: { created_at?: string | null @@ -157,9 +159,139 @@ export type Database = { theme_color?: string | null updated_at?: string | null username?: string | null + collection_visibility?: 'public' | 'friends' | 'private' | null } Relationships: [] } + friendships: { + Row: { + id: string + requester_id: string + addressee_id: string + status: 'pending' | 'accepted' | 'declined' + created_at: string | null + updated_at: string | null + } + Insert: { + id?: string + requester_id: string + addressee_id: string + status?: 'pending' | 'accepted' | 'declined' + created_at?: string | null + updated_at?: string | null + } + Update: { + id?: string + requester_id?: string + addressee_id?: string + status?: 'pending' | 'accepted' | 'declined' + created_at?: string | null + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "friendships_requester_id_fkey" + columns: ["requester_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "friendships_addressee_id_fkey" + columns: ["addressee_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + } + ] + } + trades: { + Row: { + id: string + sender_id: string + receiver_id: string + status: 'pending' | 'accepted' | 'declined' | 'cancelled' + message: string | null + created_at: string | null + updated_at: string | null + } + Insert: { + id?: string + sender_id: string + receiver_id: string + status?: 'pending' | 'accepted' | 'declined' | 'cancelled' + message?: string | null + created_at?: string | null + updated_at?: string | null + } + Update: { + id?: string + sender_id?: string + receiver_id?: string + status?: 'pending' | 'accepted' | 'declined' | 'cancelled' + message?: string | null + created_at?: string | null + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "trades_sender_id_fkey" + columns: ["sender_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "trades_receiver_id_fkey" + columns: ["receiver_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + } + ] + } + trade_items: { + Row: { + id: string + trade_id: string + owner_id: string + card_id: string + quantity: number + created_at: string | null + } + Insert: { + id?: string + trade_id: string + owner_id: string + card_id: string + quantity?: number + created_at?: string | null + } + Update: { + id?: string + trade_id?: string + owner_id?: string + card_id?: string + quantity?: number + created_at?: string | null + } + Relationships: [ + { + foreignKeyName: "trade_items_trade_id_fkey" + columns: ["trade_id"] + isOneToOne: false + referencedRelation: "trades" + referencedColumns: ["id"] + }, + { + foreignKeyName: "trade_items_owner_id_fkey" + columns: ["owner_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + } + ] + } } Views: { [_ in never]: never @@ -271,4 +403,4 @@ export type CompositeTypes< ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never + : never diff --git a/src/services/friendsService.ts b/src/services/friendsService.ts new file mode 100644 index 0000000..5644e2b --- /dev/null +++ b/src/services/friendsService.ts @@ -0,0 +1,202 @@ +import { supabase } from '../lib/supabase'; + +export interface Friend { + id: string; + odship_id: string; + username: string | null; + status: 'pending' | 'accepted' | 'declined'; + isRequester: boolean; + created_at: string | null; +} + +export interface FriendshipWithProfile { + id: string; + requester_id: string; + addressee_id: string; + status: 'pending' | 'accepted' | 'declined'; + created_at: string | null; + requester: { username: string | null }; + addressee: { username: string | null }; +} + +// Get all friends (accepted friendships) +export async function getFriends(userId: string): Promise { + const { data, error } = await supabase + .from('friendships') + .select(` + id, + requester_id, + addressee_id, + status, + created_at, + requester:profiles!friendships_requester_id_fkey(username), + addressee:profiles!friendships_addressee_id_fkey(username) + `) + .eq('status', 'accepted') + .or(`requester_id.eq.${userId},addressee_id.eq.${userId}`); + + if (error) throw error; + + return (data as unknown as FriendshipWithProfile[]).map((f) => { + const isRequester = f.requester_id === userId; + return { + id: isRequester ? f.addressee_id : f.requester_id, + friendshipId: f.id, + username: isRequester ? f.addressee?.username : f.requester?.username, + status: f.status, + isRequester, + created_at: f.created_at, + }; + }); +} + +// Get pending friend requests (received) +export async function getPendingRequests(userId: string): Promise { + const { data, error } = await supabase + .from('friendships') + .select(` + id, + requester_id, + addressee_id, + status, + created_at, + requester:profiles!friendships_requester_id_fkey(username) + `) + .eq('status', 'pending') + .eq('addressee_id', userId); + + if (error) throw error; + + return (data as any[]).map((f) => ({ + id: f.requester_id, + friendshipId: f.id, + username: f.requester?.username, + status: f.status, + isRequester: false, + created_at: f.created_at, + })); +} + +// Get sent friend requests (pending) +export async function getSentRequests(userId: string): Promise { + const { data, error } = await supabase + .from('friendships') + .select(` + id, + requester_id, + addressee_id, + status, + created_at, + addressee:profiles!friendships_addressee_id_fkey(username) + `) + .eq('status', 'pending') + .eq('requester_id', userId); + + if (error) throw error; + + return (data as any[]).map((f) => ({ + id: f.addressee_id, + friendshipId: f.id, + username: f.addressee?.username, + status: f.status, + isRequester: true, + created_at: f.created_at, + })); +} + +// Search users by username +export async function searchUsers(query: string, currentUserId: string) { + const { data, error } = await supabase + .from('profiles') + .select('id, username') + .ilike('username', `%${query}%`) + .neq('id', currentUserId) + .limit(10); + + if (error) throw error; + return data; +} + +// Send friend request +export async function sendFriendRequest(requesterId: string, addresseeId: string) { + const { data, error } = await supabase + .from('friendships') + .insert({ + requester_id: requesterId, + addressee_id: addresseeId, + status: 'pending', + }) + .select() + .single(); + + if (error) throw error; + return data; +} + +// Accept friend request +export async function acceptFriendRequest(friendshipId: string) { + const { data, error } = await supabase + .from('friendships') + .update({ status: 'accepted', updated_at: new Date().toISOString() }) + .eq('id', friendshipId) + .select() + .single(); + + if (error) throw error; + return data; +} + +// Decline friend request +export async function declineFriendRequest(friendshipId: string) { + const { data, error } = await supabase + .from('friendships') + .update({ status: 'declined', updated_at: new Date().toISOString() }) + .eq('id', friendshipId) + .select() + .single(); + + if (error) throw error; + return data; +} + +// Remove friend (delete friendship) +export async function removeFriend(friendshipId: string) { + const { error } = await supabase + .from('friendships') + .delete() + .eq('id', friendshipId); + + if (error) throw error; +} + +// Check if two users are friends +export async function areFriends(userId1: string, userId2: string): Promise { + const { data, error } = await supabase + .from('friendships') + .select('id') + .eq('status', 'accepted') + .or(`and(requester_id.eq.${userId1},addressee_id.eq.${userId2}),and(requester_id.eq.${userId2},addressee_id.eq.${userId1})`) + .maybeSingle(); + + if (error) throw error; + return data !== null; +} + +// Get friendship status between two users +export async function getFriendshipStatus(userId1: string, userId2: string) { + const { data, error } = await supabase + .from('friendships') + .select('id, status, requester_id') + .or(`and(requester_id.eq.${userId1},addressee_id.eq.${userId2}),and(requester_id.eq.${userId2},addressee_id.eq.${userId1})`) + .maybeSingle(); + + if (error) throw error; + + if (!data) return { status: 'none' as const, friendshipId: null, isRequester: false }; + + return { + status: data.status as 'pending' | 'accepted' | 'declined', + friendshipId: data.id, + isRequester: data.requester_id === userId1, + }; +} diff --git a/src/services/tradesService.ts b/src/services/tradesService.ts new file mode 100644 index 0000000..41104f5 --- /dev/null +++ b/src/services/tradesService.ts @@ -0,0 +1,184 @@ +import { supabase } from '../lib/supabase'; + +export interface TradeItem { + id: string; + trade_id: string; + owner_id: string; + card_id: string; + quantity: number; +} + +export interface Trade { + id: string; + sender_id: string; + receiver_id: string; + status: 'pending' | 'accepted' | 'declined' | 'cancelled'; + message: string | null; + created_at: string | null; + updated_at: string | null; + sender?: { username: string | null }; + receiver?: { username: string | null }; + items?: TradeItem[]; +} + +export interface CreateTradeParams { + senderId: string; + receiverId: string; + message?: string; + senderCards: { cardId: string; quantity: number }[]; + receiverCards: { cardId: string; quantity: number }[]; +} + +// Get all trades for a user +export async function getTrades(userId: string): Promise { + const { data, error } = await supabase + .from('trades') + .select(` + *, + sender:profiles!trades_sender_id_fkey(username), + receiver:profiles!trades_receiver_id_fkey(username), + items:trade_items(*) + `) + .or(`sender_id.eq.${userId},receiver_id.eq.${userId}`) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data as Trade[]; +} + +// Get pending trades for a user +export async function getPendingTrades(userId: string): Promise { + const { data, error } = await supabase + .from('trades') + .select(` + *, + sender:profiles!trades_sender_id_fkey(username), + receiver:profiles!trades_receiver_id_fkey(username), + items:trade_items(*) + `) + .eq('status', 'pending') + .or(`sender_id.eq.${userId},receiver_id.eq.${userId}`) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data as Trade[]; +} + +// Get trade by ID +export async function getTradeById(tradeId: string): Promise { + const { data, error } = await supabase + .from('trades') + .select(` + *, + sender:profiles!trades_sender_id_fkey(username), + receiver:profiles!trades_receiver_id_fkey(username), + items:trade_items(*) + `) + .eq('id', tradeId) + .single(); + + if (error) throw error; + return data as Trade; +} + +// Create a new trade with items +export async function createTrade(params: CreateTradeParams): Promise { + const { senderId, receiverId, message, senderCards, receiverCards } = params; + + // Create the trade + const { data: trade, error: tradeError } = await supabase + .from('trades') + .insert({ + sender_id: senderId, + receiver_id: receiverId, + message, + status: 'pending', + }) + .select() + .single(); + + if (tradeError) throw tradeError; + + // Add sender's cards + const senderItems = senderCards.map((card) => ({ + trade_id: trade.id, + owner_id: senderId, + card_id: card.cardId, + quantity: card.quantity, + })); + + // Add receiver's cards (what sender wants) + const receiverItems = receiverCards.map((card) => ({ + trade_id: trade.id, + owner_id: receiverId, + card_id: card.cardId, + quantity: card.quantity, + })); + + const allItems = [...senderItems, ...receiverItems]; + + if (allItems.length > 0) { + const { error: itemsError } = await supabase + .from('trade_items') + .insert(allItems); + + if (itemsError) throw itemsError; + } + + return trade; +} + +// Accept a trade (executes the card transfer) +export async function acceptTrade(tradeId: string): Promise { + const { data, error } = await supabase.rpc('execute_trade', { + trade_id: tradeId, + }); + + if (error) throw error; + return data as boolean; +} + +// Decline a trade +export async function declineTrade(tradeId: string): Promise { + const { data, error } = await supabase + .from('trades') + .update({ status: 'declined', updated_at: new Date().toISOString() }) + .eq('id', tradeId) + .select() + .single(); + + if (error) throw error; + return data; +} + +// Cancel a trade (sender only) +export async function cancelTrade(tradeId: string): Promise { + const { data, error } = await supabase + .from('trades') + .update({ status: 'cancelled', updated_at: new Date().toISOString() }) + .eq('id', tradeId) + .select() + .single(); + + if (error) throw error; + return data; +} + +// Get trade history (completed/cancelled/declined trades) +export async function getTradeHistory(userId: string): Promise { + const { data, error } = await supabase + .from('trades') + .select(` + *, + sender:profiles!trades_sender_id_fkey(username), + receiver:profiles!trades_receiver_id_fkey(username), + items:trade_items(*) + `) + .or(`sender_id.eq.${userId},receiver_id.eq.${userId}`) + .in('status', ['accepted', 'declined', 'cancelled']) + .order('updated_at', { ascending: false }) + .limit(50); + + if (error) throw error; + return data as Trade[]; +}