diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index 4a8622f..03893d0 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -10,8 +10,8 @@ const { ADMIN, REGULAR } = constants.roles; type PageId = | "profile" | "invites" - | "booking" - | "appointments" + | "exchange" + | "trades" | "admin-invites" | "admin-availability" | "admin-appointments" @@ -30,8 +30,8 @@ interface NavItem { } const REGULAR_NAV_ITEMS: NavItem[] = [ - { id: "booking", label: "Book", href: "/booking", regularOnly: true }, - { id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true }, + { id: "exchange", label: "Exchange", href: "/exchange", regularOnly: true }, + { id: "trades", label: "My Trades", href: "/trades", regularOnly: true }, { id: "invites", label: "My Invites", href: "/invites", regularOnly: true }, { id: "profile", label: "My Profile", href: "/profile", regularOnly: true }, ]; diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx index 17cd74e..5b0f4c0 100644 --- a/frontend/app/profile/page.test.tsx +++ b/frontend/app/profile/page.test.tsx @@ -211,8 +211,8 @@ describe("ProfilePage - Navigation", () => { render(); await waitFor(() => { - expect(screen.getByText("Book")).toBeDefined(); - expect(screen.getByText("Appointments")).toBeDefined(); + expect(screen.getByText("Exchange")).toBeDefined(); + expect(screen.getByText("My Trades")).toBeDefined(); }); }); diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx new file mode 100644 index 0000000..60cfdcf --- /dev/null +++ b/frontend/app/trades/page.tsx @@ -0,0 +1,456 @@ +"use client"; + +import React from "react"; +import { useEffect, useState, useCallback } 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 { formatDateTime } from "../utils/date"; +import { + layoutStyles, + typographyStyles, + bannerStyles, + badgeStyles, + buttonStyles, +} from "../styles/shared"; + +type ExchangeResponse = components["schemas"]["ExchangeResponse"]; + +/** + * 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 + */ +function formatSats(sats: number): string { + const btc = sats / 100_000_000; + const btcStr = btc.toFixed(8); + const [whole, decimal] = btcStr.split("."); + const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3"); + return `${whole}.${grouped}`; +} + +/** + * Get status display properties + */ +function getTradeStatusDisplay(status: string): { + text: string; + bgColor: string; + textColor: string; +} { + switch (status) { + case "booked": + return { + text: "Pending", + bgColor: "rgba(99, 102, 241, 0.2)", + textColor: "rgba(129, 140, 248, 0.9)", + }; + case "completed": + return { + text: "Completed", + bgColor: "rgba(34, 197, 94, 0.2)", + textColor: "rgba(34, 197, 94, 0.9)", + }; + case "cancelled_by_user": + return { + text: "Cancelled", + bgColor: "rgba(239, 68, 68, 0.2)", + textColor: "rgba(239, 68, 68, 0.9)", + }; + case "cancelled_by_admin": + return { + text: "Cancelled by Admin", + bgColor: "rgba(239, 68, 68, 0.2)", + textColor: "rgba(239, 68, 68, 0.9)", + }; + case "no_show": + return { + text: "No Show", + bgColor: "rgba(251, 146, 60, 0.2)", + textColor: "rgba(251, 146, 60, 0.9)", + }; + default: + return { + text: status, + bgColor: "rgba(255, 255, 255, 0.1)", + textColor: "rgba(255, 255, 255, 0.6)", + }; + } +} + +export default function TradesPage() { + const { user, isLoading, isAuthorized } = useRequireAuth({ + requiredPermission: Permission.VIEW_OWN_APPOINTMENTS, + fallbackRedirect: "/", + }); + + const [trades, setTrades] = useState([]); + const [isLoadingTrades, setIsLoadingTrades] = useState(true); + const [cancellingId, setCancellingId] = useState(null); + const [confirmCancelId, setConfirmCancelId] = useState(null); + const [error, setError] = useState(null); + + const fetchTrades = useCallback(async () => { + try { + const data = await api.get("/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(`/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 ( +
+
Loading...
+
+ ); + } + + 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 ( +
+
+
+

My Trades

+

View and manage your Bitcoin trades

+ + {error &&
{error}
} + + {isLoadingTrades ? ( +
Loading trades...
+ ) : trades.length === 0 ? ( +
+

You don't have any trades yet.

+ + Start trading + +
+ ) : ( + <> + {/* Upcoming Trades */} + {upcomingTrades.length > 0 && ( +
+

Upcoming ({upcomingTrades.length})

+
+ {upcomingTrades.map((trade) => { + const status = getTradeStatusDisplay(trade.status); + const isBuy = trade.direction === "buy"; + return ( +
+
+
+
{formatDateTime(trade.slot_start)}
+
+ + {isBuy ? "BUY" : "SELL"} + + {formatEur(trade.eur_amount)} + + + {formatSats(trade.sats_amount)} sats + +
+
+ Rate: + + € + {trade.agreed_price_eur.toLocaleString("de-DE", { + maximumFractionDigits: 0, + })} + /BTC + + + {isBuy ? "+" : "-"} + {trade.premium_percentage}% + +
+ + {status.text} + +
+ + {trade.status === "booked" && ( +
+ {confirmCancelId === trade.id ? ( + <> + + + + ) : ( + + )} +
+ )} +
+
+ ); + })} +
+
+ )} + + {/* Past/Completed/Cancelled Trades */} + {pastOrFinalTrades.length > 0 && ( +
+

+ History ({pastOrFinalTrades.length}) +

+
+ {pastOrFinalTrades.map((trade) => { + const status = getTradeStatusDisplay(trade.status); + const isBuy = trade.direction === "buy"; + return ( +
+
{formatDateTime(trade.slot_start)}
+
+ + {isBuy ? "BUY" : "SELL"} + + {formatEur(trade.eur_amount)} + + + {formatSats(trade.sats_amount)} sats + +
+ + {status.text} + +
+ ); + })} +
+
+ )} + + )} +
+
+ ); +} + +// Page-specific styles +const styles: Record = { + 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", + }, + tradeList: { + display: "flex", + flexDirection: "column", + gap: "0.75rem", + }, + tradeCard: { + background: "rgba(255, 255, 255, 0.03)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "12px", + padding: "1.25rem", + transition: "all 0.2s", + }, + tradeCardPast: { + opacity: 0.6, + background: "rgba(255, 255, 255, 0.01)", + }, + tradeHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + gap: "1rem", + }, + tradeInfo: { + display: "flex", + flexDirection: "column", + gap: "0.25rem", + }, + tradeTime: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1rem", + fontWeight: 500, + color: "#fff", + marginBottom: "0.5rem", + }, + tradeDetails: { + display: "flex", + alignItems: "center", + gap: "0.5rem", + flexWrap: "wrap", + }, + directionBadge: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.7rem", + fontWeight: 600, + padding: "0.2rem 0.5rem", + borderRadius: "4px", + textTransform: "uppercase", + }, + amount: { + fontFamily: "'DM Mono', monospace", + fontSize: "0.95rem", + color: "#fff", + fontWeight: 500, + }, + arrow: { + color: "rgba(255, 255, 255, 0.3)", + fontSize: "0.8rem", + }, + satsAmount: { + fontFamily: "'DM Mono', monospace", + fontSize: "0.9rem", + color: "#f7931a", // Bitcoin orange + }, + rateRow: { + display: "flex", + alignItems: "center", + gap: "0.5rem", + marginTop: "0.25rem", + }, + rateLabel: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.75rem", + color: "rgba(255, 255, 255, 0.4)", + }, + rateValue: { + fontFamily: "'DM Mono', monospace", + fontSize: "0.8rem", + color: "rgba(255, 255, 255, 0.7)", + }, + premiumBadge: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.65rem", + fontWeight: 600, + padding: "0.15rem 0.4rem", + borderRadius: "3px", + }, + buttonGroup: { + display: "flex", + gap: "0.5rem", + }, + 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", + }, + emptyState: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.4)", + textAlign: "center", + padding: "3rem", + }, + emptyStateLink: { + color: "#a78bfa", + textDecoration: "none", + }, +};