arbret/frontend/app/exchange/page.tsx
counterweight 3beb23a765
refactor(frontend): improve code quality and maintainability
- Extract API error handling utility (utils/error-handling.ts)
  - Centralize error message extraction logic
  - Add type guards for API errors
  - Replace duplicated error handling across components

- Create reusable Toast component (components/Toast.tsx)
  - Extract toast notification logic from profile page
  - Support auto-dismiss functionality
  - Consistent styling with shared styles

- Extract form validation debouncing hook (hooks/useDebouncedValidation.ts)
  - Reusable debounced validation logic
  - Clean timeout management
  - Used in profile page for form validation

- Consolidate duplicate styles (styles/auth-form.ts)
  - Use shared style tokens instead of duplicating values
  - Reduce code duplication between auth-form and shared styles

- Extract loading state component (components/LoadingState.tsx)
  - Standardize loading UI across pages
  - Replace duplicated loading JSX patterns
  - Used in profile, exchange, and trades pages

- Fix useRequireAuth dependency array
  - Remove unnecessary hasPermission from dependencies
  - Add eslint-disable comment with explanation
  - Improve hook stability and performance

All frontend tests pass. Linting passes.
2025-12-25 19:04:45 +01:00

1312 lines
40 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

"use client";
import { useEffect, useState, useCallback, useMemo, ChangeEvent, CSSProperties } from "react";
import { useRouter } from "next/navigation";
import { Permission } from "../auth-context";
import { api } from "../api";
import { extractApiErrorMessage } from "../utils/error-handling";
import { Header } from "../components/Header";
import { SatsDisplay } from "../components/SatsDisplay";
import { LoadingState } from "../components/LoadingState";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
import { formatDate, formatTime, getDateRange } from "../utils/date";
import { formatEur } from "../utils/exchange";
import { layoutStyles, typographyStyles, bannerStyles, buttonStyles } from "../styles/shared";
import constants from "../../../shared/constants.json";
type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"];
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
type BookableSlot = components["schemas"]["BookableSlot"];
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
// Constants from shared config
const {
minAdvanceDays: MIN_ADVANCE_DAYS,
maxAdvanceDays: MAX_ADVANCE_DAYS,
lightningMaxEur: LIGHTNING_MAX_EUR,
} = constants.exchange;
type Direction = "buy" | "sell";
type BitcoinTransferMethod = "onchain" | "lightning";
type WizardStep = "details" | "booking" | "confirmation";
/**
* Format price for display
*/
function formatPrice(price: number): string {
return `${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`;
}
export default function ExchangePage() {
const router = useRouter();
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.CREATE_EXCHANGE,
fallbackRedirect: "/",
});
// Wizard state
const [wizardStep, setWizardStep] = useState<WizardStep>("details");
// Price and config state
const [priceData, setPriceData] = useState<ExchangePriceResponse | null>(null);
const [isPriceLoading, setIsPriceLoading] = useState(true);
const [priceError, setPriceError] = useState<string | null>(null);
const [lastPriceUpdate, setLastPriceUpdate] = useState<Date | null>(null);
// Trade form state
const [direction, setDirection] = useState<Direction>("buy");
const [bitcoinTransferMethod, setBitcoinTransferMethod] =
useState<BitcoinTransferMethod>("onchain");
const [eurAmount, setEurAmount] = useState<number>(10000); // €100 in cents
// Date/slot selection state
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [availableSlots, setAvailableSlots] = useState<BookableSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState<BookableSlot | null>(null);
const [isLoadingSlots, setIsLoadingSlots] = useState(false);
const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set());
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
// User trades state (for same-day booking check)
const [userTrades, setUserTrades] = useState<ExchangeResponse[]>([]);
const [existingTradeOnSelectedDate, setExistingTradeOnSelectedDate] =
useState<ExchangeResponse | null>(null);
// UI state
const [error, setError] = useState<string | null>(null);
const [existingTradeId, setExistingTradeId] = useState<string | null>(null);
const [isBooking, setIsBooking] = useState(false);
// Compute dates
const dates = useMemo(() => getDateRange(MIN_ADVANCE_DAYS, MAX_ADVANCE_DAYS), []);
// Config from API
const config = priceData?.config;
const eurMin = config?.eur_min ?? 100;
const eurMax = config?.eur_max ?? 3000;
const eurIncrement = config?.eur_increment ?? 20;
// Compute trade details
const price = priceData?.price;
const marketPrice = price?.market_price ?? 0;
const premiumPercent = price?.premium_percentage ?? 5;
// Calculate agreed price based on direction
const agreedPrice = useMemo(() => {
if (!marketPrice) return 0;
if (direction === "buy") {
return marketPrice * (1 + premiumPercent / 100);
} else {
return marketPrice * (1 - premiumPercent / 100);
}
}, [marketPrice, premiumPercent, direction]);
// Calculate sats amount
const satsAmount = useMemo(() => {
if (!agreedPrice) return 0;
const eurValue = eurAmount / 100;
const btcAmount = eurValue / agreedPrice;
return Math.floor(btcAmount * 100_000_000);
}, [eurAmount, agreedPrice]);
// Check if Lightning is disabled due to threshold
const isLightningDisabled = useMemo(() => {
return eurAmount > LIGHTNING_MAX_EUR * 100;
}, [eurAmount]);
// Auto-switch to onchain if Lightning becomes disabled
useEffect(() => {
if (isLightningDisabled && bitcoinTransferMethod === "lightning") {
setBitcoinTransferMethod("onchain");
}
}, [isLightningDisabled, bitcoinTransferMethod]);
// Fetch price data
const fetchPrice = useCallback(async () => {
setIsPriceLoading(true);
setPriceError(null);
try {
const data = await api.get<ExchangePriceResponse>("/api/exchange/price");
setPriceData(data);
setLastPriceUpdate(new Date());
if (data.error) {
setPriceError(data.error);
}
if (data.price?.is_stale) {
setPriceError("Price is stale. Trade booking may be blocked.");
}
} catch (err) {
console.error("Failed to fetch price:", err);
setPriceError("Failed to load price data");
} finally {
setIsPriceLoading(false);
}
}, []);
// Auto-refresh price every 60 seconds
useEffect(() => {
if (!user || !isAuthorized) return;
fetchPrice();
const interval = setInterval(fetchPrice, 60000);
return () => clearInterval(interval);
}, [user, isAuthorized, fetchPrice]);
// Fetch slots for a date
const fetchSlots = useCallback(async (date: Date) => {
setIsLoadingSlots(true);
setError(null);
setAvailableSlots([]);
setSelectedSlot(null);
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);
setError("Failed to load available slots");
} finally {
setIsLoadingSlots(false);
}
}, []);
// Fetch user trades when entering booking step
useEffect(() => {
if (!user || !isAuthorized || wizardStep !== "booking") return;
const fetchUserTrades = async () => {
try {
const data = await api.get<ExchangeResponse[]>("/api/trades");
setUserTrades(data);
} catch (err) {
console.error("Failed to fetch user trades:", err);
// Don't block the UI if this fails
}
};
fetchUserTrades();
}, [user, isAuthorized, wizardStep]);
// Fetch availability for all dates when entering booking or confirmation step
useEffect(() => {
if (!user || !isAuthorized || (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();
}, [user, isAuthorized, dates, wizardStep]);
useEffect(() => {
if (selectedDate && user && isAuthorized) {
fetchSlots(selectedDate);
}
}, [selectedDate, user, isAuthorized, fetchSlots]);
// Check if a date has an existing trade (only consider booked trades, not cancelled ones)
const getExistingTradeOnDate = useCallback(
(date: Date): 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
);
},
[userTrades]
);
const handleDateSelect = (date: Date) => {
const dateStr = formatDate(date);
if (!datesWithAvailability.has(dateStr)) {
return;
}
// Check if user already has a trade on this date
const existingTrade = getExistingTradeOnDate(date);
if (existingTrade) {
setExistingTradeOnSelectedDate(existingTrade);
setSelectedDate(null);
setSelectedSlot(null);
setAvailableSlots([]);
setError(null);
} else {
setExistingTradeOnSelectedDate(null);
setSelectedDate(date);
}
};
const handleSlotSelect = (slot: BookableSlot) => {
setSelectedSlot(slot);
setError(null);
setWizardStep("confirmation");
};
const handleContinueToBooking = () => {
setWizardStep("booking");
setError(null);
};
const handleBackToDetails = () => {
setWizardStep("details");
setSelectedDate(null);
setSelectedSlot(null);
setError(null);
setExistingTradeOnSelectedDate(null);
};
const handleBackToBooking = () => {
setWizardStep("booking");
setError(null);
};
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;
setEurAmount(clamped);
};
const handleAmountInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value.replace(/[^0-9]/g, "");
if (inputValue === "") {
setEurAmount(eurMin * 100);
return;
}
const eurValue = parseInt(inputValue, 10);
handleAmountChange(eurValue * 100);
};
const handleBook = async () => {
if (!selectedSlot) return;
setIsBooking(true);
setError(null);
setExistingTradeId(null);
try {
await api.post<ExchangeResponse>("/api/exchange", {
slot_start: selectedSlot.start_time,
direction,
bitcoin_transfer_method: bitcoinTransferMethod,
eur_amount: eurAmount,
});
// Redirect to trades page after successful booking
router.push("/trades");
} catch (err) {
const errorMessage = extractApiErrorMessage(err, "Failed to book trade");
setError(errorMessage);
// Check if it's a "same day" error and extract trade public_id (UUID)
const tradeIdMatch = errorMessage.match(/Trade ID: ([a-f0-9-]{36})/i);
if (tradeIdMatch) {
setExistingTradeId(tradeIdMatch[1]);
} else {
setExistingTradeId(null);
}
setIsBooking(false);
}
};
if (isLoading) {
return <LoadingState />;
}
if (!isAuthorized) {
return null;
}
const isPriceStale = priceData?.price?.is_stale ?? false;
return (
<main style={layoutStyles.main}>
<Header currentPage="exchange" />
<div style={styles.content}>
<h1 style={typographyStyles.pageTitle}>Exchange Bitcoin</h1>
<p style={typographyStyles.pageSubtitle}>Buy or sell Bitcoin with a 5% premium</p>
{error && (
<div style={bannerStyles.errorBanner}>
{error}
{existingTradeId && (
<div style={styles.errorLink}>
<a href={`/trades/${existingTradeId}`} style={styles.errorLinkAnchor}>
View your existing trade
</a>
</div>
)}
</div>
)}
{/* Price Display */}
<div style={styles.priceCard}>
{isPriceLoading && !priceData ? (
<div style={styles.priceLoading}>Loading price...</div>
) : priceError && !priceData?.price ? (
<div style={styles.priceError}>{priceError}</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>
{lastPriceUpdate && (
<div style={styles.priceTimestamp}>
Updated {lastPriceUpdate.toLocaleTimeString()}
{isPriceStale && <span style={styles.staleWarning}> (stale)</span>}
</div>
)}
</>
)}
</div>
{/* Step Indicator */}
<div style={styles.stepIndicator}>
<div
style={{
...styles.step,
...(wizardStep === "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,
...(wizardStep === "booking"
? styles.stepActive
: wizardStep === "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,
...(wizardStep === "confirmation" ? styles.stepActive : {}),
}}
>
<span style={styles.stepNumber}>3</span>
<span style={styles.stepLabel}>Confirm</span>
</div>
</div>
{/* Step 1: Exchange Details */}
{wizardStep === "details" && (
<div style={styles.tradeCard}>
{/* Direction Selector */}
<div style={styles.directionRow}>
<button
onClick={() => setDirection("buy")}
style={{
...styles.directionBtn,
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
}}
>
Buy BTC
</button>
<button
onClick={() => setDirection("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={() => setBitcoinTransferMethod("onchain")}
disabled={false}
style={{
...styles.paymentMethodBtn,
...(bitcoinTransferMethod === "onchain" ? styles.paymentMethodBtnActive : {}),
}}
>
<span style={styles.paymentMethodIcon}>🔗</span>
<span>Onchain</span>
</button>
<button
onClick={() => setBitcoinTransferMethod("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) => setEurAmount(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={handleContinueToBooking}
disabled={isPriceStale || !priceData?.price}
style={{
...styles.continueButton,
...(isPriceStale || !priceData?.price ? buttonStyles.buttonDisabled : {}),
}}
>
Continue to Booking
</button>
</div>
)}
{/* Step 2: Booking */}
{wizardStep === "booking" && (
<>
{/* Trade Summary Card */}
<div style={styles.summaryCard}>
<div style={styles.summaryHeader}>
<span style={styles.summaryTitle}>Your Exchange</span>
<button onClick={handleBackToDetails} 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) !== null;
return (
<button
key={dateStr}
data-testid={`date-${dateStr}`}
onClick={() => handleDateSelect(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={() => handleSlotSelect(slot)}
style={{
...styles.slotButton,
...(isSelected ? styles.slotButtonSelected : {}),
}}
>
{formatTime(slot.start_time)}
</button>
);
})}
</div>
)}
</div>
)}
</>
)}
{/* Step 2: Booking (Compressed when step 3 is active) */}
{wizardStep === "confirmation" && (
<div style={styles.compressedBookingCard}>
<div style={styles.compressedBookingHeader}>
<span style={styles.compressedBookingTitle}>Appointment</span>
<button onClick={handleBackToBooking} 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>
{selectedSlot && formatTime(selectedSlot.start_time)} -{" "}
{selectedSlot && formatTime(selectedSlot.end_time)}
</span>
</div>
</div>
)}
{/* Step 3: Confirmation */}
{wizardStep === "confirmation" && selectedSlot && (
<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={handleBook}
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={handleBackToBooking}
disabled={isBooking}
style={styles.cancelButton}
>
Back
</button>
</div>
</div>
)}
</div>
</main>
);
}
// Page-specific styles
const styles: Record<string, CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
maxWidth: "900px",
margin: "0 auto",
width: "100%",
},
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,
},
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)",
},
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",
},
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",
},
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",
},
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",
},
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)",
},
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)",
},
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",
},
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,
},
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",
},
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,
},
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",
},
};