From 8c5b1636f766f31b0e54eca8f8e996c9d5c894f2 Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Thu, 1 Oct 2026 16:18:42 +0700
Subject: [PATCH] feat(marketplace): local-first fulfilment (pickup/ships) +
ring warning
- Listings carry optional ["fulfilment","pickup"|"ships"|"both"] (selling only):
a create-page selector (default pickup), a card badge, and a Pickup/Ships filter
on GearSwapPage. The app NEVER collects an address - a ships deal exchanges it
privately in the NIP-17 trade chat, and ListingTradeActions nudges escrow for
shipped deals. No KYC rail, no labels, no carrier API.
- TradeReputationCard adds a low-diversity ring warning (verified >= 5 across <= 2
people) beyond the single-partner one.
---
AGENTS.md | 9 +++++++
NIP.md | 1 +
src/components/ListingTradeActions.tsx | 8 ++++++
src/components/MarketplaceItemCard.tsx | 9 +++++++
src/components/TradeReputationCard.tsx | 8 ++++++
src/lib/types.ts | 4 +++
src/pages/CreateListingPage.tsx | 34 ++++++++++++++++++++++++++
src/pages/GearSwapPage.tsx | 28 +++++++++++++++++++--
8 files changed, 99 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 1396e5f..2df28fb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -127,6 +127,15 @@ session with **no other memory of this project**. So:
`PayWithSats` sheet (NWC → WebLN → Cashu melt, falling back to the seller's
`lud16`). ⚠️ The sheet is **mounted only while open** so its wallet hooks
don't run on every grid card.
+ - **Shipment / fulfilment (2026-10-01):** listings carry an optional
+ `["fulfilment","pickup"|"ships"|"both"]` (selling only) — a create-page
+ selector (default **pickup**), a card badge, and a **Pickup/Ships** filter on
+ `GearSwapPage`. ⚠️ **The app never collects an address**: a `ships` deal
+ exchanges it privately in the trade chat (NIP-17), and `ListingTradeActions`
+ nudges **escrow** for shipped deals. No KYC rail, no carrier API, no labels.
+ - **Low-diversity ring warning (2026-10-01):** beyond the single-partner
+ warning, `TradeReputationCard` flags `verified >= 5 && verifiedCounterparties
+ <= 2` — "most confirmations come from very few accounts".
- **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 8b6cf3e..ed9a52d 100644
--- a/NIP.md
+++ b/NIP.md
@@ -584,6 +584,7 @@ Gear listings use the NIP-99 Classified Listings format (kind 30402) for peer-to
| `price` | Price amount and currency (e.g., `["price", "50", "USD"]`) - required if price_type is `fixed` |
| `condition` | Item condition: `new`, `like_new`, `good`, `well_used` |
| `size` | Item size (varies by category) |
+| `fulfilment` | How the deal is fulfilled: `pickup`, `ships`, or `both`. ⚠️ The app **never collects an address** — a `ships` deal exchanges it privately in the trade chat (NIP-17). |
| `g` | Geohash for pickup location |
| `location` | Human-readable pickup location |
| `a` | Reference to a skate spot (format: `30818:pubkey:d-tag`) |
diff --git a/src/components/ListingTradeActions.tsx b/src/components/ListingTradeActions.tsx
index db41b4d..668acb0 100644
--- a/src/components/ListingTradeActions.tsx
+++ b/src/components/ListingTradeActions.tsx
@@ -192,6 +192,14 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
)}
+ {/* Shipped deals are the case escrow exists for — nudge it. */}
+ {canEscrow && item.fulfilment === 'ships' && (
+
+ 📦 Shipped deal — use escrow so neither side ships into the void. Share your
+ address privately in the trade chat, never here.
+
+ )}
+
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
{!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
)}
+ {lowDiversityWarning && (
+
+ ⚠ Most confirmations come from very few accounts. A small group vouching
+ for each other can look busy — treat this record with care.
+
+ )}
{data.verified > 0 && (
Trust-weighted {weighted.toFixed(1)}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index e07ad9c..c6470ad 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -3553,6 +3553,8 @@ export interface MarketplaceItem {
condition?: 'new' | 'like_new' | 'good' | 'well_used';
listingType?: 'selling' | 'wtb';
size?: string;
+ /** How the deal is fulfilled: local pickup, shipping, or either. */
+ fulfilment?: 'pickup' | 'ships' | 'both';
expiration?: number; // Unix timestamp
images: string[];
location?: string;
@@ -3632,6 +3634,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
const priceType = event.tags.find(([name]) => name === 'price_type')?.[1] as MarketplaceItem['priceType'];
const condition = event.tags.find(([name]) => name === 'condition')?.[1] as MarketplaceItem['condition'];
const listingType = event.tags.find(([name]) => name === 'listing_type')?.[1] as MarketplaceItem['listingType'];
+ const fulfilment = event.tags.find(([name]) => name === 'fulfilment')?.[1] as MarketplaceItem['fulfilment'];
const size = event.tags.find(([name]) => name === 'size')?.[1];
const expirationTag = event.tags.find(([name]) => name === 'expiration')?.[1];
const expiration = expirationTag ? parseInt(expirationTag) : undefined;
@@ -3665,6 +3668,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
priceType,
condition,
listingType,
+ fulfilment,
size,
expiration,
images,
diff --git a/src/pages/CreateListingPage.tsx b/src/pages/CreateListingPage.tsx
index 1b28f0e..1d81a08 100644
--- a/src/pages/CreateListingPage.tsx
+++ b/src/pages/CreateListingPage.tsx
@@ -59,6 +59,7 @@ export default function CreateListingPage() {
// Form state - initialize from URL params if coming from stash
const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling');
+ const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>('pickup');
const [category, setCategory] = useState(searchParams.get('category') || '');
const [title, setTitle] = useState(searchParams.get('title') || '');
const [description, setDescription] = useState(searchParams.get('description') || '');
@@ -272,6 +273,8 @@ export default function CreateListingPage() {
if (summary.trim()) tags.push(['summary', summary.trim()]);
if (condition) tags.push(['condition', condition]);
+ // Fulfilment is only meaningful when selling (WTB is the buyer's ask).
+ if (listingType === 'selling') tags.push(['fulfilment', fulfilment]);
// Size with optional modifiers
if (size) {
@@ -678,6 +681,37 @@ export default function CreateListingPage() {
)}
+ {listingType === 'selling' && (
+