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

View file

@ -0,0 +1,252 @@
"use client";
import { CSSProperties } from "react";
import { SatsDisplay } from "../../components/SatsDisplay";
import { components } from "../../generated/api";
import { formatTime } from "../../utils/date";
import { formatEur } from "../../utils/exchange";
import { buttonStyles } from "../../styles/shared";
type BookableSlot = components["schemas"]["BookableSlot"];
type Direction = "buy" | "sell";
type BitcoinTransferMethod = "onchain" | "lightning";
interface ConfirmationStepProps {
selectedSlot: BookableSlot;
selectedDate: Date | null;
direction: Direction;
bitcoinTransferMethod: BitcoinTransferMethod;
eurAmount: number;
satsAmount: number;
agreedPrice: number;
isBooking: boolean;
isPriceStale: boolean;
onConfirm: () => void;
onBack: () => void;
}
/**
* Format price for display
*/
function formatPrice(price: number): string {
return `${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`;
}
const styles: Record<string, CSSProperties> = {
confirmCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.5rem",
maxWidth: "400px",
},
confirmTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
confirmDetails: {
marginBottom: "1.5rem",
},
confirmRow: {
display: "flex",
justifyContent: "space-between",
padding: "0.5rem 0",
borderBottom: "1px solid rgba(255, 255, 255, 0.05)",
},
confirmLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.875rem",
},
confirmValue: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "#fff",
fontSize: "0.875rem",
fontWeight: 500,
},
satsValue: {
fontFamily: "'DM Mono', monospace",
color: "#f7931a", // Bitcoin orange
},
buttonRow: {
display: "flex",
gap: "0.75rem",
},
bookButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
flex: 1,
padding: "0.875rem",
border: "none",
borderRadius: "8px",
color: "#fff",
fontWeight: 600,
cursor: "pointer",
transition: "all 0.2s",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.875rem 1.25rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
transition: "all 0.2s",
},
compressedBookingCard: {
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",
},
compressedBookingHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
},
compressedBookingTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
},
compressedBookingDetails: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
flexWrap: "wrap",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
color: "#fff",
},
summaryDivider: {
color: "rgba(255, 255, 255, 0.3)",
},
editButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "#a78bfa",
background: "transparent",
border: "none",
cursor: "pointer",
padding: 0,
},
};
/**
* Step 3 of the exchange wizard: Confirmation
* Shows compressed booking summary and final confirmation form.
*/
export function ConfirmationStep({
selectedSlot,
selectedDate,
direction,
bitcoinTransferMethod,
eurAmount,
satsAmount,
agreedPrice,
isBooking,
isPriceStale,
onConfirm,
onBack,
}: ConfirmationStepProps) {
return (
<>
{/* Compressed Booking Summary */}
<div style={styles.compressedBookingCard}>
<div style={styles.compressedBookingHeader}>
<span style={styles.compressedBookingTitle}>Appointment</span>
<button onClick={onBack} style={styles.editButton}>
Edit
</button>
</div>
<div style={styles.compressedBookingDetails}>
<span>
{selectedDate?.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})}
</span>
<span style={styles.summaryDivider}></span>
<span>
{formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
</span>
</div>
</div>
{/* Confirmation Card */}
<div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Trade</h3>
<div style={styles.confirmDetails}>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Time:</span>
<span style={styles.confirmValue}>
{formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Direction:</span>
<span
style={{
...styles.confirmValue,
color: direction === "buy" ? "#4ade80" : "#f87171",
}}
>
{direction === "buy" ? "Buy BTC" : "Sell BTC"}
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>EUR:</span>
<span style={styles.confirmValue}>{formatEur(eurAmount)}</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>BTC:</span>
<span style={{ ...styles.confirmValue, ...styles.satsValue }}>
<SatsDisplay sats={satsAmount} />
</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Rate:</span>
<span style={styles.confirmValue}>{formatPrice(agreedPrice)}/BTC</span>
</div>
<div style={styles.confirmRow}>
<span style={styles.confirmLabel}>Payment:</span>
<span style={styles.confirmValue}>
{direction === "buy" ? "Receive via " : "Send via "}
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
</span>
</div>
</div>
<div style={styles.buttonRow}>
<button
onClick={onConfirm}
disabled={isBooking || isPriceStale}
style={{
...styles.bookButton,
background:
direction === "buy"
? "linear-gradient(135deg, #4ade80 0%, #22c55e 100%)"
: "linear-gradient(135deg, #f87171 0%, #ef4444 100%)",
...(isBooking || isPriceStale ? buttonStyles.buttonDisabled : {}),
}}
>
{isBooking
? "Booking..."
: isPriceStale
? "Price Stale"
: `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
</button>
<button onClick={onBack} disabled={isBooking} style={styles.cancelButton}>
Back
</button>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,377 @@
"use client";
import { ChangeEvent, CSSProperties } from "react";
import { SatsDisplay } from "../../components/SatsDisplay";
import { formatEur } from "../../utils/exchange";
import { buttonStyles } from "../../styles/shared";
import constants from "../../../../shared/constants.json";
const { lightningMaxEur: LIGHTNING_MAX_EUR } = constants.exchange;
type Direction = "buy" | "sell";
type BitcoinTransferMethod = "onchain" | "lightning";
interface ExchangeDetailsStepProps {
direction: Direction;
onDirectionChange: (direction: Direction) => void;
bitcoinTransferMethod: BitcoinTransferMethod;
onBitcoinTransferMethodChange: (method: BitcoinTransferMethod) => void;
eurAmount: number;
onEurAmountChange: (amount: number) => void;
satsAmount: number;
eurMin: number;
eurMax: number;
eurIncrement: number;
isPriceStale: boolean;
hasPrice: boolean;
onContinue: () => void;
}
const styles: Record<string, CSSProperties> = {
tradeCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.5rem",
marginBottom: "2rem",
},
directionRow: {
display: "flex",
gap: "0.5rem",
marginBottom: "1.5rem",
},
directionBtn: {
flex: 1,
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 600,
padding: "0.875rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.6)",
cursor: "pointer",
transition: "all 0.2s",
},
directionBtnBuyActive: {
background: "rgba(74, 222, 128, 0.15)",
border: "1px solid #4ade80",
color: "#4ade80",
},
directionBtnSellActive: {
background: "rgba(248, 113, 113, 0.15)",
border: "1px solid #f87171",
color: "#f87171",
},
paymentMethodSection: {
marginBottom: "1.5rem",
},
paymentMethodLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.9rem",
marginBottom: "0.75rem",
},
required: {
color: "#f87171",
},
paymentMethodRow: {
display: "flex",
gap: "0.5rem",
},
paymentMethodBtn: {
flex: 1,
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.95rem",
fontWeight: 600,
padding: "0.875rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.6)",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
},
paymentMethodBtnActive: {
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid #a78bfa",
color: "#a78bfa",
},
paymentMethodBtnDisabled: {
opacity: 0.4,
cursor: "not-allowed",
},
paymentMethodIcon: {
fontSize: "1.2rem",
},
thresholdMessage: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(251, 146, 60, 0.9)",
marginTop: "0.5rem",
padding: "0.5rem",
background: "rgba(251, 146, 60, 0.1)",
borderRadius: "6px",
border: "1px solid rgba(251, 146, 60, 0.2)",
},
amountSection: {
marginBottom: "1.5rem",
},
amountHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.75rem",
},
amountLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.9rem",
},
amountInputWrapper: {
display: "flex",
alignItems: "center",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
padding: "0.5rem 0.75rem",
},
amountCurrency: {
fontFamily: "'DM Mono', monospace",
color: "rgba(255, 255, 255, 0.5)",
fontSize: "1rem",
marginRight: "0.25rem",
},
amountInput: {
fontFamily: "'DM Mono', monospace",
fontSize: "1.25rem",
fontWeight: 600,
color: "#fff",
background: "transparent",
border: "none",
outline: "none",
width: "80px",
textAlign: "right" as const,
},
slider: {
width: "100%",
height: "8px",
appearance: "none" as const,
background: "rgba(255, 255, 255, 0.1)",
borderRadius: "4px",
outline: "none",
cursor: "pointer",
},
amountRange: {
display: "flex",
justifyContent: "space-between",
marginTop: "0.5rem",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
},
tradeSummary: {
background: "rgba(255, 255, 255, 0.02)",
borderRadius: "8px",
padding: "1rem",
textAlign: "center" as const,
marginBottom: "1.5rem",
},
summaryText: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.8)",
fontSize: "0.95rem",
margin: 0,
},
satsValue: {
fontFamily: "'DM Mono', monospace",
color: "#f7931a", // Bitcoin orange
},
continueButton: {
width: "100%",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 600,
padding: "0.875rem",
background: "linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%)",
border: "none",
borderRadius: "8px",
color: "#fff",
cursor: "pointer",
transition: "all 0.2s",
},
};
/**
* Step 1 of the exchange wizard: Exchange Details
* Allows user to select direction (buy/sell), payment method, and amount.
*/
export function ExchangeDetailsStep({
direction,
onDirectionChange,
bitcoinTransferMethod,
onBitcoinTransferMethodChange,
eurAmount,
onEurAmountChange,
satsAmount,
eurMin,
eurMax,
eurIncrement,
isPriceStale,
hasPrice,
onContinue,
}: ExchangeDetailsStepProps) {
const isLightningDisabled = eurAmount > LIGHTNING_MAX_EUR * 100;
const handleAmountChange = (value: number) => {
// Clamp to valid range and snap to increment
const minCents = eurMin * 100;
const maxCents = eurMax * 100;
const incrementCents = eurIncrement * 100;
// Clamp value
let clamped = Math.max(minCents, Math.min(maxCents, value));
// Snap to nearest increment
clamped = Math.round(clamped / incrementCents) * incrementCents;
onEurAmountChange(clamped);
};
const handleAmountInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value.replace(/[^0-9]/g, "");
if (inputValue === "") {
onEurAmountChange(eurMin * 100);
return;
}
const eurValue = parseInt(inputValue, 10);
handleAmountChange(eurValue * 100);
};
return (
<div style={styles.tradeCard}>
{/* Direction Selector */}
<div style={styles.directionRow}>
<button
onClick={() => onDirectionChange("buy")}
style={{
...styles.directionBtn,
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
}}
>
Buy BTC
</button>
<button
onClick={() => onDirectionChange("sell")}
style={{
...styles.directionBtn,
...(direction === "sell" ? styles.directionBtnSellActive : {}),
}}
>
Sell BTC
</button>
</div>
{/* Payment Method Selector */}
<div style={styles.paymentMethodSection}>
<div style={styles.paymentMethodLabel}>
Payment Method <span style={styles.required}>*</span>
</div>
<div style={styles.paymentMethodRow}>
<button
onClick={() => onBitcoinTransferMethodChange("onchain")}
style={{
...styles.paymentMethodBtn,
...(bitcoinTransferMethod === "onchain" ? styles.paymentMethodBtnActive : {}),
}}
>
<span style={styles.paymentMethodIcon}>🔗</span>
<span>Onchain</span>
</button>
<button
onClick={() => onBitcoinTransferMethodChange("lightning")}
disabled={isLightningDisabled}
style={{
...styles.paymentMethodBtn,
...(bitcoinTransferMethod === "lightning" ? styles.paymentMethodBtnActive : {}),
...(isLightningDisabled ? styles.paymentMethodBtnDisabled : {}),
}}
>
<span style={styles.paymentMethodIcon}></span>
<span>Lightning</span>
</button>
</div>
{isLightningDisabled && (
<div style={styles.thresholdMessage}>
Lightning payments are only available for amounts up to {LIGHTNING_MAX_EUR}
</div>
)}
</div>
{/* Amount Section */}
<div style={styles.amountSection}>
<div style={styles.amountHeader}>
<span style={styles.amountLabel}>Amount (EUR)</span>
<div style={styles.amountInputWrapper}>
<span style={styles.amountCurrency}></span>
<input
type="text"
value={Math.round(eurAmount / 100)}
onChange={handleAmountInputChange}
style={styles.amountInput}
/>
</div>
</div>
<input
type="range"
min={eurMin * 100}
max={eurMax * 100}
step={eurIncrement * 100}
value={eurAmount}
onChange={(e) => onEurAmountChange(Number(e.target.value))}
style={styles.slider}
/>
<div style={styles.amountRange}>
<span>{formatEur(eurMin * 100)}</span>
<span>{formatEur(eurMax * 100)}</span>
</div>
</div>
{/* Trade Summary */}
<div style={styles.tradeSummary}>
{direction === "buy" ? (
<p style={styles.summaryText}>
You buy{" "}
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
, you sell <strong>{formatEur(eurAmount)}</strong>
</p>
) : (
<p style={styles.summaryText}>
You buy <strong>{formatEur(eurAmount)}</strong>, you sell{" "}
<strong style={styles.satsValue}>
<SatsDisplay sats={satsAmount} />
</strong>
</p>
)}
</div>
{/* Continue Button */}
<button
onClick={onContinue}
disabled={isPriceStale || !hasPrice}
style={{
...styles.continueButton,
...(isPriceStale || !hasPrice ? buttonStyles.buttonDisabled : {}),
}}
>
Continue to Booking
</button>
</div>
);
}

View file

@ -0,0 +1,130 @@
"use client";
import { CSSProperties } from "react";
import { components } from "../../generated/api";
type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"];
interface PriceDisplayProps {
priceData: ExchangePriceResponse | null;
isLoading: boolean;
error: string | null;
lastUpdate: Date | null;
direction: "buy" | "sell";
agreedPrice: number;
}
/**
* Format price for display
*/
function formatPrice(price: number): string {
return `${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`;
}
const styles: Record<string, CSSProperties> = {
priceCard: {
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",
},
priceRow: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
flexWrap: "wrap",
},
priceLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.9rem",
},
priceValue: {
fontFamily: "'DM Mono', monospace",
color: "#fff",
fontSize: "1.1rem",
fontWeight: 500,
},
priceDivider: {
color: "rgba(255, 255, 255, 0.2)",
margin: "0 0.25rem",
},
premiumBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
fontWeight: 600,
padding: "0.2rem 0.5rem",
borderRadius: "4px",
marginLeft: "0.25rem",
background: "rgba(255, 255, 255, 0.1)",
color: "rgba(255, 255, 255, 0.7)",
},
priceTimestamp: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
marginTop: "0.5rem",
},
staleWarning: {
color: "#f87171",
fontWeight: 600,
},
priceLoading: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
textAlign: "center" as const,
},
priceError: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "#f87171",
textAlign: "center" as const,
},
};
/**
* Component that displays exchange price information.
* Shows market price, agreed price, premium percentage, and last update time.
*/
export function PriceDisplay({
priceData,
isLoading,
error,
lastUpdate,
direction,
agreedPrice,
}: PriceDisplayProps) {
const marketPrice = priceData?.price?.market_price ?? 0;
const premiumPercent = priceData?.price?.premium_percentage ?? 5;
const isPriceStale = priceData?.price?.is_stale ?? false;
return (
<div style={styles.priceCard}>
{isLoading && !priceData ? (
<div style={styles.priceLoading}>Loading price...</div>
) : error && !priceData?.price ? (
<div style={styles.priceError}>{error}</div>
) : (
<>
<div style={styles.priceRow}>
<span style={styles.priceLabel}>Market:</span>
<span style={styles.priceValue}>{formatPrice(marketPrice)}</span>
<span style={styles.priceDivider}></span>
<span style={styles.priceLabel}>Our price:</span>
<span style={styles.priceValue}>{formatPrice(agreedPrice)}</span>
<span style={styles.premiumBadge}>
{direction === "buy" ? "+" : "-"}
{premiumPercent}%
</span>
</div>
{lastUpdate && (
<div style={styles.priceTimestamp}>
Updated {lastUpdate.toLocaleTimeString()}
{isPriceStale && <span style={styles.staleWarning}> (stale)</span>}
</div>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { CSSProperties } from "react";
type WizardStep = "details" | "booking" | "confirmation";
interface StepIndicatorProps {
currentStep: WizardStep;
}
const styles: Record<string, CSSProperties> = {
stepIndicator: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
marginBottom: "2rem",
},
step: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
opacity: 0.4,
},
stepActive: {
opacity: 1,
},
stepCompleted: {
opacity: 0.7,
},
stepNumber: {
fontFamily: "'DM Mono', monospace",
width: "28px",
height: "28px",
borderRadius: "50%",
background: "rgba(255, 255, 255, 0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.875rem",
fontWeight: 600,
color: "#fff",
},
stepLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "#fff",
},
stepDivider: {
width: "40px",
height: "1px",
background: "rgba(255, 255, 255, 0.2)",
},
};
/**
* Component that displays the wizard step indicator.
* Shows which step the user is currently on and which steps are completed.
*/
export function StepIndicator({ currentStep }: StepIndicatorProps) {
return (
<div style={styles.stepIndicator}>
<div
style={{
...styles.step,
...(currentStep === "details" ? styles.stepActive : styles.stepCompleted),
}}
>
<span style={styles.stepNumber}>1</span>
<span style={styles.stepLabel}>Exchange Details</span>
</div>
<div style={styles.stepDivider} />
<div
style={{
...styles.step,
...(currentStep === "booking"
? styles.stepActive
: currentStep === "confirmation"
? styles.stepCompleted
: {}),
}}
>
<span style={styles.stepNumber}>2</span>
<span style={styles.stepLabel}>Book Appointment</span>
</div>
<div style={styles.stepDivider} />
<div
style={{
...styles.step,
...(currentStep === "confirmation" ? styles.stepActive : {}),
}}
>
<span style={styles.stepNumber}>3</span>
<span style={styles.stepLabel}>Confirm</span>
</div>
</div>
);
}

View file

@ -0,0 +1,97 @@
import { useState, useEffect, useCallback } from "react";
import { api } from "../../api";
import { components } from "../../generated/api";
import { formatDate } from "../../utils/date";
type BookableSlot = components["schemas"]["BookableSlot"];
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
interface UseAvailableSlotsOptions {
/** Whether the user is authenticated and authorized */
enabled?: boolean;
/** Dates to check availability for */
dates: Date[];
/** Current wizard step - only fetch when in booking or confirmation step */
wizardStep?: "details" | "booking" | "confirmation";
}
interface UseAvailableSlotsResult {
/** Available slots for the selected date */
availableSlots: BookableSlot[];
/** Set of date strings that have availability */
datesWithAvailability: Set<string>;
/** Whether slots are currently being loaded for a specific date */
isLoadingSlots: boolean;
/** Whether availability is being checked for all dates */
isLoadingAvailability: boolean;
/** Fetch slots for a specific date */
fetchSlots: (date: Date) => Promise<void>;
}
/**
* Hook for managing available slots and date availability.
* Fetches availability for all dates when entering booking/confirmation steps.
*/
export function useAvailableSlots(options: UseAvailableSlotsOptions): UseAvailableSlotsResult {
const { enabled = true, dates, wizardStep } = options;
const [availableSlots, setAvailableSlots] = useState<BookableSlot[]>([]);
const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set());
const [isLoadingSlots, setIsLoadingSlots] = useState(false);
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
const fetchSlots = useCallback(
async (date: Date) => {
if (!enabled) return;
setIsLoadingSlots(true);
setAvailableSlots([]);
try {
const dateStr = formatDate(date);
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
setAvailableSlots(data.slots);
} catch (err) {
console.error("Failed to fetch slots:", err);
} finally {
setIsLoadingSlots(false);
}
},
[enabled]
);
// Fetch availability for all dates when entering booking or confirmation step
useEffect(() => {
if (!enabled || (wizardStep !== "booking" && wizardStep !== "confirmation")) return;
const fetchAllAvailability = async () => {
setIsLoadingAvailability(true);
const availabilitySet = new Set<string>();
const promises = dates.map(async (date) => {
try {
const dateStr = formatDate(date);
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
if (data.slots.length > 0) {
availabilitySet.add(dateStr);
}
} catch (err) {
console.error(`Failed to fetch availability for ${formatDate(date)}:`, err);
}
});
await Promise.all(promises);
setDatesWithAvailability(availabilitySet);
setIsLoadingAvailability(false);
};
fetchAllAvailability();
}, [enabled, dates, wizardStep]);
return {
availableSlots,
datesWithAvailability,
isLoadingSlots,
isLoadingAvailability,
fetchSlots,
};
}

View file

@ -0,0 +1,73 @@
import { useState, useEffect, useCallback } from "react";
import { api } from "../../api";
import { components } from "../../generated/api";
type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"];
interface UseExchangePriceOptions {
/** Whether the user is authenticated and authorized */
enabled?: boolean;
/** Auto-refresh interval in milliseconds (default: 60000) */
refreshInterval?: number;
}
interface UseExchangePriceResult {
priceData: ExchangePriceResponse | null;
isLoading: boolean;
error: string | null;
lastUpdate: Date | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching and managing exchange price data.
* Automatically refreshes price data at specified intervals.
*/
export function useExchangePrice(options: UseExchangePriceOptions = {}): UseExchangePriceResult {
const { enabled = true, refreshInterval = 60000 } = options;
const [priceData, setPriceData] = useState<ExchangePriceResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const fetchPrice = useCallback(async () => {
if (!enabled) return;
setIsLoading(true);
setError(null);
try {
const data = await api.get<ExchangePriceResponse>("/api/exchange/price");
setPriceData(data);
setLastUpdate(new Date());
if (data.error) {
setError(data.error);
}
if (data.price?.is_stale) {
setError("Price is stale. Trade booking may be blocked.");
}
} catch (err) {
console.error("Failed to fetch price:", err);
setError("Failed to load price data");
} finally {
setIsLoading(false);
}
}, [enabled]);
useEffect(() => {
if (!enabled) return;
fetchPrice();
const interval = setInterval(fetchPrice, refreshInterval);
return () => clearInterval(interval);
}, [enabled, fetchPrice, refreshInterval]);
return {
priceData,
isLoading,
error,
lastUpdate,
refetch: fetchPrice,
};
}

File diff suppressed because it is too large Load diff