arbret/frontend/app/exchange/components/BookingStep.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

347 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)}
</>
);
}