arbret/frontend/app/exchange/page.tsx

404 lines
13 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Permission } from "../auth-context";
import { exchangeApi, tradesApi } 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";
import { useTranslation } from "../hooks/useTranslation";
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";
2025-12-23 17:03:51 +01:00
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 t = useTranslation("exchange");
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.CREATE_EXCHANGE,
fallbackRedirect: "/",
});
// Wizard state
const [wizardStep, setWizardStep] = useState<WizardStep>("details");
// Trade form state
const [direction, setDirection] = useState<Direction>("buy");
const [bitcoinTransferMethod, setBitcoinTransferMethod] =
useState<BitcoinTransferMethod>("onchain");
const [eurAmount, setEurAmount] = useState<number>(10000); // €100 in cents
// Date/slot selection state
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [selectedSlot, setSelectedSlot] = useState<components["schemas"]["BookableSlot"] | null>(
null
);
2025-12-23 17:03:51 +01:00
// User trades state (for same-day booking check)
const [userTrades, setUserTrades] = useState<ExchangeResponse[]>([]);
const [existingTradeOnSelectedDate, setExistingTradeOnSelectedDate] =
useState<ExchangeResponse | null>(null);
// UI state
const [error, setError] = useState<string | null>(null);
2025-12-23 17:03:51 +01:00
const [existingTradeId, setExistingTradeId] = useState<string | null>(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 eurMinBuy = config?.eur_min_buy ?? 10000;
const eurMaxBuy = config?.eur_max_buy ?? 300000;
const eurMinSell = config?.eur_min_sell ?? 10000;
const eurMaxSell = config?.eur_max_sell ?? 300000;
const eurIncrement = config?.eur_increment ?? 20;
// Get direction-specific min/max
const eurMin = direction === "buy" ? eurMinBuy : eurMinSell;
const eurMax = direction === "buy" ? eurMaxBuy : eurMaxSell;
// Compute trade details
const price = priceData?.price;
const marketPrice = price?.market_price ?? 0;
const premiumBuy = config?.premium_buy ?? 5;
const premiumSell = config?.premium_sell ?? 5;
const smallTradeThreshold = config?.small_trade_threshold_eur ?? 0;
const smallTradeExtraPremium = config?.small_trade_extra_premium ?? 0;
// Calculate total premium: base premium for direction + extra if small trade
const totalPremiumPercent = useMemo(() => {
const basePremium = direction === "buy" ? premiumBuy : premiumSell;
if (eurAmount <= smallTradeThreshold) {
return basePremium + smallTradeExtraPremium;
}
return basePremium;
}, [direction, premiumBuy, premiumSell, eurAmount, smallTradeThreshold, smallTradeExtraPremium]);
// Calculate agreed price based on direction and total premium
const agreedPrice = useMemo(() => {
if (!marketPrice) return 0;
if (direction === "buy") {
return marketPrice * (1 + totalPremiumPercent / 100);
} else {
return marketPrice * (1 - totalPremiumPercent / 100);
}
}, [marketPrice, totalPremiumPercent, 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]);
// Clamp amount when direction changes (min/max may differ per direction)
useEffect(() => {
if (eurAmount < eurMin) {
setEurAmount(eurMin);
} else if (eurAmount > eurMax) {
setEurAmount(eurMax);
}
}, [direction, eurMin, eurMax]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch slots when date is selected
useEffect(() => {
if (selectedDate && user && isAuthorized) {
fetchSlots(selectedDate);
}
}, [selectedDate, user, isAuthorized, fetchSlots]);
2025-12-23 17:03:51 +01:00
// Fetch user trades when entering booking step
useEffect(() => {
if (!user || !isAuthorized || wizardStep !== "booking") return;
2025-12-23 17:03:51 +01:00
const fetchUserTrades = async () => {
try {
const data = await tradesApi.getTrades();
2025-12-23 17:03:51 +01:00
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);
2025-12-23 17:03:51 +01:00
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);
2025-12-23 17:03:51 +01:00
setWizardStep("confirmation");
};
const handleContinueToBooking = () => {
setWizardStep("booking");
setError(null);
};
const handleBackToDetails = () => {
setWizardStep("details");
setSelectedDate(null);
setSelectedSlot(null);
setError(null);
2025-12-23 17:03:51 +01:00
setExistingTradeOnSelectedDate(null);
};
const handleBackToBooking = () => {
setWizardStep("booking");
setError(null);
};
const handleBook = async () => {
if (!selectedSlot) return;
setIsBooking(true);
setError(null);
setExistingTradeId(null);
try {
await exchangeApi.createExchange({
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);
2025-12-23 17:03:51 +01:00
// 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) {
2025-12-23 17:03:51 +01:00
setExistingTradeId(tradeIdMatch[1]);
} else {
setExistingTradeId(null);
}
setIsBooking(false);
}
};
if (isLoading) {
return <LoadingState />;
}
if (!isAuthorized) {
return null;
}
const isPriceStale = priceData?.price?.is_stale ?? false;
return (
<main style={layoutStyles.main}>
<Header currentPage="exchange" />
<div style={styles.content}>
<h1 style={typographyStyles.pageTitle}>{t("page.title")}</h1>
<p style={typographyStyles.pageSubtitle}>{t("page.subtitle")}</p>
{error && (
<div style={bannerStyles.errorBanner}>
{error}
{existingTradeId && (
<div style={styles.errorLink}>
<a href={`/trades/${existingTradeId}`} style={styles.errorLinkAnchor}>
{t("page.viewExistingTrade")}
</a>
</div>
)}
</div>
)}
{/* Price Display */}
<PriceDisplay
priceData={priceData}
isLoading={isPriceLoading}
error={priceError}
lastUpdate={lastPriceUpdate}
direction={direction}
agreedPrice={agreedPrice}
premiumPercent={totalPremiumPercent}
/>
{/* Step Indicator */}
<StepIndicator currentStep={wizardStep} />
{/* Step 1: Exchange Details */}
{wizardStep === "details" && (
<ExchangeDetailsStep
direction={direction}
onDirectionChange={setDirection}
bitcoinTransferMethod={bitcoinTransferMethod}
onBitcoinTransferMethodChange={setBitcoinTransferMethod}
eurAmount={eurAmount}
onEurAmountChange={setEurAmount}
satsAmount={satsAmount}
eurMin={eurMin / 100}
eurMax={eurMax / 100}
eurIncrement={eurIncrement}
isPriceStale={isPriceStale}
hasPrice={!!priceData?.price}
onContinue={handleContinueToBooking}
/>
)}
{/* Step 2: Booking */}
{wizardStep === "booking" && (
<BookingStep
direction={direction}
bitcoinTransferMethod={bitcoinTransferMethod}
eurAmount={eurAmount}
satsAmount={satsAmount}
dates={dates}
selectedDate={selectedDate}
availableSlots={availableSlots}
selectedSlot={selectedSlot}
datesWithAvailability={datesWithAvailability}
isLoadingSlots={isLoadingSlots}
isLoadingAvailability={isLoadingAvailability}
existingTradeOnSelectedDate={existingTradeOnSelectedDate}
userTrades={userTrades}
onDateSelect={handleDateSelect}
onSlotSelect={handleSlotSelect}
onBackToDetails={handleBackToDetails}
/>
2025-12-23 17:03:51 +01:00
)}
2025-12-23 17:03:51 +01:00
{/* Step 3: Confirmation */}
{wizardStep === "confirmation" && selectedSlot && (
<ConfirmationStep
selectedSlot={selectedSlot}
selectedDate={selectedDate}
direction={direction}
bitcoinTransferMethod={bitcoinTransferMethod}
eurAmount={eurAmount}
satsAmount={satsAmount}
agreedPrice={agreedPrice}
isBooking={isBooking}
isPriceStale={isPriceStale}
onConfirm={handleBook}
onBack={handleBackToBooking}
/>
)}
</div>
</main>
);
}