diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx new file mode 100644 index 0000000..b1e8db1 --- /dev/null +++ b/frontend/app/exchange/page.tsx @@ -0,0 +1,805 @@ +"use client"; + +import React from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; +import { Permission } from "../auth-context"; +import { api } from "../api"; +import { Header } from "../components/Header"; +import { useRequireAuth } from "../hooks/useRequireAuth"; +import { components } from "../generated/api"; +import { formatDate, formatTime, getDateRange } from "../utils/date"; +import { layoutStyles, typographyStyles, bannerStyles, buttonStyles } from "../styles/shared"; + +type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"]; +type ExchangeResponse = components["schemas"]["ExchangeResponse"]; +type BookableSlot = components["schemas"]["BookableSlot"]; +type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; + +// Constants from shared config (will be fetched from API) +const MIN_ADVANCE_DAYS = 1; +const MAX_ADVANCE_DAYS = 30; + +type Direction = "buy" | "sell"; + +/** + * Format EUR amount from cents to display string + */ +function formatEur(cents: number): string { + return `€${(cents / 100).toLocaleString("de-DE")}`; +} + +/** + * Format satoshi amount with thousand separators + * e.g., 476190 -> "0.00 476 190 sats" + */ +function formatSats(sats: number): string { + const btc = sats / 100_000_000; + const btcStr = btc.toFixed(8); + const [whole, decimal] = btcStr.split("."); + + // Group decimal into chunks of 3 for readability + const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3"); + return `${whole}.${grouped} sats`; +} + +/** + * Format price for display + */ +function formatPrice(price: number): string { + return `€${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`; +} + +export default function ExchangePage() { + const { user, isLoading, isAuthorized } = useRequireAuth({ + requiredPermission: Permission.BOOK_APPOINTMENT, + fallbackRedirect: "/", + }); + + // 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 [eurAmount, setEurAmount] = useState(10000); // €100 in cents + + // 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); + + // UI state + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(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("/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(`/api/booking/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 on mount + useEffect(() => { + if (!user || !isAuthorized) 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/booking/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]); + + 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); + setSuccessMessage(null); + } + }; + + const handleSlotSelect = (slot: BookableSlot) => { + setSelectedSlot(slot); + setError(null); + }; + + const handleBook = async () => { + if (!selectedSlot) return; + + setIsBooking(true); + setError(null); + + try { + const exchange = await api.post("/api/exchange", { + slot_start: selectedSlot.start_time, + direction, + eur_amount: eurAmount, + }); + + const dirLabel = direction === "buy" ? "Buy" : "Sell"; + setSuccessMessage( + `${dirLabel} trade booked for ${formatTime(exchange.slot_start)}! ` + + `${formatEur(exchange.eur_amount)} ↔ ${formatSats(exchange.sats_amount)}` + ); + setSelectedSlot(null); + + // Refresh slots + if (selectedDate) { + await fetchSlots(selectedDate); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to book trade"); + } finally { + setIsBooking(false); + } + }; + + const cancelSlotSelection = () => { + setSelectedSlot(null); + setError(null); + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthorized) { + return null; + } + + const isPriceStale = priceData?.price?.is_stale ?? false; + + return ( +
+
+
+

Exchange Bitcoin

+

Buy or sell Bitcoin with a 5% premium

+ + {successMessage &&
{successMessage}
} + {error &&
{error}
} + + {/* 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)} +
+ )} + + )} +
+ + {/* Trade Form */} +
+ {/* Direction Selector */} +
+ + +
+ + {/* Amount Slider */} +
+
+ Amount + {formatEur(eurAmount)} +
+ setEurAmount(Number(e.target.value))} + style={styles.slider} + /> +
+ {formatEur(eurMin * 100)} + {formatEur(eurMax * 100)} +
+
+ + {/* Trade Summary */} +
+ {direction === "buy" ? ( +

+ You pay {formatEur(eurAmount)}, you receive{" "} + {formatSats(satsAmount)} +

+ ) : ( +

+ You send {formatSats(satsAmount)}, you + receive {formatEur(eurAmount)} +

+ )} +
+
+ + {/* 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; + + return ( + + ); + })} +
+
+ + {/* Available Slots */} + {selectedDate && ( +
+

+ 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 ( + + ); + })} +
+ )} +
+ )} + + {/* Confirm Booking */} + {selectedSlot && ( +
+

Confirm Trade

+
+
+ Time: + + {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} + +
+
+ Direction: + + {direction === "buy" ? "Buy BTC" : "Sell BTC"} + +
+
+ EUR: + {formatEur(eurAmount)} +
+
+ BTC: + + {formatSats(satsAmount)} + +
+
+ Rate: + {formatPrice(agreedPrice)}/BTC +
+
+ +
+ + +
+
+ )} +
+
+ ); +} + +// 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", + }, + 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, + }, + 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", + }, + amountValue: { + fontFamily: "'DM Mono', monospace", + color: "#fff", + fontSize: "1.25rem", + fontWeight: 600, + }, + 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, + }, + 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 + }, + 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", + }, +}; diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index 2f7398b..48a33c8 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -124,35 +124,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/exchange/price": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Exchange Price - * @description Get the current BTC/EUR price for trading. - * - * Returns the latest price from the database. If no price exists or the price - * is stale, attempts to fetch a fresh price from Bitfinex. - * - * The response includes: - * - market_price: The raw price from the exchange - * - agreed_price: The price with admin premium applied - * - is_stale: Whether the price is older than 5 minutes - * - config: Trading configuration (min/max EUR, increment) - */ - get: operations["get_exchange_price_api_exchange_price_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/profile": { parameters: { query?: never; @@ -465,10 +436,257 @@ export interface paths { patch?: never; trace?: never; }; + "/api/exchange/price": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Exchange Price + * @description Get the current BTC/EUR price for trading. + * + * Returns the latest price from the database. If no price exists or the price + * is stale, attempts to fetch a fresh price from Bitfinex. + * + * The response includes: + * - market_price: The raw price from the exchange + * - agreed_price: The price with admin premium applied + * - is_stale: Whether the price is older than 5 minutes + * - config: Trading configuration (min/max EUR, increment) + */ + get: operations["get_exchange_price_api_exchange_price_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/exchange": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Exchange + * @description Create a new exchange trade booking. + * + * Validates: + * - Slot is on a valid date and time boundary + * - Slot is within admin availability + * - Slot is not already booked + * - Price is not stale + * - EUR amount is within configured limits + */ + post: operations["create_exchange_api_exchange_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/trades": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get My Trades + * @description Get the current user's exchanges, sorted by date (newest first). + */ + get: operations["get_my_trades_api_trades_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/trades/{exchange_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel My Trade + * @description Cancel one of the current user's exchanges. + */ + post: operations["cancel_my_trade_api_trades__exchange_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/trades/upcoming": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Upcoming Trades + * @description Get all upcoming booked trades, sorted by slot time ascending. + */ + get: operations["get_upcoming_trades_api_admin_trades_upcoming_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/trades/past": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Past Trades + * @description Get past trades with optional filters. + * + * Filters: + * - status: Filter by exchange status + * - start_date, end_date: Filter by slot_start date range + * - user_search: Search by user email (partial match) + */ + get: operations["get_past_trades_api_admin_trades_past_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/trades/{exchange_id}/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Complete Trade + * @description Mark a trade as completed. Only possible after slot time has passed. + */ + post: operations["complete_trade_api_admin_trades__exchange_id__complete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/trades/{exchange_id}/no-show": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Mark No Show + * @description Mark a trade as no-show. Only possible after slot time has passed. + */ + post: operations["mark_no_show_api_admin_trades__exchange_id__no_show_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/trades/{exchange_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin Cancel Trade + * @description Cancel any trade (admin only). + */ + post: operations["admin_cancel_trade_api_admin_trades__exchange_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + /** + * AdminExchangeResponse + * @description Response model for admin exchange view (includes user contact). + */ + AdminExchangeResponse: { + /** Id */ + id: number; + /** User Id */ + user_id: number; + /** User Email */ + user_email: string; + user_contact: components["schemas"]["ExchangeUserContact"]; + /** + * Slot Start + * Format: date-time + */ + slot_start: string; + /** + * Slot End + * Format: date-time + */ + slot_end: string; + /** Direction */ + direction: string; + /** Eur Amount */ + eur_amount: number; + /** Sats Amount */ + sats_amount: number; + /** Market Price Eur */ + market_price_eur: number; + /** Agreed Price Eur */ + agreed_price_eur: number; + /** Premium Percentage */ + premium_percentage: number; + /** Status */ + status: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Cancelled At */ + cancelled_at: string | null; + /** Completed At */ + completed_at: string | null; + }; /** * AdminUserResponse * @description Minimal user info for admin dropdowns. @@ -627,6 +845,82 @@ export interface components { /** Error */ error?: string | null; }; + /** + * ExchangeRequest + * @description Request to create an exchange trade. + */ + ExchangeRequest: { + /** + * Slot Start + * Format: date-time + */ + slot_start: string; + /** Direction */ + direction: string; + /** Eur Amount */ + eur_amount: number; + }; + /** + * ExchangeResponse + * @description Response model for an exchange trade. + */ + ExchangeResponse: { + /** Id */ + id: number; + /** User Id */ + user_id: number; + /** User Email */ + user_email: string; + /** + * Slot Start + * Format: date-time + */ + slot_start: string; + /** + * Slot End + * Format: date-time + */ + slot_end: string; + /** Direction */ + direction: string; + /** Eur Amount */ + eur_amount: number; + /** Sats Amount */ + sats_amount: number; + /** Market Price Eur */ + market_price_eur: number; + /** Agreed Price Eur */ + agreed_price_eur: number; + /** Premium Percentage */ + premium_percentage: number; + /** Status */ + status: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Cancelled At */ + cancelled_at: string | null; + /** Completed At */ + completed_at: string | null; + }; + /** + * ExchangeUserContact + * @description User contact info for admin view. + */ + ExchangeUserContact: { + /** Email */ + email: string; + /** Contact Email */ + contact_email: string | null; + /** Telegram */ + telegram: string | null; + /** Signal */ + signal: string | null; + /** Nostr Npub */ + nostr_npub: string | null; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -1050,26 +1344,6 @@ export interface operations { }; }; }; - get_exchange_price_api_exchange_price_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ExchangePriceResponse"]; - }; - }; - }; - }; get_profile_api_profile_get: { parameters: { query?: never; @@ -1593,4 +1867,255 @@ export interface operations { }; }; }; + get_exchange_price_api_exchange_price_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExchangePriceResponse"]; + }; + }; + }; + }; + create_exchange_api_exchange_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExchangeRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExchangeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_my_trades_api_trades_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExchangeResponse"][]; + }; + }; + }; + }; + cancel_my_trade_api_trades__exchange_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + exchange_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExchangeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_upcoming_trades_api_admin_trades_upcoming_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminExchangeResponse"][]; + }; + }; + }; + }; + get_past_trades_api_admin_trades_past_get: { + parameters: { + query?: { + status?: string | null; + start_date?: string | null; + end_date?: string | null; + user_search?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminExchangeResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + complete_trade_api_admin_trades__exchange_id__complete_post: { + parameters: { + query?: never; + header?: never; + path: { + exchange_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminExchangeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + mark_no_show_api_admin_trades__exchange_id__no_show_post: { + parameters: { + query?: never; + header?: never; + path: { + exchange_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminExchangeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_cancel_trade_api_admin_trades__exchange_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + exchange_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminExchangeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; }