From 185d49183c805ed91f1614e15f1b52414a8fd094 Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem Date: Wed, 30 Sep 2026 12:34:17 +0100 Subject: [PATCH] fix(frontend): distinguish an empty trade list from a fetch error (#282) `useTradeList` (and the homepage listings component) collapsed "no listings" and "failed to load" into the same empty array, so the empty state showed even during an error. - `useTradeList` exposes `isEmpty`, true only when a load **succeeded** with zero trades; it is `false` while loading and `false` when `error` is set, so the two states are mutually exclusive. `error` remains non-null only on failure. - `MarketplaceListings` (the homepage trade list) now tracks an `error` state: a non-OK response or a thrown fetch sets it (previously a non-OK response was ignored entirely), renders an inline error banner with a Retry button, and only renders the empty state when the load succeeded with no results. Tests: `useTradeList` success-empty / failure / success-non-empty, and `MarketplaceListings` error-banner-vs-empty-state. 12 passed; `tsc` clean. Closes #282 --- .../components/MarketplaceListings.test.tsx | 21 ++++++++ frontend/components/MarketplaceListings.tsx | 32 ++++++++++-- frontend/hooks/__tests__/useTradeList.test.ts | 49 +++++++++++++++++++ frontend/hooks/useTradeList.ts | 12 +++++ 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/frontend/components/MarketplaceListings.test.tsx b/frontend/components/MarketplaceListings.test.tsx index f0f829d..b8130a2 100644 --- a/frontend/components/MarketplaceListings.test.tsx +++ b/frontend/components/MarketplaceListings.test.tsx @@ -149,4 +149,25 @@ describe("MarketplaceListings Filter Panel (Issue #328)", () => { expect(global.fetch).toHaveBeenLastCalledWith(expect.not.stringContaining("assetType=")); }); }); + + // Issue #282 — an empty response and a network error must not look the same. + it("shows an error banner (not the empty state) when a filtered fetch fails", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("network down")); + + render(); + + fireEvent.change(screen.getByLabelText("Carrier"), { target: { value: "MTN" } }); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent(/could not load listings/i); + // The "no listings" empty state must NOT be shown while in an error state. + expect(screen.queryByText("No active listings right now")).not.toBeInTheDocument(); + }); + + it("shows the empty state only when there are no trades and no error", () => { + render(); + + expect(screen.getByText("No active listings right now")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/components/MarketplaceListings.tsx b/frontend/components/MarketplaceListings.tsx index 0f1021a..84a4b5a 100644 --- a/frontend/components/MarketplaceListings.tsx +++ b/frontend/components/MarketplaceListings.tsx @@ -155,6 +155,7 @@ export default function MarketplaceListings({ const [trades, setTrades] = useState(initialTrades); const [pagination, setPagination] = useState(initialPagination); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const apiUrl = process.env["NEXT_PUBLIC_API_URL"] ?? "http://localhost:3001"; @@ -180,6 +181,7 @@ export default function MarketplaceListings({ const fetchListings = useCallback( async (filters: { assetType?: string; carrier?: string; minAmount?: string; maxAmount?: string }) => { setLoading(true); + setError(null); try { const params = new URLSearchParams(); params.set("page", "1"); @@ -190,13 +192,15 @@ export default function MarketplaceListings({ if (filters.maxAmount) params.set("maxAmount", filters.maxAmount); const res = await fetch(`${apiUrl}/api/v1/trades?${params.toString()}`); - if (res.ok) { - const data = (await res.json()) as TradesResponse; - setTrades(data.data ?? []); - setPagination(data.pagination ?? { page: 1, limit: 20, total: 0, totalPages: 0 }); + if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`); } + const data = (await res.json()) as TradesResponse; + setTrades(data.data ?? []); + setPagination(data.pagination ?? { page: 1, limit: 20, total: 0, totalPages: 0 }); } catch (err) { console.error("Failed to fetch filtered trades:", err); + setError("Could not load listings. Please try again."); } finally { setLoading(false); } @@ -248,6 +252,10 @@ export default function MarketplaceListings({ const hasActiveFilters = Boolean(assetType || carrier || minAmount || maxAmount); + // "No results" is only shown for a *successful* empty response — never while + // loading, and never when the fetch failed (that renders the error banner). + const isEmpty = !loading && error === null && trades.length === 0; + return (
{/* Filter Panel */} @@ -360,7 +368,21 @@ export default function MarketplaceListings({
Updating listings…
- ) : trades.length === 0 ? ( + ) : error ? ( +
+

{error}

+ +
+ ) : isEmpty ? ( ) : ( trades.map((trade) => ) diff --git a/frontend/hooks/__tests__/useTradeList.test.ts b/frontend/hooks/__tests__/useTradeList.test.ts index e193fac..327d843 100644 --- a/frontend/hooks/__tests__/useTradeList.test.ts +++ b/frontend/hooks/__tests__/useTradeList.test.ts @@ -145,4 +145,53 @@ describe("useTradeList Hook", () => { expect.stringContaining("maxAmount=5000") ); }); + + it("exposes isEmpty=true only on a successful empty response", async () => { + mockedApiFetch.mockResolvedValueOnce({ data: [], total: 0 }); + + const { result } = renderHook(() => useTradeList({ page: 1, limit: 10 })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBeNull(); + expect(result.current.isEmpty).toBe(true); + }); + + it("keeps isEmpty=false (and sets error) when the fetch fails", async () => { + const apiError = new ApiError("marketplace unavailable", 500); + mockedApiFetch.mockRejectedValueOnce(apiError); + + const { result } = renderHook(() => useTradeList({ page: 1, limit: 10 })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBe(apiError); + expect(result.current.isEmpty).toBe(false); + }); + + it("keeps isEmpty=false on a successful non-empty response", async () => { + mockedApiFetch.mockResolvedValueOnce({ + data: [ + { + id: "trade-1", + sellerId: "user-1", + assetCode: "MTN_AIRTIME", + amount: 5000, + price: 5000, + currency: "NGN", + status: TradeStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + total: 1, + }); + + const { result } = renderHook(() => useTradeList({ page: 1, limit: 10 })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBeNull(); + expect(result.current.isEmpty).toBe(false); + }); }); diff --git a/frontend/hooks/useTradeList.ts b/frontend/hooks/useTradeList.ts index dbca0ab..8a62552 100644 --- a/frontend/hooks/useTradeList.ts +++ b/frontend/hooks/useTradeList.ts @@ -18,6 +18,12 @@ export interface UseTradeListReturn { trades: TradeOffer[]; total: number; isLoading: boolean; + /** + * `true` only when the latest load **succeeded** and returned zero trades. + * Never `true` while loading or when `error` is set — so a caller can tell + * "no listings available" apart from "failed to load listings". + */ + isEmpty: boolean; error: ApiError | Error | null; refetch: () => Promise; } @@ -47,6 +53,7 @@ export function useTradeList(options: UseTradeListOptions): UseTradeListReturn { const [trades, setTrades] = useState([]); const [total, setTotal] = useState(0); const [isLoading, setIsLoading] = useState(true); + const [isEmpty, setIsEmpty] = useState(false); const [error, setError] = useState(null); const queryParams = new URLSearchParams(); @@ -69,6 +76,7 @@ export function useTradeList(options: UseTradeListOptions): UseTradeListReturn { setTotal(cached.data.total); setIsLoading(false); setError(null); + setIsEmpty(cached.data.trades.length === 0); return; } } @@ -77,6 +85,7 @@ export function useTradeList(options: UseTradeListOptions): UseTradeListReturn { setIsLoading(true); } setError(null); + setIsEmpty(false); try { let promise = inFlightPromises.get(cacheKey); @@ -98,12 +107,14 @@ export function useTradeList(options: UseTradeListOptions): UseTradeListReturn { setTrades(result.trades); setTotal(result.total); setIsLoading(false); + setIsEmpty(result.trades.length === 0); } } catch (err) { inFlightPromises.delete(cacheKey); if (isMounted.current) { const errorObj = err instanceof ApiError ? err : (err as Error); setError(errorObj); + setIsEmpty(false); setIsLoading(false); } } @@ -127,6 +138,7 @@ export function useTradeList(options: UseTradeListOptions): UseTradeListReturn { trades, total, isLoading, + isEmpty, error, refetch: () => fetchData(true), };