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:
parent
50735d1718
commit
8c5b1636f7
8 changed files with 99 additions and 2 deletions
|
|
@ -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
1
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`) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue