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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
252
frontend/app/exchange/components/ConfirmationStep.tsx
Normal file
252
frontend/app/exchange/components/ConfirmationStep.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
377
frontend/app/exchange/components/ExchangeDetailsStep.tsx
Normal file
377
frontend/app/exchange/components/ExchangeDetailsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
frontend/app/exchange/components/PriceDisplay.tsx
Normal file
130
frontend/app/exchange/components/PriceDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/app/exchange/components/StepIndicator.tsx
Normal file
98
frontend/app/exchange/components/StepIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/app/exchange/hooks/useAvailableSlots.ts
Normal file
97
frontend/app/exchange/hooks/useAvailableSlots.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
73
frontend/app/exchange/hooks/useExchangePrice.ts
Normal file
73
frontend/app/exchange/hooks/useExchangePrice.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue