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 d987cc9..7034b51 100644 --- a/frontend/components/MarketplaceListings.tsx +++ b/frontend/components/MarketplaceListings.tsx @@ -156,6 +156,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"; @@ -181,6 +182,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"); @@ -191,13 +193,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); } @@ -249,6 +253,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 */} 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), };