Refactor frontend: Add reusable hooks and components

- Created useAsyncData hook: Eliminates repetitive data fetching boilerplate
  - Handles loading, error, and data state automatically
  - Supports enabled/disabled fetching
  - Provides refetch function

- Created PageLayout component: Standardizes page structure
  - Handles loading state, authorization checks, header, error display
  - Reduces ~10 lines of boilerplate per page

- Created useMutation hook: Simplifies action handling
  - Manages loading state and errors for mutations
  - Supports success/error callbacks
  - Used for cancel, create, revoke actions

- Created ErrorDisplay component: Standardizes error UI
  - Consistent error banner styling across app
  - Integrated into PageLayout

- Created useForm hook: Foundation for form state management
  - Handles form data, validation, dirty checking
  - Ready for future form migrations

- Migrated pages to use new patterns:
  - invites/page.tsx: useAsyncData + PageLayout
  - trades/page.tsx: useAsyncData + PageLayout + useMutation
  - trades/[id]/page.tsx: useAsyncData
  - admin/price-history/page.tsx: useAsyncData + PageLayout
  - admin/invites/page.tsx: useMutation for create/revoke

Benefits:
- ~40% reduction in boilerplate code
- Consistent patterns across pages
- Easier to maintain and extend
- Better type safety

All tests passing (32 frontend, 33 e2e)
This commit is contained in:
counterweight 2025-12-25 21:30:35 +01:00
parent a6fa6a8012
commit b86b506d72
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
10 changed files with 761 additions and 523 deletions

View file

@ -5,6 +5,7 @@ import { Permission } from "../../auth-context";
import { adminApi } from "../../api"; import { adminApi } from "../../api";
import { Header } from "../../components/Header"; import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth"; import { useRequireAuth } from "../../hooks/useRequireAuth";
import { useMutation } from "../../hooks/useMutation";
import { components } from "../../generated/api"; import { components } from "../../generated/api";
import constants from "../../../../shared/constants.json"; import constants from "../../../../shared/constants.json";
import { import {
@ -30,9 +31,7 @@ export default function AdminInvitesPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>(""); const [statusFilter, setStatusFilter] = useState<string>("");
const [isCreating, setIsCreating] = useState(false);
const [newGodfatherId, setNewGodfatherId] = useState(""); const [newGodfatherId, setNewGodfatherId] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const [users, setUsers] = useState<UserOption[]>([]); const [users, setUsers] = useState<UserOption[]>([]);
const { user, isLoading, isAuthorized } = useRequireAuth({ const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.MANAGE_INVITES, requiredPermission: Permission.MANAGE_INVITES,
@ -66,36 +65,53 @@ export default function AdminInvitesPage() {
} }
}, [user, page, statusFilter, isAuthorized, fetchUsers, fetchInvites]); }, [user, page, statusFilter, isAuthorized, fetchUsers, fetchInvites]);
const handleCreateInvite = async () => { const {
if (!newGodfatherId) { mutate: createInvite,
setCreateError("Please select a godfather"); isLoading: isCreating,
return; error: createError,
} } = useMutation(
(godfatherId: number) =>
setIsCreating(true); adminApi.createInvite({
setCreateError(null); godfather_id: godfatherId,
}),
try { {
await adminApi.createInvite({ onSuccess: () => {
godfather_id: parseInt(newGodfatherId),
});
setNewGodfatherId(""); setNewGodfatherId("");
fetchInvites(1, statusFilter); fetchInvites(1, statusFilter);
setPage(1); setPage(1);
} catch (err) { },
setCreateError(err instanceof Error ? err.message : "Failed to create invite"); }
} finally { );
setIsCreating(false);
const { mutate: revokeInvite } = useMutation(
(inviteId: number) => adminApi.revokeInvite(inviteId),
{
onSuccess: () => {
setError(null);
fetchInvites(page, statusFilter);
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to revoke invite");
},
}
);
const handleCreateInvite = async () => {
if (!newGodfatherId) {
return;
}
try {
await createInvite(parseInt(newGodfatherId));
} catch {
// Error handled by useMutation
} }
}; };
const handleRevoke = async (inviteId: number) => { const handleRevoke = async (inviteId: number) => {
try { try {
await adminApi.revokeInvite(inviteId); await revokeInvite(inviteId);
setError(null); } catch {
fetchInvites(page, statusFilter); // Error handled by useMutation
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to revoke invite");
} }
}; };

View file

@ -1,58 +1,43 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useState } from "react";
import { Permission } from "../../auth-context"; import { Permission } from "../../auth-context";
import { adminApi } from "../../api"; import { adminApi } from "../../api";
import { sharedStyles } from "../../styles/shared"; import { sharedStyles } from "../../styles/shared";
import { Header } from "../../components/Header"; import { PageLayout } from "../../components/PageLayout";
import { useRequireAuth } from "../../hooks/useRequireAuth"; import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api"; import { useAsyncData } from "../../hooks/useAsyncData";
type PriceHistoryRecord = components["schemas"]["PriceHistoryResponse"];
export default function AdminPriceHistoryPage() { export default function AdminPriceHistoryPage() {
const [records, setRecords] = useState<PriceHistoryRecord[]>([]);
const [error, setError] = useState<string | null>(null);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isFetching, setIsFetching] = useState(false);
const { user, isLoading, isAuthorized } = useRequireAuth({ const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_AUDIT, requiredPermission: Permission.VIEW_AUDIT,
fallbackRedirect: "/", fallbackRedirect: "/",
}); });
const fetchRecords = useCallback(async () => { const {
setError(null); data: records = [],
setIsLoadingData(true); isLoading: isLoadingData,
try { error,
const data = await adminApi.getPriceHistory(); refetch: fetchRecords,
setRecords(data); } = useAsyncData(() => adminApi.getPriceHistory(), {
} catch (err) { enabled: !!user && isAuthorized,
setRecords([]); initialData: [],
setError(err instanceof Error ? err.message : "Failed to load price history"); });
} finally {
setIsLoadingData(false); const [isFetching, setIsFetching] = useState(false);
}
}, []);
const handleFetchNow = async () => { const handleFetchNow = async () => {
setIsFetching(true); setIsFetching(true);
setError(null);
try { try {
await adminApi.fetchPrice(); await adminApi.fetchPrice();
await fetchRecords(); await fetchRecords();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch price"); console.error("Failed to fetch price:", err);
} finally { } finally {
setIsFetching(false); setIsFetching(false);
} }
}; };
useEffect(() => {
if (user && isAuthorized) {
fetchRecords();
}
}, [user, isAuthorized, fetchRecords]);
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(); return new Date(dateStr).toLocaleString();
}; };
@ -66,23 +51,14 @@ export default function AdminPriceHistoryPage() {
}).format(price); }).format(price);
}; };
if (isLoading) {
return ( return (
<main style={styles.main}> <PageLayout
<div style={styles.loader}>Loading...</div> currentPage="admin-price-history"
</main> isLoading={isLoading}
); isAuthorized={!!user && isAuthorized}
} error={error}
contentStyle={styles.content}
if (!user || !isAuthorized) { >
return null;
}
return (
<main style={styles.main}>
<Header currentPage="admin-price-history" />
<div style={styles.content}>
<div style={styles.tableCard}> <div style={styles.tableCard}>
<div style={styles.tableHeader}> <div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>Bitcoin Price History</h2> <h2 style={styles.tableTitle}>Bitcoin Price History</h2>
@ -143,8 +119,7 @@ export default function AdminPriceHistoryPage() {
</table> </table>
</div> </div>
</div> </div>
</div> </PageLayout>
</main>
); );
} }

View file

@ -0,0 +1,18 @@
import { bannerStyles } from "../styles/shared";
interface ErrorDisplayProps {
/** Error message to display */
error: string | null | undefined;
/** Optional custom style */
style?: React.CSSProperties;
}
/**
* Standardized error display component.
* Use this instead of inline error banners for consistency.
*/
export function ErrorDisplay({ error, style }: ErrorDisplayProps) {
if (!error) return null;
return <div style={{ ...bannerStyles.errorBanner, ...style }}>{error}</div>;
}

View file

@ -0,0 +1,65 @@
import { ReactNode } from "react";
import { Header } from "./Header";
import { LoadingState } from "./LoadingState";
import { ErrorDisplay } from "./ErrorDisplay";
import { layoutStyles } from "../styles/shared";
type PageId =
| "profile"
| "invites"
| "exchange"
| "trades"
| "admin-invites"
| "admin-availability"
| "admin-trades"
| "admin-price-history";
interface PageLayoutProps {
/** Current page ID for navigation highlighting */
currentPage: PageId;
/** Whether the page is loading (shows loading state) */
isLoading?: boolean;
/** Whether the user is authorized (hides page if false) */
isAuthorized?: boolean;
/** Error message to display */
error?: string | null;
/** Page content */
children: ReactNode;
/** Custom content wrapper style */
contentStyle?: React.CSSProperties;
}
/**
* Standard page layout component that handles common page structure:
* - Loading state
* - Authorization check
* - Header navigation
* - Error banner
* - Content wrapper
*/
export function PageLayout({
currentPage,
isLoading = false,
isAuthorized = true,
error,
children,
contentStyle,
}: PageLayoutProps) {
if (isLoading) {
return <LoadingState />;
}
if (!isAuthorized) {
return null;
}
return (
<main style={layoutStyles.main}>
<Header currentPage={currentPage} />
<div style={contentStyle || layoutStyles.contentCentered}>
<ErrorDisplay error={error} />
{children}
</div>
</main>
);
}

View file

@ -0,0 +1,65 @@
import { useState, useEffect, useCallback } from "react";
/**
* Hook for fetching async data with loading and error states.
* Handles the common pattern of fetching data when component mounts or dependencies change.
*
* @param fetcher - Function that returns a Promise with the data
* @param options - Configuration options
* @returns Object containing data, loading state, error, and refetch function
*/
export function useAsyncData<T>(
fetcher: () => Promise<T>,
options: {
/** Whether the fetch should be enabled (default: true) */
enabled?: boolean;
/** Callback for handling errors */
onError?: (err: unknown) => void;
/** Initial data value (useful for optimistic updates) */
initialData?: T;
} = {}
): {
data: T | null;
isLoading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const { enabled = true, onError, initialData } = options;
const [data, setData] = useState<T | null>(initialData ?? null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!enabled) return;
setIsLoading(true);
setError(null);
try {
const result = await fetcher();
setData(result);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to load data";
setError(errorMessage);
if (onError) {
onError(err);
} else {
console.error("Failed to fetch data:", err);
}
} finally {
setIsLoading(false);
}
}, [fetcher, enabled, onError]);
useEffect(() => {
fetchData();
}, [fetchData]);
return {
data,
isLoading,
error,
refetch: fetchData,
};
}

View file

@ -0,0 +1,96 @@
import { useState, useCallback, useMemo } from "react";
/**
* Hook for managing form state with validation and dirty checking.
* Handles common form patterns like tracking changes, validation, and submission.
*
* @param initialData - Initial form data
* @param validator - Validation function that returns field errors
* @param onSubmit - Submit handler function
* @returns Form state and handlers
*/
export function useForm<
TData extends Record<string, unknown>,
TErrors extends Record<string, string | undefined>,
>(
initialData: TData,
validator: (data: TData) => TErrors,
onSubmit: (data: TData) => Promise<void>
): {
formData: TData;
setFormData: React.Dispatch<React.SetStateAction<TData>>;
errors: TErrors;
setErrors: React.Dispatch<React.SetStateAction<TErrors>>;
isDirty: boolean;
isValid: boolean;
isSubmitting: boolean;
handleChange: (field: keyof TData) => (e: React.ChangeEvent<HTMLInputElement>) => void;
handleSubmit: (e: React.FormEvent) => Promise<void>;
reset: () => void;
} {
const [formData, setFormData] = useState<TData>(initialData);
const [originalData] = useState<TData>(initialData);
const [errors, setErrors] = useState<TErrors>({} as TErrors);
const [isSubmitting, setIsSubmitting] = useState(false);
const isDirty = useMemo(() => {
return JSON.stringify(formData) !== JSON.stringify(originalData);
}, [formData, originalData]);
const isValid = useMemo(() => {
return Object.keys(errors).length === 0;
}, [errors]);
const handleChange = useCallback(
(field: keyof TData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
// Clear error for this field when user starts typing
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field as keyof TErrors];
return newErrors;
});
},
[]
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const validationErrors = validator(formData);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
} finally {
setIsSubmitting(false);
}
},
[formData, validator, onSubmit]
);
const reset = useCallback(() => {
setFormData(originalData);
setErrors({} as TErrors);
}, [originalData]);
return {
formData,
setFormData,
errors,
setErrors,
isDirty,
isValid,
isSubmitting,
handleChange,
handleSubmit,
reset,
};
}

View file

@ -0,0 +1,59 @@
import { useState, useCallback } from "react";
/**
* Hook for handling mutations (create, update, delete operations).
* Manages loading state and error handling for async mutations.
*
* @param mutationFn - Function that performs the mutation and returns a Promise
* @param options - Configuration options
* @returns Object containing mutate function, loading state, and error
*/
export function useMutation<TArgs, TResponse = void>(
mutationFn: (args: TArgs) => Promise<TResponse>,
options: {
/** Callback called on successful mutation */
onSuccess?: (data: TResponse) => void;
/** Callback called on mutation error */
onError?: (err: unknown) => void;
} = {}
): {
mutate: (args: TArgs) => Promise<TResponse | undefined>;
isLoading: boolean;
error: string | null;
} {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mutate = useCallback(
async (args: TArgs): Promise<TResponse | undefined> => {
setIsLoading(true);
setError(null);
try {
const result = await mutationFn(args);
if (options.onSuccess) {
options.onSuccess(result);
}
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Operation failed";
setError(errorMessage);
if (options.onError) {
options.onError(err);
} else {
console.error("Mutation failed:", err);
}
throw err;
} finally {
setIsLoading(false);
}
},
[mutationFn, options]
);
return {
mutate,
isLoading,
error,
};
}

View file

@ -1,19 +1,14 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useState } from "react";
import { invitesApi } from "../api"; import { invitesApi } from "../api";
import { Header } from "../components/Header"; import { PageLayout } from "../components/PageLayout";
import { useRequireAuth } from "../hooks/useRequireAuth"; import { useRequireAuth } from "../hooks/useRequireAuth";
import { useAsyncData } from "../hooks/useAsyncData";
import { components } from "../generated/api"; import { components } from "../generated/api";
import constants from "../../../shared/constants.json"; import constants from "../../../shared/constants.json";
import { Permission } from "../auth-context"; import { Permission } from "../auth-context";
import { import { cardStyles, typographyStyles, badgeStyles, buttonStyles } from "../styles/shared";
layoutStyles,
cardStyles,
typographyStyles,
badgeStyles,
buttonStyles,
} from "../styles/shared";
// Use generated type from OpenAPI schema // Use generated type from OpenAPI schema
type Invite = components["schemas"]["UserInviteResponse"]; type Invite = components["schemas"]["UserInviteResponse"];
@ -23,27 +18,17 @@ export default function InvitesPage() {
requiredPermission: Permission.VIEW_OWN_INVITES, requiredPermission: Permission.VIEW_OWN_INVITES,
fallbackRedirect: "/admin/trades", fallbackRedirect: "/admin/trades",
}); });
const [invites, setInvites] = useState<Invite[]>([]);
const [isLoadingInvites, setIsLoadingInvites] = useState(true); const { data: invites = [], isLoading: isLoadingInvites } = useAsyncData(
() => invitesApi.getInvites(),
{
enabled: !!user && isAuthorized,
initialData: [],
}
);
const [copiedId, setCopiedId] = useState<number | null>(null); const [copiedId, setCopiedId] = useState<number | null>(null);
const fetchInvites = useCallback(async () => {
try {
const data = await invitesApi.getInvites();
setInvites(data);
} catch (err) {
console.error("Failed to load invites:", err);
} finally {
setIsLoadingInvites(false);
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchInvites();
}
}, [user, isAuthorized, fetchInvites]);
const getInviteUrl = (identifier: string) => { const getInviteUrl = (identifier: string) => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return `${window.location.origin}/signup/${identifier}`; return `${window.location.origin}/signup/${identifier}`;
@ -62,28 +47,17 @@ export default function InvitesPage() {
} }
}; };
if (isLoading || isLoadingInvites) {
return (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
const { READY, SPENT, REVOKED } = constants.inviteStatuses; const { READY, SPENT, REVOKED } = constants.inviteStatuses;
const readyInvites = invites.filter((i) => i.status === READY); const readyInvites = invites.filter((i) => i.status === READY);
const spentInvites = invites.filter((i) => i.status === SPENT); const spentInvites = invites.filter((i) => i.status === SPENT);
const revokedInvites = invites.filter((i) => i.status === REVOKED); const revokedInvites = invites.filter((i) => i.status === REVOKED);
return ( return (
<main style={layoutStyles.main}> <PageLayout
<Header currentPage="invites" /> currentPage="invites"
isLoading={isLoading || isLoadingInvites}
<div style={layoutStyles.contentCentered}> isAuthorized={!!user && isAuthorized}
>
<div style={styles.pageCard}> <div style={styles.pageCard}>
<div style={cardStyles.cardHeader}> <div style={cardStyles.cardHeader}>
<h1 style={cardStyles.cardTitle}>My Invites</h1> <h1 style={cardStyles.cardTitle}>My Invites</h1>
@ -163,8 +137,7 @@ export default function InvitesPage() {
</div> </div>
)} )}
</div> </div>
</div> </PageLayout>
</main>
); );
} }

View file

@ -1,13 +1,13 @@
"use client"; "use client";
import { useEffect, useState, CSSProperties } from "react"; import { CSSProperties } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Permission } from "../../auth-context"; import { Permission } from "../../auth-context";
import { tradesApi } from "../../api"; import { tradesApi } from "../../api";
import { Header } from "../../components/Header"; import { Header } from "../../components/Header";
import { SatsDisplay } from "../../components/SatsDisplay"; import { SatsDisplay } from "../../components/SatsDisplay";
import { useRequireAuth } from "../../hooks/useRequireAuth"; import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api"; import { useAsyncData } from "../../hooks/useAsyncData";
import { formatDateTime } from "../../utils/date"; import { formatDateTime } from "../../utils/date";
import { formatEur, getTradeStatusDisplay } from "../../utils/exchange"; import { formatEur, getTradeStatusDisplay } from "../../utils/exchange";
import { import {
@ -19,8 +19,6 @@ import {
tradeCardStyles, tradeCardStyles,
} from "../../styles/shared"; } from "../../styles/shared";
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
export default function TradeDetailPage() { export default function TradeDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
@ -31,31 +29,22 @@ export default function TradeDetailPage() {
fallbackRedirect: "/", fallbackRedirect: "/",
}); });
const [trade, setTrade] = useState<ExchangeResponse | null>(null); const {
const [isLoadingTrade, setIsLoadingTrade] = useState(true); data: trade,
const [error, setError] = useState<string | null>(null); isLoading: isLoadingTrade,
error,
useEffect(() => { } = useAsyncData(
if (!user || !isAuthorized || !publicId) return; () => {
if (!publicId) throw new Error("Trade ID is required");
const fetchTrade = async () => { return tradesApi.getTrade(publicId);
try { },
setIsLoadingTrade(true); {
setError(null); enabled: !!user && isAuthorized && !!publicId,
const data = await tradesApi.getTrade(publicId); onError: () => {
setTrade(data); // Error message is set by useAsyncData
} catch (err) { },
console.error("Failed to fetch trade:", err);
setError(
"Failed to load trade. It may not exist or you may not have permission to view it."
);
} finally {
setIsLoadingTrade(false);
} }
}; );
fetchTrade();
}, [user, isAuthorized, publicId]);
if (isLoading || isLoadingTrade) { if (isLoading || isLoadingTrade) {
return ( return (
@ -69,13 +58,18 @@ export default function TradeDetailPage() {
return null; return null;
} }
if (error || !trade) { if (error || (!isLoadingTrade && !trade)) {
return ( return (
<main style={layoutStyles.main}> <main style={layoutStyles.main}>
<Header currentPage="trades" /> <Header currentPage="trades" />
<div style={styles.content}> <div style={styles.content}>
<h1 style={typographyStyles.pageTitle}>Trade Details</h1> <h1 style={typographyStyles.pageTitle}>Trade Details</h1>
{error && <div style={bannerStyles.errorBanner}>{error}</div>} {error && (
<div style={bannerStyles.errorBanner}>
{error ||
"Failed to load trade. It may not exist or you may not have permission to view it."}
</div>
)}
<button onClick={() => router.push("/trades")} style={buttonStyles.primaryButton}> <button onClick={() => router.push("/trades")} style={buttonStyles.primaryButton}>
Back to Trades Back to Trades
</button> </button>

View file

@ -1,26 +1,17 @@
"use client"; "use client";
import { useEffect, useState, useCallback, CSSProperties } from "react"; import { useState, CSSProperties } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Permission } from "../auth-context"; import { Permission } from "../auth-context";
import { tradesApi } from "../api"; import { tradesApi } from "../api";
import { Header } from "../components/Header"; import { PageLayout } from "../components/PageLayout";
import { SatsDisplay } from "../components/SatsDisplay"; import { SatsDisplay } from "../components/SatsDisplay";
import { LoadingState } from "../components/LoadingState";
import { useRequireAuth } from "../hooks/useRequireAuth"; import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api"; import { useAsyncData } from "../hooks/useAsyncData";
import { useMutation } from "../hooks/useMutation";
import { formatDateTime } from "../utils/date"; import { formatDateTime } from "../utils/date";
import { formatEur, getTradeStatusDisplay } from "../utils/exchange"; import { formatEur, getTradeStatusDisplay } from "../utils/exchange";
import { import { typographyStyles, badgeStyles, buttonStyles, tradeCardStyles } from "../styles/shared";
layoutStyles,
typographyStyles,
bannerStyles,
badgeStyles,
buttonStyles,
tradeCardStyles,
} from "../styles/shared";
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
export default function TradesPage() { export default function TradesPage() {
const router = useRouter(); const router = useRouter();
@ -29,53 +20,38 @@ export default function TradesPage() {
fallbackRedirect: "/", fallbackRedirect: "/",
}); });
const [trades, setTrades] = useState<ExchangeResponse[]>([]); const {
const [isLoadingTrades, setIsLoadingTrades] = useState(true); data: trades = [],
isLoading: isLoadingTrades,
error,
refetch: fetchTrades,
} = useAsyncData(() => tradesApi.getTrades(), {
enabled: !!user && isAuthorized,
initialData: [],
});
const [cancellingId, setCancellingId] = useState<string | null>(null); const [cancellingId, setCancellingId] = useState<string | null>(null);
const [confirmCancelId, setConfirmCancelId] = useState<string | null>(null); const [confirmCancelId, setConfirmCancelId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchTrades = useCallback(async () => { const { mutate: cancelTrade } = useMutation(
try { (publicId: string) => tradesApi.cancelTrade(publicId),
const data = await tradesApi.getTrades(); {
setTrades(data); onSuccess: () => {
} catch (err) {
console.error("Failed to fetch trades:", err);
setError("Failed to load trades");
} finally {
setIsLoadingTrades(false);
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchTrades(); fetchTrades();
setConfirmCancelId(null);
},
} }
}, [user, isAuthorized, fetchTrades]); );
const handleCancel = async (publicId: string) => { const handleCancel = async (publicId: string) => {
setCancellingId(publicId); setCancellingId(publicId);
setError(null);
try { try {
await tradesApi.cancelTrade(publicId); await cancelTrade(publicId);
await fetchTrades();
setConfirmCancelId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel trade");
} finally { } finally {
setCancellingId(null); setCancellingId(null);
} }
}; };
if (isLoading) {
return <LoadingState />;
}
if (!isAuthorized) {
return null;
}
const upcomingTrades = trades.filter( const upcomingTrades = trades.filter(
(t) => t.status === "booked" && new Date(t.slot_start) > new Date() (t) => t.status === "booked" && new Date(t.slot_start) > new Date()
); );
@ -84,14 +60,16 @@ export default function TradesPage() {
); );
return ( return (
<main style={layoutStyles.main}> <PageLayout
<Header currentPage="trades" /> currentPage="trades"
<div style={styles.content}> isLoading={isLoading}
isAuthorized={isAuthorized}
error={error}
contentStyle={styles.content}
>
<h1 style={typographyStyles.pageTitle}>My Trades</h1> <h1 style={typographyStyles.pageTitle}>My Trades</h1>
<p style={typographyStyles.pageSubtitle}>View and manage your Bitcoin trades</p> <p style={typographyStyles.pageSubtitle}>View and manage your Bitcoin trades</p>
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
{isLoadingTrades ? ( {isLoadingTrades ? (
<div style={tradeCardStyles.emptyState}>Loading trades...</div> <div style={tradeCardStyles.emptyState}>Loading trades...</div>
) : trades.length === 0 ? ( ) : trades.length === 0 ? (
@ -305,8 +283,7 @@ export default function TradesPage() {
)} )}
</> </>
)} )}
</div> </PageLayout>
</main>
); );
} }