arbret/frontend/app/exchange/page.tsx
counterweight 6d0f125536
refactor(frontend): break down large Exchange page component
Break down the 1300+ line Exchange page into smaller, focused components:

- Create useExchangePrice hook
  - Handles price fetching and auto-refresh logic
  - Manages price loading and error states
  - Centralizes price-related state management

- Create useAvailableSlots hook
  - Manages slot fetching and availability checking
  - Handles date availability state
  - Fetches availability when entering booking/confirmation steps

- Create PriceDisplay component
  - Displays market price, agreed price, and premium
  - Shows price update timestamp and stale warnings
  - Handles loading and error states

- Create ExchangeDetailsStep component
  - Step 1 of wizard: direction, payment method, amount selection
  - Contains all form logic for trade details
  - Validates and displays trade summary

- Create BookingStep component
  - Step 2 of wizard: date and slot selection
  - Shows trade summary card
  - Handles date availability and existing trade warnings

- Create ConfirmationStep component
  - Step 3 of wizard: final confirmation
  - Shows compressed booking summary
  - Displays all trade details for review

- Create StepIndicator component
  - Visual indicator of current wizard step
  - Shows completed and active steps

- Refactor ExchangePage
  - Reduced from 1300+ lines to ~350 lines
  - Uses new hooks and components
  - Maintains all existing functionality
  - Improved maintainability and testability

All frontend tests pass. Linting passes.
2025-12-25 19:11:23 +01:00

373 lines
11 KiB
TypeScript

"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<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
);
// 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);
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 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<ExchangeResponse[]>("/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<ExchangeResponse>("/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 <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}>Exchange Bitcoin</h1>
<p style={typographyStyles.pageSubtitle}>Buy or sell Bitcoin with a 5% premium</p>
{error && (
<div style={bannerStyles.errorBanner}>
{error}
{existingTradeId && (
<div style={styles.errorLink}>
<a href={`/trades/${existingTradeId}`} style={styles.errorLinkAnchor}>
View your existing trade
</a>
</div>
)}
</div>
)}
{/* Price Display */}
<PriceDisplay
priceData={priceData}
isLoading={isPriceLoading}
error={priceError}
lastUpdate={lastPriceUpdate}
direction={direction}
agreedPrice={agreedPrice}
/>
{/* 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}
eurMax={eurMax}
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}
/>
)}
{/* 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>
);
}