lots of stuff

This commit is contained in:
counterweight 2025-12-23 17:03:51 +01:00
parent f946fbf7b8
commit 4be45f8f7c
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
9 changed files with 513 additions and 236 deletions

View file

@ -1,3 +1,4 @@
import uuid
from datetime import UTC, date, datetime, time from datetime import UTC, date, datetime, time
from enum import Enum as PyEnum from enum import Enum as PyEnum
from typing import TypedDict from typing import TypedDict
@ -16,6 +17,7 @@ from sqlalchemy import (
UniqueConstraint, UniqueConstraint,
select, select,
) )
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -328,9 +330,20 @@ class Exchange(Base):
"""Bitcoin exchange trades booked by users.""" """Bitcoin exchange trades booked by users."""
__tablename__ = "exchanges" __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) 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( user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True Integer, ForeignKey("users.id"), nullable=False, index=True
) )

View file

@ -1,5 +1,6 @@
"""Exchange routes for Bitcoin trading.""" """Exchange routes for Bitcoin trading."""
import uuid
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query 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 email = user_email if user_email is not None else exchange.user.email
return ExchangeResponse( return ExchangeResponse(
id=exchange.id, id=exchange.id,
public_id=str(exchange.public_id),
user_id=exchange.user_id, user_id=exchange.user_id,
user_email=email, user_email=email,
slot_start=exchange.slot_start, slot_start=exchange.slot_start,
@ -396,7 +398,8 @@ async def create_exchange(
status_code=400, status_code=400,
detail=( detail=(
f"You already have a trade booked on {slot_date.strftime('%Y-%m-%d')}. " 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 # Calculate sats amount based on agreed price
sats_amount = calculate_sats_amount(request.eur_amount, 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 # Create the exchange
exchange = Exchange( exchange = Exchange(
user_id=current_user.id, user_id=current_user.id,
@ -532,12 +555,14 @@ async def create_exchange(
try: try:
await db.commit() await db.commit()
await db.refresh(exchange) await db.refresh(exchange)
except IntegrityError: except IntegrityError as e:
await db.rollback() await db.rollback()
# This should rarely happen now since we check explicitly above,
# but keep it for other potential integrity violations
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="This slot has already been booked. Select another slot.", detail="Database constraint violation. Please try again.",
) from None ) from e
return _to_exchange_response(exchange, current_user.email) return _to_exchange_response(exchange, current_user.email)
@ -565,16 +590,16 @@ async def get_my_trades(
return [_to_exchange_response(ex, current_user.email) for ex in exchanges] return [_to_exchange_response(ex, current_user.email) for ex in exchanges]
@trades_router.get("/{exchange_id}", response_model=ExchangeResponse) @trades_router.get("/{public_id}", response_model=ExchangeResponse)
async def get_my_trade( async def get_my_trade(
exchange_id: int, public_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)), current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
) -> ExchangeResponse: ) -> ExchangeResponse:
"""Get a specific trade by ID. User can only access their own trades.""" """Get a specific trade by public ID. User can only access their own trades."""
result = await db.execute( result = await db.execute(
select(Exchange).where( select(Exchange).where(
and_(Exchange.id == exchange_id, Exchange.user_id == current_user.id) and_(Exchange.public_id == public_id, Exchange.user_id == current_user.id)
) )
) )
exchange = result.scalar_one_or_none() exchange = result.scalar_one_or_none()
@ -588,9 +613,9 @@ async def get_my_trade(
return _to_exchange_response(exchange, current_user.email) return _to_exchange_response(exchange, current_user.email)
@trades_router.post("/{exchange_id}/cancel", response_model=ExchangeResponse) @trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse)
async def cancel_my_trade( async def cancel_my_trade(
exchange_id: int, public_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)), current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
) -> ExchangeResponse: ) -> ExchangeResponse:
@ -599,14 +624,14 @@ async def cancel_my_trade(
result = await db.execute( result = await db.execute(
select(Exchange) select(Exchange)
.options(joinedload(Exchange.user)) .options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id) .where(Exchange.public_id == public_id)
) )
exchange = result.scalar_one_or_none() exchange = result.scalar_one_or_none()
if not exchange: if not exchange:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"Trade {exchange_id} not found", detail="Trade not found",
) )
# Verify ownership # Verify ownership
@ -651,6 +676,7 @@ def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse:
user = exchange.user user = exchange.user
return AdminExchangeResponse( return AdminExchangeResponse(
id=exchange.id, id=exchange.id,
public_id=str(exchange.public_id),
user_id=exchange.user_id, user_id=exchange.user_id,
user_email=user.email, user_email=user.email,
user_contact=ExchangeUserContact( user_contact=ExchangeUserContact(
@ -760,11 +786,9 @@ async def get_past_trades(
return [_to_admin_exchange_response(ex) for ex in exchanges] return [_to_admin_exchange_response(ex) for ex in exchanges]
@admin_trades_router.post( @admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse)
"/{exchange_id}/complete", response_model=AdminExchangeResponse
)
async def complete_trade( async def complete_trade(
exchange_id: int, public_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
@ -773,14 +797,14 @@ async def complete_trade(
result = await db.execute( result = await db.execute(
select(Exchange) select(Exchange)
.options(joinedload(Exchange.user)) .options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id) .where(Exchange.public_id == public_id)
) )
exchange = result.scalar_one_or_none() exchange = result.scalar_one_or_none()
if not exchange: if not exchange:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"Trade {exchange_id} not found", detail="Trade not found",
) )
# Check slot has passed # Check slot has passed
@ -806,11 +830,9 @@ async def complete_trade(
return _to_admin_exchange_response(exchange) return _to_admin_exchange_response(exchange)
@admin_trades_router.post( @admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse)
"/{exchange_id}/no-show", response_model=AdminExchangeResponse
)
async def mark_no_show( async def mark_no_show(
exchange_id: int, public_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
@ -819,14 +841,14 @@ async def mark_no_show(
result = await db.execute( result = await db.execute(
select(Exchange) select(Exchange)
.options(joinedload(Exchange.user)) .options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id) .where(Exchange.public_id == public_id)
) )
exchange = result.scalar_one_or_none() exchange = result.scalar_one_or_none()
if not exchange: if not exchange:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"Trade {exchange_id} not found", detail="Trade not found",
) )
# Check slot has passed # Check slot has passed
@ -852,9 +874,9 @@ async def mark_no_show(
return _to_admin_exchange_response(exchange) 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( async def admin_cancel_trade(
exchange_id: int, public_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)), _current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
@ -863,14 +885,14 @@ async def admin_cancel_trade(
result = await db.execute( result = await db.execute(
select(Exchange) select(Exchange)
.options(joinedload(Exchange.user)) .options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id) .where(Exchange.public_id == public_id)
) )
exchange = result.scalar_one_or_none() exchange = result.scalar_one_or_none()
if not exchange: if not exchange:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"Trade {exchange_id} not found", detail="Trade not found",
) )
# Check status is BOOKED # Check status is BOOKED

View file

@ -205,7 +205,8 @@ class ExchangeRequest(BaseModel):
class ExchangeResponse(BaseModel): class ExchangeResponse(BaseModel):
"""Response model for an exchange trade.""" """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_id: int
user_email: str user_email: str
slot_start: datetime slot_start: datetime
@ -236,7 +237,8 @@ class ExchangeUserContact(BaseModel):
class AdminExchangeResponse(BaseModel): class AdminExchangeResponse(BaseModel):
"""Response model for admin exchange view (includes user contact).""" """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_id: int
user_email: str user_email: str
user_contact: ExchangeUserContact user_contact: ExchangeUserContact

View file

@ -670,14 +670,14 @@ class TestGetMyTrade:
}, },
) )
assert create_response.status_code == 200 assert create_response.status_code == 200
trade_id = create_response.json()["id"] public_id = create_response.json()["public_id"]
# Get the trade # Get the trade
get_response = await client.get(f"/api/trades/{trade_id}") get_response = await client.get(f"/api/trades/{public_id}")
assert get_response.status_code == 200 assert get_response.status_code == 200
data = get_response.json() data = get_response.json()
assert data["id"] == trade_id assert data["public_id"] == public_id
assert data["direction"] == "buy" assert data["direction"] == "buy"
assert data["bitcoin_transfer_method"] == "onchain" assert data["bitcoin_transfer_method"] == "onchain"
@ -701,13 +701,13 @@ class TestGetMyTrade:
}, },
) )
assert create_response.status_code == 200 assert create_response.status_code == 200
trade_id = create_response.json()["id"] public_id = create_response.json()["public_id"]
# Second user tries to get it # Second user tries to get it
async with client_factory.create( async with client_factory.create(
cookies=alt_regular_user["cookies"] cookies=alt_regular_user["cookies"]
) as client: ) as client:
get_response = await client.get(f"/api/trades/{trade_id}") get_response = await client.get(f"/api/trades/{public_id}")
assert get_response.status_code == 404 assert get_response.status_code == 404
@ -717,7 +717,10 @@ class TestGetMyTrade:
): ):
"""Getting a nonexistent trade returns 404.""" """Getting a nonexistent trade returns 404."""
async with client_factory.create(cookies=regular_user["cookies"]) as client: async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/trades/99999") # Use a valid UUID format but non-existent
response = await client.get(
"/api/trades/00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404 assert response.status_code == 404
@ -742,10 +745,10 @@ class TestCancelTrade:
"eur_amount": 10000, "eur_amount": 10000,
}, },
) )
trade_id = book_response.json()["id"] public_id = book_response.json()["public_id"]
# Cancel # 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 assert response.status_code == 200
data = response.json() data = response.json()
@ -772,7 +775,7 @@ class TestCancelTrade:
}, },
) )
assert book_response.status_code == 200 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 # Verify the slot is NOT available
slots_response = await client.get( slots_response = await client.get(
@ -784,7 +787,7 @@ class TestCancelTrade:
assert f"{target_date}T09:00:00Z" not in slot_starts assert f"{target_date}T09:00:00Z" not in slot_starts
# Cancel the trade # 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 assert cancel_response.status_code == 200
# Verify the slot IS available again # Verify the slot IS available again
@ -815,11 +818,11 @@ class TestCancelTrade:
"eur_amount": 10000, "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) # Second user tries to cancel (no mock needed for this)
async with client_factory.create(cookies=alt_regular_user["cookies"]) as client: 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 assert response.status_code == 403
@ -827,7 +830,9 @@ class TestCancelTrade:
async def test_cannot_cancel_nonexistent_trade(self, client_factory, regular_user): async def test_cannot_cancel_nonexistent_trade(self, client_factory, regular_user):
"""Returns 404 for non-existent trade.""" """Returns 404 for non-existent trade."""
async with client_factory.create(cookies=regular_user["cookies"]) as client: 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 assert response.status_code == 404
@ -850,11 +855,11 @@ class TestCancelTrade:
"eur_amount": 10000, "eur_amount": 10000,
}, },
) )
trade_id = book_response.json()["id"] public_id = book_response.json()["public_id"]
await client.post(f"/api/trades/{trade_id}/cancel") await client.post(f"/api/trades/{public_id}/cancel")
# Try to cancel again # 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 response.status_code == 400
assert "cancelled_by_user" in response.json()["detail"] assert "cancelled_by_user" in response.json()["detail"]
@ -884,11 +889,11 @@ class TestCancelTrade:
db.add(exchange) db.add(exchange)
await db.commit() await db.commit()
await db.refresh(exchange) await db.refresh(exchange)
trade_id = exchange.id public_id = exchange.public_id
# User tries to cancel # User tries to cancel
async with client_factory.create(cookies=regular_user["cookies"]) as client: 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 response.status_code == 400
assert "already passed" in response.json()["detail"] assert "already passed" in response.json()["detail"]
@ -1008,11 +1013,11 @@ class TestAdminCompleteTrade:
db.add(exchange) db.add(exchange)
await db.commit() await db.commit()
await db.refresh(exchange) await db.refresh(exchange)
trade_id = exchange.id public_id = exchange.public_id
# Admin completes # Admin completes
async with client_factory.create(cookies=admin_user["cookies"]) as client: 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 assert response.status_code == 200
data = response.json() data = response.json()
@ -1038,11 +1043,11 @@ class TestAdminCompleteTrade:
"eur_amount": 10000, "eur_amount": 10000,
}, },
) )
trade_id = book_response.json()["id"] public_id = book_response.json()["public_id"]
# Admin tries to complete # Admin tries to complete
async with client_factory.create(cookies=admin_user["cookies"]) as client: 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 response.status_code == 400
assert "not yet started" in response.json()["detail"] assert "not yet started" in response.json()["detail"]
@ -1071,11 +1076,11 @@ class TestAdminCompleteTrade:
db.add(exchange) db.add(exchange)
await db.commit() await db.commit()
await db.refresh(exchange) await db.refresh(exchange)
trade_id = exchange.id public_id = exchange.public_id
# Regular user tries to complete # Regular user tries to complete
async with client_factory.create(cookies=regular_user["cookies"]) as client: 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 assert response.status_code == 403
@ -1103,11 +1108,11 @@ class TestAdminCompleteTrade:
db.add(exchange) db.add(exchange)
await db.commit() await db.commit()
await db.refresh(exchange) await db.refresh(exchange)
trade_id = exchange.id public_id = exchange.public_id
# Regular user tries to mark as no-show # Regular user tries to mark as no-show
async with client_factory.create(cookies=regular_user["cookies"]) as client: 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 assert response.status_code == 403
@ -1139,11 +1144,11 @@ class TestAdminNoShowTrade:
db.add(exchange) db.add(exchange)
await db.commit() await db.commit()
await db.refresh(exchange) await db.refresh(exchange)
trade_id = exchange.id public_id = exchange.public_id
# Admin marks no-show # Admin marks no-show
async with client_factory.create(cookies=admin_user["cookies"]) as client: 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 assert response.status_code == 200
data = response.json() data = response.json()
@ -1172,11 +1177,11 @@ class TestAdminCancelTrade:
"eur_amount": 10000, "eur_amount": 10000,
}, },
) )
trade_id = book_response.json()["id"] public_id = book_response.json()["public_id"]
# Admin cancels # Admin cancels
async with client_factory.create(cookies=admin_user["cookies"]) as client: 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 assert response.status_code == 200
data = response.json() data = response.json()
@ -1201,10 +1206,10 @@ class TestAdminCancelTrade:
"eur_amount": 10000, "eur_amount": 10000,
}, },
) )
trade_id = book_response.json()["id"] public_id = book_response.json()["public_id"]
# User tries admin cancel # 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 assert response.status_code == 403

View file

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

View file

@ -27,7 +27,7 @@ const {
type Direction = "buy" | "sell"; type Direction = "buy" | "sell";
type BitcoinTransferMethod = "onchain" | "lightning"; type BitcoinTransferMethod = "onchain" | "lightning";
type WizardStep = "details" | "booking"; type WizardStep = "details" | "booking" | "confirmation";
/** /**
* Format price for display * Format price for display
@ -66,9 +66,14 @@ export default function ExchangePage() {
const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set()); const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set());
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true); 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 // UI state
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [existingTradeId, setExistingTradeId] = useState<number | null>(null); const [existingTradeId, setExistingTradeId] = useState<string | null>(null);
const [isBooking, setIsBooking] = useState(false); const [isBooking, setIsBooking] = useState(false);
// Compute dates // Compute dates
@ -167,10 +172,28 @@ export default function ExchangePage() {
} }
}, []); }, []);
// Fetch availability for all dates when entering booking step // Fetch user trades when entering booking step
useEffect(() => { useEffect(() => {
if (!user || !isAuthorized || wizardStep !== "booking") return; 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 () => { const fetchAllAvailability = async () => {
setIsLoadingAvailability(true); setIsLoadingAvailability(true);
const availabilitySet = new Set<string>(); const availabilitySet = new Set<string>();
@ -201,9 +224,36 @@ export default function ExchangePage() {
} }
}, [selectedDate, user, isAuthorized, fetchSlots]); }, [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 handleDateSelect = (date: Date) => {
const dateStr = formatDate(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); setSelectedDate(date);
} }
}; };
@ -211,6 +261,7 @@ export default function ExchangePage() {
const handleSlotSelect = (slot: BookableSlot) => { const handleSlotSelect = (slot: BookableSlot) => {
setSelectedSlot(slot); setSelectedSlot(slot);
setError(null); setError(null);
setWizardStep("confirmation");
}; };
const handleContinueToBooking = () => { const handleContinueToBooking = () => {
@ -223,6 +274,12 @@ export default function ExchangePage() {
setSelectedDate(null); setSelectedDate(null);
setSelectedSlot(null); setSelectedSlot(null);
setError(null); setError(null);
setExistingTradeOnSelectedDate(null);
};
const handleBackToBooking = () => {
setWizardStep("booking");
setError(null);
}; };
const handleAmountChange = (value: number) => { const handleAmountChange = (value: number) => {
@ -282,10 +339,10 @@ export default function ExchangePage() {
} }
setError(errorMessage); setError(errorMessage);
// Check if it's a "same day" error and extract trade ID // Check if it's a "same day" error and extract trade public_id (UUID)
const tradeIdMatch = errorMessage.match(/Trade ID: (\d+)/); const tradeIdMatch = errorMessage.match(/Trade ID: ([a-f0-9-]{36})/i);
if (tradeIdMatch) { if (tradeIdMatch) {
setExistingTradeId(parseInt(tradeIdMatch[1], 10)); setExistingTradeId(tradeIdMatch[1]);
} else { } else {
setExistingTradeId(null); setExistingTradeId(null);
} }
@ -294,11 +351,6 @@ export default function ExchangePage() {
} }
}; };
const cancelSlotSelection = () => {
setSelectedSlot(null);
setError(null);
};
if (isLoading) { if (isLoading) {
return ( return (
<main style={layoutStyles.main}> <main style={layoutStyles.main}>
@ -377,12 +429,26 @@ export default function ExchangePage() {
<div <div
style={{ style={{
...styles.step, ...styles.step,
...(wizardStep === "booking" ? styles.stepActive : {}), ...(wizardStep === "booking"
? styles.stepActive
: wizardStep === "confirmation"
? styles.stepCompleted
: {}),
}} }}
> >
<span style={styles.stepNumber}>2</span> <span style={styles.stepNumber}>2</span>
<span style={styles.stepLabel}>Book Appointment</span> <span style={styles.stepLabel}>Book Appointment</span>
</div> </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> </div>
{/* Step 1: Exchange Details */} {/* Step 1: Exchange Details */}
@ -553,6 +619,7 @@ export default function ExchangePage() {
const isSelected = selectedDate && formatDate(selectedDate) === dateStr; const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
const hasAvailability = datesWithAvailability.has(dateStr); const hasAvailability = datesWithAvailability.has(dateStr);
const isDisabled = !hasAvailability || isLoadingAvailability; const isDisabled = !hasAvailability || isLoadingAvailability;
const hasExistingTrade = getExistingTradeOnDate(date) !== null;
return ( return (
<button <button
@ -564,6 +631,7 @@ export default function ExchangePage() {
...styles.dateButton, ...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}), ...(isSelected ? styles.dateButtonSelected : {}),
...(isDisabled ? styles.dateButtonDisabled : {}), ...(isDisabled ? styles.dateButtonDisabled : {}),
...(hasExistingTrade && !isDisabled ? styles.dateButtonHasTrade : {}),
}} }}
> >
<div style={styles.dateWeekday}> <div style={styles.dateWeekday}>
@ -575,14 +643,32 @@ export default function ExchangePage() {
day: "numeric", day: "numeric",
})} })}
</div> </div>
{hasExistingTrade && !isDisabled && <div style={styles.dateWarning}></div>}
</button> </button>
); );
})} })}
</div> </div>
</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 */} {/* Available Slots */}
{selectedDate && ( {selectedDate && !existingTradeOnSelectedDate && (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitle}> <h2 style={styles.sectionTitle}>
Available Slots for{" "} Available Slots for{" "}
@ -618,83 +704,109 @@ export default function ExchangePage() {
)} )}
</div> </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> </div>
</main> </main>
); );
@ -937,6 +1049,33 @@ const styles: Record<string, CSSProperties> = {
padding: "1rem 1.5rem", padding: "1rem 1.5rem",
marginBottom: "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: { summaryHeader: {
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
@ -1080,6 +1219,10 @@ const styles: Record<string, CSSProperties> = {
background: "rgba(255, 255, 255, 0.01)", background: "rgba(255, 255, 255, 0.01)",
border: "1px solid rgba(255, 255, 255, 0.04)", 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: { dateWeekday: {
color: "#fff", color: "#fff",
fontWeight: 500, fontWeight: 500,
@ -1090,6 +1233,11 @@ const styles: Record<string, CSSProperties> = {
color: "rgba(255, 255, 255, 0.5)", color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem", fontSize: "0.8rem",
}, },
dateWarning: {
fontSize: "0.7rem",
marginTop: "0.25rem",
opacity: 0.8,
},
slotGrid: { slotGrid: {
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",

View file

@ -416,7 +416,27 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -429,7 +449,7 @@ export interface paths {
* Cancel My Trade * Cancel My Trade
* @description Cancel one of the current user's exchanges. * @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; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@ -481,7 +501,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/trades/{exchange_id}/complete": { "/api/admin/trades/{public_id}/complete": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -494,14 +514,14 @@ export interface paths {
* Complete Trade * Complete Trade
* @description Mark a trade as completed. Only possible after slot time has passed. * @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; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/trades/{exchange_id}/no-show": { "/api/admin/trades/{public_id}/no-show": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -514,14 +534,14 @@ export interface paths {
* Mark No Show * Mark No Show
* @description Mark a trade as no-show. Only possible after slot time has passed. * @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; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/trades/{exchange_id}/cancel": { "/api/admin/trades/{public_id}/cancel": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -534,7 +554,7 @@ export interface paths {
* Admin Cancel Trade * Admin Cancel Trade
* @description Cancel any trade (admin only). * @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; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@ -575,6 +595,8 @@ export interface components {
AdminExchangeResponse: { AdminExchangeResponse: {
/** Id */ /** Id */
id: number; id: number;
/** Public Id */
public_id: string;
/** User Id */ /** User Id */
user_id: number; user_id: number;
/** User Email */ /** User Email */
@ -760,6 +782,8 @@ export interface components {
ExchangeResponse: { ExchangeResponse: {
/** Id */ /** Id */
id: number; id: number;
/** Public Id */
public_id: string;
/** User Id */ /** User Id */
user_id: number; user_id: number;
/** User Email */ /** 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: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { 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; 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: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
exchange_id: number; public_id: string;
}; };
cookie?: never; 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: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
exchange_id: number; public_id: string;
}; };
cookie?: never; 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: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path: {
exchange_id: number; public_id: string;
}; };
cookie?: never; cookie?: never;
}; };

View file

@ -24,7 +24,7 @@ type ExchangeResponse = components["schemas"]["ExchangeResponse"];
export default function TradeDetailPage() { export default function TradeDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const tradeId = params?.id ? parseInt(params.id as string, 10) : null; const publicId = params?.id as string | undefined;
const { user, isLoading, isAuthorized } = useRequireAuth({ const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_EXCHANGES, requiredPermission: Permission.VIEW_OWN_EXCHANGES,
@ -36,13 +36,13 @@ export default function TradeDetailPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!user || !isAuthorized || !tradeId) return; if (!user || !isAuthorized || !publicId) return;
const fetchTrade = async () => { const fetchTrade = async () => {
try { try {
setIsLoadingTrade(true); setIsLoadingTrade(true);
setError(null); setError(null);
const data = await api.get<ExchangeResponse>(`/api/trades/${tradeId}`); const data = await api.get<ExchangeResponse>(`/api/trades/${publicId}`);
setTrade(data); setTrade(data);
} catch (err) { } catch (err) {
console.error("Failed to fetch trade:", err); console.error("Failed to fetch trade:", err);
@ -55,7 +55,7 @@ export default function TradeDetailPage() {
}; };
fetchTrade(); fetchTrade();
}, [user, isAuthorized, tradeId]); }, [user, isAuthorized, publicId]);
if (isLoading || isLoadingTrade) { if (isLoading || isLoadingTrade) {
return ( return (
@ -221,7 +221,7 @@ export default function TradeDetailPage() {
return; return;
} }
try { try {
await api.post(`/api/trades/${trade.id}/cancel`, {}); await api.post(`/api/trades/${trade.public_id}/cancel`, {});
router.push("/trades"); router.push("/trades");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel trade"); setError(err instanceof Error ? err.message : "Failed to cancel trade");

View file

@ -30,8 +30,8 @@ export default function TradesPage() {
const [trades, setTrades] = useState<ExchangeResponse[]>([]); const [trades, setTrades] = useState<ExchangeResponse[]>([]);
const [isLoadingTrades, setIsLoadingTrades] = useState(true); const [isLoadingTrades, setIsLoadingTrades] = useState(true);
const [cancellingId, setCancellingId] = useState<number | null>(null); const [cancellingId, setCancellingId] = useState<string | null>(null);
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null); const [confirmCancelId, setConfirmCancelId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchTrades = useCallback(async () => { const fetchTrades = useCallback(async () => {
@ -52,12 +52,12 @@ export default function TradesPage() {
} }
}, [user, isAuthorized, fetchTrades]); }, [user, isAuthorized, fetchTrades]);
const handleCancel = async (tradeId: number) => { const handleCancel = async (publicId: string) => {
setCancellingId(tradeId); setCancellingId(publicId);
setError(null); setError(null);
try { try {
await api.post<ExchangeResponse>(`/api/trades/${tradeId}/cancel`, {}); await api.post<ExchangeResponse>(`/api/trades/${publicId}/cancel`, {});
await fetchTrades(); await fetchTrades();
setConfirmCancelId(null); setConfirmCancelId(null);
} catch (err) { } catch (err) {
@ -115,14 +115,7 @@ export default function TradesPage() {
const status = getTradeStatusDisplay(trade.status); const status = getTradeStatusDisplay(trade.status);
const isBuy = trade.direction === "buy"; const isBuy = trade.direction === "buy";
return ( return (
<div <div key={trade.id} style={tradeCardStyles.tradeCard}>
key={trade.id}
style={{
...tradeCardStyles.tradeCard,
cursor: "pointer",
}}
onClick={() => router.push(`/trades/${trade.id}`)}
>
<div style={tradeCardStyles.tradeHeader}> <div style={tradeCardStyles.tradeHeader}>
<div style={tradeCardStyles.tradeInfo}> <div style={tradeCardStyles.tradeInfo}>
<div style={tradeCardStyles.tradeTime}> <div style={tradeCardStyles.tradeTime}>
@ -181,34 +174,54 @@ export default function TradesPage() {
</span> </span>
</div> </div>
{trade.status === "booked" && ( <div style={tradeCardStyles.buttonGroup}>
<div style={tradeCardStyles.buttonGroup}> {trade.status === "booked" && (
{confirmCancelId === trade.id ? ( <>
<> {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 <button
onClick={() => handleCancel(trade.id)} onClick={(e) => {
disabled={cancellingId === trade.id} e.stopPropagation();
style={styles.confirmButton} setConfirmCancelId(trade.public_id);
> }}
{cancellingId === trade.id ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmCancelId(null)}
style={buttonStyles.secondaryButton} style={buttonStyles.secondaryButton}
> >
No Cancel
</button> </button>
</> )}
) : ( </>
<button )}
onClick={() => setConfirmCancelId(trade.id)} <button
style={buttonStyles.secondaryButton} onClick={(e) => {
> e.stopPropagation();
Cancel router.push(`/trades/${trade.public_id}`);
</button> }}
)} style={styles.viewDetailsButton}
</div> >
)} View Details
</button>
</div>
</div> </div>
</div> </div>
); );
@ -233,9 +246,7 @@ export default function TradesPage() {
style={{ style={{
...tradeCardStyles.tradeCard, ...tradeCardStyles.tradeCard,
...styles.tradeCardPast, ...styles.tradeCardPast,
cursor: "pointer",
}} }}
onClick={() => router.push(`/trades/${trade.id}`)}
> >
<div style={tradeCardStyles.tradeTime}> <div style={tradeCardStyles.tradeTime}>
{formatDateTime(trade.slot_start)} {formatDateTime(trade.slot_start)}
@ -269,16 +280,26 @@ export default function TradesPage() {
<SatsDisplay sats={trade.sats_amount} /> <SatsDisplay sats={trade.sats_amount} />
</span> </span>
</div> </div>
<span <div style={tradeCardStyles.buttonGroup}>
style={{ <span
...badgeStyles.badge, style={{
background: status.bgColor, ...badgeStyles.badge,
color: status.textColor, background: status.bgColor,
marginTop: "0.5rem", color: status.textColor,
}} }}
> >
{status.text} {status.text}
</span> </span>
<button
onClick={(e) => {
e.stopPropagation();
router.push(`/trades/${trade.public_id}`);
}}
style={styles.viewDetailsButton}
>
View Details
</button>
</div>
</div> </div>
); );
})} })}
@ -330,4 +351,15 @@ const styles: Record<string, CSSProperties> = {
color: "#a78bfa", color: "#a78bfa",
textDecoration: "none", 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",
},
}; };