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
7 changes: 6 additions & 1 deletion app/frontend/src/app/marketplace/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import * as React from "react";

export default function MarketplaceLoading() {
return (
<div className="min-h-screen text-white selection:bg-indigo-500/30">
<div
className="min-h-screen text-white selection:bg-indigo-500/30"
role="status"
aria-busy="true"
>
<span className="sr-only">Loading marketplace listings…</span>
<div className="space-y-10">
<div className="h-8 w-1/3 rounded-full bg-white/5 animate-pulse" />
<div className="h-72 rounded-3xl bg-white/5 border border-white/5 animate-pulse" />
Expand Down
30 changes: 22 additions & 8 deletions app/frontend/src/app/marketplace/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import dynamic from "next/dynamic";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { UsernameCard } from "@/components/UsernameCard";
import { ListingDetailModal } from "@/components/ListingDetailModal";
import type { MarketplaceListing } from "@/hooks/marketplaceApi";
Expand Down Expand Up @@ -110,13 +110,21 @@ function MarketplacePageContent() {
// Stable connection-status derived from the provider
const isConnected = realtimeApi.isConnected;

// Monotonic id guarding against stale responses: when a retry is triggered
// while a previous request is still in flight, only the newest request may
// commit listings, error, or loading state.
const loadRequestIdRef = useRef(0);

const loadListings = useCallback(async () => {
const requestId = ++loadRequestIdRef.current;
try {
setLoading(true);
setError(null);
const data = await marketplaceApi.fetchListings();
setListings(data ?? []);
if (requestId !== loadRequestIdRef.current) return;
setListings(Array.isArray(data) ? data : []);
} catch (err) {
if (requestId !== loadRequestIdRef.current) return;
const captured = err instanceof Error ? err : new Error(String(err));
setError(captured.message || "Failed to load marketplace listings");
errorReporter.captureError(captured, {
Expand All @@ -125,7 +133,9 @@ function MarketplacePageContent() {
extra: { source: "MarketplacePageContent", operation: "fetchListings" },
});
} finally {
setLoading(false);
if (requestId === loadRequestIdRef.current) {
setLoading(false);
}
}
}, [marketplaceApi]);

Expand Down Expand Up @@ -317,14 +327,15 @@ function MarketplacePageContent() {
<button
type="button"
onClick={loadListings}
className="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-300 text-xs font-bold rounded-lg transition"
disabled={loading}
className="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 disabled:opacity-50 disabled:cursor-not-allowed text-red-300 text-xs font-bold rounded-lg transition"
>
Retry
</button>
</div>
)}

{!loading && !error && <StatsBar listings={listings} />}
{listings.length > 0 && !error && <StatsBar listings={listings} />}

{/* ── CONTROLS ─────────────────────────────── */}
<div className="flex flex-col gap-4 mb-8">
Expand Down Expand Up @@ -420,14 +431,16 @@ function MarketplacePageContent() {
</div>

{/* ── RESULTS COUNT ─────────────────────────── */}
{!loading && !error && (
{listings.length > 0 && !error && (
<p className="text-xs text-neutral-600 font-bold uppercase tracking-widest mb-6">
{filtered.length} listing{filtered.length !== 1 ? "s" : ""} found
{search && ` for "${search}"`}
</p>
)}

{/* ── GRID ─────────────────────────────────── */}
{/* Full-page failure only when nothing is on screen yet; with cached
listings the inline banner above handles retry without wiping data. */}
{error && listings.length === 0 ? (
<div className="py-20 text-center space-y-6">
<div className="text-5xl mb-2">⚠️</div>
Expand All @@ -443,13 +456,14 @@ function MarketplacePageContent() {
<button
type="button"
onClick={loadListings}
className="px-6 py-3 bg-indigo-500 hover:bg-indigo-600 font-bold text-sm text-white rounded-xl transition active:scale-95 shadow-lg shadow-indigo-500/20"
disabled={loading}
className="px-6 py-3 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-sm text-white rounded-xl transition active:scale-95 shadow-lg shadow-indigo-500/20"
>
Try again
</button>
</div>
</div>
) : loading ? (
) : loading && listings.length === 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div
Expand Down
61 changes: 53 additions & 8 deletions app/frontend/src/components/BidModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import { useEffect, useRef, useState } from "react";
import type { MarketplaceListing } from "@/hooks/marketplaceApi";
import { validateBidRequest } from "@/hooks/marketplaceApi";
import { useMarketplaceApi } from "@/hooks/MarketplaceApiContext";
import { errorReporter } from "@/lib/errorReporter";
import { SigningSummary } from "./SigningSummary";

type BidModalProps = {
Expand All @@ -27,8 +29,9 @@ export function BidModal({ listing, onClose, onBidSuccess }: BidModalProps) {
const { placeBid, formatCountdown } = useMarketplaceApi();

const minBid = listing ? listing.currentBid + 1 : 1;
const parsedAmount = parseFloat(amount);
const isValid = !isNaN(parsedAmount) && parsedAmount >= minBid;
// Strict numeric parse — "12abc" and "" must both be rejected.
const parsedAmount = amount.trim() === "" ? NaN : Number(amount);
const isValid = Number.isFinite(parsedAmount) && parsedAmount >= minBid;

useEffect(() => {
if (!listing) return;
Expand All @@ -43,17 +46,59 @@ export function BidModal({ listing, onClose, onBidSuccess }: BidModalProps) {
}, [listing]);

async function handleConfirm() {
if (!listing || !isValid) return;
// Double-submit guard: ignore re-entry while a request is in flight.
if (!listing || bidState === "loading") return;

// Validate the request shape before any network submission.
const validation = validateBidRequest(listing.username, parsedAmount, {
minAmount: minBid,
});
if (!validation.ok) {
setBidState("error");
setErrorMsg(validation.reason);
return;
}

setBidState("loading");
setErrorMsg("");

const result = await placeBid(listing.username, parsedAmount);
if (result.success) {
setBidState("success");
onBidSuccess(listing.username, parsedAmount);
} else {
try {
const result = await placeBid(listing.username, parsedAmount);
if (result.success) {
setBidState("success");
onBidSuccess(listing.username, parsedAmount);
return;
}
setBidState("error");
setErrorMsg(result.reason);
// Capture the user-visible failure for observability. Only safe fields
// are attached — no request bodies or wallet material.
void errorReporter.captureError(
new Error(`Bid submission failed: ${result.reason}`),
{
route: "/marketplace",
codeOrigin: "BidModal.placeBid",
extra: {
source: "BidModal",
operation: "placeBid",
listingId: listing.id,
username: listing.username,
},
},
);
} catch (err) {
// Defensive: providers resolve with results, but never let an
// unexpected rejection crash the modal.
setBidState("error");
setErrorMsg("Something went wrong while placing your bid. Please try again.");
void errorReporter.captureError(
err instanceof Error ? err : new Error(String(err)),
{
route: "/marketplace",
codeOrigin: "BidModal.placeBid",
extra: { source: "BidModal", operation: "placeBid", listingId: listing.id },
},
);
}
}

Expand Down
23 changes: 23 additions & 0 deletions app/frontend/src/hooks/__tests__/mockMarketplaceProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,29 @@ describe("placeBid", () => {
reason: expect.stringContaining("rejected"),
});
});

it.each([
["empty username", "", 100],
["NaN amount", "nova", NaN],
["zero amount", "nova", 0],
["negative amount", "nova", -1],
])("rejects %s immediately, mirroring the production request contract", async (_label, username, amount) => {
let resolved = false;
const promise = mockMarketplaceProvider
.placeBid(username as string, amount as number)
.then((result) => {
resolved = true;
return result;
});

// Rejected synchronously — no simulated wallet delay is scheduled.
await Promise.resolve();
expect(resolved).toBe(true);
await expect(promise).resolves.toMatchObject({
success: false,
reason: expect.any(String),
});
});
});

// ── formatCountdown ───────────────────────────────────────────────────────────
Expand Down
Loading
Loading