"use client"; import { useEffect, useState, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Permission } from "../auth-context"; import { api } from "../api"; import { extractApiErrorMessage } from "../utils/error-handling"; import { Header } from "../components/Header"; import { LoadingState } from "../components/LoadingState"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { components } from "../generated/api"; import { formatDate, getDateRange } from "../utils/date"; import { layoutStyles, typographyStyles, bannerStyles } from "../styles/shared"; import constants from "../../../shared/constants.json"; import { useExchangePrice } from "./hooks/useExchangePrice"; import { useAvailableSlots } from "./hooks/useAvailableSlots"; import { PriceDisplay } from "./components/PriceDisplay"; import { StepIndicator } from "./components/StepIndicator"; import { ExchangeDetailsStep } from "./components/ExchangeDetailsStep"; import { BookingStep } from "./components/BookingStep"; import { ConfirmationStep } from "./components/ConfirmationStep"; type ExchangeResponse = components["schemas"]["ExchangeResponse"]; // 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" | "confirmation"; const styles = { content: { flex: 1, padding: "2rem", maxWidth: "900px", margin: "0 auto", width: "100%", }, 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", }, } as const; export default function ExchangePage() { const router = useRouter(); const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.CREATE_EXCHANGE, fallbackRedirect: "/", }); // Wizard state const [wizardStep, setWizardStep] = useState("details"); // 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 [selectedSlot, setSelectedSlot] = useState( null ); // User trades state (for same-day booking check) const [userTrades, setUserTrades] = useState([]); const [existingTradeOnSelectedDate, setExistingTradeOnSelectedDate] = useState(null); // 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), []); // Use custom hooks for price and slots const { priceData, isLoading: isPriceLoading, error: priceError, lastUpdate: lastPriceUpdate, } = useExchangePrice({ enabled: !!user && isAuthorized, }); const { availableSlots, datesWithAvailability, isLoadingSlots, isLoadingAvailability, fetchSlots, } = useAvailableSlots({ enabled: !!user && isAuthorized, dates, wizardStep, }); // 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 slots when date is selected useEffect(() => { if (selectedDate && user && isAuthorized) { fetchSlots(selectedDate); } }, [selectedDate, user, isAuthorized, fetchSlots]); // Fetch user trades when entering booking step useEffect(() => { if (!user || !isAuthorized || wizardStep !== "booking") return; const fetchUserTrades = async () => { try { const data = await api.get("/api/trades"); setUserTrades(data); } catch (err) { console.error("Failed to fetch user trades:", err); // Don't block the UI if this fails } }; fetchUserTrades(); }, [user, isAuthorized, wizardStep]); // Check if a date has an existing trade (only consider booked trades, not cancelled ones) const getExistingTradeOnDate = useCallback( (date: Date): ExchangeResponse | null => { const dateStr = formatDate(date); return ( userTrades.find((trade) => { const tradeDate = formatDate(new Date(trade.slot_start)); return tradeDate === dateStr && trade.status === "booked"; }) || null ); }, [userTrades] ); const handleDateSelect = (date: Date) => { const dateStr = formatDate(date); if (!datesWithAvailability.has(dateStr)) { return; } // Check if user already has a trade on this date const existingTrade = getExistingTradeOnDate(date); if (existingTrade) { setExistingTradeOnSelectedDate(existingTrade); setSelectedDate(null); setSelectedSlot(null); setError(null); } else { setExistingTradeOnSelectedDate(null); setSelectedDate(date); } }; const handleSlotSelect = (slot: components["schemas"]["BookableSlot"]) => { setSelectedSlot(slot); setError(null); setWizardStep("confirmation"); }; const handleContinueToBooking = () => { setWizardStep("booking"); setError(null); }; const handleBackToDetails = () => { setWizardStep("details"); setSelectedDate(null); setSelectedSlot(null); setError(null); setExistingTradeOnSelectedDate(null); }; const handleBackToBooking = () => { setWizardStep("booking"); setError(null); }; 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) { const errorMessage = extractApiErrorMessage(err, "Failed to book trade"); setError(errorMessage); // Check if it's a "same day" error and extract trade public_id (UUID) const tradeIdMatch = errorMessage.match(/Trade ID: ([a-f0-9-]{36})/i); if (tradeIdMatch) { setExistingTradeId(tradeIdMatch[1]); } else { setExistingTradeId(null); } setIsBooking(false); } }; if (isLoading) { return ; } 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 */} {/* Step Indicator */} {/* Step 1: Exchange Details */} {wizardStep === "details" && ( )} {/* Step 2: Booking */} {wizardStep === "booking" && ( )} {/* Step 3: Confirmation */} {wizardStep === "confirmation" && selectedSlot && ( )}
); }