- 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.
1312 lines
40 KiB
TypeScript
1312 lines
40 KiB
TypeScript
"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",
|
||
},
|
||
};
|