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:
parent
a12db574d4
commit
16c787d3f3
10 changed files with 225 additions and 2 deletions
13
AGENTS.md
13
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/<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
1
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`) |
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
77
src/components/HandoffHelper.tsx
Normal file
77
src/components/HandoffHelper.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue