diff --git a/backend/main.py b/backend/main.py
index 0e089c9..6f911a3 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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)
diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py
index e33d69e..cd2f07a 100644
--- a/backend/routes/exchange.py
+++ b/backend/routes/exchange.py
@@ -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),
diff --git a/backend/tests/test_exchange.py b/backend/tests/test_exchange.py
index e866021..e637f79 100644
--- a/backend/tests/test_exchange.py
+++ b/backend/tests/test_exchange.py
@@ -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):
diff --git a/frontend/app/admin/trades/page.tsx b/frontend/app/admin/trades/page.tsx
index 4d52a79..1356544 100644
--- a/frontend/app/admin/trades/page.tsx
+++ b/frontend/app/admin/trades/page.tsx
@@ -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 (
+
+ {char}
+
+ );
+ });
+ };
+
+ return (
+
+ ₿
+ {whole}.
+ {renderPart(part1, 0)}
+
+ {renderPart(part2, 2)}
+
+ {renderPart(part3, 5)}
+ sats
+
+ );
}
/**
@@ -289,7 +331,9 @@ export default function AdminTradesPage() {
{formatEur(trade.eur_amount)}
↔
- {formatSats(trade.sats_amount)} sats
+
+
+
diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx
index 086fab4..45ab773 100644
--- a/frontend/app/exchange/page.tsx
+++ b/frontend/app/exchange/page.tsx
@@ -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 (
+
+ {char}
+
+ );
+ });
+ return chars;
+ };
+
+ return (
+
+ ₿
+ {whole}.
+ {renderPart(part1, 0)}
+
+ {renderPart(part2, 2)}
+
+ {renderPart(part3, 5)}
+ sats
+
+ );
}
/**
@@ -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
("details");
+
// Price and config state
const [priceData, setPriceData] = useState(null);
const [isPriceLoading, setIsPriceLoading] = useState(true);
@@ -75,7 +126,6 @@ export default function ExchangePage() {
// UI state
const [error, setError] = useState(null);
- const [successMessage, setSuccessMessage] = useState(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) => {
+ 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("/api/exchange", {
+ await api.post("/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() {
Exchange Bitcoin
Buy or sell Bitcoin with a 5% premium
- {successMessage && {successMessage}
}
{error && {error}
}
{/* Price Display */}
@@ -282,22 +357,8 @@ export default function ExchangePage() {
{formatPrice(marketPrice)}
•
Our price:
-
- {formatPrice(agreedPrice)}
-
-
+ {formatPrice(agreedPrice)}
+
{direction === "buy" ? "+" : "-"}
{premiumPercent}%
@@ -312,207 +373,288 @@ export default function ExchangePage() {
)}
- {/* Trade Form */}
-
- {/* Direction Selector */}
-
-
-
+ {/* Step Indicator */}
+
+
+ 1
+ Exchange Details
-
- {/* Amount Slider */}
-
-
- Amount
- {formatEur(eurAmount)}
-
-
setEurAmount(Number(e.target.value))}
- style={styles.slider}
- />
-
- {formatEur(eurMin * 100)}
- {formatEur(eurMax * 100)}
-
-
-
- {/* Trade Summary */}
-
- {direction === "buy" ? (
-
- You pay {formatEur(eurAmount)}, you receive{" "}
- {formatSats(satsAmount)}
-
- ) : (
-
- You send {formatSats(satsAmount)}, you
- receive {formatEur(eurAmount)}
-
- )}
+
+
+ 2
+ Book Appointment
- {/* Date Selection */}
-
-
Select a Date
-
- {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" && (
+
+ {/* Direction Selector */}
+
+
+
+
- return (
-
+ )}
+
+ {/* Step 2: Booking */}
+ {wizardStep === "booking" && (
+ <>
+ {/* Trade Summary Card */}
+
+
+ Your Exchange
+
+ Edit
+
+
+
+
-
- {date.toLocaleDateString("en-US", { weekday: "short" })}
-
-
- {date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- })}
-
-
- );
- })}
-
-
+ {direction === "buy" ? "Buy" : "Sell"} BTC
+
+
•
+
{formatEur(eurAmount)}
+
↔
+
+
+
+
+
- {/* Available Slots */}
- {selectedDate && (
-
-
- Available Slots for{" "}
- {selectedDate.toLocaleDateString("en-US", {
- weekday: "long",
- month: "long",
- day: "numeric",
- })}
-
+ {/* Date Selection */}
+
+
Select a Date
+
+ {dates.map((date) => {
+ const dateStr = formatDate(date);
+ const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
+ const hasAvailability = datesWithAvailability.has(dateStr);
+ const isDisabled = !hasAvailability || isLoadingAvailability;
- {isLoadingSlots ? (
-
Loading slots...
- ) : availableSlots.length === 0 ? (
-
No available slots for this date
- ) : (
-
- {availableSlots.map((slot) => {
- const isSelected = selectedSlot?.start_time === slot.start_time;
return (
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)}
+
+ {date.toLocaleDateString("en-US", { weekday: "short" })}
+
+
+ {date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })}
+
);
})}
+
+
+ {/* Available Slots */}
+ {selectedDate && (
+
+
+ Available Slots for{" "}
+ {selectedDate.toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+ {isLoadingSlots ? (
+
Loading slots...
+ ) : availableSlots.length === 0 ? (
+
No available slots for this date
+ ) : (
+
+ {availableSlots.map((slot) => {
+ const isSelected = selectedSlot?.start_time === slot.start_time;
+ return (
+ handleSlotSelect(slot)}
+ style={{
+ ...styles.slotButton,
+ ...(isSelected ? styles.slotButtonSelected : {}),
+ }}
+ >
+ {formatTime(slot.start_time)}
+
+ );
+ })}
+
+ )}
+
)}
-
- )}
- {/* Confirm Booking */}
- {selectedSlot && (
-
-
Confirm Trade
-
-
- Time:
-
- {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
-
-
-
- Direction:
-
- {direction === "buy" ? "Buy BTC" : "Sell BTC"}
-
-
-
- EUR:
- {formatEur(eurAmount)}
-
-
- BTC:
-
- {formatSats(satsAmount)}
-
-
-
- Rate:
- {formatPrice(agreedPrice)}/BTC
-
-
+ {/* Confirm Booking */}
+ {selectedSlot && (
+
+
Confirm Trade
+
+
+ Time:
+
+ {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
+
+
+
+ Direction:
+
+ {direction === "buy" ? "Buy BTC" : "Sell BTC"}
+
+
+
+ EUR:
+ {formatEur(eurAmount)}
+
+
+ BTC:
+
+
+
+
+
+ Rate:
+ {formatPrice(agreedPrice)}/BTC
+
+
-
-
- {isBooking
- ? "Booking..."
- : isPriceStale
- ? "Price Stale"
- : `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
-
-
- Cancel
-
-
-
+
+
+ {isBooking
+ ? "Booking..."
+ : isPriceStale
+ ? "Price Stale"
+ : `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
+
+
+ Cancel
+
+
+
+ )}
+ >
)}
@@ -563,6 +705,8 @@ const styles: Record
= {
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 = {
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 = {
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 = {
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 = {
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",
},
diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx
index 4f78986..67af4ce 100644
--- a/frontend/app/trades/page.tsx
+++ b/frontend/app/trades/page.tsx
@@ -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 (
+
+ {char}
+
+ );
+ });
+ };
+
+ return (
+
+ ₿
+ {whole}.
+ {renderPart(part1, 0)}
+
+ {renderPart(part2, 2)}
+
+ {renderPart(part3, 5)}
+ sats
+
+ );
}
/**
@@ -196,7 +238,7 @@ export default function TradesPage() {
{formatEur(trade.eur_amount)}
↔
- {formatSats(trade.sats_amount)} sats
+
@@ -208,18 +250,6 @@ export default function TradesPage() {
})}
/BTC
-
- {isBuy ? "+" : "-"}
- {trade.premium_percentage}%
-
{formatEur(trade.eur_amount)}
↔
- {formatSats(trade.sats_amount)} sats
+
= {
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",
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 4a17e91..db1ec35 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -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,
},
});