feat(offers): structured marketplace offers (kind 30403) with accept/decline

Promotes 'Offer' from a prefilled DM to a real, queryable event:
- Buyer publishes a 30403 offer (d/a/p + optional amount + message)
- Seller sees incoming offers on the listing detail page and can Accept/Decline
  (publishes a 30403 response referencing the offer)
- Buyer sees their offer's status and can Withdraw it
- New: useMarketplaceOffers (parse + hooks), OfferDialog, ListingOffersPanel
- NIP.md documents kind 30403; AGENTS.md updated

Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
SkateSpots 2026-09-26 23:17:51 +07:00
parent 2e29f12a84
commit 7307b00edc
6 changed files with 550 additions and 6 deletions

View file

@ -39,6 +39,7 @@ session with **no other memory of this project**. So:
kind; the constants live in `src/lib/types.ts`. Highlights: kind; the constants live in `src/lib/types.ts`. Highlights:
- `30818` skate spot · `30017` shop · `31923`/`31925` events + RSVP · `30823` tours - `30818` skate spot · `30017` shop · `31923`/`31925` events + RSVP · `30823` tours
- `30402` NIP-99 listing = marketplace item, and the **Assertion Event** for trades - `30402` NIP-99 listing = marketplace item, and the **Assertion Event** for trades
- `30403` marketplace offer (+ seller response) — see `NIP.md`
- `31871` trade attestation (sale/purchase) · `31872` trade confirmation request - `31871` trade attestation (sale/purchase) · `31872` trade confirmation request
→ `src/hooks/useTradeAttestations.ts`; reputation UI in `TradeReputationCard` → `src/hooks/useTradeAttestations.ts`; reputation UI in `TradeReputationCard`
- `35000–35012` role profiles (skater/filmer/coach/…) · `35006` crews · `30003` spot collections - `35000–35012` role profiles (skater/filmer/coach/…) · `35006` crews · `30003` spot collections
@ -51,7 +52,9 @@ session with **no other memory of this project**. So:
buyers contact the seller and settle off-app. A **`/trades`** page ("My Trades") buyers contact the seller and settle off-app. A **`/trades`** page ("My Trades")
manages incoming requests, pending confirmations, and the user's trade records, manages incoming requests, pending confirmations, and the user's trade records,
and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute
or mistake. or mistake. Structured **offers** (kind `30403`, `useMarketplaceOffers` + the
`ListingOffersPanel`/`OfferDialog`) let a buyer propose an amount and the seller
accept/decline.
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer - **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a

21
NIP.md
View file

@ -777,6 +777,27 @@ For trust and spam prevention, sellers should have a verified NIP-05 identifier.
} }
``` ```
## Marketplace Offers (kind 30403)
A buyer's structured proposal for a listing, which the seller can accept or
decline. SkateSpots-native (no existing NIP defines offers). Addressable, so an
offer/response can be updated in place.
An **offer** is authored by the buyer; a **response** by the seller:
| Field | Offer (`d = <buyer>:offer:<listing-d>`) | Response (`d = <seller>:offer-response:<buyer>`) |
| --- | --- | --- |
| `a` | the listing coord (`30402:<seller>:<d>`) | the listing coord |
| `p` | the **seller** | the **buyer** |
| `e` | — | the **offer** event id being answered |
| `status` | `open` \| `withdrawn` | `accepted` \| `declined` |
| `amount` | optional `["amount","45000","SATS"]` | mirrored |
| `alt` | `SkateSpots: marketplace offer` | same |
`content` is an optional human message. Clients show a seller their incoming
offers (open, `#p` = them) and a buyer their own offer plus the seller's
response. Payment is still arranged directly between the two skaters.
## Community Contributions ## Community Contributions
Community members can contribute to spots without being the owner. Contributions are stored as separate events that reference the original spot. Community members can contribute to spots without being the owner. Contributions are stored as separate events that reference the original spot.

View file

@ -0,0 +1,181 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import {
useListingOffers,
useOfferActions,
type MarketplaceOffer,
} from '@/hooks/useMarketplaceOffers';
import { genUserName } from '@/lib/genUserName';
import type { MarketplaceItem } from '@/lib/types';
import { Handshake, CheckCircle, X, Loader2 } from 'lucide-react';
function PeerName({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
return (
<Link to={`/${nip19.npubEncode(pubkey)}`} className="font-bold text-white hover:text-[#ccff00]">
{name}
</Link>
);
}
function statusBadge(status: MarketplaceOffer['status']) {
switch (status) {
case 'accepted':
return <Badge className="bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">Accepted</Badge>;
case 'declined':
return <Badge className="bg-red-500/15 text-red-400 border border-red-500/30">Declined</Badge>;
case 'withdrawn':
return <Badge variant="secondary" className="bg-gray-700 text-gray-400">Withdrawn</Badge>;
default:
return <Badge className="bg-orange-500/15 text-orange-400 border border-orange-500/30">Open</Badge>;
}
}
/**
* Offers for a listing: the seller sees incoming offers with accept/decline;
* a buyer sees their own offer and the seller's response.
*/
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
const { user } = useCurrentUser();
const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
const { respondToOffer } = useOfferActions();
const { toast } = useToast();
const [busy, setBusy] = useState<string | null>(null);
if (!user || !data) return null;
const respond = async (offer: MarketplaceOffer, status: 'accepted' | 'declined') => {
setBusy(offer.id);
try {
await respondToOffer(offer, status);
toast({
title: status === 'accepted' ? 'Offer accepted' : 'Offer declined',
description: 'The buyer will see your response.',
});
} catch (e) {
toast({
title: 'Could not respond',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
const withdraw = async (offer: MarketplaceOffer) => {
setBusy(offer.id);
try {
await respondToOffer(offer, 'withdrawn');
toast({ title: 'Offer withdrawn' });
} catch (e) {
toast({
title: 'Could not withdraw',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
// Seller view: incoming offers on their listing.
if (isOwner) {
const incoming = data.offers.filter((o) => o.seller === user.pubkey);
if (incoming.length === 0) return null;
return (
<Card className="bg-black/90 border-2 border-orange-500/25">
<CardContent className="space-y-3 p-4">
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-orange-400">
<Handshake className="h-4 w-4" /> Offers ({incoming.length})
</h3>
{incoming.map((offer) => {
const response = data.responses.find((r) => r.offerId === offer.id);
return (
<div key={offer.id} className="rounded-xl border border-white/10 bg-white/[0.03] p-3 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-200"><PeerName pubkey={offer.buyer} /></span>
{offer.amount ? (
<span className="text-sm font-bold text-[#ccff00]">
{offer.amount.toLocaleString()} {offer.currency ?? 'SATS'}
</span>
) : null}
{response ? statusBadge(response.status) : statusBadge(offer.status)}
</div>
{offer.message && <p className="text-xs text-gray-400">{offer.message}</p>}
{!response && offer.status === 'open' && (
<div className="flex gap-2 pt-1">
<Button
size="sm"
onClick={() => respond(offer, 'accepted')}
disabled={busy === offer.id}
className="flex-1 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy === offer.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
Accept
</Button>
<Button
size="sm"
variant="outline"
onClick={() => respond(offer, 'declined')}
disabled={busy === offer.id}
className="flex-1 gap-1.5 border-red-500/30 text-red-400 hover:bg-red-500/10"
>
<X className="h-3.5 w-3.5" /> Decline
</Button>
</div>
)}
</div>
);
})}
</CardContent>
</Card>
);
}
// Buyer view: my own offer + the seller's response.
const myOffer = data.offers.find((o) => o.buyer === user.pubkey);
if (!myOffer) return null;
const response = data.responses.find((r) => r.offerId === myOffer.id);
return (
<Card className="bg-black/90 border-2 border-orange-500/25">
<CardContent className="space-y-2 p-4">
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-orange-400">
<Handshake className="h-4 w-4" /> Your offer
</h3>
<div className="flex items-center gap-2">
{myOffer.amount ? (
<span className="text-sm font-bold text-[#ccff00]">
{myOffer.amount.toLocaleString()} {myOffer.currency ?? 'SATS'}
</span>
) : (
<span className="text-sm text-gray-300">Offer</span>
)}
{statusBadge(response ? response.status : myOffer.status)}
</div>
{response?.message && <p className="text-xs text-gray-400">{response.message}</p>}
{!response && myOffer.status === 'open' && (
<Button
size="sm"
variant="outline"
onClick={() => withdraw(myOffer)}
disabled={busy === myOffer.id}
className="w-full border-white/15 gap-1.5"
>
{busy === myOffer.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Withdraw offer
</Button>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,120 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useOfferActions } from '@/hooks/useMarketplaceOffers';
import { useToast } from '@/hooks/useToast';
import { Handshake, Loader2 } from 'lucide-react';
/** Buyer: make a structured offer (amount + note) on a listing. */
export function OfferDialog({
open,
onOpenChange,
listing,
sellerName,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
listing: { pubkey: string; identifier: string };
sellerName: string;
}) {
const { createOffer } = useOfferActions();
const { toast } = useToast();
const [amount, setAmount] = useState('');
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const submit = async () => {
const parsed = amount.trim() ? Number(amount) : undefined;
if (parsed !== undefined && (!Number.isFinite(parsed) || parsed <= 0)) {
toast({ title: 'Enter a valid amount', variant: 'destructive' });
return;
}
setBusy(true);
try {
await createOffer({ listing, amount: parsed, currency: 'SATS', note });
toast({
title: 'Offer sent',
description: `${sellerName} can accept or decline it.`,
});
onOpenChange(false);
setAmount('');
setNote('');
} catch (e) {
toast({
title: 'Could not send the offer',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-orange-500/40 bg-black sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<Handshake className="h-5 w-5 text-orange-400" /> Make an offer
</DialogTitle>
<DialogDescription className="text-sm text-gray-400">
Offer an amount for this listing. {sellerName} can accept or decline;
you arrange payment between yourselves.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="offer-amount" className="text-xs uppercase tracking-wide text-gray-400">
Amount (sats, optional)
</Label>
<Input
id="offer-amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
inputMode="numeric"
placeholder="e.g. 45000"
className="h-11 border-gray-700 bg-gray-900 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400">
Message (optional)
</Label>
<Textarea
id="offer-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
placeholder="Anything the seller should know…"
className="border-gray-700 bg-gray-900 text-white"
/>
</div>
<div className="flex gap-2 pt-1">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
Cancel
</Button>
<Button
onClick={submit}
disabled={busy}
className="flex-1 gap-2 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Handshake className="h-4 w-4" />}
Send offer
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,208 @@
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
/**
* Marketplace offers — a buyer's structured proposal ("I'll give X for this"),
* which the seller can accept or decline. SkateSpots-native, documented in
* `NIP.md`.
*
* Kind `30403` (addressable):
* offer → d = `<buyer>:offer:<listing-d>`, a = listing, p = seller
* response → d = `<seller>:offer-response:<buyer>`, a = listing, p = buyer,
* e = the buyer's offer event id
* state lives in a `status` tag: open | accepted | declined | withdrawn.
*/
export const OFFER_KIND = 30403;
export type OfferStatus = 'open' | 'accepted' | 'declined' | 'withdrawn';
export type OfferRole = 'offer' | 'response';
export interface MarketplaceOffer {
id: string;
role: OfferRole;
/** Author: the buyer for an offer, the seller for a response. */
author: string;
buyer: string;
seller: string;
listingPubkey: string;
listingIdentifier: string;
listingCoord: string;
amount?: number;
currency?: string;
status: OfferStatus;
message: string;
/** The offer event id this response answers (responses only). */
offerId?: string;
createdAt: number;
event: NostrEvent;
}
function tag(event: NostrEvent, name: string): string | undefined {
return event.tags.find(([n]) => n === name)?.[1];
}
export function parseListingCoord(aTag: string | undefined): { pubkey: string; identifier: string } | null {
if (!aTag) return null;
const [kindStr, pubkey, ...rest] = aTag.split(':');
if (Number(kindStr) !== MARKETPLACE_ITEM_KIND || !pubkey || rest.length === 0) return null;
return { pubkey, identifier: rest.join(':') };
}
export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
if (event.kind !== OFFER_KIND) return null;
const d = tag(event, 'd');
const a = tag(event, 'a');
const coord = parseListingCoord(a);
if (!d || !a || !coord) return null;
const role: OfferRole = d.includes(':offer-response:') ? 'response' : 'offer';
const counterparty = tag(event, 'p');
const amountTag = event.tags.find(([n]) => n === 'amount');
const status = (tag(event, 'status') as OfferStatus) ?? 'open';
const buyer = role === 'offer' ? event.pubkey : (counterparty ?? '');
const seller = role === 'offer' ? (counterparty ?? coord.pubkey) : event.pubkey;
return {
id: event.id,
role,
author: event.pubkey,
buyer,
seller,
listingPubkey: coord.pubkey,
listingIdentifier: coord.identifier,
listingCoord: a,
amount: amountTag?.[1] ? Number(amountTag[1]) : undefined,
currency: amountTag?.[2],
status,
message: event.content,
offerId: tag(event, 'e'),
createdAt: event.created_at,
event,
};
}
/** Newest event per addressable slot (`d`). */
function dedupeNewest(events: NostrEvent[]): NostrEvent[] {
const byD = new Map<string, NostrEvent>();
for (const ev of [...events].sort((a, b) => b.created_at - a.created_at)) {
const d = tag(ev, 'd') ?? ev.id;
if (!byD.has(d)) byD.set(d, ev);
}
return [...byD.values()];
}
/** All offers + responses for a listing. */
export function useListingOffers(listing?: { pubkey: string; identifier: string }) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined;
return useQuery<{ offers: MarketplaceOffer[]; responses: MarketplaceOffer[] }>({
queryKey: ['listing-offers', coord, getQueryKeySuffix()],
enabled: !!coord,
staleTime: 60 * 1000,
queryFn: async () => {
if (!coord) return { offers: [], responses: [] };
const events = await querySkateContent(
[{ kinds: [OFFER_KIND], '#a': [coord], limit: 100 }],
{ signal: AbortSignal.timeout(8000) },
);
const parsed = dedupeNewest(events)
.map(parseOffer)
.filter((o): o is MarketplaceOffer => !!o);
return {
offers: parsed.filter((o) => o.role === 'offer'),
responses: parsed.filter((o) => o.role === 'response'),
};
},
});
}
/** Offers received by a seller (open, addressed to them). */
export function useIncomingOffers(sellerPubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<MarketplaceOffer[]>({
queryKey: ['incoming-offers', sellerPubkey, getQueryKeySuffix()],
enabled: !!sellerPubkey,
staleTime: 60 * 1000,
queryFn: async () => {
if (!sellerPubkey) return [];
const events = await querySkateContent(
[{ kinds: [OFFER_KIND], '#p': [sellerPubkey], limit: 100 }],
{ signal: AbortSignal.timeout(8000) },
);
return dedupeNewest(events)
.map(parseOffer)
.filter((o): o is MarketplaceOffer => !!o && o.role === 'offer' && o.status === 'open');
},
});
}
export function useOfferActions() {
const { user } = useCurrentUser();
const { mutateAsync: publish } = useNostrPublish();
const createOffer = async ({
listing,
amount,
currency = 'SATS',
note,
}: {
listing: { pubkey: string; identifier: string };
amount?: number;
currency?: string;
note?: string;
}) => {
if (!user) throw new Error('Log in to make an offer.');
const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`;
const tags: string[][] = [
['d', `${user.pubkey}:offer:${listing.identifier}`],
['a', coord],
['p', listing.pubkey],
['status', 'open'],
['alt', 'SkateSpots: marketplace offer'],
];
if (amount && amount > 0) tags.push(['amount', String(amount), currency]);
await publish({
kind: OFFER_KIND,
content: note?.trim() || '',
tags,
});
};
/** Seller responds to a buyer's offer (accept/decline), or buyer withdraws. */
const respondToOffer = async (offer: MarketplaceOffer, status: OfferStatus, note?: string) => {
if (!user) throw new Error('Log in to respond.');
const isBuyerWithdrawing = user.pubkey === offer.buyer && status === 'withdrawn';
const tags: string[][] = isBuyerWithdrawing
? [
['d', `${offer.buyer}:offer:${offer.listingIdentifier}`],
['a', offer.listingCoord],
['p', offer.seller],
]
: [
['d', `${user.pubkey}:offer-response:${offer.buyer}`],
['a', offer.listingCoord],
['p', offer.buyer],
['e', offer.id],
];
tags.push(['status', status], ['alt', 'SkateSpots: marketplace offer']);
const amountTag = offer.event.tags.find(([n]) => n === 'amount');
if (amountTag && !isBuyerWithdrawing) tags.push(amountTag);
await publish({
kind: OFFER_KIND,
content: note?.trim() || offer.message,
tags,
});
};
return { createOffer, respondToOffer };
}

View file

@ -12,6 +12,8 @@ import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
import { TrustBadge } from '@/components/TrustBadge'; import { TrustBadge } from '@/components/TrustBadge';
import { TradeReputationCard } from '@/components/TradeReputationCard'; import { TradeReputationCard } from '@/components/TradeReputationCard';
import { ListingTradeActions } from '@/components/ListingTradeActions'; import { ListingTradeActions } from '@/components/ListingTradeActions';
import { ListingOffersPanel } from '@/components/ListingOffersPanel';
import { OfferDialog } from '@/components/OfferDialog';
import { MarkSoldDialog } from '@/components/MarkSoldDialog'; import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useMarketplaceItem } from '@/hooks/useMarketplace'; import { useMarketplaceItem } from '@/hooks/useMarketplace';
@ -58,6 +60,7 @@ export default function ListingDetailPage() {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [showMarkSold, setShowMarkSold] = useState(false); const [showMarkSold, setShowMarkSold] = useState(false);
const [showDelete, setShowDelete] = useState(false); const [showDelete, setShowDelete] = useState(false);
const [showOffer, setShowOffer] = useState(false);
useSeoMeta({ useSeoMeta({
title: item ? `${item.title} — SkateSpots Marketplace` : 'Listing — SkateSpots', title: item ? `${item.title} — SkateSpots Marketplace` : 'Listing — SkateSpots',
@ -105,8 +108,6 @@ export default function ListingDetailPage() {
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing })); const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
const offerMessage = `Hi! I saw your listing “${item.title}” on SkateSpots and I'd like to make an offer.`;
const handleShare = async () => { const handleShare = async () => {
try { try {
await navigator.clipboard.writeText(window.location.href); await navigator.clipboard.writeText(window.location.href);
@ -319,9 +320,7 @@ export default function ListingDetailPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => setShowOffer(true)}
navigate(`/messages/${npub}?message=${encodeURIComponent(offerMessage)}`)
}
className="mt-2 w-full gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10" className="mt-2 w-full gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
> >
<Handshake className="h-4 w-4" /> Make an offer <Handshake className="h-4 w-4" /> Make an offer
@ -338,6 +337,9 @@ export default function ListingDetailPage() {
</Card> </Card>
)} )}
{/* Offers: seller sees incoming; buyer sees their own offer */}
<ListingOffersPanel item={item} isOwner={isOwnListing} />
{/* Seller trade reputation */} {/* Seller trade reputation */}
<TradeReputationCard pubkey={item.pubkey} /> <TradeReputationCard pubkey={item.pubkey} />
</aside> </aside>
@ -351,6 +353,15 @@ export default function ListingDetailPage() {
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
/> />
{!isOwnListing && (
<OfferDialog
open={showOffer}
onOpenChange={setShowOffer}
listing={{ pubkey: item.pubkey, identifier: item.identifier }}
sellerName={displayName}
/>
)}
{isOwnListing && ( {isOwnListing && (
<MarkSoldDialog <MarkSoldDialog
open={showMarkSold} open={showMarkSold}