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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue