# How the Trade Offer System Facilitates Creation, Negotiation, and Acceptance of Card Trades

> Discover how our trade offer system streamlines card trading. Learn about its architecture for creation, negotiation, and acceptance, ensuring seamless peer-to-peer exchanges.

- Repository: [Marcel Panse/tcg-pocket-collection-tracker](https://github.com/marcelpanse/tcg-pocket-collection-tracker)
- Tags: architecture
- Published: 2026-03-06

---

**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](https://github.com/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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/services/trade/tradeService.ts), this layer provides direct Supabase CRUD helpers including `getActiveTrades`, `insertTrade`, and `updateTrade`.

- **React Query Hook Layer**: The [`frontend/src/services/trade/useTrade.ts`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/services/trade/useTrade.ts) file wraps API calls in queries and mutations such as `useInsertTrade`, `useUpdateTrade`, and `useActiveTrades`, automatically keeping the UI cache in sync.

- **UI Component Layer**: Components like [`TradeOffer.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/TradeOffer.tsx), [`TradePartner.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/TradePartner.tsx), [`TradeListRow.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/TradeListRow.tsx), and [`Actions.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/Actions.tsx) render 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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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:

```tsx
// 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:

```tsx
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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/tradeService.ts). After successful insertion, the mutation invalidates the `['trades']` query cache, causing all components monitoring trades to refresh instantly:

```tsx
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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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'`

```tsx
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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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:

```tsx
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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/services/trade/tradeService.ts) | Direct Supabase CRUD operations for trade rows |
| [`frontend/src/services/trade/useTrade.ts`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/services/trade/useTrade.ts) | React Query hooks: `useInsertTrade`, `useUpdateTrade`, `useActiveTrades`, `useAllTrades` |
| [`frontend/src/pages/trade/components/TradeOffer.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/pages/trade/components/TradeOffer.tsx) | UI for selecting cards and submitting offers with rarity validation |
| [`frontend/src/pages/trade/components/Actions.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/pages/trade/components/Actions.tsx) | Decision engine for accept/decline/finish actions and collection updates |
| [`frontend/src/pages/trade/components/TradePartner.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/pages/trade/components/TradePartner.tsx) | Profile view and active trade list container |
| [`frontend/src/pages/trade/components/TradeListRow.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/pages/trade/components/TradeListRow.tsx) | Individual trade row rendering with status indicators |
| [`frontend/src/pages/trade/components/TradeList.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/frontend/src/services/trade/useTrade.ts) for mutations and [`frontend/src/pages/trade/components/Actions.tsx`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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.