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 + +
+
+ -
- {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 ( ); })}
+
+ + {/* 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 ( + + ); + })} +
+ )} +
)} -
- )} - {/* 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 +
+
-
- - -
-
+
+ + +
+
+ )} + )}
@@ -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, }, });