"use client"; import { useEffect, useState, useCallback, CSSProperties } from "react"; import { Permission } from "../../auth-context"; import { adminApi } from "../../api"; import { Header } from "../../components/Header"; import { SatsDisplay } from "../../components/SatsDisplay"; import { useRequireAuth } from "../../hooks/useRequireAuth"; import { components } from "../../generated/api"; import { formatDateTime } from "../../utils/date"; import { formatEur, getTradeStatusDisplay } from "../../utils/exchange"; import { layoutStyles, typographyStyles, bannerStyles, badgeStyles, buttonStyles, tradeCardStyles, } from "../../styles/shared"; type AdminExchangeResponse = components["schemas"]["AdminExchangeResponse"]; type Tab = "upcoming" | "past"; export default function AdminTradesPage() { const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.VIEW_ALL_EXCHANGES, fallbackRedirect: "/", }); const [activeTab, setActiveTab] = useState("upcoming"); const [upcomingTrades, setUpcomingTrades] = useState([]); const [pastTrades, setPastTrades] = useState([]); const [isLoadingTrades, setIsLoadingTrades] = useState(true); const [error, setError] = useState(null); // Action state - use Set to track multiple concurrent actions const [actioningIds, setActioningIds] = useState>(new Set()); const [confirmAction, setConfirmAction] = useState<{ id: string; type: "complete" | "no_show" | "cancel"; } | null>(null); // Past trades filters const [statusFilter, setStatusFilter] = useState("all"); const [userSearch, setUserSearch] = useState(""); const fetchUpcomingTrades = useCallback(async (): Promise => { try { const data = await adminApi.getUpcomingTrades(); setUpcomingTrades(data); return null; } catch (err) { console.error("Failed to fetch upcoming trades:", err); return "Failed to load upcoming trades"; } }, []); const fetchPastTrades = useCallback(async (): Promise => { try { const params: { status?: string; user_search?: string } = {}; if (statusFilter !== "all") { params.status = statusFilter; } if (userSearch.trim()) { params.user_search = userSearch.trim(); } const data = await adminApi.getPastTrades( Object.keys(params).length > 0 ? params : undefined ); setPastTrades(data); return null; } catch (err) { console.error("Failed to fetch past trades:", err); return "Failed to load past trades"; } }, [statusFilter, userSearch]); useEffect(() => { if (user && isAuthorized) { setIsLoadingTrades(true); setError(null); Promise.all([fetchUpcomingTrades(), fetchPastTrades()]) .then(([upcomingErr, pastErr]) => { // Combine errors if both failed const errors = [upcomingErr, pastErr].filter(Boolean); if (errors.length > 0) { setError(errors.join("; ")); } }) .finally(() => { setIsLoadingTrades(false); }); } }, [user, isAuthorized, fetchUpcomingTrades, fetchPastTrades]); const handleAction = async (publicId: string, action: "complete" | "no_show" | "cancel") => { // Add this trade to the set of actioning trades setActioningIds((prev) => new Set(prev).add(publicId)); setError(null); try { if (action === "complete") { await adminApi.completeTrade(publicId); } else if (action === "no_show") { await adminApi.noShowTrade(publicId); } else if (action === "cancel") { await adminApi.cancelTrade(publicId); } // Refetch trades - errors from fetch are informational, not critical const [upcomingErr, pastErr] = await Promise.all([fetchUpcomingTrades(), fetchPastTrades()]); const fetchErrors = [upcomingErr, pastErr].filter(Boolean); if (fetchErrors.length > 0) { setError(fetchErrors.join("; ")); } setConfirmAction(null); } catch (err) { setError(err instanceof Error ? err.message : `Failed to ${action} trade`); } finally { // Remove this trade from the set of actioning trades setActioningIds((prev) => { const next = new Set(prev); next.delete(publicId); return next; }); } }; if (isLoading) { return (
Loading...
); } if (!isAuthorized) { return null; } const trades = activeTab === "upcoming" ? upcomingTrades : pastTrades; return (

Trades

Manage Bitcoin exchange trades

{error &&
{error}
} {/* Tabs */}
{/* Filters for Past tab */} {activeTab === "past" && (
setUserSearch(e.target.value)} style={styles.searchInput} />
)} {isLoadingTrades ? (
Loading trades...
) : trades.length === 0 ? (
{activeTab === "upcoming" ? "No upcoming trades." : "No trades found."}
) : (
{trades.map((trade) => { const status = getTradeStatusDisplay(trade.status); const isBuy = trade.direction === "buy"; const isPast = new Date(trade.slot_start) <= new Date(); const canComplete = trade.status === "booked" && isPast && activeTab === "past"; return (
{formatDateTime(trade.slot_start)}
{/* User Info */}
{trade.user_email} {trade.user_contact.telegram && ( {trade.user_contact.telegram} )} {trade.user_contact.signal && ( Signal: {trade.user_contact.signal} )}
{/* Trade Details */}
{isBuy ? "SELL BTC" : "BUY BTC"} {isBuy ? `Send via ${trade.bitcoin_transfer_method === "onchain" ? "Onchain" : "Lightning"}` : `Receive via ${trade.bitcoin_transfer_method === "onchain" ? "Onchain" : "Lightning"}`} {formatEur(trade.eur_amount)}
Rate: € {trade.agreed_price_eur.toLocaleString("de-DE", { maximumFractionDigits: 0, })} /BTC Market: € {trade.market_price_eur.toLocaleString("de-DE", { maximumFractionDigits: 0, })}
{status.text}
{/* Actions */}
{confirmAction?.id === trade.public_id ? ( <> ) : ( <> {canComplete && ( <> )} {trade.status === "booked" && ( )} )}
); })}
)}
); } // Page-specific styles (trade card styles are shared via tradeCardStyles) const styles: Record = { content: { flex: 1, padding: "2rem", maxWidth: "900px", margin: "0 auto", width: "100%", }, tabRow: { display: "flex", gap: "0.5rem", marginBottom: "1.5rem", }, tabButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.9rem", fontWeight: 500, padding: "0.75rem 1.5rem", background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "8px", color: "rgba(255, 255, 255, 0.6)", cursor: "pointer", transition: "all 0.2s", }, tabButtonActive: { background: "rgba(167, 139, 250, 0.15)", border: "1px solid #a78bfa", color: "#a78bfa", }, filterRow: { display: "flex", gap: "0.75rem", marginBottom: "1.5rem", flexWrap: "wrap", }, filterSelect: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.5rem 1rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "6px", color: "#fff", fontSize: "0.875rem", }, searchInput: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.5rem 1rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "6px", color: "#fff", fontSize: "0.875rem", minWidth: "200px", }, // Admin-specific: user contact info userInfo: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem", flexWrap: "wrap", }, userEmail: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.875rem", color: "rgba(255, 255, 255, 0.7)", }, contactBadge: { fontFamily: "'DM Mono', monospace", fontSize: "0.7rem", padding: "0.15rem 0.5rem", background: "rgba(99, 102, 241, 0.15)", border: "1px solid rgba(99, 102, 241, 0.3)", borderRadius: "4px", color: "rgba(129, 140, 248, 0.9)", }, // Admin-specific: vertical button group buttonGroup: { display: "flex", flexDirection: "column", gap: "0.5rem", alignItems: "flex-end", }, successButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.4rem 0.85rem", fontSize: "0.75rem", fontWeight: 500, background: "rgba(34, 197, 94, 0.2)", border: "1px solid rgba(34, 197, 94, 0.3)", borderRadius: "6px", color: "#4ade80", cursor: "pointer", transition: "all 0.2s", }, warningButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.4rem 0.85rem", fontSize: "0.75rem", fontWeight: 500, background: "rgba(251, 146, 60, 0.2)", border: "1px solid rgba(251, 146, 60, 0.3)", borderRadius: "6px", color: "#fb923c", cursor: "pointer", transition: "all 0.2s", }, dangerButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.4rem 0.85rem", fontSize: "0.75rem", fontWeight: 500, background: "rgba(239, 68, 68, 0.2)", border: "1px solid rgba(239, 68, 68, 0.3)", borderRadius: "6px", color: "#f87171", cursor: "pointer", transition: "all 0.2s", }, };