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 && ( + ))} + +

+ We never collect your address — for shipping, share it privately in the trade chat. +

+ + )} + {/* Size */} {currentSizeOptions.length > 0 && (
diff --git a/src/pages/GearSwapPage.tsx b/src/pages/GearSwapPage.tsx index 145a51c..e6e6025 100644 --- a/src/pages/GearSwapPage.tsx +++ b/src/pages/GearSwapPage.tsx @@ -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('all'); const [sortBy, setSortBy] = useState('newest'); + const [fulfilmentFilter, setFulfilmentFilter] = useState<'all' | 'pickup' | 'ships'>('all'); const [trustFilter, setTrustFilter] = useState('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() {
+ {/* Fulfilment */} + + {/* Sort */}