"use client"; import React from "react"; import { useEffect, useState, useCallback, useMemo } from "react"; import { Permission } from "../auth-context"; import { api } from "../api"; import { Header } from "../components/Header"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { components } from "../generated/api"; import { formatDate, formatTime, getDateRange } from "../utils/date"; import { layoutStyles, typographyStyles, bannerStyles, buttonStyles } from "../styles/shared"; type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"]; type ExchangeResponse = components["schemas"]["ExchangeResponse"]; type BookableSlot = components["schemas"]["BookableSlot"]; type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; // Constants from shared config (will be fetched from API) const MIN_ADVANCE_DAYS = 1; const MAX_ADVANCE_DAYS = 30; type Direction = "buy" | "sell"; /** * Format EUR amount from cents to display string */ function formatEur(cents: number): string { return `€${(cents / 100).toLocaleString("de-DE")}`; } /** * Format satoshi amount with thousand separators * e.g., 476190 -> "0.00 476 190 sats" */ function formatSats(sats: number): string { 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`; } /** * Format price for display */ function formatPrice(price: number): string { return `€${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`; } export default function ExchangePage() { const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.BOOK_APPOINTMENT, fallbackRedirect: "/", }); // 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 [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 [successMessage, setSuccessMessage] = 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]); // 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 on mount useEffect(() => { if (!user || !isAuthorized) 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]); 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); setSuccessMessage(null); } }; const handleSlotSelect = (slot: BookableSlot) => { setSelectedSlot(slot); setError(null); }; const handleBook = async () => { if (!selectedSlot) return; setIsBooking(true); setError(null); try { const 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); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to book trade"); } finally { 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

{successMessage &&
{successMessage}
} {error &&
{error}
} {/* 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)}
)} )}
{/* Trade Form */}
{/* Direction Selector */}
{/* 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)}

)}
{/* 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: {formatSats(satsAmount)}
Rate: {formatPrice(agreedPrice)}/BTC
)}
); } // 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", }, 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, }, 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", }, amountValue: { fontFamily: "'DM Mono', monospace", color: "#fff", fontSize: "1.25rem", fontWeight: 600, }, 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, }, 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 }, 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", }, };