From a6fa6a80128baac494f04c3a4ecbdb43b071a2e8 Mon Sep 17 00:00:00 2001 From: counterweight Date: Thu, 25 Dec 2025 20:32:11 +0100 Subject: [PATCH] Refactor API layer into structured domain-specific modules - Created new api/ directory with domain-specific API modules: - api/client.ts: Base API client with error handling - api/auth.ts: Authentication endpoints - api/exchange.ts: Exchange/price endpoints - api/trades.ts: User trade endpoints - api/profile.ts: Profile management endpoints - api/invites.ts: Invite endpoints - api/admin.ts: Admin endpoints - api/index.ts: Centralized exports - Migrated all API calls from ad-hoc api.get/post/put to typed domain APIs - Updated all imports across codebase - Fixed test mocks to use new API structure - Fixed type issues in validation utilities - Removed old api.ts file Benefits: - Type-safe endpoints (no more string typos) - Centralized API surface (easy to discover endpoints) - Better organization (domain-specific modules) - Uses generated OpenAPI types automatically --- frontend/app/admin/availability/page.tsx | 13 +- frontend/app/admin/invites/page.tsx | 14 +- frontend/app/admin/price-history/page.tsx | 6 +- frontend/app/admin/trades/page.tsx | 33 +- frontend/app/api/admin.ts | 156 ++++++++++ frontend/app/api/auth.ts | 41 +++ frontend/app/{api.ts => api/client.ts} | 10 +- frontend/app/api/exchange.ts | 41 +++ frontend/app/api/index.ts | 12 + frontend/app/api/invites.ts | 24 ++ frontend/app/api/profile.ts | 30 ++ frontend/app/api/trades.ts | 30 ++ frontend/app/auth-context.tsx | 14 +- .../app/exchange/hooks/useAvailableSlots.ts | 7 +- .../app/exchange/hooks/useExchangePrice.ts | 4 +- frontend/app/exchange/page.tsx | 6 +- frontend/app/hooks/useDebouncedValidation.ts | 13 +- frontend/app/invites/page.tsx | 4 +- frontend/app/profile/page.test.tsx | 294 ++++++++---------- frontend/app/profile/page.tsx | 6 +- frontend/app/signup/page.tsx | 12 +- frontend/app/trades/[id]/page.tsx | 6 +- frontend/app/trades/page.tsx | 6 +- frontend/app/utils/validation.ts | 2 +- 24 files changed, 529 insertions(+), 255 deletions(-) create mode 100644 frontend/app/api/admin.ts create mode 100644 frontend/app/api/auth.ts rename frontend/app/{api.ts => api/client.ts} (86%) create mode 100644 frontend/app/api/exchange.ts create mode 100644 frontend/app/api/index.ts create mode 100644 frontend/app/api/invites.ts create mode 100644 frontend/app/api/profile.ts create mode 100644 frontend/app/api/trades.ts diff --git a/frontend/app/admin/availability/page.tsx b/frontend/app/admin/availability/page.tsx index e8e1740..4a1f59c 100644 --- a/frontend/app/admin/availability/page.tsx +++ b/frontend/app/admin/availability/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import { Permission } from "../../auth-context"; -import { api } from "../../api"; +import { adminApi } from "../../api"; import { Header } from "../../components/Header"; import { useRequireAuth } from "../../hooks/useRequireAuth"; import { components } from "../../generated/api"; @@ -25,7 +25,6 @@ import { const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.exchange; type _AvailabilityDay = components["schemas"]["AvailabilityDay"]; -type AvailabilityResponse = components["schemas"]["AvailabilityResponse"]; type TimeSlot = components["schemas"]["TimeSlot"]; // Generate time options for dropdowns (15-min intervals) @@ -73,9 +72,7 @@ export default function AdminAvailabilityPage() { try { const fromDate = formatDate(dateRange[0]); const toDate = formatDate(dateRange[dateRange.length - 1]); - const data = await api.get( - `/api/admin/availability?from=${fromDate}&to=${toDate}` - ); + const data = await adminApi.getAvailability(fromDate, toDate); const map = new Map(); for (const day of data.days) { @@ -140,7 +137,7 @@ export default function AdminAvailabilityPage() { end_time: s.end_time + ":00", })); - await api.put("/api/admin/availability", { + await adminApi.updateAvailability({ date: formatDate(selectedDate), slots, }); @@ -161,7 +158,7 @@ export default function AdminAvailabilityPage() { setError(null); try { - await api.put("/api/admin/availability", { + await adminApi.updateAvailability({ date: formatDate(selectedDate), slots: [], }); @@ -203,7 +200,7 @@ export default function AdminAvailabilityPage() { setError(null); try { - await api.post("/api/admin/availability/copy", { + await adminApi.copyAvailability({ source_date: copySource, target_dates: Array.from(copyTargets), }); diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 4f30ba2..6018376 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import { Permission } from "../../auth-context"; -import { api } from "../../api"; +import { adminApi } from "../../api"; import { Header } from "../../components/Header"; import { useRequireAuth } from "../../hooks/useRequireAuth"; import { components } from "../../generated/api"; @@ -41,7 +41,7 @@ export default function AdminInvitesPage() { const fetchUsers = useCallback(async () => { try { - const data = await api.get("/api/admin/users"); + const data = await adminApi.getUsers(); setUsers(data); } catch (err) { console.error("Failed to fetch users:", err); @@ -51,11 +51,7 @@ export default function AdminInvitesPage() { const fetchInvites = useCallback(async (page: number, status: string) => { setError(null); try { - let url = `/api/admin/invites?page=${page}&per_page=10`; - if (status) { - url += `&status=${status}`; - } - const data = await api.get(url); + const data = await adminApi.getInvites(page, 10, status || undefined); setData(data); } catch (err) { setData(null); @@ -80,7 +76,7 @@ export default function AdminInvitesPage() { setCreateError(null); try { - await api.post("/api/admin/invites", { + await adminApi.createInvite({ godfather_id: parseInt(newGodfatherId), }); setNewGodfatherId(""); @@ -95,7 +91,7 @@ export default function AdminInvitesPage() { const handleRevoke = async (inviteId: number) => { try { - await api.post(`/api/admin/invites/${inviteId}/revoke`); + await adminApi.revokeInvite(inviteId); setError(null); fetchInvites(page, statusFilter); } catch (err) { diff --git a/frontend/app/admin/price-history/page.tsx b/frontend/app/admin/price-history/page.tsx index fb8a69e..0a579db 100644 --- a/frontend/app/admin/price-history/page.tsx +++ b/frontend/app/admin/price-history/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import { Permission } from "../../auth-context"; -import { api } from "../../api"; +import { adminApi } from "../../api"; import { sharedStyles } from "../../styles/shared"; import { Header } from "../../components/Header"; import { useRequireAuth } from "../../hooks/useRequireAuth"; @@ -24,7 +24,7 @@ export default function AdminPriceHistoryPage() { setError(null); setIsLoadingData(true); try { - const data = await api.get("/api/audit/price-history"); + const data = await adminApi.getPriceHistory(); setRecords(data); } catch (err) { setRecords([]); @@ -38,7 +38,7 @@ export default function AdminPriceHistoryPage() { setIsFetching(true); setError(null); try { - await api.post("/api/audit/price-history/fetch", {}); + await adminApi.fetchPrice(); await fetchRecords(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to fetch price"); diff --git a/frontend/app/admin/trades/page.tsx b/frontend/app/admin/trades/page.tsx index 6817a25..4fb9372 100644 --- a/frontend/app/admin/trades/page.tsx +++ b/frontend/app/admin/trades/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, CSSProperties } from "react"; import { Permission } from "../../auth-context"; -import { api } from "../../api"; +import { adminApi } from "../../api"; import { Header } from "../../components/Header"; import { SatsDisplay } from "../../components/SatsDisplay"; import { useRequireAuth } from "../../hooks/useRequireAuth"; @@ -47,7 +47,7 @@ export default function AdminTradesPage() { const fetchUpcomingTrades = useCallback(async (): Promise => { try { - const data = await api.get("/api/admin/trades/upcoming"); + const data = await adminApi.getUpcomingTrades(); setUpcomingTrades(data); return null; } catch (err) { @@ -58,21 +58,17 @@ export default function AdminTradesPage() { const fetchPastTrades = useCallback(async (): Promise => { try { - let url = "/api/admin/trades/past"; - const params = new URLSearchParams(); - + const params: { status?: string; user_search?: string } = {}; if (statusFilter !== "all") { - params.append("status", statusFilter); + params.status = statusFilter; } if (userSearch.trim()) { - params.append("user_search", userSearch.trim()); + params.user_search = userSearch.trim(); } - if (params.toString()) { - url += `?${params.toString()}`; - } - - const data = await api.get(url); + const data = await adminApi.getPastTrades( + Object.keys(params).length > 0 ? params : undefined + ); setPastTrades(data); return null; } catch (err) { @@ -105,12 +101,13 @@ export default function AdminTradesPage() { setError(null); try { - const endpoint = - action === "no_show" - ? `/api/admin/trades/${publicId}/no-show` - : `/api/admin/trades/${publicId}/${action}`; - - await api.post(endpoint, {}); + 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); diff --git a/frontend/app/api/admin.ts b/frontend/app/api/admin.ts new file mode 100644 index 0000000..93cd1a4 --- /dev/null +++ b/frontend/app/api/admin.ts @@ -0,0 +1,156 @@ +import { client } from "./client"; +import { components } from "../generated/api"; + +type AdminUserResponse = components["schemas"]["AdminUserResponse"]; +type PaginatedInvites = components["schemas"]["PaginatedResponse_InviteResponse_"]; +type AdminExchangeResponse = components["schemas"]["AdminExchangeResponse"]; +type AvailabilityResponse = components["schemas"]["AvailabilityResponse"]; +type PriceHistoryRecord = components["schemas"]["PriceHistoryResponse"]; + +interface CreateInviteRequest { + godfather_id: number; +} + +interface UpdateAvailabilityRequest { + date: string; + slots: Array<{ start_time: string; end_time: string }>; +} + +interface CopyAvailabilityRequest { + source_date: string; + target_dates: string[]; +} + +interface GetPastTradesParams { + status?: string; + user_search?: string; +} + +/** + * Admin API endpoints + */ +export const adminApi = { + /** + * Get all users (for dropdowns, etc.) + */ + getUsers(): Promise { + return client.get("/api/admin/users"); + }, + + /** + * Get paginated invites + */ + getInvites(page: number, perPage: number = 10, status?: string): Promise { + const params = new URLSearchParams(); + params.append("page", page.toString()); + params.append("per_page", perPage.toString()); + if (status) { + params.append("status", status); + } + return client.get(`/api/admin/invites?${params.toString()}`); + }, + + /** + * Create a new invite + */ + createInvite(request: CreateInviteRequest): Promise { + return client.post("/api/admin/invites", request); + }, + + /** + * Revoke an invite + */ + revokeInvite(inviteId: number): Promise { + return client.post(`/api/admin/invites/${inviteId}/revoke`); + }, + + /** + * Get upcoming trades + */ + getUpcomingTrades(): Promise { + return client.get("/api/admin/trades/upcoming"); + }, + + /** + * Get past trades with optional filters + */ + getPastTrades(params?: GetPastTradesParams): Promise { + const searchParams = new URLSearchParams(); + if (params?.status) { + searchParams.append("status", params.status); + } + if (params?.user_search) { + searchParams.append("user_search", params.user_search); + } + const queryString = searchParams.toString(); + const url = queryString ? `/api/admin/trades/past?${queryString}` : "/api/admin/trades/past"; + return client.get(url); + }, + + /** + * Complete a trade + */ + completeTrade(publicId: string): Promise { + return client.post( + `/api/admin/trades/${encodeURIComponent(publicId)}/complete`, + {} + ); + }, + + /** + * Mark a trade as no-show + */ + noShowTrade(publicId: string): Promise { + return client.post( + `/api/admin/trades/${encodeURIComponent(publicId)}/no-show`, + {} + ); + }, + + /** + * Cancel a trade (admin) + */ + cancelTrade(publicId: string): Promise { + return client.post( + `/api/admin/trades/${encodeURIComponent(publicId)}/cancel`, + {} + ); + }, + + /** + * Get availability for a date range + */ + getAvailability(fromDate: string, toDate: string): Promise { + return client.get( + `/api/admin/availability?from=${encodeURIComponent(fromDate)}&to=${encodeURIComponent(toDate)}` + ); + }, + + /** + * Update availability for a specific date + */ + updateAvailability(request: UpdateAvailabilityRequest): Promise { + return client.put("/api/admin/availability", request); + }, + + /** + * Copy availability from one date to multiple dates + */ + copyAvailability(request: CopyAvailabilityRequest): Promise { + return client.post("/api/admin/availability/copy", request); + }, + + /** + * Get price history records + */ + getPriceHistory(): Promise { + return client.get("/api/audit/price-history"); + }, + + /** + * Trigger a price fetch + */ + fetchPrice(): Promise { + return client.post("/api/audit/price-history/fetch", {}); + }, +}; diff --git a/frontend/app/api/auth.ts b/frontend/app/api/auth.ts new file mode 100644 index 0000000..da0e9f2 --- /dev/null +++ b/frontend/app/api/auth.ts @@ -0,0 +1,41 @@ +import { client } from "./client"; +import { components } from "../generated/api"; + +type User = components["schemas"]["UserResponse"]; + +/** + * Authentication API endpoints + */ +export const authApi = { + /** + * Get current authenticated user + */ + getMe(): Promise { + return client.get("/api/auth/me"); + }, + + /** + * Login with email and password + */ + login(email: string, password: string): Promise { + return client.post("/api/auth/login", { email, password }); + }, + + /** + * Register a new user with invite code + */ + register(email: string, password: string, inviteIdentifier: string): Promise { + return client.post("/api/auth/register", { + email, + password, + invite_identifier: inviteIdentifier, + }); + }, + + /** + * Logout current user + */ + logout(): Promise { + return client.post("/api/auth/logout"); + }, +}; diff --git a/frontend/app/api.ts b/frontend/app/api/client.ts similarity index 86% rename from frontend/app/api.ts rename to frontend/app/api/client.ts index 2f83505..6e21b02 100644 --- a/frontend/app/api.ts +++ b/frontend/app/api/client.ts @@ -1,7 +1,7 @@ -import { API_URL } from "./config"; +import { API_URL } from "../config"; /** - * Simple API client that centralizes fetch configuration. + * Base API client that centralizes fetch configuration. * All requests include credentials and proper headers. */ @@ -46,7 +46,11 @@ async function request(endpoint: string, options: RequestInit = {}): Promise< return res.json(); } -export const api = { +/** + * Base API client methods. + * Domain-specific APIs should use these internally. + */ +export const client = { get(endpoint: string): Promise { return request(endpoint); }, diff --git a/frontend/app/api/exchange.ts b/frontend/app/api/exchange.ts new file mode 100644 index 0000000..374dfe4 --- /dev/null +++ b/frontend/app/api/exchange.ts @@ -0,0 +1,41 @@ +import { client } from "./client"; +import { components } from "../generated/api"; + +type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"]; +type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; +type ExchangeResponse = components["schemas"]["ExchangeResponse"]; + +interface CreateExchangeRequest { + slot_start: string; + direction: "buy" | "sell"; + bitcoin_transfer_method: "onchain" | "lightning"; + eur_amount: number; +} + +/** + * Exchange API endpoints + */ +export const exchangeApi = { + /** + * Get current exchange price + */ + getPrice(): Promise { + return client.get("/api/exchange/price"); + }, + + /** + * Get available slots for a specific date + */ + getSlots(date: string): Promise { + return client.get( + `/api/exchange/slots?date=${encodeURIComponent(date)}` + ); + }, + + /** + * Create a new exchange/trade + */ + createExchange(request: CreateExchangeRequest): Promise { + return client.post("/api/exchange", request); + }, +}; diff --git a/frontend/app/api/index.ts b/frontend/app/api/index.ts new file mode 100644 index 0000000..3d7f2ba --- /dev/null +++ b/frontend/app/api/index.ts @@ -0,0 +1,12 @@ +/** + * Centralized API exports. + * Import domain-specific APIs from here. + */ + +export { ApiError, client } from "./client"; +export { authApi } from "./auth"; +export { exchangeApi } from "./exchange"; +export { tradesApi } from "./trades"; +export { profileApi } from "./profile"; +export { invitesApi } from "./invites"; +export { adminApi } from "./admin"; diff --git a/frontend/app/api/invites.ts b/frontend/app/api/invites.ts new file mode 100644 index 0000000..92d71a6 --- /dev/null +++ b/frontend/app/api/invites.ts @@ -0,0 +1,24 @@ +import { client } from "./client"; +import { components } from "../generated/api"; + +type Invite = components["schemas"]["UserInviteResponse"]; +type InviteCheckResponse = components["schemas"]["InviteCheckResponse"]; + +/** + * Invites API endpoints (user-facing) + */ +export const invitesApi = { + /** + * Get all invites for the current user + */ + getInvites(): Promise { + return client.get("/api/invites"); + }, + + /** + * Check if an invite code is valid + */ + checkInvite(code: string): Promise { + return client.get(`/api/invites/${encodeURIComponent(code)}/check`); + }, +}; diff --git a/frontend/app/api/profile.ts b/frontend/app/api/profile.ts new file mode 100644 index 0000000..1bcdc84 --- /dev/null +++ b/frontend/app/api/profile.ts @@ -0,0 +1,30 @@ +import { client } from "./client"; +import { components } from "../generated/api"; + +type ProfileData = components["schemas"]["ProfileResponse"]; + +interface UpdateProfileRequest { + contact_email: string | null; + telegram: string | null; + signal: string | null; + nostr_npub: string | null; +} + +/** + * Profile API endpoints + */ +export const profileApi = { + /** + * Get current user's profile + */ + getProfile(): Promise { + return client.get("/api/profile"); + }, + + /** + * Update current user's profile + */ + updateProfile(request: UpdateProfileRequest): Promise { + return client.put("/api/profile", request); + }, +}; diff --git a/frontend/app/api/trades.ts b/frontend/app/api/trades.ts new file mode 100644 index 0000000..7e5f1fd --- /dev/null +++ b/frontend/app/api/trades.ts @@ -0,0 +1,30 @@ +import { client } from "./client"; +import { components } from "../generated/api"; + +type ExchangeResponse = components["schemas"]["ExchangeResponse"]; + +/** + * Trades API endpoints (user-facing) + */ +export const tradesApi = { + /** + * Get all trades for the current user + */ + getTrades(): Promise { + return client.get("/api/trades"); + }, + + /** + * Get a specific trade by public ID + */ + getTrade(publicId: string): Promise { + return client.get(`/api/trades/${encodeURIComponent(publicId)}`); + }, + + /** + * Cancel a trade + */ + cancelTrade(publicId: string): Promise { + return client.post(`/api/trades/${encodeURIComponent(publicId)}/cancel`, {}); + }, +}; diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx index 85b79f8..a12b73c 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; -import { api } from "./api"; +import { authApi } from "./api"; import { components } from "./generated/api"; import { extractApiErrorMessage } from "./utils/error-handling"; @@ -54,7 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const checkAuth = async () => { try { - const userData = await api.get("/api/auth/me"); + const userData = await authApi.getMe(); setUser(userData); } catch { // Not authenticated @@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const login = async (email: string, password: string) => { try { - const userData = await api.post("/api/auth/login", { email, password }); + const userData = await authApi.login(email, password); setUser(userData); } catch (err) { throw new Error(extractApiErrorMessage(err, "Login failed")); @@ -74,11 +74,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const register = async (email: string, password: string, inviteIdentifier: string) => { try { - const userData = await api.post("/api/auth/register", { - email, - password, - invite_identifier: inviteIdentifier, - }); + const userData = await authApi.register(email, password, inviteIdentifier); setUser(userData); } catch (err) { throw new Error(extractApiErrorMessage(err, "Registration failed")); @@ -87,7 +83,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = async () => { try { - await api.post("/api/auth/logout"); + await authApi.logout(); } catch { // Ignore errors on logout } diff --git a/frontend/app/exchange/hooks/useAvailableSlots.ts b/frontend/app/exchange/hooks/useAvailableSlots.ts index c1ba1ba..4f3b25d 100644 --- a/frontend/app/exchange/hooks/useAvailableSlots.ts +++ b/frontend/app/exchange/hooks/useAvailableSlots.ts @@ -1,10 +1,9 @@ import { useState, useEffect, useCallback } from "react"; -import { api } from "../../api"; +import { exchangeApi } from "../../api"; import { components } from "../../generated/api"; import { formatDate } from "../../utils/date"; type BookableSlot = components["schemas"]["BookableSlot"]; -type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; interface UseAvailableSlotsOptions { /** Whether the user is authenticated and authorized */ @@ -48,7 +47,7 @@ export function useAvailableSlots(options: UseAvailableSlotsOptions): UseAvailab try { const dateStr = formatDate(date); - const data = await api.get(`/api/exchange/slots?date=${dateStr}`); + const data = await exchangeApi.getSlots(dateStr); setAvailableSlots(data.slots); } catch (err) { console.error("Failed to fetch slots:", err); @@ -70,7 +69,7 @@ export function useAvailableSlots(options: UseAvailableSlotsOptions): UseAvailab const promises = dates.map(async (date) => { try { const dateStr = formatDate(date); - const data = await api.get(`/api/exchange/slots?date=${dateStr}`); + const data = await exchangeApi.getSlots(dateStr); if (data.slots.length > 0) { availabilitySet.add(dateStr); } diff --git a/frontend/app/exchange/hooks/useExchangePrice.ts b/frontend/app/exchange/hooks/useExchangePrice.ts index ae8661b..9988f2b 100644 --- a/frontend/app/exchange/hooks/useExchangePrice.ts +++ b/frontend/app/exchange/hooks/useExchangePrice.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import { api } from "../../api"; +import { exchangeApi } from "../../api"; import { components } from "../../generated/api"; type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"]; @@ -37,7 +37,7 @@ export function useExchangePrice(options: UseExchangePriceOptions = {}): UseExch setError(null); try { - const data = await api.get("/api/exchange/price"); + const data = await exchangeApi.getPrice(); setPriceData(data); setLastUpdate(new Date()); diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx index c083bb7..b8cac6a 100644 --- a/frontend/app/exchange/page.tsx +++ b/frontend/app/exchange/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Permission } from "../auth-context"; -import { api } from "../api"; +import { exchangeApi, tradesApi } from "../api"; import { extractApiErrorMessage } from "../utils/error-handling"; import { Header } from "../components/Header"; import { LoadingState } from "../components/LoadingState"; @@ -166,7 +166,7 @@ export default function ExchangePage() { const fetchUserTrades = async () => { try { - const data = await api.get("/api/trades"); + const data = await tradesApi.getTrades(); setUserTrades(data); } catch (err) { console.error("Failed to fetch user trades:", err); @@ -242,7 +242,7 @@ export default function ExchangePage() { setExistingTradeId(null); try { - await api.post("/api/exchange", { + await exchangeApi.createExchange({ slot_start: selectedSlot.start_time, direction, bitcoin_transfer_method: bitcoinTransferMethod, diff --git a/frontend/app/hooks/useDebouncedValidation.ts b/frontend/app/hooks/useDebouncedValidation.ts index 7477a75..46b9583 100644 --- a/frontend/app/hooks/useDebouncedValidation.ts +++ b/frontend/app/hooks/useDebouncedValidation.ts @@ -9,16 +9,19 @@ import { useEffect, useRef, useState } from "react"; * @param delay - Debounce delay in milliseconds (default: 500) * @returns Object containing current errors and a function to manually trigger validation */ -export function useDebouncedValidation( +export function useDebouncedValidation< + T, + E extends Record = Record, +>( formData: T, - validator: (data: T) => Record, + validator: (data: T) => E, delay: number = 500 ): { - errors: Record; - setErrors: React.Dispatch>>; + errors: E; + setErrors: React.Dispatch>; validate: (data?: T) => void; } { - const [errors, setErrors] = useState>({}); + const [errors, setErrors] = useState({} as E); const validationTimeoutRef = useRef(null); const formDataRef = useRef(formData); diff --git a/frontend/app/invites/page.tsx b/frontend/app/invites/page.tsx index da507d3..90effd6 100644 --- a/frontend/app/invites/page.tsx +++ b/frontend/app/invites/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import { api } from "../api"; +import { invitesApi } from "../api"; import { Header } from "../components/Header"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { components } from "../generated/api"; @@ -29,7 +29,7 @@ export default function InvitesPage() { const fetchInvites = useCallback(async () => { try { - const data = await api.get("/api/invites"); + const data = await invitesApi.getInvites(); setInvites(data); } catch (err) { console.error("Failed to load invites:", err); diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx index 1616b15..61957c8 100644 --- a/frontend/app/profile/page.test.tsx +++ b/frontend/app/profile/page.test.tsx @@ -53,6 +53,24 @@ vi.mock("../auth-context", () => ({ }, })); +// Mock profileApi +const mockGetProfile = vi.fn(); +const mockUpdateProfile = vi.fn(); + +vi.mock("../api", async (importOriginal) => { + const actual = await importOriginal(); + // Create a getter that returns the mock functions + return { + ...actual, + get profileApi() { + return { + getProfile: mockGetProfile, + updateProfile: mockUpdateProfile, + }; + }, + }; +}); + // Mock profile data const mockProfileData = { contact_email: "contact@example.com", @@ -80,6 +98,9 @@ beforeEach(() => { mockHasPermission.mockImplementation( (permission: string) => mockUser?.permissions.includes(permission) ?? false ); + // Reset API mocks + mockGetProfile.mockResolvedValue(mockProfileData); + mockUpdateProfile.mockResolvedValue(mockProfileData); }); afterEach(() => { @@ -89,17 +110,14 @@ afterEach(() => { describe("ProfilePage - Display", () => { test("renders loading state initially", () => { mockIsLoading = true; - vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {})); + mockGetProfile.mockImplementation(() => new Promise(() => {})); render(); expect(screen.getByText("Loading...")).toBeDefined(); }); test("renders profile page title", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -108,10 +126,7 @@ describe("ProfilePage - Display", () => { }); test("displays login email as read-only", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -122,10 +137,7 @@ describe("ProfilePage - Display", () => { }); test("shows read-only badge for login email", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -134,10 +146,7 @@ describe("ProfilePage - Display", () => { }); test("shows hint about login email", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -146,10 +155,7 @@ describe("ProfilePage - Display", () => { }); test("displays contact details section hint", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -158,10 +164,7 @@ describe("ProfilePage - Display", () => { }); test("displays fetched profile data", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -173,34 +176,22 @@ describe("ProfilePage - Display", () => { }); test("fetches profile with credentials", async () => { - const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { - expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:8000/api/profile", - expect.objectContaining({ - credentials: "include", - }) - ); + expect(mockGetProfile).toHaveBeenCalled(); }); }); test("displays empty fields when profile has null values", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response); + mockGetProfile.mockResolvedValue({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }); render(); await waitFor(() => { @@ -213,10 +204,7 @@ describe("ProfilePage - Display", () => { describe("ProfilePage - Navigation", () => { test("shows nav links for regular user", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -226,10 +214,7 @@ describe("ProfilePage - Navigation", () => { }); test("highlights My Profile in nav", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -273,13 +258,12 @@ describe("ProfilePage - Access Control", () => { roles: ["admin"], permissions: ["view_audit"], }; - const fetchSpy = vi.spyOn(global, "fetch"); render(); // Give it a moment to potentially fetch await new Promise((r) => setTimeout(r, 100)); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockGetProfile).not.toHaveBeenCalled(); }); }); @@ -304,10 +288,7 @@ describe("ProfilePage - Loading State", () => { describe("ProfilePage - Form Behavior", () => { test("submit button is disabled when no changes", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); await waitFor(() => { @@ -317,10 +298,7 @@ describe("ProfilePage - Form Behavior", () => { }); test("submit button is enabled after field changes", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockProfileData), - } as Response); + mockGetProfile.mockResolvedValue(mockProfileData); render(); @@ -338,16 +316,12 @@ describe("ProfilePage - Form Behavior", () => { }); test("auto-prepends @ to telegram when user starts with letter", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response); + mockGetProfile.mockResolvedValue({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }); render(); @@ -364,16 +338,12 @@ describe("ProfilePage - Form Behavior", () => { }); test("does not auto-prepend @ if user types @ first", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response); + mockGetProfile.mockResolvedValue({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }); render(); @@ -392,28 +362,25 @@ describe("ProfilePage - Form Behavior", () => { describe("ProfilePage - Form Submission", () => { test("shows success toast after successful save", async () => { - const fetchSpy = vi - .spyOn(global, "fetch") + mockGetProfile .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response) + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }) .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - contact_email: "new@example.com", - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response); + contact_email: "new@example.com", + telegram: null, + signal: null, + nostr_npub: null, + }); + mockUpdateProfile.mockResolvedValue({ + contact_email: "new@example.com", + telegram: null, + signal: null, + nostr_npub: null, + }); render(); @@ -433,40 +400,33 @@ describe("ProfilePage - Form Submission", () => { expect(screen.getByText(/saved successfully/i)).toBeDefined(); }); - // Verify PUT was called - expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:8000/api/profile", - expect.objectContaining({ - method: "PUT", - credentials: "include", - }) - ); + // Verify updateProfile was called + expect(mockUpdateProfile).toHaveBeenCalledWith({ + contact_email: "new@example.com", + telegram: null, + signal: null, + nostr_npub: null, + }); }); test("shows inline errors from backend validation", async () => { - vi.spyOn(global, "fetch") - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response) - .mockResolvedValueOnce({ - ok: false, - status: 422, - json: () => - Promise.resolve({ - detail: { - field_errors: { - telegram: "Backend error: invalid handle", - }, - }, - }), - } as Response); + mockGetProfile.mockResolvedValue({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }); + // Import ApiError from the actual module (not mocked) + const { ApiError } = await import("../api/client"); + mockUpdateProfile.mockRejectedValue( + new ApiError("Validation failed", 422, { + detail: { + field_errors: { + telegram: "Backend error: invalid handle", + }, + }, + }) + ); render(); @@ -488,18 +448,13 @@ describe("ProfilePage - Form Submission", () => { }); test("shows error toast on network failure", async () => { - vi.spyOn(global, "fetch") - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response) - .mockRejectedValueOnce(new Error("Network error")); + mockGetProfile.mockResolvedValue({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }); + mockUpdateProfile.mockRejectedValue(new Error("Network error")); render(); @@ -521,23 +476,28 @@ describe("ProfilePage - Form Submission", () => { }); test("submit button shows 'Saving...' while submitting", async () => { - let resolveSubmit: (value: Response) => void; - const submitPromise = new Promise((resolve) => { + let resolveSubmit: (value: { + contact_email: string | null; + telegram: string | null; + signal: string | null; + nostr_npub: string | null; + }) => void; + const submitPromise = new Promise<{ + contact_email: string | null; + telegram: string | null; + signal: string | null; + nostr_npub: string | null; + }>((resolve) => { resolveSubmit = resolve; }); - vi.spyOn(global, "fetch") - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response) - .mockReturnValueOnce(submitPromise as Promise); + mockGetProfile.mockResolvedValue({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }); + mockUpdateProfile.mockReturnValue(submitPromise); render(); @@ -559,15 +519,11 @@ describe("ProfilePage - Form Submission", () => { // Resolve the promise resolveSubmit!({ - ok: true, - json: () => - Promise.resolve({ - contact_email: "new@example.com", - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response); + contact_email: "new@example.com", + telegram: null, + signal: null, + nostr_npub: null, + }); await waitFor(() => { expect(screen.getByRole("button", { name: /save changes/i })).toBeDefined(); diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 7720d0c..5d8c1da 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react"; -import { api } from "../api"; +import { profileApi } from "../api"; import { extractApiErrorMessage, extractFieldErrors } from "../utils/error-handling"; import { Permission } from "../auth-context"; import { Header } from "../components/Header"; @@ -81,7 +81,7 @@ export default function ProfilePage() { const fetchProfile = useCallback(async () => { try { - const data = await api.get("/api/profile"); + const data = await profileApi.getProfile(); const formValues = toFormData(data); setFormData(formValues); setOriginalData(formValues); @@ -132,7 +132,7 @@ export default function ProfilePage() { setIsSubmitting(true); try { - const data = await api.put("/api/profile", { + const data = await profileApi.updateProfile({ contact_email: formData.contact_email || null, telegram: formData.telegram || null, signal: formData.signal || null, diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index d36dc56..c08dcb6 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -3,15 +3,9 @@ import { useState, useEffect, useCallback, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "../auth-context"; -import { api } from "../api"; +import { invitesApi } from "../api"; import { authFormStyles as styles } from "../styles/auth-form"; -interface InviteCheckResponse { - valid: boolean; - status?: string; - error?: string; -} - function SignupContent() { const searchParams = useSearchParams(); const initialCode = searchParams.get("code") || ""; @@ -49,9 +43,7 @@ function SignupContent() { setInviteError(""); try { - const response = await api.get( - `/api/invites/${encodeURIComponent(code.trim())}/check` - ); + const response = await invitesApi.checkInvite(code.trim()); if (response.valid) { setInviteValid(true); diff --git a/frontend/app/trades/[id]/page.tsx b/frontend/app/trades/[id]/page.tsx index db773c6..28cd57f 100644 --- a/frontend/app/trades/[id]/page.tsx +++ b/frontend/app/trades/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, CSSProperties } from "react"; import { useParams, useRouter } from "next/navigation"; import { Permission } from "../../auth-context"; -import { api } from "../../api"; +import { tradesApi } from "../../api"; import { Header } from "../../components/Header"; import { SatsDisplay } from "../../components/SatsDisplay"; import { useRequireAuth } from "../../hooks/useRequireAuth"; @@ -42,7 +42,7 @@ export default function TradeDetailPage() { try { setIsLoadingTrade(true); setError(null); - const data = await api.get(`/api/trades/${publicId}`); + const data = await tradesApi.getTrade(publicId); setTrade(data); } catch (err) { console.error("Failed to fetch trade:", err); @@ -221,7 +221,7 @@ export default function TradeDetailPage() { return; } try { - await api.post(`/api/trades/${trade.public_id}/cancel`, {}); + await tradesApi.cancelTrade(trade.public_id); router.push("/trades"); } catch (err) { setError(err instanceof Error ? err.message : "Failed to cancel trade"); diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx index f9e7a13..699dba1 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, CSSProperties } from "react"; import { useRouter } from "next/navigation"; import { Permission } from "../auth-context"; -import { api } from "../api"; +import { tradesApi } from "../api"; import { Header } from "../components/Header"; import { SatsDisplay } from "../components/SatsDisplay"; import { LoadingState } from "../components/LoadingState"; @@ -37,7 +37,7 @@ export default function TradesPage() { const fetchTrades = useCallback(async () => { try { - const data = await api.get("/api/trades"); + const data = await tradesApi.getTrades(); setTrades(data); } catch (err) { console.error("Failed to fetch trades:", err); @@ -58,7 +58,7 @@ export default function TradesPage() { setError(null); try { - await api.post(`/api/trades/${publicId}/cancel`, {}); + await tradesApi.cancelTrade(publicId); await fetchTrades(); setConfirmCancelId(null); } catch (err) { diff --git a/frontend/app/utils/validation.ts b/frontend/app/utils/validation.ts index 625e3aa..5c465a4 100644 --- a/frontend/app/utils/validation.ts +++ b/frontend/app/utils/validation.ts @@ -98,7 +98,7 @@ export function validateNostrNpub(value: string): string | undefined { /** * Field errors object type. */ -export interface FieldErrors { +export interface FieldErrors extends Record { contact_email?: string; telegram?: string; signal?: string;