"use client"; import { useEffect, useState, useCallback, useMemo, ChangeEvent, CSSProperties } from "react"; import { useRouter } from "next/navigation"; import { Permission } from "../auth-context"; import { api } from "../api"; import { Header } from "../components/Header"; import { SatsDisplay } from "../components/SatsDisplay"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { components } from "../generated/api"; import { formatDate, formatTime, getDateRange } from "../utils/date"; import { formatEur } from "../utils/exchange"; import { layoutStyles, typographyStyles, bannerStyles, buttonStyles } from "../styles/shared"; import constants from "../../../shared/constants.json"; type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"]; type ExchangeResponse = components["schemas"]["ExchangeResponse"]; type BookableSlot = components["schemas"]["BookableSlot"]; type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; // Constants from shared config const { minAdvanceDays: MIN_ADVANCE_DAYS, maxAdvanceDays: MAX_ADVANCE_DAYS, lightningMaxEur: LIGHTNING_MAX_EUR, } = constants.exchange; type Direction = "buy" | "sell"; type BitcoinTransferMethod = "onchain" | "lightning"; type WizardStep = "details" | "booking"; /** * Format price for display */ function formatPrice(price: number): string { return `€${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`; } 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); const [priceError, setPriceError] = useState(null); const [lastPriceUpdate, setLastPriceUpdate] = useState(null); // Trade form state const [direction, setDirection] = useState("buy"); const [bitcoinTransferMethod, setBitcoinTransferMethod] = useState("onchain"); const [eurAmount, setEurAmount] = useState(10000); // €100 in cents // Date/slot selection state const [selectedDate, setSelectedDate] = useState(null); const [availableSlots, setAvailableSlots] = useState([]); const [selectedSlot, setSelectedSlot] = useState(null); const [isLoadingSlots, setIsLoadingSlots] = useState(false); const [datesWithAvailability, setDatesWithAvailability] = useState>(new Set()); const [isLoadingAvailability, setIsLoadingAvailability] = useState(true); // UI state const [error, setError] = useState(null); const [existingTradeId, setExistingTradeId] = useState(null); const [isBooking, setIsBooking] = useState(false); // Compute dates const dates = useMemo(() => getDateRange(MIN_ADVANCE_DAYS, MAX_ADVANCE_DAYS), []); // Config from API const config = priceData?.config; const eurMin = config?.eur_min ?? 100; const eurMax = config?.eur_max ?? 3000; const eurIncrement = config?.eur_increment ?? 20; // Compute trade details const price = priceData?.price; const marketPrice = price?.market_price ?? 0; const premiumPercent = price?.premium_percentage ?? 5; // Calculate agreed price based on direction const agreedPrice = useMemo(() => { if (!marketPrice) return 0; if (direction === "buy") { return marketPrice * (1 + premiumPercent / 100); } else { return marketPrice * (1 - premiumPercent / 100); } }, [marketPrice, premiumPercent, direction]); // Calculate sats amount const satsAmount = useMemo(() => { if (!agreedPrice) return 0; const eurValue = eurAmount / 100; const btcAmount = eurValue / agreedPrice; return Math.floor(btcAmount * 100_000_000); }, [eurAmount, agreedPrice]); // Check if Lightning is disabled due to threshold const isLightningDisabled = useMemo(() => { return eurAmount > LIGHTNING_MAX_EUR * 100; }, [eurAmount]); // Auto-switch to onchain if Lightning becomes disabled useEffect(() => { if (isLightningDisabled && bitcoinTransferMethod === "lightning") { setBitcoinTransferMethod("onchain"); } }, [isLightningDisabled, bitcoinTransferMethod]); // Fetch price data const fetchPrice = useCallback(async () => { setIsPriceLoading(true); setPriceError(null); try { const data = await api.get("/api/exchange/price"); setPriceData(data); setLastPriceUpdate(new Date()); if (data.error) { setPriceError(data.error); } if (data.price?.is_stale) { setPriceError("Price is stale. Trade booking may be blocked."); } } catch (err) { console.error("Failed to fetch price:", err); setPriceError("Failed to load price data"); } finally { setIsPriceLoading(false); } }, []); // Auto-refresh price every 60 seconds useEffect(() => { if (!user || !isAuthorized) return; fetchPrice(); const interval = setInterval(fetchPrice, 60000); return () => clearInterval(interval); }, [user, isAuthorized, fetchPrice]); // Fetch slots for a date const fetchSlots = useCallback(async (date: Date) => { setIsLoadingSlots(true); setError(null); setAvailableSlots([]); setSelectedSlot(null); try { const dateStr = formatDate(date); const data = await api.get(`/api/exchange/slots?date=${dateStr}`); setAvailableSlots(data.slots); } catch (err) { console.error("Failed to fetch slots:", err); setError("Failed to load available slots"); } finally { setIsLoadingSlots(false); } }, []); // Fetch availability for all dates when entering booking step useEffect(() => { if (!user || !isAuthorized || wizardStep !== "booking") return; const fetchAllAvailability = async () => { setIsLoadingAvailability(true); const availabilitySet = new Set(); const promises = dates.map(async (date) => { try { const dateStr = formatDate(date); const data = await api.get(`/api/exchange/slots?date=${dateStr}`); if (data.slots.length > 0) { availabilitySet.add(dateStr); } } catch (err) { console.error(`Failed to fetch availability for ${formatDate(date)}:`, err); } }); await Promise.all(promises); setDatesWithAvailability(availabilitySet); setIsLoadingAvailability(false); }; fetchAllAvailability(); }, [user, isAuthorized, dates, wizardStep]); useEffect(() => { if (selectedDate && user && isAuthorized) { fetchSlots(selectedDate); } }, [selectedDate, user, isAuthorized, fetchSlots]); const handleDateSelect = (date: Date) => { const dateStr = formatDate(date); if (datesWithAvailability.has(dateStr)) { setSelectedDate(date); } }; const handleSlotSelect = (slot: BookableSlot) => { setSelectedSlot(slot); 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: 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; setIsBooking(true); setError(null); setExistingTradeId(null); try { await api.post("/api/exchange", { slot_start: selectedSlot.start_time, direction, bitcoin_transfer_method: bitcoinTransferMethod, eur_amount: eurAmount, }); // Redirect to trades page after successful booking router.push("/trades"); } catch (err) { let errorMessage = "Failed to book trade"; if (err instanceof Error) { // Check if it's an ApiError with detail in data if ("data" in err && err.data && typeof err.data === "object") { const data = err.data as { detail?: string }; errorMessage = data.detail || err.message; } else { errorMessage = err.message; } } setError(errorMessage); // Check if it's a "same day" error and extract trade ID const tradeIdMatch = errorMessage.match(/Trade ID: (\d+)/); if (tradeIdMatch) { setExistingTradeId(parseInt(tradeIdMatch[1], 10)); } else { setExistingTradeId(null); } setIsBooking(false); } }; const cancelSlotSelection = () => { setSelectedSlot(null); setError(null); }; if (isLoading) { return (
Loading...
); } if (!isAuthorized) { return null; } const isPriceStale = priceData?.price?.is_stale ?? false; return (

Exchange Bitcoin

Buy or sell Bitcoin with a 5% premium

{error && (
{error} {existingTradeId && ( )}
)} {/* Price Display */}
{isPriceLoading && !priceData ? (
Loading price...
) : priceError && !priceData?.price ? (
{priceError}
) : ( <>
Market: {formatPrice(marketPrice)} Our price: {formatPrice(agreedPrice)} {direction === "buy" ? "+" : "-"} {premiumPercent}%
{lastPriceUpdate && (
Updated {lastPriceUpdate.toLocaleTimeString()} {isPriceStale && (stale)}
)} )}
{/* Step Indicator */}
1 Exchange Details
2 Book Appointment
{/* Step 1: Exchange Details */} {wizardStep === "details" && (
{/* Direction Selector */}
{/* Payment Method Selector */}
Payment Method *
{isLightningDisabled && (
Lightning payments are only available for amounts up to €{LIGHTNING_MAX_EUR}
)}
{/* Amount Section */}
Amount (EUR)
setEurAmount(Number(e.target.value))} style={styles.slider} />
{formatEur(eurMin * 100)} {formatEur(eurMax * 100)}
{/* Trade Summary */}
{direction === "buy" ? (

You buy{" "} , you sell {formatEur(eurAmount)}

) : (

You buy {formatEur(eurAmount)}, you sell{" "}

)}
{/* Continue Button */}
)} {/* Step 2: Booking */} {wizardStep === "booking" && ( <> {/* Trade Summary Card */}
Your Exchange
{direction === "buy" ? "Buy" : "Sell"} BTC {formatEur(eurAmount)} {direction === "buy" ? "Receive via " : "Send via "} {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
{/* 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; 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:
Rate: {formatPrice(agreedPrice)}/BTC
Payment: {direction === "buy" ? "Receive via " : "Send via "} {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
)} )}
); } // Page-specific styles const styles: Record = { content: { flex: 1, padding: "2rem", maxWidth: "900px", margin: "0 auto", width: "100%", }, priceCard: { 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", }, priceRow: { display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap", }, priceLabel: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.5)", fontSize: "0.9rem", }, priceValue: { fontFamily: "'DM Mono', monospace", color: "#fff", fontSize: "1.1rem", fontWeight: 500, }, priceDivider: { color: "rgba(255, 255, 255, 0.2)", margin: "0 0.25rem", }, premiumBadge: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.75rem", fontWeight: 600, 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", fontSize: "0.75rem", color: "rgba(255, 255, 255, 0.4)", marginTop: "0.5rem", }, staleWarning: { color: "#f87171", fontWeight: 600, }, priceLoading: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.5)", textAlign: "center" as const, }, priceError: { fontFamily: "'DM Sans', system-ui, sans-serif", 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)", borderRadius: "12px", padding: "1.5rem", marginBottom: "2rem", }, directionRow: { display: "flex", gap: "0.5rem", marginBottom: "1.5rem", }, directionBtn: { flex: 1, fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "1rem", fontWeight: 600, padding: "0.875rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "8px", color: "rgba(255, 255, 255, 0.6)", cursor: "pointer", transition: "all 0.2s", }, directionBtnBuyActive: { background: "rgba(74, 222, 128, 0.15)", border: "1px solid #4ade80", color: "#4ade80", }, directionBtnSellActive: { background: "rgba(248, 113, 113, 0.15)", border: "1px solid #f87171", color: "#f87171", }, amountSection: { marginBottom: "1.5rem", }, amountHeader: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.75rem", }, amountLabel: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.7)", fontSize: "0.9rem", }, 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", fontSize: "1.25rem", fontWeight: 600, color: "#fff", background: "transparent", border: "none", outline: "none", width: "80px", textAlign: "right" as const, }, slider: { width: "100%", height: "8px", appearance: "none" as const, background: "rgba(255, 255, 255, 0.1)", borderRadius: "4px", outline: "none", cursor: "pointer", }, amountRange: { display: "flex", justifyContent: "space-between", marginTop: "0.5rem", fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.75rem", color: "rgba(255, 255, 255, 0.4)", }, tradeSummary: { background: "rgba(255, 255, 255, 0.02)", borderRadius: "8px", padding: "1rem", textAlign: "center" as const, marginBottom: "1.5rem", }, summaryText: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.8)", fontSize: "0.95rem", margin: 0, }, satsValue: { 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)", }, summaryPaymentMethod: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.875rem", color: "rgba(255, 255, 255, 0.6)", }, paymentMethodSection: { marginBottom: "1.5rem", }, paymentMethodLabel: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.7)", fontSize: "0.9rem", marginBottom: "0.75rem", }, required: { color: "#f87171", }, paymentMethodRow: { display: "flex", gap: "0.5rem", }, paymentMethodBtn: { flex: 1, fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.95rem", fontWeight: 600, padding: "0.875rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "8px", color: "rgba(255, 255, 255, 0.6)", cursor: "pointer", transition: "all 0.2s", display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", }, paymentMethodBtnActive: { background: "rgba(167, 139, 250, 0.15)", border: "1px solid #a78bfa", color: "#a78bfa", }, paymentMethodBtnDisabled: { opacity: 0.4, cursor: "not-allowed", }, paymentMethodIcon: { fontSize: "1.2rem", }, thresholdMessage: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.75rem", color: "rgba(251, 146, 60, 0.9)", marginTop: "0.5rem", padding: "0.5rem", background: "rgba(251, 146, 60, 0.1)", borderRadius: "6px", border: "1px solid rgba(251, 146, 60, 0.2)", }, errorLink: { marginTop: "0.75rem", paddingTop: "0.75rem", borderTop: "1px solid rgba(255, 255, 255, 0.1)", }, errorLinkAnchor: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "#a78bfa", textDecoration: "none", fontWeight: 500, fontSize: "0.9rem", }, section: { marginBottom: "2rem", }, sectionTitle: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "1.1rem", fontWeight: 500, color: "#fff", marginBottom: "1rem", }, dateGrid: { display: "flex", flexWrap: "wrap", gap: "0.5rem", }, dateButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.75rem 1rem", background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "10px", cursor: "pointer", minWidth: "90px", textAlign: "center" as const, transition: "all 0.2s", }, dateButtonSelected: { background: "rgba(167, 139, 250, 0.15)", border: "1px solid #a78bfa", }, dateButtonDisabled: { opacity: 0.4, cursor: "not-allowed", background: "rgba(255, 255, 255, 0.01)", border: "1px solid rgba(255, 255, 255, 0.04)", }, dateWeekday: { color: "#fff", fontWeight: 500, fontSize: "0.875rem", marginBottom: "0.25rem", }, dateDay: { color: "rgba(255, 255, 255, 0.5)", fontSize: "0.8rem", }, slotGrid: { display: "flex", flexWrap: "wrap", gap: "0.5rem", }, slotButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.6rem 1.25rem", background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "8px", color: "#fff", cursor: "pointer", fontSize: "0.9rem", transition: "all 0.2s", }, slotButtonSelected: { background: "rgba(167, 139, 250, 0.15)", border: "1px solid #a78bfa", }, emptyState: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.4)", padding: "1rem 0", }, confirmCard: { background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "12px", padding: "1.5rem", maxWidth: "400px", }, confirmTitle: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "1.1rem", fontWeight: 500, color: "#fff", marginBottom: "1rem", }, confirmDetails: { marginBottom: "1.5rem", }, confirmRow: { display: "flex", justifyContent: "space-between", padding: "0.5rem 0", borderBottom: "1px solid rgba(255, 255, 255, 0.05)", }, confirmLabel: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.5)", fontSize: "0.875rem", }, confirmValue: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "#fff", fontSize: "0.875rem", fontWeight: 500, }, buttonRow: { display: "flex", gap: "0.75rem", }, bookButton: { fontFamily: "'DM Sans', system-ui, sans-serif", flex: 1, padding: "0.875rem", border: "none", borderRadius: "8px", color: "#fff", fontWeight: 600, cursor: "pointer", transition: "all 0.2s", }, cancelButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.875rem 1.25rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "8px", color: "rgba(255, 255, 255, 0.7)", cursor: "pointer", transition: "all 0.2s", }, };