diff --git a/frontend/app/exchange/components/BookingStep.tsx b/frontend/app/exchange/components/BookingStep.tsx new file mode 100644 index 0000000..e84f076 --- /dev/null +++ b/frontend/app/exchange/components/BookingStep.tsx @@ -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; + isLoadingSlots: boolean; + isLoadingAvailability: boolean; + existingTradeOnSelectedDate: ExchangeResponse | null; + userTrades: ExchangeResponse[]; + onDateSelect: (date: Date) => void; + onSlotSelect: (slot: BookableSlot) => void; + onBackToDetails: () => void; +} + +const styles: Record = { + 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 */} +
+
+ Your Exchange + +
+
+ + {direction === "buy" ? "Buy" : "Sell"} BTC + + + {formatEur(eurAmount)} + + + + + + + {direction === "buy" ? "Receive via " : "Send via "} + {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} + +
+
+ + {/* Date Selection */} +
+

Select a Date

+
+ {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 ( + + ); + })} +
+
+ + {/* Warning for existing trade on selected date */} + {existingTradeOnSelectedDate && ( +
+
+ You already have a trade booked on this day. You can only book one trade per day. +
+
+ + View your existing trade → + +
+
+ )} + + {/* Available Slots */} + {selectedDate && !existingTradeOnSelectedDate && ( +
+

+ Available Slots for{" "} + {selectedDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })} +

+ + {isLoadingSlots ? ( +
Loading slots...
+ ) : availableSlots.length === 0 ? ( +
No available slots for this date
+ ) : ( +
+ {availableSlots.map((slot) => { + const isSelected = selectedSlot?.start_time === slot.start_time; + return ( + + ); + })} +
+ )} +
+ )} + + ); +} diff --git a/frontend/app/exchange/components/ConfirmationStep.tsx b/frontend/app/exchange/components/ConfirmationStep.tsx new file mode 100644 index 0000000..1cb0197 --- /dev/null +++ b/frontend/app/exchange/components/ConfirmationStep.tsx @@ -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 = { + 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 */} +
+
+ Appointment + +
+
+ + {selectedDate?.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + })} + + + + {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} + +
+
+ + {/* Confirmation Card */} +
+

Confirm Trade

+
+
+ Time: + + {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} + +
+
+ Direction: + + {direction === "buy" ? "Buy BTC" : "Sell BTC"} + +
+
+ EUR: + {formatEur(eurAmount)} +
+
+ BTC: + + + +
+
+ Rate: + {formatPrice(agreedPrice)}/BTC +
+
+ Payment: + + {direction === "buy" ? "Receive via " : "Send via "} + {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} + +
+
+ +
+ + +
+
+ + ); +} diff --git a/frontend/app/exchange/components/ExchangeDetailsStep.tsx b/frontend/app/exchange/components/ExchangeDetailsStep.tsx new file mode 100644 index 0000000..3219baf --- /dev/null +++ b/frontend/app/exchange/components/ExchangeDetailsStep.tsx @@ -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 = { + 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) => { + const inputValue = e.target.value.replace(/[^0-9]/g, ""); + if (inputValue === "") { + onEurAmountChange(eurMin * 100); + return; + } + const eurValue = parseInt(inputValue, 10); + handleAmountChange(eurValue * 100); + }; + + return ( +
+ {/* Direction Selector */} +
+ + +
+ + {/* Payment Method Selector */} +
+
+ Payment Method * +
+
+ + +
+ {isLightningDisabled && ( +
+ Lightning payments are only available for amounts up to €{LIGHTNING_MAX_EUR} +
+ )} +
+ + {/* Amount Section */} +
+
+ Amount (EUR) +
+ + +
+
+ onEurAmountChange(Number(e.target.value))} + style={styles.slider} + /> +
+ {formatEur(eurMin * 100)} + {formatEur(eurMax * 100)} +
+
+ + {/* Trade Summary */} +
+ {direction === "buy" ? ( +

+ You buy{" "} + + + + , you sell {formatEur(eurAmount)} +

+ ) : ( +

+ You buy {formatEur(eurAmount)}, you sell{" "} + + + +

+ )} +
+ + {/* Continue Button */} + +
+ ); +} diff --git a/frontend/app/exchange/components/PriceDisplay.tsx b/frontend/app/exchange/components/PriceDisplay.tsx new file mode 100644 index 0000000..e9e03a5 --- /dev/null +++ b/frontend/app/exchange/components/PriceDisplay.tsx @@ -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 = { + 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 ( +
+ {isLoading && !priceData ? ( +
Loading price...
+ ) : error && !priceData?.price ? ( +
{error}
+ ) : ( + <> +
+ Market: + {formatPrice(marketPrice)} + + Our price: + {formatPrice(agreedPrice)} + + {direction === "buy" ? "+" : "-"} + {premiumPercent}% + +
+ {lastUpdate && ( +
+ Updated {lastUpdate.toLocaleTimeString()} + {isPriceStale && (stale)} +
+ )} + + )} +
+ ); +} diff --git a/frontend/app/exchange/components/StepIndicator.tsx b/frontend/app/exchange/components/StepIndicator.tsx new file mode 100644 index 0000000..d082990 --- /dev/null +++ b/frontend/app/exchange/components/StepIndicator.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { CSSProperties } from "react"; + +type WizardStep = "details" | "booking" | "confirmation"; + +interface StepIndicatorProps { + currentStep: WizardStep; +} + +const styles: Record = { + 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 ( +
+
+ 1 + Exchange Details +
+
+
+ 2 + Book Appointment +
+
+
+ 3 + Confirm +
+
+ ); +} diff --git a/frontend/app/exchange/hooks/useAvailableSlots.ts b/frontend/app/exchange/hooks/useAvailableSlots.ts new file mode 100644 index 0000000..c1ba1ba --- /dev/null +++ b/frontend/app/exchange/hooks/useAvailableSlots.ts @@ -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; + /** 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; +} + +/** + * 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([]); + const [datesWithAvailability, setDatesWithAvailability] = useState>(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(`/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(); + + const promises = dates.map(async (date) => { + try { + const dateStr = formatDate(date); + const data = await api.get(`/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, + }; +} diff --git a/frontend/app/exchange/hooks/useExchangePrice.ts b/frontend/app/exchange/hooks/useExchangePrice.ts new file mode 100644 index 0000000..ae8661b --- /dev/null +++ b/frontend/app/exchange/hooks/useExchangePrice.ts @@ -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; +} + +/** + * 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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchPrice = useCallback(async () => { + if (!enabled) return; + + setIsLoading(true); + setError(null); + + try { + const data = await api.get("/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, + }; +} diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx index 9cf6f70..c083bb7 100644 --- a/frontend/app/exchange/page.tsx +++ b/frontend/app/exchange/page.tsx @@ -1,24 +1,26 @@ "use client"; -import { useEffect, useState, useCallback, useMemo, ChangeEvent, CSSProperties } from "react"; +import { useEffect, useState, useCallback, useMemo } 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 { formatDate, getDateRange } from "../utils/date"; +import { layoutStyles, typographyStyles, bannerStyles } from "../styles/shared"; import constants from "../../../shared/constants.json"; +import { useExchangePrice } from "./hooks/useExchangePrice"; +import { useAvailableSlots } from "./hooks/useAvailableSlots"; +import { PriceDisplay } from "./components/PriceDisplay"; +import { StepIndicator } from "./components/StepIndicator"; +import { ExchangeDetailsStep } from "./components/ExchangeDetailsStep"; +import { BookingStep } from "./components/BookingStep"; +import { ConfirmationStep } from "./components/ConfirmationStep"; -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 { @@ -31,12 +33,27 @@ 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 })}`; -} +const styles = { + content: { + flex: 1, + padding: "2rem", + maxWidth: "900px", + margin: "0 auto", + width: "100%", + }, + 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", + }, +} as const; export default function ExchangePage() { const router = useRouter(); @@ -48,12 +65,6 @@ export default function ExchangePage() { // Wizard state const [wizardStep, setWizardStep] = useState("details"); - // Price and config state - const [priceData, setPriceData] = useState(null); - const [isPriceLoading, setIsPriceLoading] = useState(true); - const [priceError, setPriceError] = useState(null); - const [lastPriceUpdate, setLastPriceUpdate] = useState(null); - // Trade form state const [direction, setDirection] = useState("buy"); const [bitcoinTransferMethod, setBitcoinTransferMethod] = @@ -62,11 +73,9 @@ export default function ExchangePage() { // Date/slot selection state const [selectedDate, setSelectedDate] = useState(null); - const [availableSlots, setAvailableSlots] = useState([]); - const [selectedSlot, setSelectedSlot] = useState(null); - const [isLoadingSlots, setIsLoadingSlots] = useState(false); - const [datesWithAvailability, setDatesWithAvailability] = useState>(new Set()); - const [isLoadingAvailability, setIsLoadingAvailability] = useState(true); + const [selectedSlot, setSelectedSlot] = useState( + null + ); // User trades state (for same-day booking check) const [userTrades, setUserTrades] = useState([]); @@ -81,6 +90,28 @@ export default function ExchangePage() { // Compute dates const dates = useMemo(() => getDateRange(MIN_ADVANCE_DAYS, MAX_ADVANCE_DAYS), []); + // Use custom hooks for price and slots + const { + priceData, + isLoading: isPriceLoading, + error: priceError, + lastUpdate: lastPriceUpdate, + } = useExchangePrice({ + enabled: !!user && isAuthorized, + }); + + const { + availableSlots, + datesWithAvailability, + isLoadingSlots, + isLoadingAvailability, + fetchSlots, + } = useAvailableSlots({ + enabled: !!user && isAuthorized, + dates, + wizardStep, + }); + // Config from API const config = priceData?.config; const eurMin = config?.eur_min ?? 100; @@ -122,57 +153,12 @@ export default function ExchangePage() { } }, [isLightningDisabled, bitcoinTransferMethod]); - // Fetch price data - const fetchPrice = useCallback(async () => { - setIsPriceLoading(true); - setPriceError(null); - - try { - const data = await api.get("/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 + // Fetch slots when date is selected 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(`/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); + if (selectedDate && user && isAuthorized) { + fetchSlots(selectedDate); } - }, []); + }, [selectedDate, user, isAuthorized, fetchSlots]); // Fetch user trades when entering booking step useEffect(() => { @@ -191,41 +177,6 @@ export default function ExchangePage() { 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(); - - const promises = dates.map(async (date) => { - try { - const dateStr = formatDate(date); - const data = await api.get(`/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 => { @@ -252,7 +203,6 @@ export default function ExchangePage() { setExistingTradeOnSelectedDate(existingTrade); setSelectedDate(null); setSelectedSlot(null); - setAvailableSlots([]); setError(null); } else { setExistingTradeOnSelectedDate(null); @@ -260,7 +210,7 @@ export default function ExchangePage() { } }; - const handleSlotSelect = (slot: BookableSlot) => { + const handleSlotSelect = (slot: components["schemas"]["BookableSlot"]) => { setSelectedSlot(slot); setError(null); setWizardStep("confirmation"); @@ -284,31 +234,6 @@ export default function ExchangePage() { 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) => { - 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; @@ -373,940 +298,76 @@ export default function ExchangePage() { )} {/* Price Display */} -
- {isPriceLoading && !priceData ? ( -
Loading price...
- ) : priceError && !priceData?.price ? ( -
{priceError}
- ) : ( - <> -
- Market: - {formatPrice(marketPrice)} - - Our price: - {formatPrice(agreedPrice)} - - {direction === "buy" ? "+" : "-"} - {premiumPercent}% - -
- {lastPriceUpdate && ( -
- Updated {lastPriceUpdate.toLocaleTimeString()} - {isPriceStale && (stale)} -
- )} - - )} -
+ {/* Step Indicator */} -
-
- 1 - Exchange Details -
-
-
- 2 - Book Appointment -
-
-
- 3 - Confirm -
-
+ {/* Step 1: Exchange Details */} {wizardStep === "details" && ( -
- {/* Direction Selector */} -
- - -
- - {/* Payment Method Selector */} -
-
- Payment Method * -
-
- - -
- {isLightningDisabled && ( -
- Lightning payments are only available for amounts up to €{LIGHTNING_MAX_EUR} -
- )} -
- - {/* Amount Section */} -
-
- Amount (EUR) -
- - -
-
- setEurAmount(Number(e.target.value))} - style={styles.slider} - /> -
- {formatEur(eurMin * 100)} - {formatEur(eurMax * 100)} -
-
- - {/* Trade Summary */} -
- {direction === "buy" ? ( -

- You buy{" "} - - - - , you sell {formatEur(eurAmount)} -

- ) : ( -

- You buy {formatEur(eurAmount)}, you sell{" "} - - - -

- )} -
- - {/* Continue Button */} - -
+ )} {/* Step 2: Booking */} {wizardStep === "booking" && ( - <> - {/* Trade Summary Card */} -
-
- Your Exchange - -
-
- - {direction === "buy" ? "Buy" : "Sell"} BTC - - - {formatEur(eurAmount)} - - - - - - - {direction === "buy" ? "Receive via " : "Send via "} - {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} - -
-
- - {/* Date Selection */} -
-

Select a Date

-
- {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 ( - - ); - })} -
-
- - {/* Warning for existing trade on selected date */} - {existingTradeOnSelectedDate && ( -
-
- You already have a trade booked on this day. You can only book one trade per day. -
- -
- )} - - {/* Available Slots */} - {selectedDate && !existingTradeOnSelectedDate && ( -
-

- Available Slots for{" "} - {selectedDate.toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - })} -

- - {isLoadingSlots ? ( -
Loading slots...
- ) : availableSlots.length === 0 ? ( -
No available slots for this date
- ) : ( -
- {availableSlots.map((slot) => { - const isSelected = selectedSlot?.start_time === slot.start_time; - return ( - - ); - })} -
- )} -
- )} - - )} - - {/* Step 2: Booking (Compressed when step 3 is active) */} - {wizardStep === "confirmation" && ( -
-
- Appointment - -
-
- - {selectedDate?.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - })} - - - - {selectedSlot && formatTime(selectedSlot.start_time)} -{" "} - {selectedSlot && formatTime(selectedSlot.end_time)} - -
-
+ )} {/* Step 3: Confirmation */} {wizardStep === "confirmation" && selectedSlot && ( -
-

Confirm Trade

-
-
- Time: - - {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} - -
-
- Direction: - - {direction === "buy" ? "Buy BTC" : "Sell BTC"} - -
-
- EUR: - {formatEur(eurAmount)} -
-
- BTC: - - - -
-
- Rate: - {formatPrice(agreedPrice)}/BTC -
-
- Payment: - - {direction === "buy" ? "Receive via " : "Send via "} - {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} - -
-
- -
- - -
-
+ )}
); } - -// Page-specific styles -const styles: Record = { - 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", - }, -};