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.
373 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|