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+).
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, CSSProperties } from "react";
|
|
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 { formatDateTime } from "../utils/date";
|
|
import { formatEur, getTradeStatusDisplay } from "../utils/exchange";
|
|
import {
|
|
layoutStyles,
|
|
typographyStyles,
|
|
bannerStyles,
|
|
badgeStyles,
|
|
buttonStyles,
|
|
tradeCardStyles,
|
|
} from "../styles/shared";
|
|
|
|
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
|
|
|
|
export default function TradesPage() {
|
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
|
requiredPermission: Permission.VIEW_OWN_EXCHANGES,
|
|
fallbackRedirect: "/",
|
|
});
|
|
|
|
const [trades, setTrades] = useState<ExchangeResponse[]>([]);
|
|
const [isLoadingTrades, setIsLoadingTrades] = useState(true);
|
|
const [cancellingId, setCancellingId] = useState<number | null>(null);
|
|
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchTrades = useCallback(async () => {
|
|
try {
|
|
const data = await api.get<ExchangeResponse[]>("/api/trades");
|
|
setTrades(data);
|
|
} catch (err) {
|
|
console.error("Failed to fetch trades:", err);
|
|
setError("Failed to load trades");
|
|
} finally {
|
|
setIsLoadingTrades(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (user && isAuthorized) {
|
|
fetchTrades();
|
|
}
|
|
}, [user, isAuthorized, fetchTrades]);
|
|
|
|
const handleCancel = async (tradeId: number) => {
|
|
setCancellingId(tradeId);
|
|
setError(null);
|
|
|
|
try {
|
|
await api.post<ExchangeResponse>(`/api/trades/${tradeId}/cancel`, {});
|
|
await fetchTrades();
|
|
setConfirmCancelId(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to cancel trade");
|
|
} finally {
|
|
setCancellingId(null);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<main style={layoutStyles.main}>
|
|
<div style={layoutStyles.loader}>Loading...</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (!isAuthorized) {
|
|
return null;
|
|
}
|
|
|
|
const upcomingTrades = trades.filter(
|
|
(t) => t.status === "booked" && new Date(t.slot_start) > new Date()
|
|
);
|
|
const pastOrFinalTrades = trades.filter(
|
|
(t) => t.status !== "booked" || new Date(t.slot_start) <= new Date()
|
|
);
|
|
|
|
return (
|
|
<main style={layoutStyles.main}>
|
|
<Header currentPage="trades" />
|
|
<div style={styles.content}>
|
|
<h1 style={typographyStyles.pageTitle}>My Trades</h1>
|
|
<p style={typographyStyles.pageSubtitle}>View and manage your Bitcoin trades</p>
|
|
|
|
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
|
|
|
|
{isLoadingTrades ? (
|
|
<div style={tradeCardStyles.emptyState}>Loading trades...</div>
|
|
) : trades.length === 0 ? (
|
|
<div style={tradeCardStyles.emptyState}>
|
|
<p>You don't have any trades yet.</p>
|
|
<a href="/exchange" style={styles.emptyStateLink}>
|
|
Start trading
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Upcoming Trades */}
|
|
{upcomingTrades.length > 0 && (
|
|
<div style={styles.section}>
|
|
<h2 style={styles.sectionTitle}>Upcoming ({upcomingTrades.length})</h2>
|
|
<div style={tradeCardStyles.tradeList}>
|
|
{upcomingTrades.map((trade) => {
|
|
const status = getTradeStatusDisplay(trade.status);
|
|
const isBuy = trade.direction === "buy";
|
|
return (
|
|
<div key={trade.id} style={tradeCardStyles.tradeCard}>
|
|
<div style={tradeCardStyles.tradeHeader}>
|
|
<div style={tradeCardStyles.tradeInfo}>
|
|
<div style={tradeCardStyles.tradeTime}>
|
|
{formatDateTime(trade.slot_start)}
|
|
</div>
|
|
<div style={tradeCardStyles.tradeDetails}>
|
|
<span
|
|
style={{
|
|
...tradeCardStyles.directionBadge,
|
|
background: isBuy
|
|
? "rgba(74, 222, 128, 0.15)"
|
|
: "rgba(248, 113, 113, 0.15)",
|
|
color: isBuy ? "#4ade80" : "#f87171",
|
|
}}
|
|
>
|
|
{isBuy ? "BUY" : "SELL"}
|
|
</span>
|
|
<span style={tradeCardStyles.amount}>
|
|
{formatEur(trade.eur_amount)}
|
|
</span>
|
|
<span style={tradeCardStyles.arrow}>↔</span>
|
|
<span style={tradeCardStyles.satsAmount}>
|
|
<SatsDisplay sats={trade.sats_amount} />
|
|
</span>
|
|
</div>
|
|
<div style={tradeCardStyles.rateRow}>
|
|
<span style={tradeCardStyles.rateLabel}>Rate:</span>
|
|
<span style={tradeCardStyles.rateValue}>
|
|
€
|
|
{trade.agreed_price_eur.toLocaleString("de-DE", {
|
|
maximumFractionDigits: 0,
|
|
})}
|
|
/BTC
|
|
</span>
|
|
</div>
|
|
<span
|
|
style={{
|
|
...badgeStyles.badge,
|
|
background: status.bgColor,
|
|
color: status.textColor,
|
|
marginTop: "0.5rem",
|
|
}}
|
|
>
|
|
{status.text}
|
|
</span>
|
|
</div>
|
|
|
|
{trade.status === "booked" && (
|
|
<div style={tradeCardStyles.buttonGroup}>
|
|
{confirmCancelId === trade.id ? (
|
|
<>
|
|
<button
|
|
onClick={() => handleCancel(trade.id)}
|
|
disabled={cancellingId === trade.id}
|
|
style={styles.confirmButton}
|
|
>
|
|
{cancellingId === trade.id ? "..." : "Confirm"}
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmCancelId(null)}
|
|
style={buttonStyles.secondaryButton}
|
|
>
|
|
No
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={() => setConfirmCancelId(trade.id)}
|
|
style={buttonStyles.secondaryButton}
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Past/Completed/Cancelled Trades */}
|
|
{pastOrFinalTrades.length > 0 && (
|
|
<div style={styles.section}>
|
|
<h2 style={typographyStyles.sectionTitleMuted}>
|
|
History ({pastOrFinalTrades.length})
|
|
</h2>
|
|
<div style={tradeCardStyles.tradeList}>
|
|
{pastOrFinalTrades.map((trade) => {
|
|
const status = getTradeStatusDisplay(trade.status);
|
|
const isBuy = trade.direction === "buy";
|
|
return (
|
|
<div
|
|
key={trade.id}
|
|
style={{ ...tradeCardStyles.tradeCard, ...styles.tradeCardPast }}
|
|
>
|
|
<div style={tradeCardStyles.tradeTime}>
|
|
{formatDateTime(trade.slot_start)}
|
|
</div>
|
|
<div style={tradeCardStyles.tradeDetails}>
|
|
<span
|
|
style={{
|
|
...tradeCardStyles.directionBadge,
|
|
background: isBuy
|
|
? "rgba(74, 222, 128, 0.1)"
|
|
: "rgba(248, 113, 113, 0.1)",
|
|
color: isBuy ? "rgba(74, 222, 128, 0.7)" : "rgba(248, 113, 113, 0.7)",
|
|
}}
|
|
>
|
|
{isBuy ? "BUY" : "SELL"}
|
|
</span>
|
|
<span style={tradeCardStyles.amount}>{formatEur(trade.eur_amount)}</span>
|
|
<span style={tradeCardStyles.arrow}>↔</span>
|
|
<span style={tradeCardStyles.satsAmount}>
|
|
<SatsDisplay sats={trade.sats_amount} />
|
|
</span>
|
|
</div>
|
|
<span
|
|
style={{
|
|
...badgeStyles.badge,
|
|
background: status.bgColor,
|
|
color: status.textColor,
|
|
marginTop: "0.5rem",
|
|
}}
|
|
>
|
|
{status.text}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// Page-specific styles (trade card styles are shared via tradeCardStyles)
|
|
const styles: Record<string, CSSProperties> = {
|
|
content: {
|
|
flex: 1,
|
|
padding: "2rem",
|
|
maxWidth: "800px",
|
|
margin: "0 auto",
|
|
width: "100%",
|
|
},
|
|
section: {
|
|
marginBottom: "2rem",
|
|
},
|
|
sectionTitle: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
fontSize: "1.1rem",
|
|
fontWeight: 500,
|
|
color: "#fff",
|
|
marginBottom: "1rem",
|
|
},
|
|
tradeCardPast: {
|
|
opacity: 0.6,
|
|
background: "rgba(255, 255, 255, 0.01)",
|
|
},
|
|
confirmButton: {
|
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
|
padding: "0.35rem 0.75rem",
|
|
fontSize: "0.75rem",
|
|
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",
|
|
},
|
|
emptyStateLink: {
|
|
color: "#a78bfa",
|
|
textDecoration: "none",
|
|
},
|
|
};
|