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:

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.tsx before 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 in Actions.tsx
  • Collection updates use getAndIncrement logic to adjust amount_owned values 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →