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:
parent
3beb23a765
commit
6d0f125536
8 changed files with 1490 additions and 1055 deletions
347
frontend/app/exchange/components/BookingStep.tsx
Normal file
347
frontend/app/exchange/components/BookingStep.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue