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.""" """FastAPI application entry point."""
import asyncio
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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 audit as audit_routes
from routes import auth as auth_routes from routes import auth as auth_routes
from routes import availability as availability_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 invites as invites_routes
from routes import meta as meta_routes from routes import meta as meta_routes
from routes import profile as profile_routes from routes import profile as profile_routes
from shared_constants import PRICE_REFRESH_SECONDS
from validate_constants import validate_shared_constants 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Create database tables on startup and validate constants.""" """Create database tables on startup and validate constants."""
global _price_fetch_task
# Validate shared constants match backend definitions # Validate shared constants match backend definitions
validate_shared_constants() validate_shared_constants()
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Start background price fetcher
_price_fetch_task = asyncio.create_task(periodic_price_fetcher())
yield 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) app = FastAPI(lifespan=lifespan)

View file

@ -61,10 +61,13 @@ class ExchangeConfigResponse(BaseModel):
class PriceResponse(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 market_price: float # Raw price from exchange
agreed_price: float # Price with premium applied
premium_percentage: int premium_percentage: int
timestamp: datetime timestamp: datetime
is_stale: bool is_stale: bool
@ -115,13 +118,6 @@ def apply_premium_for_direction(
return market_price * (1 - premium_percentage / 100) 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( def calculate_sats_amount(
eur_cents: int, eur_cents: int,
price_eur_per_btc: float, price_eur_per_btc: float,
@ -204,7 +200,7 @@ async def get_exchange_price(
The response includes: The response includes:
- market_price: The raw price from the exchange - 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 - is_stale: Whether the price is older than 5 minutes
- config: Trading configuration (min/max EUR, increment) - config: Trading configuration (min/max EUR, increment)
""" """
@ -237,7 +233,6 @@ async def get_exchange_price(
return ExchangePriceResponse( return ExchangePriceResponse(
price=PriceResponse( price=PriceResponse(
market_price=price_value, market_price=price_value,
agreed_price=apply_premium(price_value, PREMIUM_PERCENTAGE),
premium_percentage=PREMIUM_PERCENTAGE, premium_percentage=PREMIUM_PERCENTAGE,
timestamp=timestamp, timestamp=timestamp,
is_stale=False, is_stale=False,
@ -250,9 +245,6 @@ async def get_exchange_price(
return ExchangePriceResponse( return ExchangePriceResponse(
price=PriceResponse( price=PriceResponse(
market_price=cached_price.price, market_price=cached_price.price,
agreed_price=apply_premium(
cached_price.price, PREMIUM_PERCENTAGE
),
premium_percentage=PREMIUM_PERCENTAGE, premium_percentage=PREMIUM_PERCENTAGE,
timestamp=cached_price.timestamp, timestamp=cached_price.timestamp,
is_stale=True, is_stale=True,
@ -271,7 +263,6 @@ async def get_exchange_price(
return ExchangePriceResponse( return ExchangePriceResponse(
price=PriceResponse( price=PriceResponse(
market_price=cached_price.price, market_price=cached_price.price,
agreed_price=apply_premium(cached_price.price, PREMIUM_PERCENTAGE),
premium_percentage=PREMIUM_PERCENTAGE, premium_percentage=PREMIUM_PERCENTAGE,
timestamp=cached_price.timestamp, timestamp=cached_price.timestamp,
is_stale=is_price_stale(cached_price.timestamp), is_stale=is_price_stale(cached_price.timestamp),

View file

@ -98,8 +98,7 @@ class TestExchangePriceEndpoint:
assert "config" in data assert "config" in data
assert data["price"]["market_price"] == 20000.0 assert data["price"]["market_price"] == 20000.0
assert data["price"]["premium_percentage"] == 5 assert data["price"]["premium_percentage"] == 5
# Agreed price should be market * 1.05 (5% premium) # Note: agreed_price is calculated on frontend based on direction (buy/sell)
assert data["price"]["agreed_price"] == pytest.approx(21000.0, rel=0.001)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_admin_cannot_get_price(self, client_factory, admin_user): 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 btc = sats / 100_000_000;
const btcStr = btc.toFixed(8); const btcStr = btc.toFixed(8);
const [whole, decimal] = btcStr.split("."); 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>
<span style={styles.amount}>{formatEur(trade.eur_amount)}</span> <span style={styles.amount}>{formatEur(trade.eur_amount)}</span>
<span style={styles.arrow}></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>
<div style={styles.rateRow}> <div style={styles.rateRow}>

View file

@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { useEffect, useState, useCallback, useMemo } from "react"; import { useEffect, useState, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Permission } from "../auth-context"; import { Permission } from "../auth-context";
import { api } from "../api"; import { api } from "../api";
import { Header } from "../components/Header"; import { Header } from "../components/Header";
@ -20,6 +21,7 @@ const MIN_ADVANCE_DAYS = 1;
const MAX_ADVANCE_DAYS = 30; const MAX_ADVANCE_DAYS = 30;
type Direction = "buy" | "sell"; type Direction = "buy" | "sell";
type WizardStep = "details" | "booking";
/** /**
* Format EUR amount from cents to display string * Format EUR amount from cents to display string
@ -29,17 +31,62 @@ function formatEur(cents: number): string {
} }
/** /**
* Format satoshi amount with thousand separators * Format satoshi amount with styled components
* e.g., 476190 -> "0.00 476 190 sats" * 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 btc = sats / 100_000_000;
const btcStr = btc.toFixed(8); const btcStr = btc.toFixed(8);
const [whole, decimal] = btcStr.split("."); const [whole, decimal] = btcStr.split(".");
// Group decimal into chunks of 3 for readability // Group decimal into chunks: first 2, then two groups of 3
const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3"); const part1 = decimal.slice(0, 2);
return `${whole}.${grouped} sats`; 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() { export default function ExchangePage() {
const router = useRouter();
const { user, isLoading, isAuthorized } = useRequireAuth({ const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.CREATE_EXCHANGE, requiredPermission: Permission.CREATE_EXCHANGE,
fallbackRedirect: "/", fallbackRedirect: "/",
}); });
// Wizard state
const [wizardStep, setWizardStep] = useState<WizardStep>("details");
// Price and config state // Price and config state
const [priceData, setPriceData] = useState<ExchangePriceResponse | null>(null); const [priceData, setPriceData] = useState<ExchangePriceResponse | null>(null);
const [isPriceLoading, setIsPriceLoading] = useState(true); const [isPriceLoading, setIsPriceLoading] = useState(true);
@ -75,7 +126,6 @@ export default function ExchangePage() {
// UI state // UI state
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isBooking, setIsBooking] = useState(false); const [isBooking, setIsBooking] = useState(false);
// Compute dates // 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(() => { useEffect(() => {
if (!user || !isAuthorized) return; if (!user || !isAuthorized || wizardStep !== "booking") return;
const fetchAllAvailability = async () => { const fetchAllAvailability = async () => {
setIsLoadingAvailability(true); setIsLoadingAvailability(true);
@ -188,7 +238,7 @@ export default function ExchangePage() {
}; };
fetchAllAvailability(); fetchAllAvailability();
}, [user, isAuthorized, dates]); }, [user, isAuthorized, dates, wizardStep]);
useEffect(() => { useEffect(() => {
if (selectedDate && user && isAuthorized) { if (selectedDate && user && isAuthorized) {
@ -200,7 +250,6 @@ export default function ExchangePage() {
const dateStr = formatDate(date); const dateStr = formatDate(date);
if (datesWithAvailability.has(dateStr)) { if (datesWithAvailability.has(dateStr)) {
setSelectedDate(date); setSelectedDate(date);
setSuccessMessage(null);
} }
}; };
@ -209,6 +258,43 @@ export default function ExchangePage() {
setError(null); 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 () => { const handleBook = async () => {
if (!selectedSlot) return; if (!selectedSlot) return;
@ -216,26 +302,16 @@ export default function ExchangePage() {
setError(null); setError(null);
try { try {
const exchange = await api.post<ExchangeResponse>("/api/exchange", { await api.post<ExchangeResponse>("/api/exchange", {
slot_start: selectedSlot.start_time, slot_start: selectedSlot.start_time,
direction, direction,
eur_amount: eurAmount, eur_amount: eurAmount,
}); });
const dirLabel = direction === "buy" ? "Buy" : "Sell"; // Redirect to trades page after successful booking
setSuccessMessage( router.push("/trades");
`${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);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to book trade"); setError(err instanceof Error ? err.message : "Failed to book trade");
} finally {
setIsBooking(false); setIsBooking(false);
} }
}; };
@ -266,7 +342,6 @@ export default function ExchangePage() {
<h1 style={typographyStyles.pageTitle}>Exchange Bitcoin</h1> <h1 style={typographyStyles.pageTitle}>Exchange Bitcoin</h1>
<p style={typographyStyles.pageSubtitle}>Buy or sell Bitcoin with a 5% premium</p> <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>} {error && <div style={bannerStyles.errorBanner}>{error}</div>}
{/* Price Display */} {/* Price Display */}
@ -282,22 +357,8 @@ export default function ExchangePage() {
<span style={styles.priceValue}>{formatPrice(marketPrice)}</span> <span style={styles.priceValue}>{formatPrice(marketPrice)}</span>
<span style={styles.priceDivider}></span> <span style={styles.priceDivider}></span>
<span style={styles.priceLabel}>Our price:</span> <span style={styles.priceLabel}>Our price:</span>
<span <span style={styles.priceValue}>{formatPrice(agreedPrice)}</span>
style={{ <span style={styles.premiumBadge}>
...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",
}}
>
{direction === "buy" ? "+" : "-"} {direction === "buy" ? "+" : "-"}
{premiumPercent}% {premiumPercent}%
</span> </span>
@ -312,7 +373,31 @@ export default function ExchangePage() {
)} )}
</div> </div>
{/* Trade Form */} {/* 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>
<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>
{/* Step 1: Exchange Details */}
{wizardStep === "details" && (
<div style={styles.tradeCard}> <div style={styles.tradeCard}>
{/* Direction Selector */} {/* Direction Selector */}
<div style={styles.directionRow}> <div style={styles.directionRow}>
@ -336,11 +421,19 @@ export default function ExchangePage() {
</button> </button>
</div> </div>
{/* Amount Slider */} {/* Amount Section */}
<div style={styles.amountSection}> <div style={styles.amountSection}>
<div style={styles.amountHeader}> <div style={styles.amountHeader}>
<span style={styles.amountLabel}>Amount</span> <span style={styles.amountLabel}>Amount (EUR)</span>
<span style={styles.amountValue}>{formatEur(eurAmount)}</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> </div>
<input <input
type="range" type="range"
@ -361,16 +454,63 @@ export default function ExchangePage() {
<div style={styles.tradeSummary}> <div style={styles.tradeSummary}>
{direction === "buy" ? ( {direction === "buy" ? (
<p style={styles.summaryText}> <p style={styles.summaryText}>
You pay <strong>{formatEur(eurAmount)}</strong>, you receive{" "} You buy{" "}
<strong style={styles.satsValue}>{formatSats(satsAmount)}</strong> <strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
, you sell <strong>{formatEur(eurAmount)}</strong>
</p> </p>
) : ( ) : (
<p style={styles.summaryText}> <p style={styles.summaryText}>
You send <strong style={styles.satsValue}>{formatSats(satsAmount)}</strong>, you You buy <strong>{formatEur(eurAmount)}</strong>, you sell{" "}
receive <strong>{formatEur(eurAmount)}</strong> <strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
</p> </p>
)} )}
</div> </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.summaryDirection,
color: direction === "buy" ? "#4ade80" : "#f87171",
}}
>
{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> </div>
{/* Date Selection */} {/* Date Selection */}
@ -476,7 +616,7 @@ export default function ExchangePage() {
<div style={styles.confirmRow}> <div style={styles.confirmRow}>
<span style={styles.confirmLabel}>BTC:</span> <span style={styles.confirmLabel}>BTC:</span>
<span style={{ ...styles.confirmValue, ...styles.satsValue }}> <span style={{ ...styles.confirmValue, ...styles.satsValue }}>
{formatSats(satsAmount)} <SatsDisplay sats={satsAmount} />
</span> </span>
</div> </div>
<div style={styles.confirmRow}> <div style={styles.confirmRow}>
@ -514,6 +654,8 @@ export default function ExchangePage() {
</div> </div>
</div> </div>
)} )}
</>
)}
</div> </div>
</main> </main>
); );
@ -563,6 +705,8 @@ const styles: Record<string, React.CSSProperties> = {
padding: "0.2rem 0.5rem", padding: "0.2rem 0.5rem",
borderRadius: "4px", borderRadius: "4px",
marginLeft: "0.25rem", marginLeft: "0.25rem",
background: "rgba(255, 255, 255, 0.1)",
color: "rgba(255, 255, 255, 0.7)",
}, },
priceTimestamp: { priceTimestamp: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
@ -584,6 +728,48 @@ const styles: Record<string, React.CSSProperties> = {
color: "#f87171", color: "#f87171",
textAlign: "center" as const, 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: { tradeCard: {
background: "rgba(255, 255, 255, 0.03)", background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)", 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)", color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.9rem", 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", fontFamily: "'DM Mono', monospace",
color: "#fff",
fontSize: "1.25rem", fontSize: "1.25rem",
fontWeight: 600, fontWeight: 600,
color: "#fff",
background: "transparent",
border: "none",
outline: "none",
width: "80px",
textAlign: "right" as const,
}, },
slider: { slider: {
width: "100%", width: "100%",
@ -661,6 +866,7 @@ const styles: Record<string, React.CSSProperties> = {
borderRadius: "8px", borderRadius: "8px",
padding: "1rem", padding: "1rem",
textAlign: "center" as const, textAlign: "center" as const,
marginBottom: "1.5rem",
}, },
summaryText: { summaryText: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
@ -672,6 +878,61 @@ const styles: Record<string, React.CSSProperties> = {
fontFamily: "'DM Mono', monospace", fontFamily: "'DM Mono', monospace",
color: "#f7931a", // Bitcoin orange 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: { section: {
marginBottom: "2rem", 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 btc = sats / 100_000_000;
const btcStr = btc.toFixed(8); const btcStr = btc.toFixed(8);
const [whole, decimal] = btcStr.split("."); 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.amount}>{formatEur(trade.eur_amount)}</span>
<span style={styles.arrow}></span> <span style={styles.arrow}></span>
<span style={styles.satsAmount}> <span style={styles.satsAmount}>
{formatSats(trade.sats_amount)} sats <SatsDisplay sats={trade.sats_amount} />
</span> </span>
</div> </div>
<div style={styles.rateRow}> <div style={styles.rateRow}>
@ -208,18 +250,6 @@ export default function TradesPage() {
})} })}
/BTC /BTC
</span> </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> </div>
<span <span
style={{ style={{
@ -297,7 +327,7 @@ export default function TradesPage() {
<span style={styles.amount}>{formatEur(trade.eur_amount)}</span> <span style={styles.amount}>{formatEur(trade.eur_amount)}</span>
<span style={styles.arrow}></span> <span style={styles.arrow}></span>
<span style={styles.satsAmount}> <span style={styles.satsAmount}>
{formatSats(trade.sats_amount)} sats <SatsDisplay sats={trade.sats_amount} />
</span> </span>
</div> </div>
<span <span
@ -421,13 +451,6 @@ const styles: Record<string, React.CSSProperties> = {
fontSize: "0.8rem", fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.7)", 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: { buttonGroup: {
display: "flex", display: "flex",
gap: "0.5rem", gap: "0.5rem",

View file

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