feat(market): first-class barter in offers (item tag)
Listings already supported price_type=trade, and attestations carry method=trade,
so swaps counted for reputation. Offers, though, could only name a sats amount.
- 30403 messages may now carry an optional 'item' tag: what the sender is
offering in kind. It combines with amount (e.g. 2,000 sats + an 8.25 deck) or
stands alone for a pure swap.
- OfferDialog: 'Or trading something' field + validation (amount, item or note).
- ListingOffersPanel: the thread shows the offered item, and the counter UI takes
an optional item alongside the amount.
- Bell/OS text reads it ('offered 2,000 SATS + an 8.25 deck').
- NIP.md documents the item tag; AGENTS notes barter is now first-class.
This commit is contained in:
parent
0b7371263c
commit
d9a906ab6e
6 changed files with 109 additions and 35 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -92,10 +92,13 @@ session with **no other memory of this project**. So:
|
|||
wired in `OsNotificationBridge`). The bell also has a dedicated **Trades tab**
|
||||
(5th tab, `Handshake`) listing them, with its own count. **Barter is supported
|
||||
by design**: trade attestations carry `method` (`cash|lightning|trade|other` —
|
||||
`MarkSoldDialog` picks it), so a swap feeds reputation exactly like a sale; an
|
||||
offer with **no `amount`** plus a note is how a swap is proposed (there is
|
||||
deliberately **no** "For trade" listing type yet — `LISTING_TYPES` is
|
||||
`selling|wtb`).
|
||||
`MarkSoldDialog` picks it), so a swap feeds reputation exactly like a sale.
|
||||
**Barter is first-class on the offer side too (2026-09-29):** an offer/counter
|
||||
may carry an `item` tag (`["item","my 8.25 deck"]`) instead of or alongside
|
||||
`amount` — `OfferDialog` and the counter UI expose it, and the bell/OS text
|
||||
reads it ("offered 2,000 SATS + an 8.25 deck"). Listings already support
|
||||
`price_type=trade` ("Looking to swap"); `LISTING_TYPES` itself is still
|
||||
`selling|wtb`.
|
||||
- **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
|
||||
|
|
|
|||
6
NIP.md
6
NIP.md
|
|
@ -795,6 +795,7 @@ of a negotiation is simply its newest message.
|
|||
| `role` | `offer` (authored by the buyer) \| `response` (authored by the seller) |
|
||||
| `status` | `open` \| `accepted` \| `declined` \| `withdrawn` |
|
||||
| `amount` | optional `["amount","45000","SATS"]` |
|
||||
| `item` | optional `["item","my 8.25 deck + spare wheels"]` — an **in-kind offer (barter)**; may be combined with `amount` |
|
||||
| `alt` | `SkateSpots: marketplace offer` |
|
||||
|
||||
`content` is an optional human message. Either party may answer the newest `open`
|
||||
|
|
@ -808,7 +809,10 @@ its newest `open` message.
|
|||
> Clients MUST still parse those (`role` inferred from the `d` marker when the
|
||||
> `role` tag is absent), but new messages use the unique-`d` thread form.
|
||||
|
||||
Payment is still arranged directly between the two skaters.
|
||||
Payment is still arranged directly between the two skaters. **Bartering is
|
||||
first-class**: an offer or counter may carry an `item` (what the sender is
|
||||
offering) instead of, or alongside, `amount` — and listings already support
|
||||
`price_type=trade`.
|
||||
|
||||
## Community Contributions
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,10 @@ function statusBadge(status: MarketplaceOffer['status']) {
|
|||
}
|
||||
|
||||
function amountLabel(m: MarketplaceOffer): string {
|
||||
return m.amount ? `${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}` : 'no amount';
|
||||
const parts: string[] = [];
|
||||
if (m.amount) parts.push(`${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}`);
|
||||
if (m.offerItem) parts.push(m.offerItem);
|
||||
return parts.length ? parts.join(' + ') : 'no amount';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,6 +63,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [counterFor, setCounterFor] = useState<string | null>(null);
|
||||
const [counterAmount, setCounterAmount] = useState('');
|
||||
const [counterItem, setCounterItem] = useState('');
|
||||
|
||||
if (!user || !data) return null;
|
||||
|
||||
|
|
@ -114,8 +118,9 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
|
||||
const sendCounter = async (thread: OfferThread) => {
|
||||
const amount = Number(counterAmount);
|
||||
if (!counterAmount.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||
toast({ title: 'Enter a counter amount', variant: 'destructive' });
|
||||
const hasAmount = counterAmount.trim() !== '' && Number.isFinite(amount) && amount > 0;
|
||||
if (!hasAmount && !counterItem.trim()) {
|
||||
toast({ title: 'Enter an amount or an item to trade', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setBusy(thread.latest.id);
|
||||
|
|
@ -123,12 +128,17 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
await counterOffer({
|
||||
listing: { pubkey: item.pubkey, identifier: item.identifier },
|
||||
previous: thread.latest,
|
||||
amount,
|
||||
amount: hasAmount ? amount : undefined,
|
||||
offerItem: counterItem,
|
||||
});
|
||||
await nudge(thread.latest.author, `↩️ Counter-offer: ${amount.toLocaleString()} sats`);
|
||||
const what = [hasAmount ? `${amount.toLocaleString()} sats` : null, counterItem.trim() || null]
|
||||
.filter(Boolean)
|
||||
.join(' + ');
|
||||
await nudge(thread.latest.author, `↩️ Counter-offer: ${what}`);
|
||||
toast({ title: 'Counter-offer sent', description: 'It’s their turn now.' });
|
||||
setCounterFor(null);
|
||||
setCounterAmount('');
|
||||
setCounterItem('');
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not send the counter',
|
||||
|
|
@ -212,6 +222,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
onClick={() => {
|
||||
setCounterFor(counterFor === latest.id ? null : latest.id);
|
||||
setCounterAmount(latest.amount ? String(Math.round(latest.amount)) : '');
|
||||
setCounterItem(latest.offerItem ?? '');
|
||||
}}
|
||||
className="flex-1 gap-1.5 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
|
||||
>
|
||||
|
|
@ -219,25 +230,33 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
</Button>
|
||||
</div>
|
||||
{counterFor === latest.id && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={counterAmount}
|
||||
onChange={(e) => setCounterAmount(e.target.value)}
|
||||
placeholder="Your counter (sats)"
|
||||
className="h-9 border-white/15 bg-white/5 text-sm text-white"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => sendCounter(thread)}
|
||||
disabled={busy === latest.id}
|
||||
className="shrink-0 gap-1.5 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
|
||||
>
|
||||
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
autoFocus
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={counterAmount}
|
||||
onChange={(e) => setCounterAmount(e.target.value)}
|
||||
placeholder="Your counter (sats)"
|
||||
value={counterItem}
|
||||
onChange={(e) => setCounterItem(e.target.value)}
|
||||
placeholder="…or counter with an item to trade (optional)"
|
||||
className="h-9 border-white/15 bg-white/5 text-sm text-white"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => sendCounter(thread)}
|
||||
disabled={busy === latest.id}
|
||||
className="shrink-0 gap-1.5 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
|
||||
>
|
||||
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export function OfferDialog({
|
|||
const { mutateAsync: sendDM } = useSendDirectMessage();
|
||||
const { toast } = useToast();
|
||||
const [amount, setAmount] = useState('');
|
||||
const [offerItem, setOfferItem] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
|
|
@ -42,17 +43,22 @@ export function OfferDialog({
|
|||
toast({ title: 'Enter a valid amount', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (!parsed && !offerItem.trim() && !note.trim()) {
|
||||
toast({ title: 'Describe your offer', description: 'Add an amount, an item to trade, or a message.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await createOffer({ listing, amount: parsed, currency: 'SATS', note });
|
||||
await createOffer({ listing, amount: parsed, currency: 'SATS', offerItem, note });
|
||||
|
||||
// Best-effort nudge to the seller via NIP-17 DM — never fail the offer.
|
||||
try {
|
||||
const amountLine = parsed ? ` for ${parsed.toLocaleString()} sats` : '';
|
||||
const itemLine = offerItem.trim() ? ` (offering: ${offerItem.trim()})` : '';
|
||||
const noteLine = note.trim() ? `\n\n“${note.trim()}”` : '';
|
||||
await sendDM({
|
||||
recipients: listing.pubkey,
|
||||
content: `🛹 New offer on your listing “${title}”${amountLine}.${noteLine}`,
|
||||
content: `🛹 New offer on your listing “${title}”${amountLine}${itemLine}.${noteLine}`,
|
||||
});
|
||||
} catch {
|
||||
/* DM optional (no NIP-44 signer, etc.) */
|
||||
|
|
@ -64,6 +70,7 @@ export function OfferDialog({
|
|||
});
|
||||
onOpenChange(false);
|
||||
setAmount('');
|
||||
setOfferItem('');
|
||||
setNote('');
|
||||
} catch (e) {
|
||||
toast({
|
||||
|
|
@ -84,8 +91,8 @@ export function OfferDialog({
|
|||
<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.
|
||||
Offer sats, or swap something — {sellerName} can accept, decline or counter.
|
||||
You arrange the handover between yourselves.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -103,6 +110,21 @@ export function OfferDialog({
|
|||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offer-item" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Or trading something (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="offer-item"
|
||||
value={offerItem}
|
||||
onChange={(e) => setOfferItem(e.target.value)}
|
||||
placeholder="e.g. my 8.25 deck + spare wheels"
|
||||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Swapping? Put what you're offering here — you can combine it with sats.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Message (optional)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export interface MarketplaceOffer {
|
|||
listingCoord: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
/** What the sender is offering in kind — how a swap/barter is expressed. */
|
||||
offerItem?: string;
|
||||
status: OfferStatus;
|
||||
message: string;
|
||||
/** The message this one answers (`e`), when it's a counter/accept/decline. */
|
||||
|
|
@ -92,6 +94,7 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
|
|||
listingCoord: a,
|
||||
amount: amountTag?.[1] ? Number(amountTag[1]) : undefined,
|
||||
currency: amountTag?.[2],
|
||||
offerItem: tag(event, 'item'),
|
||||
status,
|
||||
message: event.content,
|
||||
replyTo: tag(event, 'e'),
|
||||
|
|
@ -237,6 +240,7 @@ export function useOfferActions() {
|
|||
counterparty,
|
||||
amount,
|
||||
currency = 'SATS',
|
||||
offerItem,
|
||||
note,
|
||||
status = 'open',
|
||||
replyTo,
|
||||
|
|
@ -246,6 +250,8 @@ export function useOfferActions() {
|
|||
counterparty: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
/** In-kind offer (a swap/barter) — free text, e.g. "my 8.25 deck". */
|
||||
offerItem?: string;
|
||||
note?: string;
|
||||
status?: OfferStatus;
|
||||
replyTo?: string;
|
||||
|
|
@ -266,20 +272,27 @@ export function useOfferActions() {
|
|||
];
|
||||
if (replyTo) tags.push(['e', replyTo]);
|
||||
if (amount && amount > 0) tags.push(['amount', String(amount), currency]);
|
||||
if (offerItem?.trim()) tags.push(['item', offerItem.trim().slice(0, 120)]);
|
||||
|
||||
await publish({ kind: OFFER_KIND, content: note?.trim() || '', tags });
|
||||
};
|
||||
|
||||
/** Buyer: open the negotiation. */
|
||||
const createOffer = (args: { listing: { pubkey: string; identifier: string }; amount?: number; currency?: string; note?: string }) =>
|
||||
publishMessage({ ...args, counterparty: args.listing.pubkey });
|
||||
const createOffer = (args: {
|
||||
listing: { pubkey: string; identifier: string };
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
offerItem?: string;
|
||||
note?: string;
|
||||
}) => publishMessage({ ...args, counterparty: args.listing.pubkey });
|
||||
|
||||
/** Either side answers with a new amount — the counter-offer volley. */
|
||||
/** Either side answers with a new amount and/or a different item offered. */
|
||||
const counterOffer = (args: {
|
||||
listing: { pubkey: string; identifier: string };
|
||||
previous: MarketplaceOffer;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
offerItem?: string;
|
||||
note?: string;
|
||||
}) =>
|
||||
publishMessage({
|
||||
|
|
@ -287,6 +300,7 @@ export function useOfferActions() {
|
|||
counterparty: args.previous.author,
|
||||
amount: args.amount,
|
||||
currency: args.currency,
|
||||
offerItem: args.offerItem,
|
||||
note: args.note,
|
||||
status: 'open',
|
||||
replyTo: args.previous.id,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export interface TradeNotification {
|
|||
counterparty: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
/** In-kind offer (a swap/barter), when the sender offered an item. */
|
||||
offerItem?: string;
|
||||
listingPubkey: string;
|
||||
listingIdentifier: string;
|
||||
/** Present for offer/counter items, so the UI can act inline. */
|
||||
|
|
@ -57,6 +59,7 @@ export function useTradeNotifications(pubkey: string | undefined) {
|
|||
counterparty: latest.author,
|
||||
amount: latest.amount,
|
||||
currency: latest.currency,
|
||||
offerItem: latest.offerItem,
|
||||
listingPubkey: latest.listingPubkey,
|
||||
listingIdentifier: latest.listingIdentifier,
|
||||
offer: latest,
|
||||
|
|
@ -81,12 +84,21 @@ export function useTradeNotifications(pubkey: string | undefined) {
|
|||
return { notifications, isLoading: offersLoading || requestsLoading };
|
||||
}
|
||||
|
||||
/** "2,000 SATS + an 8.25 deck" — sats, an item, or both. */
|
||||
function offerWhat(n: TradeNotification): string {
|
||||
const parts: string[] = [];
|
||||
if (n.amount) parts.push(`${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}`);
|
||||
if (n.offerItem) parts.push(n.offerItem);
|
||||
return parts.join(' + ');
|
||||
}
|
||||
|
||||
export function tradeNotificationText(n: TradeNotification): string {
|
||||
const what = offerWhat(n);
|
||||
switch (n.type) {
|
||||
case 'offer':
|
||||
return n.amount ? `offered ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'made an offer';
|
||||
return what ? `offered ${what}` : 'made an offer';
|
||||
case 'counter':
|
||||
return n.amount ? `countered with ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'sent a counter-offer';
|
||||
return what ? `countered with ${what}` : 'sent a counter-offer';
|
||||
case 'accepted':
|
||||
return n.amount ? `accepted your offer — ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'accepted your offer';
|
||||
case 'declined':
|
||||
|
|
|
|||
Loading…
Reference in a new issue