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.
This commit is contained in:
counterweight 2025-12-25 19:11:23 +01:00
parent 3beb23a765
commit 6d0f125536
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
8 changed files with 1490 additions and 1055 deletions

View file

@ -0,0 +1,347 @@
"use client";
import { CSSProperties } from "react";
import { SatsDisplay } from "../../components/SatsDisplay";
import { components } from "../../generated/api";
import { formatDate, formatTime } from "../../utils/date";
import { formatEur } from "../../utils/exchange";
import { bannerStyles } from "../../styles/shared";
type BookableSlot = components["schemas"]["BookableSlot"];
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
type Direction = "buy" | "sell";
type BitcoinTransferMethod = "onchain" | "lightning";
interface BookingStepProps {
direction: Direction;
bitcoinTransferMethod: BitcoinTransferMethod;
eurAmount: number;
satsAmount: number;
dates: Date[];
selectedDate: Date | null;
availableSlots: BookableSlot[];
selectedSlot: BookableSlot | null;
datesWithAvailability: Set<string>;
isLoadingSlots: boolean;
isLoadingAvailability: boolean;
existingTradeOnSelectedDate: ExchangeResponse | null;
userTrades: ExchangeResponse[];
onDateSelect: (date: Date) => void;
onSlotSelect: (slot: BookableSlot) => void;
onBackToDetails: () => void;
}
const styles: Record<string, CSSProperties> = {
summaryCard: {
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",
},
summaryHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
},
summaryTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
},
editButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "#a78bfa",
background: "transparent",
border: "none",
cursor: "pointer",
padding: 0,
},
summaryDetails: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
flexWrap: "wrap",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
color: "#fff",
},
summaryDirection: {
fontWeight: 600,
},
summaryDivider: {
color: "rgba(255, 255, 255, 0.3)",
},
summaryPaymentMethod: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.6)",
},
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)",
},
dateButtonHasTrade: {
border: "1px solid rgba(251, 146, 60, 0.5)",
background: "rgba(251, 146, 60, 0.1)",
},
dateWeekday: {
color: "#fff",
fontWeight: 500,
fontSize: "0.875rem",
marginBottom: "0.25rem",
},
dateDay: {
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem",
},
dateWarning: {
fontSize: "0.7rem",
marginTop: "0.25rem",
opacity: 0.8,
},
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",
},
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",
},
};
/**
* Check if a date has an existing trade (only consider booked trades, not cancelled ones)
*/
function getExistingTradeOnDate(
date: Date,
userTrades: ExchangeResponse[]
): 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
);
}
/**
* Step 2 of the exchange wizard: Booking
* Allows user to select a date and time slot for the exchange.
*/
export function BookingStep({
direction,
bitcoinTransferMethod,
eurAmount,
satsAmount,
dates,
selectedDate,
availableSlots,
selectedSlot,
datesWithAvailability,
isLoadingSlots,
isLoadingAvailability,
existingTradeOnSelectedDate,
userTrades,
onDateSelect,
onSlotSelect,
onBackToDetails,
}: BookingStepProps) {
return (
<>
{/* Trade Summary Card */}
<div style={styles.summaryCard}>
<div style={styles.summaryHeader}>
<span style={styles.summaryTitle}>Your Exchange</span>
<button onClick={onBackToDetails} style={styles.editButton}>
Edit
</button>
</div>
<div style={styles.summaryDetails}>
<span
style={{
...styles.summaryDirection,
color: direction === "buy" ? "#4ade80" : "#f87171",
}}
>
{direction === "buy" ? "Buy" : "Sell"} BTC
</span>
<span style={styles.summaryDivider}></span>
<span>{formatEur(eurAmount)}</span>
<span style={styles.summaryDivider}></span>
<span style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</span>
<span style={styles.summaryDivider}></span>
<span style={styles.summaryPaymentMethod}>
{direction === "buy" ? "Receive via " : "Send via "}
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
</span>
</div>
</div>
{/* Date Selection */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Select a Date</h2>
<div style={styles.dateGrid}>
{dates.map((date) => {
const dateStr = formatDate(date);
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
const hasAvailability = datesWithAvailability.has(dateStr);
const isDisabled = !hasAvailability || isLoadingAvailability;
const hasExistingTrade = getExistingTradeOnDate(date, userTrades) !== null;
return (
<button
key={dateStr}
data-testid={`date-${dateStr}`}
onClick={() => onDateSelect(date)}
disabled={isDisabled}
style={{
...styles.dateButton,
...(isSelected ? styles.dateButtonSelected : {}),
...(isDisabled ? styles.dateButtonDisabled : {}),
...(hasExistingTrade && !isDisabled ? styles.dateButtonHasTrade : {}),
}}
>
<div style={styles.dateWeekday}>
{date.toLocaleDateString("en-US", { weekday: "short" })}
</div>
<div style={styles.dateDay}>
{date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</div>
{hasExistingTrade && !isDisabled && <div style={styles.dateWarning}></div>}
</button>
);
})}
</div>
</div>
{/* Warning for existing trade on selected date */}
{existingTradeOnSelectedDate && (
<div style={bannerStyles.errorBanner}>
<div>
You already have a trade booked on this day. You can only book one trade per day.
</div>
<div style={styles.errorLink}>
<a
href={`/trades/${existingTradeOnSelectedDate.public_id}`}
style={styles.errorLinkAnchor}
>
View your existing trade
</a>
</div>
</div>
)}
{/* Available Slots */}
{selectedDate && !existingTradeOnSelectedDate && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available Slots for{" "}
{selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
</h2>
{isLoadingSlots ? (
<div style={styles.emptyState}>Loading slots...</div>
) : availableSlots.length === 0 ? (
<div style={styles.emptyState}>No available slots for this date</div>
) : (
<div style={styles.slotGrid}>
{availableSlots.map((slot) => {
const isSelected = selectedSlot?.start_time === slot.start_time;
return (
<button
key={slot.start_time}
onClick={() => onSlotSelect(slot)}
style={{
...styles.slotButton,
...(isSelected ? styles.slotButtonSelected : {}),
}}
>
{formatTime(slot.start_time)}
</button>
);
})}
</div>
)}
</div>
)}
</>
);
}