diff --git a/AGENTS.md b/AGENTS.md index 348e562..127b7a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ session with **no other memory of this project**. So: kind; the constants live in `src/lib/types.ts`. Highlights: - `30818` skate spot · `30017` shop · `31923`/`31925` events + RSVP · `30823` tours - `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 → `src/hooks/useTradeAttestations.ts`; reputation UI in `TradeReputationCard` - `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") manages incoming requests, pending confirmations, and the user's trade records, 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 refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a diff --git a/NIP.md b/NIP.md index c361f83..1803249 100644 --- a/NIP.md +++ b/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 = :offer:`) | Response (`d = :offer-response:`) | +| --- | --- | --- | +| `a` | the listing coord (`30402::`) | 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 members can contribute to spots without being the owner. Contributions are stored as separate events that reference the original spot. diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx new file mode 100644 index 0000000..b0a6517 --- /dev/null +++ b/src/components/ListingOffersPanel.tsx @@ -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 ( + + {name} + + ); +} + +function statusBadge(status: MarketplaceOffer['status']) { + switch (status) { + case 'accepted': + return Accepted; + case 'declined': + return Declined; + case 'withdrawn': + return Withdrawn; + default: + return Open; + } +} + +/** + * 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(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 ( + + +

+ Offers ({incoming.length}) +

+ {incoming.map((offer) => { + const response = data.responses.find((r) => r.offerId === offer.id); + return ( +
+
+ + {offer.amount ? ( + + {offer.amount.toLocaleString()} {offer.currency ?? 'SATS'} + + ) : null} + {response ? statusBadge(response.status) : statusBadge(offer.status)} +
+ {offer.message &&

{offer.message}

} + {!response && offer.status === 'open' && ( +
+ + +
+ )} +
+ ); + })} +
+
+ ); + } + + // 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 ( + + +

+ Your offer +

+
+ {myOffer.amount ? ( + + {myOffer.amount.toLocaleString()} {myOffer.currency ?? 'SATS'} + + ) : ( + Offer + )} + {statusBadge(response ? response.status : myOffer.status)} +
+ {response?.message &&

{response.message}

} + {!response && myOffer.status === 'open' && ( + + )} +
+
+ ); +} diff --git a/src/components/OfferDialog.tsx b/src/components/OfferDialog.tsx new file mode 100644 index 0000000..155e863 --- /dev/null +++ b/src/components/OfferDialog.tsx @@ -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 ( + + + + + Make an offer + + + Offer an amount for this listing. {sellerName} can accept or decline; + you arrange payment between yourselves. + + + +
+
+ + setAmount(e.target.value)} + inputMode="numeric" + placeholder="e.g. 45000" + className="h-11 border-gray-700 bg-gray-900 text-white" + /> +
+
+ +