lots of stuff
This commit is contained in:
parent
f946fbf7b8
commit
4be45f8f7c
9 changed files with 513 additions and 236 deletions
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue