fix: Remove agreed_price from price API response

The agreed_price depends on trade direction (buy/sell) and must be
calculated on the frontend. Returning a buy-side-only agreed_price
from the API was misleading and unused.

Frontend already calculates the direction-aware price correctly.
This commit is contained in:
counterweight 2025-12-23 10:36:18 +01:00
parent 1008eea2d9
commit bf57fc6b77
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
7 changed files with 640 additions and 270 deletions

View file

@ -1,11 +1,15 @@
"""FastAPI application entry point."""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import Base, engine
from database import Base, async_session, engine
from models import PriceHistory
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
from routes import audit as audit_routes
from routes import auth as auth_routes
from routes import availability as availability_routes
@ -13,19 +17,63 @@ from routes import exchange as exchange_routes
from routes import invites as invites_routes
from routes import meta as meta_routes
from routes import profile as profile_routes
from shared_constants import PRICE_REFRESH_SECONDS
from validate_constants import validate_shared_constants
logger = logging.getLogger(__name__)
# Background task handle
_price_fetch_task: asyncio.Task | None = None
async def periodic_price_fetcher():
"""Background task that fetches BTC/EUR price every minute."""
logger.info(
"Starting periodic price fetcher (every %d seconds)", PRICE_REFRESH_SECONDS
)
while True:
try:
price_value, timestamp = await fetch_btc_eur_price()
async with async_session() as db:
new_price = PriceHistory(
source=SOURCE_BITFINEX,
pair=PAIR_BTC_EUR,
price=price_value,
timestamp=timestamp,
)
db.add(new_price)
await db.commit()
logger.info("Fetched BTC/EUR price: €%.2f", price_value)
except Exception as e:
logger.error("Failed to fetch price: %s", e)
await asyncio.sleep(PRICE_REFRESH_SECONDS)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Create database tables on startup and validate constants."""
global _price_fetch_task
# Validate shared constants match backend definitions
validate_shared_constants()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Start background price fetcher
_price_fetch_task = asyncio.create_task(periodic_price_fetcher())
yield
# Cancel background task on shutdown
if _price_fetch_task:
_price_fetch_task.cancel()
try:
await _price_fetch_task
except asyncio.CancelledError:
logger.info("Price fetcher task cancelled")
app = FastAPI(lifespan=lifespan)

View file

@ -61,10 +61,13 @@ class ExchangeConfigResponse(BaseModel):
class PriceResponse(BaseModel):
"""Current BTC/EUR price with premium applied."""
"""Current BTC/EUR price for trading.
Note: The actual agreed price depends on trade direction (buy/sell)
and is calculated by the frontend using market_price and premium_percentage.
"""
market_price: float # Raw price from exchange
agreed_price: float # Price with premium applied
premium_percentage: int
timestamp: datetime
is_stale: bool
@ -115,13 +118,6 @@ def apply_premium_for_direction(
return market_price * (1 - premium_percentage / 100)
def apply_premium(market_price: float, premium_percentage: int) -> float:
"""Apply buy-side premium (for price display)."""
return apply_premium_for_direction(
market_price, premium_percentage, TradeDirection.BUY
)
def calculate_sats_amount(
eur_cents: int,
price_eur_per_btc: float,
@ -204,7 +200,7 @@ async def get_exchange_price(
The response includes:
- market_price: The raw price from the exchange
- agreed_price: The price with admin premium applied
- premium_percentage: The premium to apply to trades
- is_stale: Whether the price is older than 5 minutes
- config: Trading configuration (min/max EUR, increment)
"""
@ -237,7 +233,6 @@ async def get_exchange_price(
return ExchangePriceResponse(
price=PriceResponse(
market_price=price_value,
agreed_price=apply_premium(price_value, PREMIUM_PERCENTAGE),
premium_percentage=PREMIUM_PERCENTAGE,
timestamp=timestamp,
is_stale=False,
@ -250,9 +245,6 @@ async def get_exchange_price(
return ExchangePriceResponse(
price=PriceResponse(
market_price=cached_price.price,
agreed_price=apply_premium(
cached_price.price, PREMIUM_PERCENTAGE
),
premium_percentage=PREMIUM_PERCENTAGE,
timestamp=cached_price.timestamp,
is_stale=True,
@ -271,7 +263,6 @@ async def get_exchange_price(
return ExchangePriceResponse(
price=PriceResponse(
market_price=cached_price.price,
agreed_price=apply_premium(cached_price.price, PREMIUM_PERCENTAGE),
premium_percentage=PREMIUM_PERCENTAGE,
timestamp=cached_price.timestamp,
is_stale=is_price_stale(cached_price.timestamp),

View file

@ -98,8 +98,7 @@ class TestExchangePriceEndpoint:
assert "config" in data
assert data["price"]["market_price"] == 20000.0
assert data["price"]["premium_percentage"] == 5
# Agreed price should be market * 1.05 (5% premium)
assert data["price"]["agreed_price"] == pytest.approx(21000.0, rel=0.001)
# Note: agreed_price is calculated on frontend based on direction (buy/sell)
@pytest.mark.asyncio
async def test_admin_cannot_get_price(self, client_factory, admin_user):

View file

@ -26,14 +26,56 @@ function formatEur(cents: number): string {
}
/**
* Format satoshi amount with thousand separators
* Format satoshi amount with styled components
* Leading zeros are subtle, main digits are prominent
*/
function formatSats(sats: number): string {
function SatsDisplay({ sats }: { sats: number }) {
const btc = sats / 100_000_000;
const btcStr = btc.toFixed(8);
const [whole, decimal] = btcStr.split(".");
const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3");
return `${whole}.${grouped}`;
const part1 = decimal.slice(0, 2);
const part2 = decimal.slice(2, 5);
const part3 = decimal.slice(5, 8);
const fullDecimal = part1 + part2 + part3;
let firstNonZero = fullDecimal.length;
for (let i = 0; i < fullDecimal.length; i++) {
if (fullDecimal[i] !== "0") {
firstNonZero = i;
break;
}
}
const subtleStyle: React.CSSProperties = {
opacity: 0.45,
fontWeight: 400,
};
const renderPart = (part: string, startIdx: number) => {
return part.split("").map((char, i) => {
const globalIdx = startIdx + i;
const isSubtle = globalIdx < firstNonZero;
return (
<span key={globalIdx} style={isSubtle ? subtleStyle : undefined}>
{char}
</span>
);
});
};
return (
<span style={{ fontFamily: "'DM Mono', monospace" }}>
<span style={subtleStyle}></span>
<span style={subtleStyle}> {whole}.</span>
{renderPart(part1, 0)}
<span> </span>
{renderPart(part2, 2)}
<span> </span>
{renderPart(part3, 5)}
<span> sats</span>
</span>
);
}
/**
@ -289,7 +331,9 @@ export default function AdminTradesPage() {
</span>
<span style={styles.amount}>{formatEur(trade.eur_amount)}</span>
<span style={styles.arrow}></span>
<span style={styles.satsAmount}>{formatSats(trade.sats_amount)} sats</span>
<span style={styles.satsAmount}>
<SatsDisplay sats={trade.sats_amount} />
</span>
</div>
<div style={styles.rateRow}>

View file

@ -2,6 +2,7 @@
import React from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Permission } from "../auth-context";
import { api } from "../api";
import { Header } from "../components/Header";
@ -20,6 +21,7 @@ const MIN_ADVANCE_DAYS = 1;
const MAX_ADVANCE_DAYS = 30;
type Direction = "buy" | "sell";
type WizardStep = "details" | "booking";
/**
* Format EUR amount from cents to display string
@ -29,17 +31,62 @@ function formatEur(cents: number): string {
}
/**
* Format satoshi amount with thousand separators
* e.g., 476190 -> "0.00 476 190 sats"
* Format satoshi amount with styled components
* Leading zeros are subtle, main digits are prominent
* e.g., 1876088 -> "₿ 0.01" (subtle) + "876 088 sats" (prominent)
*/
function formatSats(sats: number): string {
function SatsDisplay({ sats }: { sats: number }) {
const btc = sats / 100_000_000;
const btcStr = btc.toFixed(8);
const [whole, decimal] = btcStr.split(".");
// Group decimal into chunks of 3 for readability
const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3");
return `${whole}.${grouped} sats`;
// Group decimal into chunks: first 2, then two groups of 3
const part1 = decimal.slice(0, 2);
const part2 = decimal.slice(2, 5);
const part3 = decimal.slice(5, 8);
// Find where meaningful digits start (first non-zero after decimal)
const fullDecimal = part1 + part2 + part3;
let firstNonZero = fullDecimal.length;
for (let i = 0; i < fullDecimal.length; i++) {
if (fullDecimal[i] !== "0") {
firstNonZero = i;
break;
}
}
// Build the display with subtle leading zeros and prominent digits
const subtleStyle: React.CSSProperties = {
opacity: 0.45,
fontWeight: 400,
};
// Determine which parts are subtle vs prominent
const renderPart = (part: string, startIdx: number) => {
const chars = part.split("").map((char, i) => {
const globalIdx = startIdx + i;
const isSubtle = globalIdx < firstNonZero;
return (
<span key={globalIdx} style={isSubtle ? subtleStyle : undefined}>
{char}
</span>
);
});
return chars;
};
return (
<span style={{ fontFamily: "'DM Mono', monospace" }}>
<span style={subtleStyle}></span>
<span style={subtleStyle}> {whole}.</span>
{renderPart(part1, 0)}
<span> </span>
{renderPart(part2, 2)}
<span> </span>
{renderPart(part3, 5)}
<span> sats</span>
</span>
);
}
/**
@ -50,11 +97,15 @@ function formatPrice(price: number): string {
}
export default function ExchangePage() {
const router = useRouter();
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.CREATE_EXCHANGE,
fallbackRedirect: "/",
});
// Wizard state
const [wizardStep, setWizardStep] = useState<WizardStep>("details");
// Price and config state
const [priceData, setPriceData] = useState<ExchangePriceResponse | null>(null);
const [isPriceLoading, setIsPriceLoading] = useState(true);
@ -75,7 +126,6 @@ export default function ExchangePage() {
// UI state
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isBooking, setIsBooking] = useState(false);
// Compute dates
@ -162,9 +212,9 @@ export default function ExchangePage() {
}
}, []);
// Fetch availability for all dates on mount
// Fetch availability for all dates when entering booking step
useEffect(() => {
if (!user || !isAuthorized) return;
if (!user || !isAuthorized || wizardStep !== "booking") return;
const fetchAllAvailability = async () => {
setIsLoadingAvailability(true);
@ -188,7 +238,7 @@ export default function ExchangePage() {
};
fetchAllAvailability();
}, [user, isAuthorized, dates]);
}, [user, isAuthorized, dates, wizardStep]);
useEffect(() => {
if (selectedDate && user && isAuthorized) {
@ -200,7 +250,6 @@ export default function ExchangePage() {
const dateStr = formatDate(date);
if (datesWithAvailability.has(dateStr)) {
setSelectedDate(date);
setSuccessMessage(null);
}
};
@ -209,6 +258,43 @@ export default function ExchangePage() {
setError(null);
};
const handleContinueToBooking = () => {
setWizardStep("booking");
setError(null);
};
const handleBackToDetails = () => {
setWizardStep("details");
setSelectedDate(null);
setSelectedSlot(null);
setError(null);
};
const handleAmountChange = (value: number) => {
// Clamp to valid range and snap to increment
const minCents = eurMin * 100;
const maxCents = eurMax * 100;
const incrementCents = eurIncrement * 100;
// Clamp value
let clamped = Math.max(minCents, Math.min(maxCents, value));
// Snap to nearest increment
clamped = Math.round(clamped / incrementCents) * incrementCents;
setEurAmount(clamped);
};
const handleAmountInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value.replace(/[^0-9]/g, "");
if (inputValue === "") {
setEurAmount(eurMin * 100);
return;
}
const eurValue = parseInt(inputValue, 10);
handleAmountChange(eurValue * 100);
};
const handleBook = async () => {
if (!selectedSlot) return;
@ -216,26 +302,16 @@ export default function ExchangePage() {
setError(null);
try {
const exchange = await api.post<ExchangeResponse>("/api/exchange", {
await api.post<ExchangeResponse>("/api/exchange", {
slot_start: selectedSlot.start_time,
direction,
eur_amount: eurAmount,
});
const dirLabel = direction === "buy" ? "Buy" : "Sell";
setSuccessMessage(
`${dirLabel} trade booked for ${formatTime(exchange.slot_start)}! ` +
`${formatEur(exchange.eur_amount)}${formatSats(exchange.sats_amount)}`
);
setSelectedSlot(null);
// Refresh slots
if (selectedDate) {
await fetchSlots(selectedDate);
}
// Redirect to trades page after successful booking
router.push("/trades");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to book trade");
} finally {
setIsBooking(false);
}
};
@ -266,7 +342,6 @@ export default function ExchangePage() {
<h1 style={typographyStyles.pageTitle}>Exchange Bitcoin</h1>
<p style={typographyStyles.pageSubtitle}>Buy or sell Bitcoin with a 5% premium</p>
{successMessage && <div style={bannerStyles.successBanner}>{successMessage}</div>}
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
{/* Price Display */}
@ -282,22 +357,8 @@ export default function ExchangePage() {
<span style={styles.priceValue}>{formatPrice(marketPrice)}</span>
<span style={styles.priceDivider}></span>
<span style={styles.priceLabel}>Our price:</span>
<span
style={{
...styles.priceValue,
color: direction === "buy" ? "#f87171" : "#4ade80",
}}
>
{formatPrice(agreedPrice)}
</span>
<span
style={{
...styles.premiumBadge,
background:
direction === "buy" ? "rgba(248, 113, 113, 0.2)" : "rgba(74, 222, 128, 0.2)",
color: direction === "buy" ? "#f87171" : "#4ade80",
}}
>
<span style={styles.priceValue}>{formatPrice(agreedPrice)}</span>
<span style={styles.premiumBadge}>
{direction === "buy" ? "+" : "-"}
{premiumPercent}%
</span>
@ -312,207 +373,288 @@ export default function ExchangePage() {
)}
</div>
{/* Trade Form */}
<div style={styles.tradeCard}>
{/* Direction Selector */}
<div style={styles.directionRow}>
<button
onClick={() => setDirection("buy")}
style={{
...styles.directionBtn,
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
}}
>
Buy BTC
</button>
<button
onClick={() => setDirection("sell")}
style={{
...styles.directionBtn,
...(direction === "sell" ? styles.directionBtnSellActive : {}),
}}
>
Sell BTC
</button>
{/* Step Indicator */}
<div style={styles.stepIndicator}>
<div
style={{
...styles.step,
...(wizardStep === "details" ? styles.stepActive : styles.stepCompleted),
}}
>
<span style={styles.stepNumber}>1</span>
<span style={styles.stepLabel}>Exchange Details</span>
</div>
{/* Amount Slider */}
<div style={styles.amountSection}>
<div style={styles.amountHeader}>
<span style={styles.amountLabel}>Amount</span>
<span style={styles.amountValue}>{formatEur(eurAmount)}</span>
</div>
<input
type="range"
min={eurMin * 100}
max={eurMax * 100}
step={eurIncrement * 100}
value={eurAmount}
onChange={(e) => setEurAmount(Number(e.target.value))}
style={styles.slider}
/>
<div style={styles.amountRange}>
<span>{formatEur(eurMin * 100)}</span>
<span>{formatEur(eurMax * 100)}</span>
</div>
</div>
{/* Trade Summary */}
<div style={styles.tradeSummary}>
{direction === "buy" ? (
<p style={styles.summaryText}>
You pay <strong>{formatEur(eurAmount)}</strong>, you receive{" "}
<strong style={styles.satsValue}>{formatSats(satsAmount)}</strong>
</p>
) : (
<p style={styles.summaryText}>
You send <strong style={styles.satsValue}>{formatSats(satsAmount)}</strong>, you
receive <strong>{formatEur(eurAmount)}</strong>
</p>
)}
<div style={styles.stepDivider} />
<div
style={{
...styles.step,
...(wizardStep === "booking" ? styles.stepActive : {}),
}}
>
<span style={styles.stepNumber}>2</span>
<span style={styles.stepLabel}>Book Appointment</span>
</div>
</div>
{/* Date Selection */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Select a Date</h2>
<div style={styles.dateGrid}>
{dates.map((date) => {
const dateStr = formatDate(date);
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
const hasAvailability = datesWithAvailability.has(dateStr);
const isDisabled = !hasAvailability || isLoadingAvailability;
{/* Step 1: Exchange Details */}
{wizardStep === "details" && (
<div style={styles.tradeCard}>
{/* Direction Selector */}
<div style={styles.directionRow}>
<button
onClick={() => setDirection("buy")}
style={{
...styles.directionBtn,
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
}}
>
Buy BTC
</button>
<button
onClick={() => setDirection("sell")}
style={{
...styles.directionBtn,
...(direction === "sell" ? styles.directionBtnSellActive : {}),
}}
>
Sell BTC
</button>
</div>
return (
<button
key={dateStr}
onClick={() => handleDateSelect(date)}
disabled={isDisabled}
{/* Amount Section */}
<div style={styles.amountSection}>
<div style={styles.amountHeader}>
<span style={styles.amountLabel}>Amount (EUR)</span>
<div style={styles.amountInputWrapper}>
<span style={styles.amountCurrency}></span>
<input
type="text"
value={Math.round(eurAmount / 100)}
onChange={handleAmountInputChange}
style={styles.amountInput}
/>
</div>
</div>
<input
type="range"
min={eurMin * 100}
max={eurMax * 100}
step={eurIncrement * 100}
value={eurAmount}
onChange={(e) => setEurAmount(Number(e.target.value))}
style={styles.slider}
/>
<div style={styles.amountRange}>
<span>{formatEur(eurMin * 100)}</span>
<span>{formatEur(eurMax * 100)}</span>
</div>
</div>
{/* Trade Summary */}
<div style={styles.tradeSummary}>
{direction === "buy" ? (
<p style={styles.summaryText}>
You buy{" "}
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
, you sell <strong>{formatEur(eurAmount)}</strong>
</p>
) : (
<p style={styles.summaryText}>
You buy <strong>{formatEur(eurAmount)}</strong>, you sell{" "}
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
</p>
)}
</div>
{/* Continue Button */}
<button
onClick={handleContinueToBooking}
disabled={isPriceStale || !priceData?.price}
style={{
...styles.continueButton,
...(isPriceStale || !priceData?.price ? buttonStyles.buttonDisabled : {}),
}}
>
Continue to Booking
</button>
</div>
)}
{/* Step 2: Booking */}
{wizardStep === "booking" && (
<>
{/* Trade Summary Card */}
<div style={styles.summaryCard}>
<div style={styles.summaryHeader}>
<span style={styles.summaryTitle}>Your Exchange</span>
<button onClick={handleBackToDetails} style={styles.editButton}>
Edit
</button>
</div>
<div style={styles.summaryDetails}>
<span
style={{
...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}),
...(isDisabled ? styles.dateButtonDisabled : {}),
...styles.summaryDirection,
color: direction === "buy" ? "#4ade80" : "#f87171",
}}
>
<div style={styles.dateWeekday}>
{date.toLocaleDateString("en-US", { weekday: "short" })}
</div>
<div style={styles.dateDay}>
{date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</div>
</button>
);
})}
</div>
</div>
{direction === "buy" ? "Buy" : "Sell"} BTC
</span>
<span style={styles.summaryDivider}></span>
<span>{formatEur(eurAmount)}</span>
<span style={styles.summaryDivider}></span>
<span style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</span>
</div>
</div>
{/* Available Slots */}
{selectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for{" "}
{selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
</h2>
{/* Date Selection */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Select a Date</h2>
<div style={styles.dateGrid}>
{dates.map((date) => {
const dateStr = formatDate(date);
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
const hasAvailability = datesWithAvailability.has(dateStr);
const isDisabled = !hasAvailability || isLoadingAvailability;
{isLoadingSlots ? (
<div style={styles.emptyState}>Loading slots...</div>
) : availableSlots.length === 0 ? (
<div style={styles.emptyState}>No available slots for this date</div>
) : (
<div style={styles.slotGrid}>
{availableSlots.map((slot) => {
const isSelected = selectedSlot?.start_time === slot.start_time;
return (
<button
key={slot.start_time}
onClick={() => handleSlotSelect(slot)}
key={dateStr}
onClick={() => handleDateSelect(date)}
disabled={isDisabled}
style={{
...styles.slotButton,
...(isSelected ? styles.slotButtonSelected : {}),
...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}),
...(isDisabled ? styles.dateButtonDisabled : {}),
}}
>
{formatTime(slot.start_time)}
<div style={styles.dateWeekday}>
{date.toLocaleDateString("en-US", { weekday: "short" })}
</div>
<div style={styles.dateDay}>
{date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</div>
</button>
);
})}
</div>
</div>
{/* Available Slots */}
{selectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for{" "}
{selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
</h2>
{isLoadingSlots ? (
<div style={styles.emptyState}>Loading slots...</div>
) : availableSlots.length === 0 ? (
<div style={styles.emptyState}>No available slots for this date</div>
) : (
<div style={styles.slotGrid}>
{availableSlots.map((slot) => {
const isSelected = selectedSlot?.start_time === slot.start_time;
return (
<button
key={slot.start_time}
onClick={() => handleSlotSelect(slot)}
style={{
...styles.slotButton,
...(isSelected ? styles.slotButtonSelected : {}),
}}
>
{formatTime(slot.start_time)}
</button>
);
})}
</div>
)}
</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 }}>
{formatSats(satsAmount)}
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Rate:</span>
<span style={styles.confirmValue}>{formatPrice(agreedPrice)}/BTC</span>
</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>
<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>
<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>
)}
</>
)}
</div>
</main>
@ -563,6 +705,8 @@ const styles: Record<string, React.CSSProperties> = {
padding: "0.2rem 0.5rem",
borderRadius: "4px",
marginLeft: "0.25rem",
background: "rgba(255, 255, 255, 0.1)",
color: "rgba(255, 255, 255, 0.7)",
},
priceTimestamp: {
fontFamily: "'DM Sans', system-ui, sans-serif",
@ -584,6 +728,48 @@ const styles: Record<string, React.CSSProperties> = {
color: "#f87171",
textAlign: "center" as const,
},
stepIndicator: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
marginBottom: "2rem",
},
step: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
opacity: 0.4,
},
stepActive: {
opacity: 1,
},
stepCompleted: {
opacity: 0.7,
},
stepNumber: {
fontFamily: "'DM Mono', monospace",
width: "28px",
height: "28px",
borderRadius: "50%",
background: "rgba(255, 255, 255, 0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.875rem",
fontWeight: 600,
color: "#fff",
},
stepLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "#fff",
},
stepDivider: {
width: "40px",
height: "1px",
background: "rgba(255, 255, 255, 0.2)",
},
tradeCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
@ -633,11 +819,30 @@ const styles: Record<string, React.CSSProperties> = {
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.9rem",
},
amountValue: {
amountInputWrapper: {
display: "flex",
alignItems: "center",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
padding: "0.5rem 0.75rem",
},
amountCurrency: {
fontFamily: "'DM Mono', monospace",
color: "rgba(255, 255, 255, 0.5)",
fontSize: "1rem",
marginRight: "0.25rem",
},
amountInput: {
fontFamily: "'DM Mono', monospace",
color: "#fff",
fontSize: "1.25rem",
fontWeight: 600,
color: "#fff",
background: "transparent",
border: "none",
outline: "none",
width: "80px",
textAlign: "right" as const,
},
slider: {
width: "100%",
@ -661,6 +866,7 @@ const styles: Record<string, React.CSSProperties> = {
borderRadius: "8px",
padding: "1rem",
textAlign: "center" as const,
marginBottom: "1.5rem",
},
summaryText: {
fontFamily: "'DM Sans', system-ui, sans-serif",
@ -672,6 +878,61 @@ const styles: Record<string, React.CSSProperties> = {
fontFamily: "'DM Mono', monospace",
color: "#f7931a", // Bitcoin orange
},
continueButton: {
width: "100%",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 600,
padding: "0.875rem",
background: "linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%)",
border: "none",
borderRadius: "8px",
color: "#fff",
cursor: "pointer",
transition: "all 0.2s",
},
summaryCard: {
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",
},
summaryHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
},
summaryTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
},
editButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "#a78bfa",
background: "transparent",
border: "none",
cursor: "pointer",
padding: 0,
},
summaryDetails: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
flexWrap: "wrap",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
color: "#fff",
},
summaryDirection: {
fontWeight: 600,
},
summaryDivider: {
color: "rgba(255, 255, 255, 0.3)",
},
section: {
marginBottom: "2rem",
},

View file

@ -26,14 +26,56 @@ function formatEur(cents: number): string {
}
/**
* Format satoshi amount with thousand separators
* Format satoshi amount with styled components
* Leading zeros are subtle, main digits are prominent
*/
function formatSats(sats: number): string {
function SatsDisplay({ sats }: { sats: number }) {
const btc = sats / 100_000_000;
const btcStr = btc.toFixed(8);
const [whole, decimal] = btcStr.split(".");
const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3");
return `${whole}.${grouped}`;
const part1 = decimal.slice(0, 2);
const part2 = decimal.slice(2, 5);
const part3 = decimal.slice(5, 8);
const fullDecimal = part1 + part2 + part3;
let firstNonZero = fullDecimal.length;
for (let i = 0; i < fullDecimal.length; i++) {
if (fullDecimal[i] !== "0") {
firstNonZero = i;
break;
}
}
const subtleStyle: React.CSSProperties = {
opacity: 0.45,
fontWeight: 400,
};
const renderPart = (part: string, startIdx: number) => {
return part.split("").map((char, i) => {
const globalIdx = startIdx + i;
const isSubtle = globalIdx < firstNonZero;
return (
<span key={globalIdx} style={isSubtle ? subtleStyle : undefined}>
{char}
</span>
);
});
};
return (
<span style={{ fontFamily: "'DM Mono', monospace" }}>
<span style={subtleStyle}></span>
<span style={subtleStyle}> {whole}.</span>
{renderPart(part1, 0)}
<span> </span>
{renderPart(part2, 2)}
<span> </span>
{renderPart(part3, 5)}
<span> sats</span>
</span>
);
}
/**
@ -196,7 +238,7 @@ export default function TradesPage() {
<span style={styles.amount}>{formatEur(trade.eur_amount)}</span>
<span style={styles.arrow}></span>
<span style={styles.satsAmount}>
{formatSats(trade.sats_amount)} sats
<SatsDisplay sats={trade.sats_amount} />
</span>
</div>
<div style={styles.rateRow}>
@ -208,18 +250,6 @@ export default function TradesPage() {
})}
/BTC
</span>
<span
style={{
...styles.premiumBadge,
background: isBuy
? "rgba(248, 113, 113, 0.15)"
: "rgba(74, 222, 128, 0.15)",
color: isBuy ? "#f87171" : "#4ade80",
}}
>
{isBuy ? "+" : "-"}
{trade.premium_percentage}%
</span>
</div>
<span
style={{
@ -297,7 +327,7 @@ export default function TradesPage() {
<span style={styles.amount}>{formatEur(trade.eur_amount)}</span>
<span style={styles.arrow}></span>
<span style={styles.satsAmount}>
{formatSats(trade.sats_amount)} sats
<SatsDisplay sats={trade.sats_amount} />
</span>
</div>
<span
@ -421,13 +451,6 @@ const styles: Record<string, React.CSSProperties> = {
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.7)",
},
premiumBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.65rem",
fontWeight: 600,
padding: "0.15rem 0.4rem",
borderRadius: "3px",
},
buttonGroup: {
display: "flex",
gap: "0.5rem",

View file

@ -6,6 +6,8 @@ export default defineConfig({
workers: 1,
// Ensure tests within a file run in order
fullyParallel: false,
// Test timeout (per test)
timeout: 10000,
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
@ -13,5 +15,7 @@ export default defineConfig({
},
use: {
baseURL: "http://localhost:3000",
// Action timeout (clicks, fills, etc.)
actionTimeout: 5000,
},
});