Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions frontend/components/MarketplaceListings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MarketplaceListings initialTrades={[]} />);

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(<MarketplaceListings initialTrades={[]} />);

expect(screen.getByText("No active listings right now")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
16 changes: 12 additions & 4 deletions frontend/components/MarketplaceListings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export default function MarketplaceListings({
const [trades, setTrades] = useState<TradeOffer[]>(initialTrades);
const [pagination, setPagination] = useState(initialPagination);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const apiUrl = process.env["NEXT_PUBLIC_API_URL"] ?? "http://localhost:3001";

Expand All @@ -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");
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 (
<div>
{/* Filter Panel */}
Expand Down
49 changes: 49 additions & 0 deletions frontend/hooks/__tests__/useTradeList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
12 changes: 12 additions & 0 deletions frontend/hooks/useTradeList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}
Expand Down Expand Up @@ -47,6 +53,7 @@ export function useTradeList(options: UseTradeListOptions): UseTradeListReturn {
const [trades, setTrades] = useState<TradeOffer[]>([]);
const [total, setTotal] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isEmpty, setIsEmpty] = useState<boolean>(false);
const [error, setError] = useState<ApiError | Error | null>(null);

const queryParams = new URLSearchParams();
Expand All @@ -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;
}
}
Expand All @@ -77,6 +85,7 @@ export function useTradeList(options: UseTradeListOptions): UseTradeListReturn {
setIsLoading(true);
}
setError(null);
setIsEmpty(false);

try {
let promise = inFlightPromises.get(cacheKey);
Expand All @@ -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);
}
}
Expand All @@ -127,6 +138,7 @@ export function useTradeList(options: UseTradeListOptions): UseTradeListReturn {
trades,
total,
isLoading,
isEmpty,
error,
refetch: () => fetchData(true),
};
Expand Down
Loading