diff --git a/AGENTS.md b/AGENTS.md index 2c2c98f..7d9e0af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,19 @@ session with **no other memory of this project**. So: `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. + - **Shipping flow + distance + guided handoff (2026-10-01):** + `["shipping","buyer"|"seller"|"split"]` (who pays postage; selector on + `CreateListingPage` when `fulfilment != pickup`). `GearSwapItemCard` / + `MarketplaceItemCard` show **distance from the viewer's last known location** + (cache only via `getLastLocation` — **never triggers a GPS prompt**) and + `GearSwapPage` gains a **Nearest (local)** sort (`itemDistanceKm` in + `lib/sortOptions.ts`). **Guided handoff** — `HandoffHelper` (rendered in + `ListingTradeActions`, buyer-only) opens the trade **DM pre-filled** + (`/messages/?message=…`): "Suggest a time & place", "Share delivery + details", "Use a locker / parcel point", plus anti-scam copy (prefer escrow, + never pay outside the rails, meet in public). Details stay in the NIP-17 DM — + the app never sees them. `ships` deals now get a safety nudge on **all** + platforms, not only when escrow is available. - **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". diff --git a/NIP.md b/NIP.md index ed9a52d..901f35a 100644 --- a/NIP.md +++ b/NIP.md @@ -585,6 +585,7 @@ Gear listings use the NIP-99 Classified Listings format (kind 30402) for peer-to | `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). | +| `shipping` | Who pays postage when it ships: `buyer`, `seller`, or `split`. | | `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/GearSwapItemCard.tsx b/src/components/GearSwapItemCard.tsx index c25870e..ac28a7f 100644 --- a/src/components/GearSwapItemCard.tsx +++ b/src/components/GearSwapItemCard.tsx @@ -13,6 +13,8 @@ import { useMarkListingSold } from '@/hooks/useMarkListingSold'; import { useToast } from '@/hooks/useToast'; import { isAdminPubkey } from '@/lib/admins'; import { genUserName } from '@/lib/genUserName'; +import { getLastLocation } from '@/lib/lastLocation'; +import { itemDistanceKm } from '@/lib/sortOptions'; import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating'; import { GEAR_CONDITIONS, GEAR_CATEGORIES, formatPrice, type MarketplaceItem } from '@/lib/types'; import { MessageCircle, MapPin, Search, CheckCircle, ShieldCheck, AlertTriangle, MoreVertical, Flag, Trash2, RotateCcw, Loader2 } from 'lucide-react'; @@ -31,6 +33,9 @@ interface GearSwapItemCardProps { export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemCardProps) { const { user } = useCurrentUser(); const navigate = useNavigate(); + // Distance from the viewer's last known location (cache only — no GPS call). + const viewerLoc = getLastLocation(); + const distanceKm = viewerLoc ? itemDistanceKm(item, viewerLoc) : null; const author = useAuthor(item.pubkey); const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity(); const { markSold, relist, isPending: isUpdatingStatus } = useMarkListingSold(); @@ -309,6 +314,27 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC {item.size} )} + {item.fulfilment && ( + + {item.fulfilment === 'pickup' + ? '📍 Pickup' + : item.fulfilment === 'ships' + ? '📦 Ships' + : '📍📦 Both'} + + )} + {item.shipping && ( + + postage: {item.shipping} + + )} + {distanceKm != null && ( + + {distanceKm < 1 + ? `${Math.round(distanceKm * 1000)} m` + : `${distanceKm.toFixed(1)} km`} + + )} {/* Location */} diff --git a/src/components/HandoffHelper.tsx b/src/components/HandoffHelper.tsx new file mode 100644 index 0000000..b4df7dd --- /dev/null +++ b/src/components/HandoffHelper.tsx @@ -0,0 +1,77 @@ +/** + * Guided handoff — turns the blank "message the seller" DM into a real + * arrangement, WITHOUT the app ever touching an address. Each button opens the + * trade chat with a pre-filled template; the actual details stay in the NIP-17 + * DM (push already covers it). Also carries the anti-scam guidance. + */ +import { useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { Package, MapPin, Lock, ShieldCheck } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import type { MarketplaceItem } from '@/lib/types'; + +export function HandoffHelper({ item }: { item: MarketplaceItem }) { + const navigate = useNavigate(); + const npub = nip19.npubEncode(item.pubkey); + const dm = (message: string) => `/messages/${npub}?message=${encodeURIComponent(message)}`; + + const ships = item.fulfilment === 'ships' || item.fulfilment === 'both'; + const pickup = item.fulfilment === 'pickup' || item.fulfilment === 'both'; + if (!ships && !pickup) return null; + + return ( +
+

+ Arrange the handoff +

+ + {pickup && ( + + )} + + {ships && ( + <> + + + + )} + +

+ These details stay in the private chat — SkateSpots never sees them. For shipped deals, + prefer escrow, and never + pay outside the app's rails. Meet in public. +

+
+ ); +} diff --git a/src/components/ListingTradeActions.tsx b/src/components/ListingTradeActions.tsx index 668acb0..8ace1a1 100644 --- a/src/components/ListingTradeActions.tsx +++ b/src/components/ListingTradeActions.tsx @@ -15,6 +15,7 @@ import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow'; import { EscrowRequestDialog } from '@/components/EscrowRequestDialog'; import { EscrowPanel } from '@/components/EscrowPanel'; import { PayWithSats } from '@/components/PayWithSats'; +import { HandoffHelper } from '@/components/HandoffHelper'; import { useUsdToSats } from '@/hooks/useExchangeRate'; import { parseEscrowToken } from '@/lib/cashuEscrow'; import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react'; @@ -199,6 +200,12 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) { address privately in the trade chat, never here.

)} + {item.fulfilment === 'ships' && !canEscrow && ( +

+ 📦 Shipped deal — prefer escrow if the seller offers it, and share your address + only in the chat. +

+ )} {/* Buyer: ask the seller to record the sale (kind 31872 request) */} {!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && ( @@ -259,6 +266,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) { {isWTB ? 'I Have This!' : 'Contact Seller'} + {/* Guided handoff — pre-filled DM templates, buyer only. */} + {user && !isOwnListing && !isWTB && !iConfirmedTrade && ( + + )} + {/* Mounted only while open — keeps the wallet hooks off every grid card. */} {showPay && ( )} + {distanceKm != null && ( + + {distanceKm < 1 + ? `${Math.round(distanceKm * 1000)} m` + : `${distanceKm.toFixed(1)} km`} + + )} + {item.shipping && ( + + {item.shipping === 'buyer' ? 'postage: buyer' : item.shipping === 'seller' ? 'postage: seller' : 'postage: split'} + + )} {typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && ( 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 shipping = event.tags.find(([name]) => name === 'shipping')?.[1] as MarketplaceItem['shipping']; const powDifficulty = event.tags.some(([name]) => name === 'nonce') ? countLeadingZeroBits(event.id) : undefined; @@ -3675,6 +3678,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null condition, listingType, fulfilment, + shipping, powDifficulty, size, expiration, diff --git a/src/pages/CreateListingPage.tsx b/src/pages/CreateListingPage.tsx index 5d92904..7868aa5 100644 --- a/src/pages/CreateListingPage.tsx +++ b/src/pages/CreateListingPage.tsx @@ -61,6 +61,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 [shippingWho, setShippingWho] = useState<'buyer' | 'seller' | 'split'>('buyer'); const [category, setCategory] = useState(searchParams.get('category') || ''); const [title, setTitle] = useState(searchParams.get('title') || ''); const [description, setDescription] = useState(searchParams.get('description') || ''); @@ -275,7 +276,10 @@ 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]); + if (listingType === 'selling') { + tags.push(['fulfilment', fulfilment]); + if (fulfilment !== 'pickup') tags.push(['shipping', shippingWho]); + } // Size with optional modifiers if (size) { @@ -722,6 +726,33 @@ export default function CreateListingPage() { )} + {listingType === 'selling' && fulfilment !== 'pickup' && ( +
+ +
+ {([ + ['buyer', 'Buyer pays'], + ['seller', 'I pay'], + ['split', 'Split'], + ] as const).map(([key, label]) => ( + + ))} +
+
+ )} + {/* Size */} {currentSizeOptions.length > 0 && (
diff --git a/src/pages/GearSwapPage.tsx b/src/pages/GearSwapPage.tsx index e6e6025..9c870b3 100644 --- a/src/pages/GearSwapPage.tsx +++ b/src/pages/GearSwapPage.tsx @@ -17,9 +17,11 @@ import { useBatchSellerSales } from '@/hooks/useSellerSales'; import { useFollowList } from '@/hooks/useFollowList'; import { GearSwapItemCard } from '@/components/GearSwapItemCard'; import { GEAR_CATEGORIES } from '@/lib/types'; +import { getLastLocation } from '@/lib/lastLocation'; +import { itemDistanceKm } from '@/lib/sortOptions'; import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle, MapPin } from 'lucide-react'; -type SortOption = 'newest' | 'price_low' | 'price_high' | 'trust'; +type SortOption = 'newest' | 'price_low' | 'price_high' | 'trust' | 'distance'; type TrustFilter = 'all' | 'verified' | 'trusted' | 'following'; export default function GearSwapPage() { @@ -150,6 +152,24 @@ export default function GearSwapPage() { return bScore - aScore; }); break; + case 'distance': { + // "Near me" — uses the viewer's LAST KNOWN location (cache only, no GPS + // prompt). Listings with no location sort last. + const viewerLoc = getLastLocation(); + const dist = new Map(); + for (const item of filtered) { + dist.set(item.id, viewerLoc ? itemDistanceKm(item, viewerLoc) : null); + } + filtered.sort((a, b) => { + const da = dist.get(a.id) ?? null; + const db = dist.get(b.id) ?? null; + if (da == null && db == null) return b.createdAt - a.createdAt; + if (da == null) return 1; + if (db == null) return -1; + return da - db; + }); + break; + } case 'newest': default: filtered.sort((a, b) => b.createdAt - a.createdAt); @@ -345,6 +365,7 @@ export default function GearSwapPage() { Price: Low → High Price: High → Low Trust Score + Nearest (local)