How the Trade Offer System Facilitates Creation, Negotiation, and Acceptance of Card Trades
The trade offer system enables seamless peer-to-peer card exchanges through a three-layer architecture—Supabase API, React Query hooks, and React components—that validates rarity matches, manages status transitions from offered to finished, and automatically synchronizes card collections upon completion.
The trade offer system in the marcelpanse/tcg-pocket-collection-tracker repository handles the complete lifecycle of card trading. Built with TypeScript, React, and Supabase, it ensures data consistency through optimistic UI updates and role-based action permissions. This guide examines the exact implementation across the service layer, custom hooks, and UI components.
Architecture Overview
The system organizes functionality across three distinct layers that communicate through React Query cache invalidation:
-
API/Database Layer: Located in
frontend/src/services/trade/tradeService.ts, this layer provides direct Supabase CRUD helpers includinggetActiveTrades,insertTrade, andupdateTrade. -
React Query Hook Layer: The
frontend/src/services/trade/useTrade.tsfile wraps API calls in queries and mutations such asuseInsertTrade,useUpdateTrade, anduseActiveTrades, automatically keeping the UI cache in sync. -
UI Component Layer: Components like
TradeOffer.tsx,TradePartner.tsx,TradeListRow.tsx, andActions.tsxrender the interface and handle user interactions from card selection to final collection updates.
Creating an Offer
Validating Rarity Matches
The trade creation process begins in frontend/src/pages/trade/components/TradeOffer.tsx. Users select one card they own and one card from a trading partner. The component enables the Offer button only when both cards share the same rarity, preventing mismatched trades at the UI level:
// TradeOffer.tsx validation logic
const enabled = yourCard && friendCard && yourCard.rarity === friendCard.rarity
Constructing the TradeRow
Upon submission, the component builds a TradeRow object containing participant identifiers, card references, and the initial status:
const trade: TradeRow = {
offering_friend_id: yourId,
receiving_friend_id: friendId,
offer_card_id: yourCard!.card_id,
receiver_card_id: friendCard!.card_id,
status: 'offered',
};
Persisting to Supabase
The component invokes useInsertTrade(), which calls insertTrade() from tradeService.ts. After successful insertion, the mutation invalidates the ['trades'] query cache, causing all components monitoring trades to refresh instantly:
const insertTradeMutation = useInsertTrade();
async function submit() {
if (!enabled) return;
insertTradeMutation.mutate(trade);
}
Negotiating Trade Status
Displaying Active Trades
The TradePartner component retrieves the current user's active trades via useActiveTrades(), which uses the ['trades'] query key. Each trade renders as a TradeListRow that users can select to reveal negotiation options.
Role-Based Action Permissions
The Actions.tsx component implements permission logic that determines which buttons to display based on the trade status and whether the current user is the offering_friend_id or receiving_friend_id:
- Accept: Only visible to the receiving friend; updates status to
'accepted' - Decline/Cancel: Available to either party; sets status to
'declined'
const updateTradeMutation = useUpdateTrade();
function accept() {
updateTradeMutation.mutate({
id: trade.id,
trade: { status: 'accepted' }
});
}
Real-Time State Synchronization
All status mutations trigger cache invalidation for the ['trades'] key. This ensures that when one user accepts a trade, the opposing party's interface updates immediately without requiring a page refresh.
Completing the Exchange
Finishing Accepted Trades
Once a trade reaches 'accepted' status, both participants see a Finish button. Clicking it transitions the status to 'finished', unlocking the collection update functionality.
Atomic Collection Updates
The Actions.tsx component executes the actual card transfer using the getAndIncrement helper (lines 55-70). This calculates new ownership amounts and processes the exchange atomically:
async function increment() {
const updates = [
getAndIncrement(trade.offer_card_id, -1), // Remove from offerer
getAndIncrement(trade.receiver_card_id, 1), // Add to receiver
];
await updateCardsMutation.mutateAsync(updates);
await end(); // Hide trade after completion
}
Archiving Completed Trades
Users can hide finished trades via the Hide option, which sets offerer_ended or receiver_ended flags in the database through updateTradeMutation.mutateAsync(). This removes the trade from the current user's view while preserving the record for the opposing party.
Key Implementation Files
| Path | Responsibility |
|---|---|
frontend/src/services/trade/tradeService.ts |
Direct Supabase CRUD operations for trade rows |
frontend/src/services/trade/useTrade.ts |
React Query hooks: useInsertTrade, useUpdateTrade, useActiveTrades, useAllTrades |
frontend/src/pages/trade/components/TradeOffer.tsx |
UI for selecting cards and submitting offers with rarity validation |
frontend/src/pages/trade/components/Actions.tsx |
Decision engine for accept/decline/finish actions and collection updates |
frontend/src/pages/trade/components/TradePartner.tsx |
Profile view and active trade list container |
frontend/src/pages/trade/components/TradeListRow.tsx |
Individual trade row rendering with status indicators |
frontend/src/pages/trade/components/TradeList.tsx |
Container component grouping trade rows and selected trade actions |
Summary
- The trade offer system employs a three-tier architecture separating database logic, React Query state management, and React UI components
- Rarity validation occurs at the UI level in
TradeOffer.tsxbefore submission to prevent invalid trades - React Query cache invalidation on the
['trades']key ensures real-time synchronization across all client views - Status transitions follow a strict flow:
'offered'→'accepted'→'finished', with role-specific permissions enforced inActions.tsx - Collection updates use
getAndIncrementlogic to adjustamount_ownedvalues atomically for both participants - Soft-delete flags (
offerer_ended/receiver_ended) allow users to archive completed trades without affecting historical records
Frequently Asked Questions
How does the system prevent users from trading cards of different rarities?
The TradeOffer.tsx component enforces rarity parity at the UI level. The submit button remains disabled unless yourCard.rarity === friendCard.rarity, as implemented in the enabled calculation. This validation ensures that invalid combinations never reach the Supabase database.
What triggers the UI to update when a trade status changes?
React Query's mutation invalidation handles updates automatically. When useUpdateTrade succeeds, it invalidates the ['trades'] cache key, causing all components using useActiveTrades to refetch data instantly. This optimistic update pattern eliminates the need for manual page refreshes.
Can both users update their collections simultaneously after finishing a trade?
Yes. The Actions.tsx component processes collection updates through updateCardsMutation.mutateAsync(), which executes both the decrement for the offering user and the increment for the receiving user in a single operation using the getAndIncrement helper function.
Where is the trade status logic centralized?
Status management is split between frontend/src/services/trade/useTrade.ts for mutations and frontend/src/pages/trade/components/Actions.tsx for UI permissions. The Actions component specifically checks offering_friend_id versus receiving_friend_id to determine which action buttons (accept, decline, finish) to render for each user.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →