feat(marketplace): shipping flow - who-pays, distances, guided handoff

- ['shipping','buyer'|'seller'|'split'] tag + create-page selector.
- Distance from the viewer's last known location (cache only, no GPS prompt) on
  both gear cards, and a 'Nearest (local)' sort on GearSwapPage.
- HandoffHelper (buyer-only, in ListingTradeActions) opens the trade DM
  pre-filled: suggest a time & place / share delivery details / use a locker,
  with anti-scam copy. Details stay in the NIP-17 DM; the app never sees them.
- ships deals now nudge safety on all platforms, not just when escrow is ready.
This commit is contained in:
SkateSpots 2026-10-01 16:55:27 +07:00
parent a12db574d4
commit 16c787d3f3
10 changed files with 225 additions and 2 deletions

View file

@ -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/<npub>?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".

1
NIP.md
View file

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

View file

@ -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}
</Badge>
)}
{item.fulfilment && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
{item.fulfilment === 'pickup'
? '📍 Pickup'
: item.fulfilment === 'ships'
? '📦 Ships'
: '📍📦 Both'}
</Badge>
)}
{item.shipping && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
postage: {item.shipping}
</Badge>
)}
{distanceKm != null && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
{distanceKm < 1
? `${Math.round(distanceKm * 1000)} m`
: `${distanceKm.toFixed(1)} km`}
</Badge>
)}
</div>
{/* Location */}

View file

@ -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 (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-gray-400">
<ShieldCheck className="h-3.5 w-3.5 text-[#ccff00]" /> Arrange the handoff
</p>
{pickup && (
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 border-white/15"
onClick={() =>
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.`),
)
}
>
<MapPin className="h-4 w-4" /> Suggest a time &amp; place
</Button>
)}
{ships && (
<>
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 border-white/15"
onClick={() =>
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.)`),
)
}
>
<Package className="h-4 w-4" /> Share delivery details
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 border-white/15"
onClick={() =>
navigate(dm(`Hi! I'd like to buy “${item.title}”. Could we use a parcel locker / parcel shop instead of a home address?`))
}
>
<Lock className="h-4 w-4" /> Use a locker / parcel point
</Button>
</>
)}
<p className="text-[10px] leading-relaxed text-gray-600">
These details stay in the private chat — SkateSpots never sees them. For shipped deals,
prefer <strong className="text-gray-400">escrow</strong>, and <strong className="text-gray-400">never
pay outside the app's rails</strong>. Meet in public.
</p>
</div>
);
}

View file

@ -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.
</p>
)}
{item.fulfilment === 'ships' && !canEscrow && (
<p className="text-[11px] text-center text-amber-400/90">
📦 Shipped deal — prefer escrow if the seller offers it, and share your address
only in the chat.
</p>
)}
{/* 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'}
</Button>
{/* Guided handoff — pre-filled DM templates, buyer only. */}
{user && !isOwnListing && !isWTB && !iConfirmedTrade && (
<HandoffHelper item={item} />
)}
{/* Mounted only while open — keeps the wallet hooks off every grid card. */}
{showPay && (
<PayWithSats

View file

@ -23,6 +23,8 @@ import { ListingTradeActions } from '@/components/ListingTradeActions';
import { useTradeReputation } from '@/hooks/useTradeAttestations';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { genUserName } from '@/lib/genUserName';
import { getLastLocation } from '@/lib/lastLocation';
import { itemDistanceKm } from '@/lib/sortOptions';
import { TrustBadge } from '@/components/TrustBadge';
import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package, AlertTriangle, CheckCircle, RotateCcw, Trash2, Loader2 } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
@ -36,6 +38,9 @@ interface MarketplaceItemCardProps {
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
const { user } = useCurrentUser();
// Distance from the viewer's last known location (no GPS call — cache only).
const viewerLoc = getLastLocation();
const distanceKm = viewerLoc ? itemDistanceKm(item, viewerLoc) : null;
const { toast } = useToast();
const { mutateAsync: publishEvent } = useNostrPublish();
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
@ -343,6 +348,18 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
: '📍📦 Pickup or ship'}
</Badge>
)}
{distanceKm != null && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{distanceKm < 1
? `${Math.round(distanceKm * 1000)} m`
: `${distanceKm.toFixed(1)} km`}
</Badge>
)}
{item.shipping && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{item.shipping === 'buyer' ? 'postage: buyer' : item.shipping === 'seller' ? 'postage: seller' : 'postage: split'}
</Badge>
)}
{typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && (
<Badge
variant="outline"

View file

@ -2,6 +2,27 @@
* Sort/geo helpers shared across pages. Kept out of SortDropdown.tsx so that
* component file exports only a component (React Fast Refresh).
*/
import ngeohash from 'ngeohash';
interface LatLng {
lat: number;
lng: number;
}
/**
* Distance in km from `from` to a listing's own geohash, or null when the
* listing carries no location. Used for the marketplace "Near me" sort + badges.
*/
export function itemDistanceKm(item: { geohash?: string }, from: LatLng): number | null {
if (!item.geohash) return null;
try {
const { latitude, longitude } = ngeohash.decode(item.geohash);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return calculateDistance(from.lat, from.lng, latitude, longitude);
} catch {
return null;
}
}
/** Weighted popularity score: comments 3x, reactions 1x, zaps 5x. */
export function calculatePopularity(

View file

@ -3556,6 +3556,8 @@ export interface MarketplaceItem {
size?: string;
/** How the deal is fulfilled: local pickup, shipping, or either. */
fulfilment?: 'pickup' | 'ships' | 'both';
/** Who pays postage, when the deal ships. */
shipping?: 'buyer' | 'seller' | 'split';
/** NIP-13 proof-of-work difficulty (leading zero bits) on the event id. */
powDifficulty?: number;
expiration?: number; // Unix timestamp
@ -3638,6 +3640,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null
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 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,

View file

@ -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() {
</div>
)}
{listingType === 'selling' && fulfilment !== 'pickup' && (
<div className="space-y-2">
<Label>Who pays postage?</Label>
<div className="grid grid-cols-3 gap-2">
{([
['buyer', 'Buyer pays'],
['seller', 'I pay'],
['split', 'Split'],
] as const).map(([key, label]) => (
<Button
key={key}
type="button"
variant={shippingWho === key ? 'default' : 'outline'}
className={
shippingWho === key
? 'bg-[#00cc66] hover:bg-[#00cc66]/90 text-black'
: 'border-gray-600 hover:bg-gray-800'
}
onClick={() => setShippingWho(key)}
>
{label}
</Button>
))}
</div>
</div>
)}
{/* Size */}
{currentSizeOptions.length > 0 && (
<div className="space-y-2">

View file

@ -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<string, number | null>();
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() {
<SelectItem value="price_low">Price: Low → High</SelectItem>
<SelectItem value="price_high">Price: High → Low</SelectItem>
<SelectItem value="trust">Trust Score</SelectItem>
<SelectItem value="distance">Nearest (local)</SelectItem>
</SelectContent>
</Select>
</div>