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.
This commit is contained in:
SkateSpots 2026-10-01 16:18:42 +07:00
parent 50735d1718
commit 8c5b1636f7
8 changed files with 99 additions and 2 deletions

View file

@ -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

1
NIP.md
View file

@ -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`) |

View file

@ -192,6 +192,14 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
</Button>
)}
{/* Shipped deals are the case escrow exists for — nudge it. */}
{canEscrow && item.fulfilment === 'ships' && (
<p className="text-[11px] text-center text-amber-400/90">
📦 Shipped deal — use escrow so neither side ships into the void. Share your
address privately in the trade chat, never here.
</p>
)}
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
{!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
<Button

View file

@ -334,6 +334,15 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{categoryLabel}
</Badge>
)}
{item.fulfilment && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{item.fulfilment === 'pickup'
? '📍 Pickup'
: item.fulfilment === 'ships'
? '📦 Ships'
: '📍📦 Pickup or ship'}
</Badge>
)}
{sellerRep && sellerRep.verified > 0 && (
<Badge variant="outline" className="text-xs border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]">
<CheckCircle className="h-3 w-3 mr-1" />

View file

@ -41,6 +41,8 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
weighted >= 5 ? 'Highly trusted trader' : weighted >= 1 ? 'Building reputation' : 'New trader';
// The classic fake-reputation pattern: several "trades", all with one account.
const singlePartnerWarning = data.verified >= 3 && people <= 1;
// A bigger ring: lots of confirmations from only a couple of accounts.
const lowDiversityWarning = !singlePartnerWarning && data.verified >= 5 && people <= 2;
return (
<Card className={cn('bg-black/90 border-2 border-[#ff6600]/25', className)}>
@ -79,6 +81,12 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
self-reported, so treat this record with care.
</p>
)}
{lowDiversityWarning && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
⚠ Most confirmations come from very few accounts. A small group vouching
for each other can look busy — treat this record with care.
</p>
)}
{data.verified > 0 && (
<p className="text-xs text-gray-500">
Trust-weighted <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>

View file

@ -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,

View file

@ -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() {
</div>
)}
{listingType === 'selling' && (
<div className="space-y-2">
<Label>How is it fulfilled?</Label>
<div className="grid grid-cols-3 gap-2">
{([
['pickup', '📍 Pickup', 'Meet locally'],
['ships', '📦 Ships', 'Post it'],
['both', '📍📦 Both', 'Either'],
] as const).map(([key, label, hint]) => (
<Button
key={key}
type="button"
variant={fulfilment === key ? 'default' : 'outline'}
className={`h-auto py-3 flex-col items-start ${
fulfilment === key
? 'bg-[#00cc66] hover:bg-[#00cc66]/90 text-black'
: 'border-gray-600 hover:bg-gray-800'
}`}
onClick={() => setFulfilment(key)}
>
<span className="font-bold">{label}</span>
<span className="text-xs opacity-70">{hint}</span>
</Button>
))}
</div>
<p className="text-xs text-gray-500">
We never collect your address — for shipping, share it privately in the trade chat.
</p>
</div>
)}
{/* Size */}
{currentSizeOptions.length > 0 && (
<div className="space-y-2">

View file

@ -17,7 +17,7 @@ import { useBatchSellerSales } from '@/hooks/useSellerSales';
import { useFollowList } from '@/hooks/useFollowList';
import { GearSwapItemCard } from '@/components/GearSwapItemCard';
import { GEAR_CATEGORIES } from '@/lib/types';
import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle } from 'lucide-react';
import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle, MapPin } from 'lucide-react';
type SortOption = 'newest' | 'price_low' | 'price_high' | 'trust';
type TrustFilter = 'all' | 'verified' | 'trusted' | 'following';
@ -36,6 +36,7 @@ export default function GearSwapPage() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortOption>('newest');
const [fulfilmentFilter, setFulfilmentFilter] = useState<'all' | 'pickup' | 'ships'>('all');
const [trustFilter, setTrustFilter] = useState<TrustFilter>('all');
const [showOnlyWTB, setShowOnlyWTB] = useState(false);
const [showOnlySelling, setShowOnlySelling] = useState(false);
@ -101,6 +102,13 @@ export default function GearSwapPage() {
filtered = filtered.filter(item => item.listingType !== 'wtb');
}
// Fulfilment — "pickup"/"ships" also match listings marked "both".
if (fulfilmentFilter === 'pickup') {
filtered = filtered.filter(item => item.fulfilment === 'pickup' || item.fulfilment === 'both');
} else if (fulfilmentFilter === 'ships') {
filtered = filtered.filter(item => item.fulfilment === 'ships' || item.fulfilment === 'both');
}
// Trust filter
if (trustFilter !== 'all' && trustScores) {
filtered = filtered.filter(item => {
@ -149,7 +157,7 @@ export default function GearSwapPage() {
}
return filtered;
}, [items, searchQuery, selectedCategory, sortBy, trustFilter, showOnlyWTB, showOnlySelling, trustScores, followingSet]);
}, [items, searchQuery, selectedCategory, sortBy, trustFilter, showOnlyWTB, showOnlySelling, fulfilmentFilter, trustScores, followingSet]);
const categoryOptions = [
{ value: 'all', label: 'All Categories' },
@ -307,6 +315,22 @@ export default function GearSwapPage() {
<div className="flex-1" />
{/* Fulfilment */}
<Select
value={fulfilmentFilter}
onValueChange={(v) => setFulfilmentFilter(v as 'all' | 'pickup' | 'ships')}
>
<SelectTrigger className="w-40 bg-gray-900 border-gray-700">
<MapPin className="h-4 w-4 mr-2" />
<SelectValue placeholder="Pickup or ship" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Pickup or ship</SelectItem>
<SelectItem value="pickup">📍 Local pickup</SelectItem>
<SelectItem value="ships">📦 Ships</SelectItem>
</SelectContent>
</Select>
{/* Sort */}
<Select
value={sortBy}