diff --git a/AGENTS.md b/AGENTS.md index d108524..e4be160 100644 --- a/AGENTS.md +++ b/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 diff --git a/NIP.md b/NIP.md index b1f2dca..dee25b3 100644 --- a/NIP.md +++ b/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 diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx index 239c708..4c096a8 100644 --- a/src/components/ListingOffersPanel.tsx +++ b/src/components/ListingOffersPanel.tsx @@ -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(null); const [counterFor, setCounterFor] = useState(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 {counterFor === latest.id && ( -
+
+
+ setCounterAmount(e.target.value)} + placeholder="Your counter (sats)" + className="h-9 border-white/15 bg-white/5 text-sm text-white" + /> + +
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" /> -
)} diff --git a/src/components/OfferDialog.tsx b/src/components/OfferDialog.tsx index f828ae7..1a7e581 100644 --- a/src/components/OfferDialog.tsx +++ b/src/components/OfferDialog.tsx @@ -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({ Make an offer - 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. @@ -103,6 +110,21 @@ export function OfferDialog({ className="h-11 border-gray-700 bg-gray-900 text-white" />
+
+ + setOfferItem(e.target.value)} + placeholder="e.g. my 8.25 deck + spare wheels" + className="h-11 border-gray-700 bg-gray-900 text-white" + /> +

+ Swapping? Put what you're offering here — you can combine it with sats. +

+