Skip to content

Commit bc7543f

Browse files
Implement share functionality in ListingDetailsPage and NewListingPage, allowing users to share listings via a button in mini app mode. Refactor price label generation for custom tokens and enhance TagsInput component for better user experience. Update image handling in ListingCard for improved performance.
1 parent fbdf95e commit bc7543f

File tree

4 files changed

+151
-43
lines changed

4 files changed

+151
-43
lines changed

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

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,25 @@ const ListingDetailsPageInner = () => {
233233
return (
234234
<div className="p-4 space-y-4">
235235
<div className="flex items-center gap-2">
236+
{isMiniApp ? (
237+
<button
238+
className="btn btn-secondary btn-sm"
239+
onClick={async () => {
240+
try {
241+
const url = typeof window !== "undefined" ? window.location.href : "";
242+
const text = `Check out this listing: ${title}\n\n${description}\n\n${priceLabel}`;
243+
const embeds: string[] = [];
244+
if (url) embeds.push(url);
245+
if (imageUrl && embeds.length < 2) embeds.push(imageUrl);
246+
await composeCast({ text, embeds });
247+
} catch (e) {
248+
console.error("share compose error", e);
249+
}
250+
}}
251+
>
252+
Share
253+
</button>
254+
) : null}
236255
<div className={`badge ${active ? "badge-success" : ""} ml-auto`}>{active ? "Active" : "Sold"}</div>
237256
</div>
238257

@@ -244,6 +263,7 @@ const ListingDetailsPageInner = () => {
244263
{imageUrl ? (
245264
<div className="w-full">
246265
<Image
266+
priority={false}
247267
src={imageUrl}
248268
alt={title}
249269
width={1200}
@@ -299,24 +319,6 @@ const ListingDetailsPageInner = () => {
299319
disabled={!active}
300320
/>
301321
) : null}
302-
{isMiniApp ? (
303-
<button
304-
className="btn btn-secondary btn-sm"
305-
onClick={async () => {
306-
try {
307-
const url = typeof window !== "undefined" ? window.location.href : "";
308-
const text = `Check out this listing: ${title}`;
309-
const embeds: string[] = [];
310-
if (url) embeds.push(url);
311-
await composeCast({ text, embeds });
312-
} catch (e) {
313-
console.error("share compose error", e);
314-
}
315-
}}
316-
>
317-
Share
318-
</button>
319-
) : null}
320322
</div>
321323
</div>
322324

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

Lines changed: 129 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { Suspense, useEffect, useMemo, useState } from "react";
44
import { useRouter, useSearchParams } from "next/navigation";
55
import { encodeAbiParameters, isAddress, parseEther, parseUnits, zeroAddress } from "viem";
66
import { useReadContract } from "wagmi";
7+
import { useMiniapp } from "~~/components/MiniappProvider";
78
import { IPFSUploader } from "~~/components/marketplace/IPFSUploader";
89
import { TagsInput } from "~~/components/marketplace/TagsInput";
910
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
1011
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth/useScaffoldWriteContract";
12+
import { resolveIpfsUrl } from "~~/services/ipfs/fetch";
1113
import { uploadJSON } from "~~/services/ipfs/upload";
1214
import TOKENS_JSON from "~~/tokens.json";
1315

@@ -22,11 +24,23 @@ const ERC20_DECIMALS_ABI = [
2224
},
2325
] as const;
2426

27+
// Minimal ERC20 ABI to read symbol (for custom tokens)
28+
const ERC20_SYMBOL_ABI = [
29+
{
30+
type: "function",
31+
name: "symbol",
32+
stateMutability: "view",
33+
inputs: [],
34+
outputs: [{ name: "", type: "string" }],
35+
},
36+
] as const;
37+
2538
const KNOWN_TOKENS = TOKENS_JSON as Record<string, `0x${string}`>;
2639

2740
const NewListingPageInner = () => {
2841
const router = useRouter();
2942
const searchParams = useSearchParams();
43+
const { composeCast, isMiniApp } = useMiniapp();
3044
const [title, setTitle] = useState("");
3145
const [description, setDescription] = useState("");
3246
const [category, setCategory] = useState("");
@@ -43,6 +57,7 @@ const NewListingPageInner = () => {
4357

4458
const { writeContractAsync: writeMarketplace } = useScaffoldWriteContract({ contractName: "Marketplace" });
4559
const { data: simpleListings } = useDeployedContractInfo({ contractName: "SimpleListings" });
60+
const { data: marketplaceInfo } = useDeployedContractInfo({ contractName: "Marketplace" });
4661

4762
const isCustomToken = currency === "TOKEN";
4863
const isKnownToken = currency !== "ETH" && currency !== "TOKEN" && Boolean(KNOWN_TOKENS[currency]);
@@ -63,6 +78,17 @@ const NewListingPageInner = () => {
6378
},
6479
});
6580

81+
// Attempt to fetch symbol for custom tokens for a nicer price label
82+
const { data: tokenSymbolData } = useReadContract({
83+
address: isCustomToken && isTokenAddressValid ? (tokenAddress as `0x${string}`) : undefined,
84+
abi: ERC20_SYMBOL_ABI,
85+
functionName: "symbol",
86+
query: {
87+
enabled: isCustomToken && isTokenAddressValid,
88+
retry: false,
89+
},
90+
});
91+
6692
useEffect(() => {
6793
if (!(isCustomToken || isKnownToken)) {
6894
setDecimalsOverride(null);
@@ -120,6 +146,24 @@ const NewListingPageInner = () => {
120146
loadingDecimals,
121147
]);
122148

149+
// Derive a human-friendly price label similar to listing page using in-memory values
150+
const priceLabel = useMemo(() => {
151+
const trimmed = (price || "").trim();
152+
if (!trimmed) return "";
153+
try {
154+
if (isCustomToken) {
155+
const sym = (tokenSymbolData as string | undefined) || "TOKEN";
156+
return `${trimmed} ${sym}`;
157+
}
158+
if (isKnownToken) {
159+
return `${trimmed} ${currency}`;
160+
}
161+
return `${trimmed} ETH`;
162+
} catch {
163+
return trimmed;
164+
}
165+
}, [price, isCustomToken, isKnownToken, tokenSymbolData, currency]);
166+
123167
const onSubmit = async (e: React.FormEvent) => {
124168
e.preventDefault();
125169
setSubmitting(true);
@@ -176,12 +220,51 @@ const NewListingPageInner = () => {
176220
],
177221
[paymentToken, priceWei],
178222
);
223+
const shareImageUrlLocal = (resolveIpfsUrl(localImageCid) as string | null) || localImageCid || undefined;
224+
225+
// Write and wait for receipt so we can derive the new listing id from logs
226+
await writeMarketplace(
227+
{
228+
functionName: "createListing",
229+
args: [simpleListings?.address as `0x${string}`, cid, encoded],
230+
},
231+
{
232+
blockConfirmations: 1,
233+
onBlockConfirmation: receipt => {
234+
try {
235+
// Parse ListingCreated log from Marketplace
236+
const mpAddress = (marketplaceInfo?.address || "").toLowerCase();
237+
const createdLog = receipt.logs.find(l => (l as any).address?.toLowerCase() === mpAddress);
238+
let newId: string | undefined;
239+
if (createdLog && (createdLog as any).topics && (createdLog as any).topics.length >= 2) {
240+
try {
241+
const hex = (createdLog as any).topics[1] as string;
242+
if (hex && hex.startsWith("0x")) newId = String(BigInt(hex));
243+
} catch {}
244+
}
179245

180-
await writeMarketplace({
181-
functionName: "createListing",
182-
args: [simpleListings?.address as `0x${string}`, cid, encoded],
183-
});
184-
router.push(`/location/${encodeURIComponent(locationId)}`);
246+
// Navigate to location first for UX consistency
247+
router.push(`/location/${encodeURIComponent(locationId)}`);
248+
249+
// Prompt to share using in-memory details even before indexing
250+
if (isMiniApp && newId) {
251+
try {
252+
const base =
253+
process.env.NEXT_PUBLIC_URL || (typeof window !== "undefined" ? window.location.origin : "");
254+
const url = `${base}/listing/${encodeURIComponent(newId)}`;
255+
const text = `Check out my new listing: ${title}\n\n${description}${priceLabel ? `\n\n${priceLabel}` : ""}`;
256+
const embeds: string[] = [];
257+
if (url) embeds.push(url);
258+
if (shareImageUrlLocal && embeds.length < 2) embeds.push(shareImageUrlLocal);
259+
setTimeout(() => {
260+
composeCast({ text, embeds }).catch(() => {});
261+
}, 300);
262+
} catch {}
263+
}
264+
} catch {}
265+
},
266+
},
267+
);
185268
return;
186269
} catch {}
187270
}
@@ -223,11 +306,47 @@ const NewListingPageInner = () => {
223306
[paymentToken, priceWei],
224307
);
225308

226-
await writeMarketplace({
227-
functionName: "createListing",
228-
args: [simpleListings?.address as `0x${string}`, cid, encoded],
229-
});
230-
router.push(`/location/${encodeURIComponent(locationId)}`);
309+
await writeMarketplace(
310+
{
311+
functionName: "createListing",
312+
args: [simpleListings?.address as `0x${string}`, cid, encoded],
313+
},
314+
{
315+
blockConfirmations: 1,
316+
onBlockConfirmation: receipt => {
317+
try {
318+
const mpAddress = (marketplaceInfo?.address || "").toLowerCase();
319+
const createdLog = receipt.logs.find(l => (l as any).address?.toLowerCase() === mpAddress);
320+
let newId: string | undefined;
321+
if (createdLog && (createdLog as any).topics && (createdLog as any).topics.length >= 2) {
322+
try {
323+
const hex = (createdLog as any).topics[1] as string;
324+
if (hex && hex.startsWith("0x")) newId = String(BigInt(hex));
325+
} catch {}
326+
}
327+
328+
router.push(`/location/${encodeURIComponent(locationId)}`);
329+
330+
if (isMiniApp && newId) {
331+
try {
332+
const base =
333+
process.env.NEXT_PUBLIC_URL || (typeof window !== "undefined" ? window.location.origin : "");
334+
const url = `${base}/listing/${encodeURIComponent(newId)}`;
335+
const text = `Check out my new listing: ${title}\n\n${description}${priceLabel ? `\n\n${priceLabel}` : ""}`;
336+
const embeds: string[] = [];
337+
if (url) embeds.push(url);
338+
const shareImageUrl2 = imageCid ? (resolveIpfsUrl(imageCid) as string | null) || imageCid : undefined;
339+
if (shareImageUrl2 && embeds.length < 2) embeds.push(shareImageUrl2);
340+
setTimeout(() => {
341+
composeCast({ text, embeds }).catch(() => {});
342+
}, 300);
343+
} catch {}
344+
}
345+
} catch {}
346+
},
347+
},
348+
);
349+
return;
231350
} finally {
232351
setSubmitting(false);
233352
}

packages/nextjs/components/marketplace/ListingCard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const ListingCard = ({
8181
<Link href={`/listing/${id}${fromQuery}`} className="card card-compact bg-base-100 shadow">
8282
{resolved ? (
8383
<Image
84+
priority={false}
8485
src={resolved}
8586
alt={title}
8687
width={800}

packages/nextjs/components/marketplace/TagsInput.tsx

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,12 @@ export const TagsInput = ({ value, onChange, placeholder, className }: TagsInput
6969
</button>
7070
</span>
7171
))}
72-
{/* Draft appears like a badge while typing */}
73-
{draft ? <span className="badge badge-outline opacity-80 shrink-0">{draft}</span> : null}
7472
<input
7573
ref={inputRef}
76-
className="flex-1 w-px min-w-[1px] outline-none bg-transparent shrink-0"
74+
className="flex-1 min-w-[6ch] outline-none bg-transparent"
7775
placeholder={value.length || draft ? undefined : placeholder}
7876
value={draft}
7977
style={{ caretColor: "var(--bc)" }}
80-
// Hide the input's text while draft is shown to avoid duplicate text, but keep caret visible
8178
onChange={e => {
8279
const text = e.target.value;
8380
if (text.includes(",")) {
@@ -97,17 +94,6 @@ export const TagsInput = ({ value, onChange, placeholder, className }: TagsInput
9794
}}
9895
onBlur={() => commitDraftTokens(draft)}
9996
/>
100-
{/* Visually hide the input text by overlaying styles via a utility class when draft exists */}
101-
<style jsx>{`
102-
input[value]:not([value=""]) {
103-
${"/* Only when there is draft text */"}
104-
color: transparent;
105-
}
106-
input::placeholder {
107-
color: inherit;
108-
opacity: 0.5;
109-
}
110-
`}</style>
11197
</div>
11298
);
11399
};

0 commit comments

Comments
 (0)