Skip to content

Commit cf962c0

Browse files
Merge pull request #11 from websurferdoteth/feat/optimistically-load-new-listing
2 parents 9449694 + 9a4b2f4 commit cf962c0

File tree

4 files changed

+160
-113
lines changed

4 files changed

+160
-113
lines changed

packages/nextjs/app/listing/new/page.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import { Suspense, useEffect, useMemo, useState } from "react";
44
import { useRouter, useSearchParams } from "next/navigation";
5+
import { useQueryClient } from "@tanstack/react-query";
56
import { encodeAbiParameters, isAddress, keccak256, parseEther, parseUnits, stringToHex, zeroAddress } from "viem";
67
import { useReadContract } from "wagmi";
78
import { useMiniapp } from "~~/components/MiniappProvider";
89
import { IPFSUploader } from "~~/components/marketplace/IPFSUploader";
910
import { TagsInput } from "~~/components/marketplace/TagsInput";
11+
import type { Listing } from "~~/hooks/marketplace/useListingsByLocation";
1012
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
1113
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth/useScaffoldWriteContract";
1214
// import { resolveIpfsUrl } from "~~/services/ipfs/fetch";
@@ -88,6 +90,7 @@ const parseContactEntries = (
8890
const NewListingPageInner = () => {
8991
const router = useRouter();
9092
const searchParams = useSearchParams();
93+
const queryClient = useQueryClient();
9194
const editingId = useMemo(() => {
9295
const v = searchParams.get("edit");
9396
return v ? v : null;
@@ -325,6 +328,44 @@ const NewListingPageInner = () => {
325328
blockConfirmations: 1,
326329
onBlockConfirmation: receipt => {
327330
const newId = extractListingId(receipt);
331+
if (newId && locationId) {
332+
const priceWei = !(isCustomToken || isKnownToken)
333+
? parseEther(price || "0")
334+
: parseUnits(price || "0", decimalsOverride ?? 18);
335+
336+
let tokenSymbol: string = "ETH";
337+
if (isCustomToken) {
338+
tokenSymbol = (tokenSymbolData as string | undefined) || "TOKEN";
339+
} else if (isKnownToken) {
340+
tokenSymbol = currency;
341+
}
342+
343+
const tokenDecimals = decimalsOverride ?? 18;
344+
345+
const optimisticListing: Listing = {
346+
id: newId,
347+
title: title.trim() || newId,
348+
image: finalImageCid,
349+
tags: tags.filter(Boolean),
350+
priceWei: priceWei.toString(),
351+
tokenSymbol,
352+
tokenDecimals,
353+
isOptimistic: true,
354+
};
355+
356+
queryClient.setQueryData<Listing[]>(["listings", "location", locationId], oldData => {
357+
if (oldData) {
358+
const filtered = oldData.filter(l => l.id !== newId);
359+
return [optimisticListing, ...filtered];
360+
}
361+
return [optimisticListing];
362+
});
363+
364+
queryClient.invalidateQueries({
365+
queryKey: ["listings", "location", locationId],
366+
});
367+
}
368+
328369
router.push(`/location/${encodeURIComponent(locationId)}`);
329370
if (newId) shareListingCast(newId);
330371
},

packages/nextjs/app/location/[id]/page.tsx

Lines changed: 5 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
"use client";
22

3-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3+
import { useEffect, useMemo, useState } from "react";
44
import Link from "next/link";
55
import { useParams } from "next/navigation";
66
import { ListingCard } from "~~/components/marketplace/ListingCard";
77
import MapRadius from "~~/components/marketplace/MapRadiusGL";
8+
import { useListingsByLocation } from "~~/hooks/marketplace/useListingsByLocation";
89

910
const LocationPage = () => {
1011
const params = useParams<{ id: string }>();
1112
const [query, setQuery] = useState("");
12-
const [listings, setListings] = useState<any[]>([]);
13-
const [loadingListings, setLoadingListings] = useState(true);
1413
const [location, setLocation] = useState<any | null>(null);
1514
const [cachedName, setCachedName] = useState<string | null>(null);
1615
const [tagFilter, setTagFilter] = useState<string>("");
1716

18-
const refreshDelay = process.env.NEXT_PUBLIC_REFRESH_DELAY ? Number(process.env.NEXT_PUBLIC_REFRESH_DELAY) : 4000;
19-
// Future: we could store geo/radius for map previews
20-
const hasRefreshedRef = useRef(false);
17+
const locationId = params?.id ? decodeURIComponent(params.id) : null;
18+
const { data: listings = [], isLoading: loadingListings } = useListingsByLocation(locationId);
2119

2220
useEffect(() => {
2321
const run = async () => {
@@ -96,111 +94,6 @@ const LocationPage = () => {
9694
run();
9795
}, [params?.id, cachedName]);
9896

99-
// Fetch listings for this location from Ponder GraphQL
100-
const fetchListings = useCallback(
101-
async (opts?: { silent?: boolean }) => {
102-
const id = decodeURIComponent(params?.id as string);
103-
if (!id) return;
104-
if (!opts?.silent) setLoadingListings(true);
105-
try {
106-
const res = await fetch(process.env.NEXT_PUBLIC_PONDER_URL || "http://localhost:42069/graphql", {
107-
method: "POST",
108-
headers: { "content-type": "application/json" },
109-
body: JSON.stringify({
110-
query: `
111-
query ListingsByLocation($loc: String!) {
112-
listingss(
113-
where: { locationId: $loc, active: true }
114-
orderBy: "createdBlockNumber"
115-
orderDirection: "desc"
116-
limit: 100
117-
) {
118-
items {
119-
id
120-
title
121-
image
122-
tags
123-
priceWei
124-
tokenSymbol
125-
tokenDecimals
126-
}
127-
}
128-
}`,
129-
variables: { loc: id },
130-
}),
131-
});
132-
const json = await res.json();
133-
const items = (json?.data?.listingss?.items || []).map((it: any) => {
134-
let tags: string[] = [];
135-
try {
136-
const raw = (it as any)?.tags;
137-
if (Array.isArray(raw)) {
138-
tags = raw.map((t: any) => String(t)).filter(Boolean);
139-
} else if (typeof raw === "string" && raw) {
140-
try {
141-
const parsed = JSON.parse(raw);
142-
if (Array.isArray(parsed)) tags = parsed.map((t: any) => String(t)).filter(Boolean);
143-
} catch {}
144-
}
145-
} catch {}
146-
return {
147-
id: it.id,
148-
title: it?.title ?? it.id,
149-
image: it?.image ?? null,
150-
tags,
151-
priceWei: it?.priceWei ?? null,
152-
tokenSymbol: it?.tokenSymbol ?? null,
153-
tokenDecimals: it?.tokenDecimals ?? null,
154-
};
155-
});
156-
setListings(items);
157-
} catch {
158-
setListings([]);
159-
} finally {
160-
if (!opts?.silent) setLoadingListings(false);
161-
}
162-
},
163-
[params?.id],
164-
);
165-
166-
useEffect(() => {
167-
fetchListings();
168-
}, [fetchListings]);
169-
170-
// One-time delayed refresh ~Xs after landing
171-
useEffect(() => {
172-
hasRefreshedRef.current = false;
173-
const timeout = setTimeout(() => {
174-
if (!hasRefreshedRef.current) {
175-
hasRefreshedRef.current = true;
176-
const doc = document.documentElement;
177-
const wasAtTop = window.scrollY === 0;
178-
const prevFromBottom = Math.max(0, doc.scrollHeight - window.scrollY - window.innerHeight);
179-
fetchListings({ silent: true }).finally(() => {
180-
// Restore position relative to bottom to account for new content height
181-
// Double rAF to ensure layout has settled after state updates
182-
requestAnimationFrame(() => {
183-
requestAnimationFrame(() => {
184-
if (wasAtTop) {
185-
return;
186-
}
187-
const newDoc = document.documentElement;
188-
const targetTop = Math.max(
189-
0,
190-
Math.min(
191-
newDoc.scrollHeight - window.innerHeight,
192-
newDoc.scrollHeight - prevFromBottom - window.innerHeight,
193-
),
194-
);
195-
window.scrollTo({ top: targetTop, left: 0, behavior: "auto" });
196-
});
197-
});
198-
});
199-
}
200-
}, refreshDelay);
201-
return () => clearTimeout(timeout);
202-
}, [fetchListings, params?.id, refreshDelay]);
203-
20497
const availableTags = useMemo(() => {
20598
const tagSet = listings.reduce((acc: Set<string>, listing) => {
20699
if (Array.isArray(listing.tags)) {
@@ -301,7 +194,7 @@ const LocationPage = () => {
301194
</div>
302195
</div>
303196
</details>
304-
<Link href={`/listing/new?loc=${encodeURIComponent(params?.id as string)}`} className="btn btn-sm btn-primary">
197+
<Link href={`/listing/new?loc=${encodeURIComponent(locationId || "")}`} className="btn btn-sm btn-primary">
305198
Create Listing
306199
</Link>
307200
</div>

packages/nextjs/components/marketplace/ListingCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { resolveIpfsUrl } from "~~/services/ipfs/fetch";
88
export interface ListingCardProps {
99
id: string | number;
1010
title: string;
11-
imageUrl?: string;
11+
imageUrl?: string | null;
1212
tags?: string[];
1313
priceWei?: string | bigint | null;
1414
tokenSymbol?: string | null;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useQuery, useQueryClient } from "@tanstack/react-query";
2+
3+
export interface Listing {
4+
id: string;
5+
title: string;
6+
image: string | null;
7+
tags: string[];
8+
priceWei: string | null;
9+
tokenSymbol: string | null;
10+
tokenDecimals: number | null;
11+
isOptimistic?: boolean;
12+
}
13+
14+
function parseTags(tags: unknown): string[] {
15+
if (Array.isArray(tags)) {
16+
return tags.map(t => String(t)).filter(Boolean);
17+
}
18+
if (typeof tags === "string" && tags) {
19+
try {
20+
const parsed = JSON.parse(tags);
21+
if (Array.isArray(parsed)) {
22+
return parsed.map(t => String(t)).filter(Boolean);
23+
}
24+
} catch {
25+
return tags.trim() ? [tags.trim()] : [];
26+
}
27+
}
28+
return [];
29+
}
30+
31+
function transformListingItem(item: any): Listing {
32+
return {
33+
id: item.id,
34+
title: item?.title ?? item.id,
35+
image: item?.image ?? null,
36+
tags: parseTags(item?.tags),
37+
priceWei: item?.priceWei ?? null,
38+
tokenSymbol: item?.tokenSymbol ?? null,
39+
tokenDecimals: item?.tokenDecimals ?? null,
40+
isOptimistic: false,
41+
};
42+
}
43+
44+
async function fetchListingsByLocation(locationId: string): Promise<Listing[]> {
45+
const ponderUrl = process.env.NEXT_PUBLIC_PONDER_URL || "http://localhost:42069/graphql";
46+
47+
const res = await fetch(ponderUrl, {
48+
method: "POST",
49+
headers: { "content-type": "application/json" },
50+
body: JSON.stringify({
51+
query: `
52+
query ListingsByLocation($loc: String!) {
53+
listingss(
54+
where: { locationId: $loc, active: true }
55+
orderBy: "createdBlockNumber"
56+
orderDirection: "desc"
57+
limit: 100
58+
) {
59+
items {
60+
id
61+
title
62+
image
63+
tags
64+
priceWei
65+
tokenSymbol
66+
tokenDecimals
67+
}
68+
}
69+
}
70+
`,
71+
variables: { loc: locationId },
72+
}),
73+
});
74+
75+
if (!res.ok) {
76+
throw new Error(`Failed to fetch listings: ${res.statusText}`);
77+
}
78+
79+
const json = await res.json();
80+
81+
if (json.errors) {
82+
throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`);
83+
}
84+
85+
const items = (json?.data?.listingss?.items || []).map(transformListingItem);
86+
return items;
87+
}
88+
89+
function mergeListings(fetched: Listing[], optimistic: Listing[]): Listing[] {
90+
const fetchedIds = new Set(fetched.map(l => l.id));
91+
const pending = optimistic.filter(l => l.isOptimistic && !fetchedIds.has(l.id));
92+
return [...pending, ...fetched];
93+
}
94+
95+
export function useListingsByLocation(locationId: string | null | undefined) {
96+
const queryClient = useQueryClient();
97+
const queryKey = ["listings", "location", locationId];
98+
99+
return useQuery({
100+
queryKey,
101+
queryFn: async () => {
102+
if (!locationId) throw new Error("Location ID is required");
103+
const cached = queryClient.getQueryData<Listing[]>(queryKey);
104+
const optimistic = cached?.filter(l => l.isOptimistic) ?? [];
105+
const fetched = await fetchListingsByLocation(locationId);
106+
107+
return mergeListings(fetched, optimistic);
108+
},
109+
enabled: !!locationId,
110+
staleTime: 1000,
111+
refetchInterval: query => (query.state.data?.some(l => l.isOptimistic) ? 2000 : false),
112+
});
113+
}

0 commit comments

Comments
 (0)