Replace 'import React from "react"' with direct imports of CSSProperties and ChangeEvent. This eliminates unused imports and follows modern React patterns where the namespace import is not required for JSX (React 17+).
1002 lines
30 KiB
TypeScript
1002 lines
30 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 { Header } from "../components/Header";
|
|
import { SatsDisplay } from "../components/SatsDisplay";
|
|
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 } = constants.exchange;
|
|
|
|
type Direction = "buy" | "sell";
|
|
type WizardStep = "details" | "booking";
|
|
|
|
/**
|
|
* 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 [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);
|
|
|
|
// UI state
|
|
const [error, setError] = 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]);
|
|
|
|
// 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 availability for all dates when entering booking step
|
|
useEffect(() => {
|
|
if (!user || !isAuthorized || wizardStep !== "booking") 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]);
|
|
|
|
const handleDateSelect = (date: Date) => {
|
|
const dateStr = formatDate(date);
|
|
if (datesWithAvailability.has(dateStr)) {
|
|
setSelectedDate(date);
|
|
}
|
|
};
|
|
|
|
const handleSlotSelect = (slot: BookableSlot) => {
|
|
setSelectedSlot(slot);
|
|
setError(null);
|
|
};
|
|
|
|
const handleContinueToBooking = () => {
|
|
setWizardStep("booking");
|
|
setError(null);
|
|
};
|
|
|
|
const handleBackToDetails = () => {
|
|
setWizardStep("details");
|
|
setSelectedDate(null);
|
|
setSelectedSlot(null);
|
|
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);
|
|
|
|
try {
|
|
await api.post<ExchangeResponse>("/api/exchange", {
|
|
slot_start: selectedSlot.start_time,
|
|
direction,
|
|
eur_amount: eurAmount,
|
|
});
|
|
|
|
// Redirect to trades page after successful booking
|
|
router.push("/trades");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to book trade");
|
|
setIsBooking(false);
|
|
}
|
|
};
|
|
|
|
const cancelSlotSelection = () => {
|
|
setSelectedSlot(null);
|
|
setError(null);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<main style={layoutStyles.main}>
|
|
<div style={layoutStyles.loader}>Loading...</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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}</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 : {}),
|
|
}}
|
|
>
|
|
<span style={styles.stepNumber}>2</span>
|
|
<span style={styles.stepLabel}>Book Appointment</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>
|
|
|
|
{/* 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>
|
|
</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;
|
|
|
|
return (
|
|
<button
|
|
key={dateStr}
|
|
data-testid={`date-${dateStr}`}
|
|
onClick={() => handleDateSelect(date)}
|
|
disabled={isDisabled}
|
|
style={{
|
|
...styles.dateButton,
|
|
...(isSelected ? styles.dateButtonSelected : {}),
|
|
...(isDisabled ? styles.dateButtonDisabled : {}),
|
|
}}
|
|
>
|
|
<div style={styles.dateWeekday}>
|
|
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
|
</div>
|
|
<div style={styles.dateDay}>
|
|
{date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
})}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Available Slots */}
|
|
{selectedDate && (
|
|
<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>
|
|
)}
|
|
|
|
{/* Confirm Booking */}
|
|
{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>
|
|
|
|
<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={cancelSlotSelection}
|
|
disabled={isBooking}
|
|
style={styles.cancelButton}
|
|
>
|
|
Cancel
|
|
</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",
|
|
},
|
|
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)",
|
|
},
|
|
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)",
|
|
},
|
|
dateWeekday: {
|
|
color: "#fff",
|
|
fontWeight: 500,
|
|
fontSize: "0.875rem",
|
|
marginBottom: "0.25rem",
|
|
},
|
|
dateDay: {
|
|
color: "rgba(255, 255, 255, 0.5)",
|
|
fontSize: "0.8rem",
|
|
},
|
|
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",
|
|
},
|
|
};
|