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 && (
+
+ navigate(
+ dm(`Hi! I'd like to buy “${item.title}”. Suggest a time and place to meet — happy to meet at a public skate spot.`),
+ )
+ }
+ >
+ Suggest a time & place
+
+ )}
+
+ {ships && (
+ <>
+
+ navigate(
+ dm(`Hi! I'd like to buy “${item.title}”. Here are my delivery details:\n\nName:\nAddress:\n\n(Only share what the courier needs.)`),
+ )
+ }
+ >
+ Share delivery details
+
+
+ navigate(dm(`Hi! I'd like to buy “${item.title}”. Could we use a parcel locker / parcel shop instead of a home address?`))
+ }
+ >
+ Use a locker / parcel point
+
+ >
+ )}
+
+
+ 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' && (
+
+
Who pays postage?
+
+ {([
+ ['buyer', 'Buyer pays'],
+ ['seller', 'I pay'],
+ ['split', 'Split'],
+ ] as const).map(([key, label]) => (
+ setShippingWho(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)