Compare commits

...

10 commits

9 changed files with 949 additions and 213 deletions

View file

@ -1,3 +1,4 @@
import uuid
from datetime import UTC, date, datetime, time
from enum import Enum as PyEnum
from typing import TypedDict
@ -16,6 +17,7 @@ from sqlalchemy import (
UniqueConstraint,
select,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -328,9 +330,20 @@ class Exchange(Base):
"""Bitcoin exchange trades booked by users."""
__tablename__ = "exchanges"
__table_args__ = (UniqueConstraint("slot_start", name="uq_exchange_slot_start"),)
# Note: No unique constraint on slot_start to allow cancelled bookings
# to be replaced. Application-level check in create_exchange ensures only
# one BOOKED trade per slot. For existing databases, manually drop the
# constraint: ALTER TABLE exchanges DROP CONSTRAINT IF EXISTS
# uq_exchange_slot_start;
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
public_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
unique=True,
index=True,
default=uuid.uuid4,
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)

View file

@ -1,5 +1,6 @@
"""Exchange routes for Bitcoin trading."""
import uuid
from datetime import UTC, date, datetime, time, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
@ -167,6 +168,7 @@ def _to_exchange_response(
email = user_email if user_email is not None else exchange.user.email
return ExchangeResponse(
id=exchange.id,
public_id=str(exchange.public_id),
user_id=exchange.user_id,
user_email=email,
slot_start=exchange.slot_start,
@ -396,7 +398,8 @@ async def create_exchange(
status_code=400,
detail=(
f"You already have a trade booked on {slot_date.strftime('%Y-%m-%d')}. "
f"Only one trade per day is allowed. Trade ID: {existing_trade.id}"
f"Only one trade per day is allowed. "
f"Trade ID: {existing_trade.public_id}"
),
)
@ -512,6 +515,26 @@ async def create_exchange(
# Calculate sats amount based on agreed price
sats_amount = calculate_sats_amount(request.eur_amount, agreed_price)
# Check if slot is already booked (only consider BOOKED status, not cancelled)
slot_booked_query = select(Exchange).where(
and_(
Exchange.slot_start == request.slot_start,
Exchange.status == ExchangeStatus.BOOKED,
)
)
slot_booked_result = await db.execute(slot_booked_query)
slot_booked = slot_booked_result.scalar_one_or_none()
if slot_booked:
slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M")
raise HTTPException(
status_code=409,
detail=(
f"This slot at {slot_str} UTC has already been booked. "
"Select another slot."
),
)
# Create the exchange
exchange = Exchange(
user_id=current_user.id,
@ -532,12 +555,14 @@ async def create_exchange(
try:
await db.commit()
await db.refresh(exchange)
except IntegrityError:
except IntegrityError as e:
await db.rollback()
# This should rarely happen now since we check explicitly above,
# but keep it for other potential integrity violations
raise HTTPException(
status_code=409,
detail="This slot has already been booked. Select another slot.",
) from None
detail="Database constraint violation. Please try again.",
) from e
return _to_exchange_response(exchange, current_user.email)
@ -565,9 +590,32 @@ async def get_my_trades(
return [_to_exchange_response(ex, current_user.email) for ex in exchanges]
@trades_router.post("/{exchange_id}/cancel", response_model=ExchangeResponse)
@trades_router.get("/{public_id}", response_model=ExchangeResponse)
async def get_my_trade(
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
) -> ExchangeResponse:
"""Get a specific trade by public ID. User can only access their own trades."""
result = await db.execute(
select(Exchange).where(
and_(Exchange.public_id == public_id, Exchange.user_id == current_user.id)
)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail="Trade not found or you don't have permission to view it.",
)
return _to_exchange_response(exchange, current_user.email)
@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse)
async def cancel_my_trade(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
) -> ExchangeResponse:
@ -576,14 +624,14 @@ async def cancel_my_trade(
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
detail="Trade not found",
)
# Verify ownership
@ -628,6 +676,7 @@ def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse:
user = exchange.user
return AdminExchangeResponse(
id=exchange.id,
public_id=str(exchange.public_id),
user_id=exchange.user_id,
user_email=user.email,
user_contact=ExchangeUserContact(
@ -737,11 +786,9 @@ async def get_past_trades(
return [_to_admin_exchange_response(ex) for ex in exchanges]
@admin_trades_router.post(
"/{exchange_id}/complete", response_model=AdminExchangeResponse
)
@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse)
async def complete_trade(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse:
@ -750,14 +797,14 @@ async def complete_trade(
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
detail="Trade not found",
)
# Check slot has passed
@ -783,11 +830,9 @@ async def complete_trade(
return _to_admin_exchange_response(exchange)
@admin_trades_router.post(
"/{exchange_id}/no-show", response_model=AdminExchangeResponse
)
@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse)
async def mark_no_show(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse:
@ -796,14 +841,14 @@ async def mark_no_show(
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
detail="Trade not found",
)
# Check slot has passed
@ -829,9 +874,9 @@ async def mark_no_show(
return _to_admin_exchange_response(exchange)
@admin_trades_router.post("/{exchange_id}/cancel", response_model=AdminExchangeResponse)
@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse)
async def admin_cancel_trade(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
) -> AdminExchangeResponse:
@ -840,14 +885,14 @@ async def admin_cancel_trade(
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
detail="Trade not found",
)
# Check status is BOOKED

View file

@ -205,7 +205,8 @@ class ExchangeRequest(BaseModel):
class ExchangeResponse(BaseModel):
"""Response model for an exchange trade."""
id: int
id: int # Keep for backward compatibility, but prefer public_id
public_id: str # UUID as string
user_id: int
user_email: str
slot_start: datetime
@ -236,7 +237,8 @@ class ExchangeUserContact(BaseModel):
class AdminExchangeResponse(BaseModel):
"""Response model for admin exchange view (includes user contact)."""
id: int
id: int # Keep for backward compatibility, but prefer public_id
public_id: str # UUID as string
user_id: int
user_email: str
user_contact: ExchangeUserContact

View file

@ -615,10 +615,13 @@ class TestUserTrades:
):
"""Returns user's trades."""
target_date = await setup_availability_and_price(client_factory, admin_user)
target_date_2 = await setup_availability_and_price(
client_factory, admin_user, target_date=in_days(2)
)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Book two trades
# Book two trades on different days
await client.post(
"/api/exchange",
json={
@ -631,7 +634,7 @@ class TestUserTrades:
await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T10:00:00Z",
"slot_start": f"{target_date_2}T10:00:00Z",
"direction": "sell",
"bitcoin_transfer_method": "lightning",
"eur_amount": 20000,
@ -646,6 +649,82 @@ class TestUserTrades:
assert len(data) == 2
class TestGetMyTrade:
"""Test getting a single trade by ID."""
@pytest.mark.asyncio
async def test_get_my_trade_success(self, client_factory, regular_user, admin_user):
"""Can get own trade by ID."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Create a trade
create_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert create_response.status_code == 200
public_id = create_response.json()["public_id"]
# Get the trade
get_response = await client.get(f"/api/trades/{public_id}")
assert get_response.status_code == 200
data = get_response.json()
assert data["public_id"] == public_id
assert data["direction"] == "buy"
assert data["bitcoin_transfer_method"] == "onchain"
@pytest.mark.asyncio
async def test_cannot_get_other_user_trade(
self, client_factory, regular_user, alt_regular_user, admin_user
):
"""Cannot get another user's trade."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# First user creates a trade
async with client_factory.create(cookies=regular_user["cookies"]) as client:
create_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert create_response.status_code == 200
public_id = create_response.json()["public_id"]
# Second user tries to get it
async with client_factory.create(
cookies=alt_regular_user["cookies"]
) as client:
get_response = await client.get(f"/api/trades/{public_id}")
assert get_response.status_code == 404
@pytest.mark.asyncio
async def test_get_nonexistent_trade_returns_404(
self, client_factory, regular_user
):
"""Getting a nonexistent trade returns 404."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Use a valid UUID format but non-existent
response = await client.get(
"/api/trades/00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
class TestCancelTrade:
"""Test cancelling trades."""
@ -666,10 +745,10 @@ class TestCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Cancel
response = await client.post(f"/api/trades/{trade_id}/cancel")
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 200
data = response.json()
@ -696,7 +775,7 @@ class TestCancelTrade:
},
)
assert book_response.status_code == 200
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Verify the slot is NOT available
slots_response = await client.get(
@ -708,7 +787,7 @@ class TestCancelTrade:
assert f"{target_date}T09:00:00Z" not in slot_starts
# Cancel the trade
cancel_response = await client.post(f"/api/trades/{trade_id}/cancel")
cancel_response = await client.post(f"/api/trades/{public_id}/cancel")
assert cancel_response.status_code == 200
# Verify the slot IS available again
@ -739,11 +818,11 @@ class TestCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Second user tries to cancel (no mock needed for this)
async with client_factory.create(cookies=alt_regular_user["cookies"]) as client:
response = await client.post(f"/api/trades/{trade_id}/cancel")
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 403
@ -751,7 +830,9 @@ class TestCancelTrade:
async def test_cannot_cancel_nonexistent_trade(self, client_factory, regular_user):
"""Returns 404 for non-existent trade."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post("/api/trades/99999/cancel")
response = await client.post(
"/api/trades/00000000-0000-0000-0000-000000000000/cancel"
)
assert response.status_code == 404
@ -774,11 +855,11 @@ class TestCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
await client.post(f"/api/trades/{trade_id}/cancel")
public_id = book_response.json()["public_id"]
await client.post(f"/api/trades/{public_id}/cancel")
# Try to cancel again
response = await client.post(f"/api/trades/{trade_id}/cancel")
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 400
assert "cancelled_by_user" in response.json()["detail"]
@ -808,11 +889,11 @@ class TestCancelTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# User tries to cancel
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/trades/{trade_id}/cancel")
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 400
assert "already passed" in response.json()["detail"]
@ -932,11 +1013,11 @@ class TestAdminCompleteTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# Admin completes
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/complete")
response = await client.post(f"/api/admin/trades/{public_id}/complete")
assert response.status_code == 200
data = response.json()
@ -962,11 +1043,11 @@ class TestAdminCompleteTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Admin tries to complete
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/complete")
response = await client.post(f"/api/admin/trades/{public_id}/complete")
assert response.status_code == 400
assert "not yet started" in response.json()["detail"]
@ -995,11 +1076,11 @@ class TestAdminCompleteTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# Regular user tries to complete
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/complete")
response = await client.post(f"/api/admin/trades/{public_id}/complete")
assert response.status_code == 403
@ -1027,11 +1108,11 @@ class TestAdminCompleteTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# Regular user tries to mark as no-show
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/no-show")
response = await client.post(f"/api/admin/trades/{public_id}/no-show")
assert response.status_code == 403
@ -1063,11 +1144,11 @@ class TestAdminNoShowTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# Admin marks no-show
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/no-show")
response = await client.post(f"/api/admin/trades/{public_id}/no-show")
assert response.status_code == 200
data = response.json()
@ -1096,11 +1177,11 @@ class TestAdminCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Admin cancels
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/cancel")
response = await client.post(f"/api/admin/trades/{public_id}/cancel")
assert response.status_code == 200
data = response.json()
@ -1125,10 +1206,10 @@ class TestAdminCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# User tries admin cancel
response = await client.post(f"/api/admin/trades/{trade_id}/cancel")
response = await client.post(f"/api/admin/trades/{public_id}/cancel")
assert response.status_code == 403

View file

@ -35,9 +35,9 @@ export default function AdminTradesPage() {
const [error, setError] = useState<string | null>(null);
// Action state - use Set to track multiple concurrent actions
const [actioningIds, setActioningIds] = useState<Set<number>>(new Set());
const [actioningIds, setActioningIds] = useState<Set<string>>(new Set());
const [confirmAction, setConfirmAction] = useState<{
id: number;
id: string;
type: "complete" | "no_show" | "cancel";
} | null>(null);
@ -99,16 +99,16 @@ export default function AdminTradesPage() {
}
}, [user, isAuthorized, fetchUpcomingTrades, fetchPastTrades]);
const handleAction = async (tradeId: number, action: "complete" | "no_show" | "cancel") => {
const handleAction = async (publicId: string, action: "complete" | "no_show" | "cancel") => {
// Add this trade to the set of actioning trades
setActioningIds((prev) => new Set(prev).add(tradeId));
setActioningIds((prev) => new Set(prev).add(publicId));
setError(null);
try {
const endpoint =
action === "no_show"
? `/api/admin/trades/${tradeId}/no-show`
: `/api/admin/trades/${tradeId}/${action}`;
? `/api/admin/trades/${publicId}/no-show`
: `/api/admin/trades/${publicId}/${action}`;
await api.post<AdminExchangeResponse>(endpoint, {});
// Refetch trades - errors from fetch are informational, not critical
@ -124,7 +124,7 @@ export default function AdminTradesPage() {
// Remove this trade from the set of actioning trades
setActioningIds((prev) => {
const next = new Set(prev);
next.delete(tradeId);
next.delete(publicId);
return next;
});
}
@ -298,18 +298,18 @@ export default function AdminTradesPage() {
{/* Actions */}
<div style={styles.buttonGroup}>
{confirmAction?.id === trade.id ? (
{confirmAction?.id === trade.public_id ? (
<>
<button
onClick={() => handleAction(trade.id, confirmAction.type)}
disabled={actioningIds.has(trade.id)}
onClick={() => handleAction(trade.public_id, confirmAction.type)}
disabled={actioningIds.has(trade.public_id)}
style={
confirmAction.type === "cancel"
? styles.dangerButton
: styles.successButton
}
>
{actioningIds.has(trade.id) ? "..." : "Confirm"}
{actioningIds.has(trade.public_id) ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmAction(null)}
@ -325,7 +325,7 @@ export default function AdminTradesPage() {
<button
onClick={() =>
setConfirmAction({
id: trade.id,
id: trade.public_id,
type: "complete",
})
}
@ -336,7 +336,7 @@ export default function AdminTradesPage() {
<button
onClick={() =>
setConfirmAction({
id: trade.id,
id: trade.public_id,
type: "no_show",
})
}
@ -350,7 +350,7 @@ export default function AdminTradesPage() {
<button
onClick={() =>
setConfirmAction({
id: trade.id,
id: trade.public_id,
type: "cancel",
})
}

View file

@ -3,7 +3,7 @@
import { useEffect, useState, useCallback, useMemo, ChangeEvent, CSSProperties } from "react";
import { useRouter } from "next/navigation";
import { Permission } from "../auth-context";
import { api } from "../api";
import { api, ApiError } from "../api";
import { Header } from "../components/Header";
import { SatsDisplay } from "../components/SatsDisplay";
import { useRequireAuth } from "../hooks/useRequireAuth";
@ -27,7 +27,7 @@ const {
type Direction = "buy" | "sell";
type BitcoinTransferMethod = "onchain" | "lightning";
type WizardStep = "details" | "booking";
type WizardStep = "details" | "booking" | "confirmation";
/**
* Format price for display
@ -66,8 +66,14 @@ export default function ExchangePage() {
const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set());
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
// User trades state (for same-day booking check)
const [userTrades, setUserTrades] = useState<ExchangeResponse[]>([]);
const [existingTradeOnSelectedDate, setExistingTradeOnSelectedDate] =
useState<ExchangeResponse | null>(null);
// UI state
const [error, setError] = useState<string | null>(null);
const [existingTradeId, setExistingTradeId] = useState<string | null>(null);
const [isBooking, setIsBooking] = useState(false);
// Compute dates
@ -166,10 +172,28 @@ export default function ExchangePage() {
}
}, []);
// Fetch availability for all dates when entering booking step
// Fetch user trades when entering booking step
useEffect(() => {
if (!user || !isAuthorized || wizardStep !== "booking") return;
const fetchUserTrades = async () => {
try {
const data = await api.get<ExchangeResponse[]>("/api/trades");
setUserTrades(data);
} catch (err) {
console.error("Failed to fetch user trades:", err);
// Don't block the UI if this fails
}
};
fetchUserTrades();
}, [user, isAuthorized, wizardStep]);
// Fetch availability for all dates when entering booking or confirmation step
useEffect(() => {
if (!user || !isAuthorized || (wizardStep !== "booking" && wizardStep !== "confirmation"))
return;
const fetchAllAvailability = async () => {
setIsLoadingAvailability(true);
const availabilitySet = new Set<string>();
@ -200,9 +224,36 @@ export default function ExchangePage() {
}
}, [selectedDate, user, isAuthorized, fetchSlots]);
// Check if a date has an existing trade (only consider booked trades, not cancelled ones)
const getExistingTradeOnDate = useCallback(
(date: Date): ExchangeResponse | null => {
const dateStr = formatDate(date);
return (
userTrades.find((trade) => {
const tradeDate = formatDate(new Date(trade.slot_start));
return tradeDate === dateStr && trade.status === "booked";
}) || null
);
},
[userTrades]
);
const handleDateSelect = (date: Date) => {
const dateStr = formatDate(date);
if (datesWithAvailability.has(dateStr)) {
if (!datesWithAvailability.has(dateStr)) {
return;
}
// Check if user already has a trade on this date
const existingTrade = getExistingTradeOnDate(date);
if (existingTrade) {
setExistingTradeOnSelectedDate(existingTrade);
setSelectedDate(null);
setSelectedSlot(null);
setAvailableSlots([]);
setError(null);
} else {
setExistingTradeOnSelectedDate(null);
setSelectedDate(date);
}
};
@ -210,6 +261,7 @@ export default function ExchangePage() {
const handleSlotSelect = (slot: BookableSlot) => {
setSelectedSlot(slot);
setError(null);
setWizardStep("confirmation");
};
const handleContinueToBooking = () => {
@ -222,6 +274,12 @@ export default function ExchangePage() {
setSelectedDate(null);
setSelectedSlot(null);
setError(null);
setExistingTradeOnSelectedDate(null);
};
const handleBackToBooking = () => {
setWizardStep("booking");
setError(null);
};
const handleAmountChange = (value: number) => {
@ -254,6 +312,7 @@ export default function ExchangePage() {
setIsBooking(true);
setError(null);
setExistingTradeId(null);
try {
await api.post<ExchangeResponse>("/api/exchange", {
@ -266,16 +325,32 @@ export default function ExchangePage() {
// Redirect to trades page after successful booking
router.push("/trades");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to book trade");
let errorMessage = "Failed to book trade";
if (err instanceof ApiError) {
// Extract detail from API error response
if (err.data && typeof err.data === "object") {
const data = err.data as { detail?: string };
errorMessage = data.detail || err.message;
} else {
errorMessage = err.message;
}
} else if (err instanceof Error) {
errorMessage = err.message;
}
setError(errorMessage);
// Check if it's a "same day" error and extract trade public_id (UUID)
const tradeIdMatch = errorMessage.match(/Trade ID: ([a-f0-9-]{36})/i);
if (tradeIdMatch) {
setExistingTradeId(tradeIdMatch[1]);
} else {
setExistingTradeId(null);
}
setIsBooking(false);
}
};
const cancelSlotSelection = () => {
setSelectedSlot(null);
setError(null);
};
if (isLoading) {
return (
<main style={layoutStyles.main}>
@ -297,7 +372,18 @@ export default function ExchangePage() {
<h1 style={typographyStyles.pageTitle}>Exchange Bitcoin</h1>
<p style={typographyStyles.pageSubtitle}>Buy or sell Bitcoin with a 5% premium</p>
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
{error && (
<div style={bannerStyles.errorBanner}>
{error}
{existingTradeId && (
<div style={styles.errorLink}>
<a href={`/trades/${existingTradeId}`} style={styles.errorLinkAnchor}>
View your existing trade
</a>
</div>
)}
</div>
)}
{/* Price Display */}
<div style={styles.priceCard}>
@ -343,12 +429,26 @@ export default function ExchangePage() {
<div
style={{
...styles.step,
...(wizardStep === "booking" ? styles.stepActive : {}),
...(wizardStep === "booking"
? styles.stepActive
: wizardStep === "confirmation"
? styles.stepCompleted
: {}),
}}
>
<span style={styles.stepNumber}>2</span>
<span style={styles.stepLabel}>Book Appointment</span>
</div>
<div style={styles.stepDivider} />
<div
style={{
...styles.step,
...(wizardStep === "confirmation" ? styles.stepActive : {}),
}}
>
<span style={styles.stepNumber}>3</span>
<span style={styles.stepLabel}>Confirm</span>
</div>
</div>
{/* Step 1: Exchange Details */}
@ -519,6 +619,7 @@ export default function ExchangePage() {
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
const hasAvailability = datesWithAvailability.has(dateStr);
const isDisabled = !hasAvailability || isLoadingAvailability;
const hasExistingTrade = getExistingTradeOnDate(date) !== null;
return (
<button
@ -530,6 +631,7 @@ export default function ExchangePage() {
...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}),
...(isDisabled ? styles.dateButtonDisabled : {}),
...(hasExistingTrade && !isDisabled ? styles.dateButtonHasTrade : {}),
}}
>
<div style={styles.dateWeekday}>
@ -541,14 +643,32 @@ export default function ExchangePage() {
day: "numeric",
})}
</div>
{hasExistingTrade && !isDisabled && <div style={styles.dateWarning}></div>}
</button>
);
})}
</div>
</div>
{/* Warning for existing trade on selected date */}
{existingTradeOnSelectedDate && (
<div style={bannerStyles.errorBanner}>
<div>
You already have a trade booked on this day. You can only book one trade per day.
</div>
<div style={styles.errorLink}>
<a
href={`/trades/${existingTradeOnSelectedDate.public_id}`}
style={styles.errorLinkAnchor}
>
View your existing trade
</a>
</div>
</div>
)}
{/* Available Slots */}
{selectedDate && (
{selectedDate && !existingTradeOnSelectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for{" "}
@ -584,83 +704,109 @@ export default function ExchangePage() {
)}
</div>
)}
{/* Confirm Booking */}
{selectedSlot && (
<div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Trade</h3>
<div style={styles.confirmDetails}>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Time:</span>
<span style={styles.confirmValue}>
{formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Direction:</span>
<span
style={{
...styles.confirmValue,
color: direction === "buy" ? "#4ade80" : "#f87171",
}}
>
{direction === "buy" ? "Buy BTC" : "Sell BTC"}
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>EUR:</span>
<span style={styles.confirmValue}>{formatEur(eurAmount)}</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>BTC:</span>
<span style={{ ...styles.confirmValue, ...styles.satsValue }}>
<SatsDisplay sats={satsAmount} />
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Rate:</span>
<span style={styles.confirmValue}>{formatPrice(agreedPrice)}/BTC</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Payment:</span>
<span style={styles.confirmValue}>
{direction === "buy" ? "Receive via " : "Send via "}
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
</span>
</div>
</div>
<div style={styles.buttonRow}>
<button
onClick={handleBook}
disabled={isBooking || isPriceStale}
style={{
...styles.bookButton,
background:
direction === "buy"
? "linear-gradient(135deg, #4ade80 0%, #22c55e 100%)"
: "linear-gradient(135deg, #f87171 0%, #ef4444 100%)",
...(isBooking || isPriceStale ? buttonStyles.buttonDisabled : {}),
}}
>
{isBooking
? "Booking..."
: isPriceStale
? "Price Stale"
: `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
</button>
<button
onClick={cancelSlotSelection}
disabled={isBooking}
style={styles.cancelButton}
>
Cancel
</button>
</div>
</div>
)}
</>
)}
{/* Step 2: Booking (Compressed when step 3 is active) */}
{wizardStep === "confirmation" && (
<div style={styles.compressedBookingCard}>
<div style={styles.compressedBookingHeader}>
<span style={styles.compressedBookingTitle}>Appointment</span>
<button onClick={handleBackToBooking} style={styles.editButton}>
Edit
</button>
</div>
<div style={styles.compressedBookingDetails}>
<span>
{selectedDate?.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})}
</span>
<span style={styles.summaryDivider}></span>
<span>
{selectedSlot && formatTime(selectedSlot.start_time)} -{" "}
{selectedSlot && formatTime(selectedSlot.end_time)}
</span>
</div>
</div>
)}
{/* Step 3: Confirmation */}
{wizardStep === "confirmation" && selectedSlot && (
<div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Trade</h3>
<div style={styles.confirmDetails}>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Time:</span>
<span style={styles.confirmValue}>
{formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Direction:</span>
<span
style={{
...styles.confirmValue,
color: direction === "buy" ? "#4ade80" : "#f87171",
}}
>
{direction === "buy" ? "Buy BTC" : "Sell BTC"}
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>EUR:</span>
<span style={styles.confirmValue}>{formatEur(eurAmount)}</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>BTC:</span>
<span style={{ ...styles.confirmValue, ...styles.satsValue }}>
<SatsDisplay sats={satsAmount} />
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Rate:</span>
<span style={styles.confirmValue}>{formatPrice(agreedPrice)}/BTC</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Payment:</span>
<span style={styles.confirmValue}>
{direction === "buy" ? "Receive via " : "Send via "}
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
</span>
</div>
</div>
<div style={styles.buttonRow}>
<button
onClick={handleBook}
disabled={isBooking || isPriceStale}
style={{
...styles.bookButton,
background:
direction === "buy"
? "linear-gradient(135deg, #4ade80 0%, #22c55e 100%)"
: "linear-gradient(135deg, #f87171 0%, #ef4444 100%)",
...(isBooking || isPriceStale ? buttonStyles.buttonDisabled : {}),
}}
>
{isBooking
? "Booking..."
: isPriceStale
? "Price Stale"
: `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
</button>
<button
onClick={handleBackToBooking}
disabled={isBooking}
style={styles.cancelButton}
>
Back
</button>
</div>
</div>
)}
</div>
</main>
);
@ -903,6 +1049,33 @@ const styles: Record<string, CSSProperties> = {
padding: "1rem 1.5rem",
marginBottom: "1.5rem",
},
compressedBookingCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1rem 1.5rem",
marginBottom: "1.5rem",
},
compressedBookingHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
},
compressedBookingTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
},
compressedBookingDetails: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
flexWrap: "wrap",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
color: "#fff",
},
summaryHeader: {
display: "flex",
justifyContent: "space-between",
@ -998,6 +1171,18 @@ const styles: Record<string, CSSProperties> = {
borderRadius: "6px",
border: "1px solid rgba(251, 146, 60, 0.2)",
},
errorLink: {
marginTop: "0.75rem",
paddingTop: "0.75rem",
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
},
errorLinkAnchor: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "#a78bfa",
textDecoration: "none",
fontWeight: 500,
fontSize: "0.9rem",
},
section: {
marginBottom: "2rem",
},
@ -1034,6 +1219,10 @@ const styles: Record<string, CSSProperties> = {
background: "rgba(255, 255, 255, 0.01)",
border: "1px solid rgba(255, 255, 255, 0.04)",
},
dateButtonHasTrade: {
border: "1px solid rgba(251, 146, 60, 0.5)",
background: "rgba(251, 146, 60, 0.1)",
},
dateWeekday: {
color: "#fff",
fontWeight: 500,
@ -1044,6 +1233,11 @@ const styles: Record<string, CSSProperties> = {
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem",
},
dateWarning: {
fontSize: "0.7rem",
marginTop: "0.25rem",
opacity: 0.8,
},
slotGrid: {
display: "flex",
flexWrap: "wrap",

View file

@ -416,7 +416,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/trades/{exchange_id}/cancel": {
"/api/trades/{public_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get My Trade
* @description Get a specific trade by public ID. User can only access their own trades.
*/
get: operations["get_my_trade_api_trades__public_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/trades/{public_id}/cancel": {
parameters: {
query?: never;
header?: never;
@ -429,7 +449,7 @@ export interface paths {
* Cancel My Trade
* @description Cancel one of the current user's exchanges.
*/
post: operations["cancel_my_trade_api_trades__exchange_id__cancel_post"];
post: operations["cancel_my_trade_api_trades__public_id__cancel_post"];
delete?: never;
options?: never;
head?: never;
@ -481,7 +501,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/trades/{exchange_id}/complete": {
"/api/admin/trades/{public_id}/complete": {
parameters: {
query?: never;
header?: never;
@ -494,14 +514,14 @@ export interface paths {
* Complete Trade
* @description Mark a trade as completed. Only possible after slot time has passed.
*/
post: operations["complete_trade_api_admin_trades__exchange_id__complete_post"];
post: operations["complete_trade_api_admin_trades__public_id__complete_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/trades/{exchange_id}/no-show": {
"/api/admin/trades/{public_id}/no-show": {
parameters: {
query?: never;
header?: never;
@ -514,14 +534,14 @@ export interface paths {
* Mark No Show
* @description Mark a trade as no-show. Only possible after slot time has passed.
*/
post: operations["mark_no_show_api_admin_trades__exchange_id__no_show_post"];
post: operations["mark_no_show_api_admin_trades__public_id__no_show_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/trades/{exchange_id}/cancel": {
"/api/admin/trades/{public_id}/cancel": {
parameters: {
query?: never;
header?: never;
@ -534,7 +554,7 @@ export interface paths {
* Admin Cancel Trade
* @description Cancel any trade (admin only).
*/
post: operations["admin_cancel_trade_api_admin_trades__exchange_id__cancel_post"];
post: operations["admin_cancel_trade_api_admin_trades__public_id__cancel_post"];
delete?: never;
options?: never;
head?: never;
@ -575,6 +595,8 @@ export interface components {
AdminExchangeResponse: {
/** Id */
id: number;
/** Public Id */
public_id: string;
/** User Id */
user_id: number;
/** User Email */
@ -760,6 +782,8 @@ export interface components {
ExchangeResponse: {
/** Id */
id: number;
/** Public Id */
public_id: string;
/** User Id */
user_id: number;
/** User Email */
@ -1685,12 +1709,43 @@ export interface operations {
};
};
};
cancel_my_trade_api_trades__exchange_id__cancel_post: {
get_my_trade_api_trades__public_id__get: {
parameters: {
query?: never;
header?: never;
path: {
exchange_id: number;
public_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExchangeResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
cancel_my_trade_api_trades__public_id__cancel_post: {
parameters: {
query?: never;
header?: never;
path: {
public_id: string;
};
cookie?: never;
};
@ -1770,12 +1825,12 @@ export interface operations {
};
};
};
complete_trade_api_admin_trades__exchange_id__complete_post: {
complete_trade_api_admin_trades__public_id__complete_post: {
parameters: {
query?: never;
header?: never;
path: {
exchange_id: number;
public_id: string;
};
cookie?: never;
};
@ -1801,12 +1856,12 @@ export interface operations {
};
};
};
mark_no_show_api_admin_trades__exchange_id__no_show_post: {
mark_no_show_api_admin_trades__public_id__no_show_post: {
parameters: {
query?: never;
header?: never;
path: {
exchange_id: number;
public_id: string;
};
cookie?: never;
};
@ -1832,12 +1887,12 @@ export interface operations {
};
};
};
admin_cancel_trade_api_admin_trades__exchange_id__cancel_post: {
admin_cancel_trade_api_admin_trades__public_id__cancel_post: {
parameters: {
query?: never;
header?: never;
path: {
exchange_id: number;
public_id: string;
};
cookie?: never;
};

View file

@ -0,0 +1,300 @@
"use client";
import { useEffect, useState, CSSProperties } from "react";
import { useParams, useRouter } from "next/navigation";
import { Permission } from "../../auth-context";
import { api } from "../../api";
import { Header } from "../../components/Header";
import { SatsDisplay } from "../../components/SatsDisplay";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
import { formatDateTime } from "../../utils/date";
import { formatEur, getTradeStatusDisplay } from "../../utils/exchange";
import {
layoutStyles,
typographyStyles,
bannerStyles,
badgeStyles,
buttonStyles,
tradeCardStyles,
} from "../../styles/shared";
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
export default function TradeDetailPage() {
const router = useRouter();
const params = useParams();
const publicId = params?.id as string | undefined;
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_EXCHANGES,
fallbackRedirect: "/",
});
const [trade, setTrade] = useState<ExchangeResponse | null>(null);
const [isLoadingTrade, setIsLoadingTrade] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!user || !isAuthorized || !publicId) return;
const fetchTrade = async () => {
try {
setIsLoadingTrade(true);
setError(null);
const data = await api.get<ExchangeResponse>(`/api/trades/${publicId}`);
setTrade(data);
} catch (err) {
console.error("Failed to fetch trade:", err);
setError(
"Failed to load trade. It may not exist or you may not have permission to view it."
);
} finally {
setIsLoadingTrade(false);
}
};
fetchTrade();
}, [user, isAuthorized, publicId]);
if (isLoading || isLoadingTrade) {
return (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!isAuthorized) {
return null;
}
if (error || !trade) {
return (
<main style={layoutStyles.main}>
<Header currentPage="trades" />
<div style={styles.content}>
<h1 style={typographyStyles.pageTitle}>Trade Details</h1>
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
<button onClick={() => router.push("/trades")} style={buttonStyles.primaryButton}>
Back to Trades
</button>
</div>
</main>
);
}
const status = getTradeStatusDisplay(trade.status);
const isBuy = trade.direction === "buy";
return (
<main style={layoutStyles.main}>
<Header currentPage="trades" />
<div style={styles.content}>
<div style={styles.header}>
<h1 style={typographyStyles.pageTitle}>Trade Details</h1>
<button onClick={() => router.push("/trades")} style={buttonStyles.secondaryButton}>
Back to Trades
</button>
</div>
<div style={styles.tradeDetailCard}>
<div style={styles.detailSection}>
<h2 style={styles.sectionTitle}>Trade Information</h2>
<div style={styles.detailGrid}>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Status:</span>
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
</div>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Time:</span>
<span style={styles.detailValue}>{formatDateTime(trade.slot_start)}</span>
</div>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Direction:</span>
<span
style={{
...styles.detailValue,
color: isBuy ? "#4ade80" : "#f87171",
fontWeight: 600,
}}
>
{isBuy ? "BUY BTC" : "SELL BTC"}
</span>
</div>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Payment Method:</span>
<span style={styles.detailValue}>
{isBuy
? `Receive via ${trade.bitcoin_transfer_method === "onchain" ? "Onchain" : "Lightning"}`
: `Send via ${trade.bitcoin_transfer_method === "onchain" ? "Onchain" : "Lightning"}`}
</span>
</div>
</div>
</div>
<div style={styles.detailSection}>
<h2 style={styles.sectionTitle}>Amounts</h2>
<div style={styles.detailGrid}>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>EUR Amount:</span>
<span style={styles.detailValue}>{formatEur(trade.eur_amount)}</span>
</div>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Bitcoin Amount:</span>
<span style={{ ...styles.detailValue, ...tradeCardStyles.satsAmount }}>
<SatsDisplay sats={trade.sats_amount} />
</span>
</div>
</div>
</div>
<div style={styles.detailSection}>
<h2 style={styles.sectionTitle}>Pricing</h2>
<div style={styles.detailGrid}>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Market Price:</span>
<span style={styles.detailValue}>
{trade.market_price_eur.toLocaleString("de-DE", {
maximumFractionDigits: 0,
})}
/BTC
</span>
</div>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Agreed Price:</span>
<span style={styles.detailValue}>
{trade.agreed_price_eur.toLocaleString("de-DE", {
maximumFractionDigits: 0,
})}
/BTC
</span>
</div>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Premium:</span>
<span style={styles.detailValue}>{trade.premium_percentage}%</span>
</div>
</div>
</div>
<div style={styles.detailSection}>
<h2 style={styles.sectionTitle}>Timestamps</h2>
<div style={styles.detailGrid}>
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Created:</span>
<span style={styles.detailValue}>{formatDateTime(trade.created_at)}</span>
</div>
{trade.cancelled_at && (
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Cancelled:</span>
<span style={styles.detailValue}>{formatDateTime(trade.cancelled_at)}</span>
</div>
)}
{trade.completed_at && (
<div style={styles.detailRow}>
<span style={styles.detailLabel}>Completed:</span>
<span style={styles.detailValue}>{formatDateTime(trade.completed_at)}</span>
</div>
)}
</div>
</div>
{trade.status === "booked" && (
<div style={styles.actionSection}>
<button
onClick={async () => {
if (
!confirm(
"Are you sure you want to cancel this trade? This action cannot be undone."
)
) {
return;
}
try {
await api.post(`/api/trades/${trade.public_id}/cancel`, {});
router.push("/trades");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel trade");
}
}}
style={buttonStyles.secondaryButton}
>
Cancel Trade
</button>
</div>
)}
</div>
</div>
</main>
);
}
const styles: Record<string, CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
maxWidth: "800px",
margin: "0 auto",
width: "100%",
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "2rem",
},
tradeDetailCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "2rem",
},
detailSection: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 600,
color: "#fff",
marginBottom: "1rem",
},
detailGrid: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
detailRow: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 0",
borderBottom: "1px solid rgba(255, 255, 255, 0.05)",
},
detailLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.9rem",
color: "rgba(255, 255, 255, 0.6)",
},
detailValue: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.9rem",
color: "#fff",
fontWeight: 500,
},
actionSection: {
marginTop: "2rem",
paddingTop: "2rem",
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
},
};

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useCallback, CSSProperties } from "react";
import { useRouter } from "next/navigation";
import { Permission } from "../auth-context";
import { api } from "../api";
import { Header } from "../components/Header";
@ -21,6 +22,7 @@ import {
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
export default function TradesPage() {
const router = useRouter();
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_EXCHANGES,
fallbackRedirect: "/",
@ -28,8 +30,8 @@ export default function TradesPage() {
const [trades, setTrades] = useState<ExchangeResponse[]>([]);
const [isLoadingTrades, setIsLoadingTrades] = useState(true);
const [cancellingId, setCancellingId] = useState<number | null>(null);
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null);
const [cancellingId, setCancellingId] = useState<string | null>(null);
const [confirmCancelId, setConfirmCancelId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchTrades = useCallback(async () => {
@ -50,12 +52,12 @@ export default function TradesPage() {
}
}, [user, isAuthorized, fetchTrades]);
const handleCancel = async (tradeId: number) => {
setCancellingId(tradeId);
const handleCancel = async (publicId: string) => {
setCancellingId(publicId);
setError(null);
try {
await api.post<ExchangeResponse>(`/api/trades/${tradeId}/cancel`, {});
await api.post<ExchangeResponse>(`/api/trades/${publicId}/cancel`, {});
await fetchTrades();
setConfirmCancelId(null);
} catch (err) {
@ -172,34 +174,54 @@ export default function TradesPage() {
</span>
</div>
{trade.status === "booked" && (
<div style={tradeCardStyles.buttonGroup}>
{confirmCancelId === trade.id ? (
<>
<div style={tradeCardStyles.buttonGroup}>
{trade.status === "booked" && (
<>
{confirmCancelId === trade.public_id ? (
<>
<button
onClick={(e) => {
e.stopPropagation();
handleCancel(trade.public_id);
}}
disabled={cancellingId === trade.public_id}
style={styles.confirmButton}
>
{cancellingId === trade.public_id ? "..." : "Confirm"}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmCancelId(null);
}}
style={buttonStyles.secondaryButton}
>
No
</button>
</>
) : (
<button
onClick={() => handleCancel(trade.id)}
disabled={cancellingId === trade.id}
style={styles.confirmButton}
>
{cancellingId === trade.id ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmCancelId(null)}
onClick={(e) => {
e.stopPropagation();
setConfirmCancelId(trade.public_id);
}}
style={buttonStyles.secondaryButton}
>
No
Cancel
</button>
</>
) : (
<button
onClick={() => setConfirmCancelId(trade.id)}
style={buttonStyles.secondaryButton}
>
Cancel
</button>
)}
</div>
)}
)}
</>
)}
<button
onClick={(e) => {
e.stopPropagation();
router.push(`/trades/${trade.public_id}`);
}}
style={styles.viewDetailsButton}
>
View Details
</button>
</div>
</div>
</div>
);
@ -221,7 +243,10 @@ export default function TradesPage() {
return (
<div
key={trade.id}
style={{ ...tradeCardStyles.tradeCard, ...styles.tradeCardPast }}
style={{
...tradeCardStyles.tradeCard,
...styles.tradeCardPast,
}}
>
<div style={tradeCardStyles.tradeTime}>
{formatDateTime(trade.slot_start)}
@ -255,16 +280,26 @@ export default function TradesPage() {
<SatsDisplay sats={trade.sats_amount} />
</span>
</div>
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
marginTop: "0.5rem",
}}
>
{status.text}
</span>
<div style={tradeCardStyles.buttonGroup}>
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
}}
>
{status.text}
</span>
<button
onClick={(e) => {
e.stopPropagation();
router.push(`/trades/${trade.public_id}`);
}}
style={styles.viewDetailsButton}
>
View Details
</button>
</div>
</div>
);
})}
@ -316,4 +351,15 @@ const styles: Record<string, CSSProperties> = {
color: "#a78bfa",
textDecoration: "none",
},
viewDetailsButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.35rem 0.75rem",
fontSize: "0.75rem",
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid rgba(167, 139, 250, 0.3)",
borderRadius: "6px",
color: "#a78bfa",
cursor: "pointer",
transition: "all 0.2s",
},
};