arbret/frontend/app/exchange/page.tsx
counterweight 1008eea2d9
Fix: Update permissions and add missing /api/exchange/slots endpoint
- Updated auth-context.tsx to use new exchange permissions
  (CREATE_EXCHANGE, VIEW_OWN_EXCHANGES, etc.) instead of old
  appointment permissions (BOOK_APPOINTMENT, etc.)

- Updated exchange/page.tsx, trades/page.tsx, admin/trades/page.tsx
  to use correct permission constants

- Updated profile/page.test.tsx mock permissions

- Updated admin/availability/page.tsx to use constants.exchange
  instead of constants.booking

- Added /api/exchange/slots endpoint to return available slots
  for a date, filtering out already booked slots

- Fixed E2E tests:
  - exchange.spec.ts: Wait for button to be enabled before clicking
  - permissions.spec.ts: Use more specific heading selector
  - price-history.spec.ts: Expect /exchange redirect for regular users
2025-12-22 21:42:42 +01:00

805 lines
24 KiB
TypeScript

"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.CREATE_EXCHANGE,
fallbackRedirect: "/",
});
// 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 [successMessage, setSuccessMessage] = 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 on mount
useEffect(() => {
if (!user || !isAuthorized) 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]);
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<ExchangeResponse>("/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 (
<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>
{successMessage && <div style={bannerStyles.successBanner}>{successMessage}</div>}
{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,
color: direction === "buy" ? "#f87171" : "#4ade80",
}}
>
{formatPrice(agreedPrice)}
</span>
<span
style={{
...styles.premiumBadge,
background:
direction === "buy" ? "rgba(248, 113, 113, 0.2)" : "rgba(74, 222, 128, 0.2)",
color: direction === "buy" ? "#f87171" : "#4ade80",
}}
>
{direction === "buy" ? "+" : "-"}
{premiumPercent}%
</span>
</div>
{lastPriceUpdate && (
<div style={styles.priceTimestamp}>
Updated {lastPriceUpdate.toLocaleTimeString()}
{isPriceStale && <span style={styles.staleWarning}> (stale)</span>}
</div>
)}
</>
)}
</div>
{/* Trade Form */}
<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 Slider */}
<div style={styles.amountSection}>
<div style={styles.amountHeader}>
<span style={styles.amountLabel}>Amount</span>
<span style={styles.amountValue}>{formatEur(eurAmount)}</span>
</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 pay <strong>{formatEur(eurAmount)}</strong>, you receive{" "}
<strong style={styles.satsValue}>{formatSats(satsAmount)}</strong>
</p>
) : (
<p style={styles.summaryText}>
You send <strong style={styles.satsValue}>{formatSats(satsAmount)}</strong>, you
receive <strong>{formatEur(eurAmount)}</strong>
</p>
)}
</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}
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 }}>
{formatSats(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, React.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",
},
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",
},
};