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:
parent
2e29f12a84
commit
7307b00edc
6 changed files with 550 additions and 6 deletions
|
|
@ -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
21
NIP.md
|
|
@ -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.
|
||||||
|
|
|
||||||
181
src/components/ListingOffersPanel.tsx
Normal file
181
src/components/ListingOffersPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/OfferDialog.tsx
Normal file
120
src/components/OfferDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/hooks/useMarketplaceOffers.ts
Normal file
208
src/hooks/useMarketplaceOffers.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue