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:
parent
1008eea2d9
commit
bf57fc6b77
7 changed files with 640 additions and 270 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue