feat(shipping): meet-at-a-spot suggestions + shipment status (kind 34967)

- HandoffHelper suggests the 3 nearest public skate spots to the listing as
  one-tap DM meetup templates (public data only; never the viewer's location).
- New addressable kind 34967 + useShipmentStatus + ShippingStatusCard: seller
  marks shipped, buyer received, either issue. p-tagged so the push service
  notifies (added to MARKET_KINDS); d=ship:<listing-d>:<author> so a status
  replaces only its author's.
- Honest framing: a self-reported claim, NOT proof - 'received' does not release
  escrow; no address stored; parcel photos with an address discouraged.
This commit is contained in:
SkateSpots 2026-10-01 17:01:57 +07:00
parent 16c787d3f3
commit 4b1c025901
7 changed files with 330 additions and 3 deletions

27
NIP.md
View file

@ -3314,6 +3314,33 @@ answered (they have a `31871` for that listing).
- Consumers **trust-weight counterparties** with the existing web-of-trust score - Consumers **trust-weight counterparties** with the existing web-of-trust score
(as with Scene vouches) so fresh-account rings cannot inflate reputation. (as with Scene vouches) so fresh-account rings cannot inflate reputation.
## Shipment Status (kind 34967)
Optional, **self-reported** logistics for a `ships` deal. Addressable — one slot
per author:
```json
{
"kind": 34967,
"content": "",
"tags": [
["d", "ship:<listing-d>:<author>"],
["a", "30402:<seller>:<listing-d>"],
["p", "<counterparty>"],
["status", "shipped|received|issue"],
["proof", "ref", "<tracking-id>"],
["alt", "SkateSpots: shipment status"]
]
}
```
- The **seller** marks `shipped`; the **buyer** marks `received`; either can
flag `issue`. Re-publishing the same `d` replaces only that author's status.
- `p` is the counterparty, so the push service notifies **without decrypting**.
- ⚠️ It is a **claim, not proof**: `received` does **not** release escrow, and the
app verifies nothing here. No address is ever stored — only a coarse status and
an optional tracking reference.
## Escrow (Cashu `cashu_token`) ## Escrow (Cashu `cashu_token`)
A `31872` may carry `["cashu_token", "<token>"]`. We use Cashu **P2PK with a A `31872` may carry `["cashu_token", "<token>"]`. We use Cashu **P2PK with a

View file

@ -15,8 +15,9 @@ only *"this pubkey has something, at time T."*
recipient, and checks the marker. recipient, and checks the marker.
It ALSO pushes **marketplace events** addressed to subscribers — offers It ALSO pushes **marketplace events** addressed to subscribers — offers
(`30403`, the whole negotiation), trade requests (`31872`) and confirmations (`30403`, the whole negotiation), trade requests (`31872`), confirmations
(`31871`) — via `#p` filters built from the current subscription set (so it (`31871`) and shipment status (`34967`: shipped / received / issue) — via `#p`
filters built from the current subscription set (so it
only receives events for subscribers; the payload is generic, never the only receives events for subscribers; the payload is generic, never the
amount or item). Re-subscribed on every subscribe/unsubscribe. amount or item). Re-subscribed on every subscribe/unsubscribe.
3. For a marked wrap it looks up that recipient's subscriptions and sends a Web 3. For a marked wrap it looks up that recipient's subscriptions and sends a Web

View file

@ -142,7 +142,8 @@ const relaySockets = new Map<string, WebSocket>();
const OFFER_KIND = 30403; // offer / counter / accepted / declined / withdrawn const OFFER_KIND = 30403; // offer / counter / accepted / declined / withdrawn
const TRADE_CONFIRM_KIND = 31871; // counterparty recorded the trade const TRADE_CONFIRM_KIND = 31871; // counterparty recorded the trade
const TRADE_REQUEST_KIND = 31872; // "please confirm our trade" const TRADE_REQUEST_KIND = 31872; // "please confirm our trade"
const MARKET_KINDS = [OFFER_KIND, TRADE_CONFIRM_KIND, TRADE_REQUEST_KIND]; const SHIPMENT_STATUS_KIND = 34967; // shipped / received / issue (self-reported)
const MARKET_KINDS = [OFFER_KIND, TRADE_CONFIRM_KIND, TRADE_REQUEST_KIND, SHIPMENT_STATUS_KIND];
function subscribedPubkeys(): string[] { function subscribedPubkeys(): string[] {
return [...new Set(Object.values(state.subscriptions).map((s) => s.pubkey))]; return [...new Set(Object.values(state.subscriptions).map((s) => s.pubkey))];
@ -152,6 +153,13 @@ function subscribedPubkeys(): string[] {
function marketBody(ev: NostrEvent): string { function marketBody(ev: NostrEvent): string {
if (ev.kind === TRADE_REQUEST_KIND) return 'asked you to confirm a trade'; if (ev.kind === TRADE_REQUEST_KIND) return 'asked you to confirm a trade';
if (ev.kind === TRADE_CONFIRM_KIND) return 'confirmed your trade'; if (ev.kind === TRADE_CONFIRM_KIND) return 'confirmed your trade';
if (ev.kind === SHIPMENT_STATUS_KIND) {
const status = ev.tags.find(([n]) => n === 'status')?.[1];
if (status === 'shipped') return 'Your item shipped';
if (status === 'received') return 'Item marked received';
if (status === 'issue') return 'There’s an issue with a shipment';
return 'Shipment update';
}
const status = ev.tags.find(([n]) => n === 'status')?.[1] ?? 'open'; const status = ev.tags.find(([n]) => n === 'status')?.[1] ?? 'open';
if (status === 'accepted') return 'Your offer was accepted'; if (status === 'accepted') return 'Your offer was accepted';
if (status === 'declined') return 'Your offer was declined'; if (status === 'declined') return 'Your offer was declined';

View file

@ -4,10 +4,14 @@
* trade chat with a pre-filled template; the actual details stay in the NIP-17 * 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. * DM (push already covers it). Also carries the anti-scam guidance.
*/ */
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import ngeohash from 'ngeohash';
import { Package, MapPin, Lock, ShieldCheck } from 'lucide-react'; import { Package, MapPin, Lock, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { itemDistanceKm } from '@/lib/sortOptions';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
export function HandoffHelper({ item }: { item: MarketplaceItem }) { export function HandoffHelper({ item }: { item: MarketplaceItem }) {
@ -17,6 +21,26 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
const ships = item.fulfilment === 'ships' || item.fulfilment === 'both'; const ships = item.fulfilment === 'ships' || item.fulfilment === 'both';
const pickup = item.fulfilment === 'pickup' || item.fulfilment === 'both'; const pickup = item.fulfilment === 'pickup' || item.fulfilment === 'both';
// Suggest nearby PUBLIC skate spots as a meetup place. Uses only the listing's
// own public geohash + public spot data — never the viewer's live location.
const { data: spots = [] } = useSkateSpots(200);
const meetSpots = useMemo(() => {
if (!pickup || !item.geohash) return [];
try {
const { latitude, longitude } = ngeohash.decode(item.geohash);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
const origin = { lat: latitude, lng: longitude };
return spots
.filter((s) => s.geohash && s.identifier !== item.identifier)
.map((s) => ({ spot: s, km: itemDistanceKm(s, origin) ?? Infinity }))
.sort((a, b) => a.km - b.km)
.slice(0, 3);
} catch {
return [];
}
}, [spots, item.geohash, item.identifier, pickup]);
if (!ships && !pickup) return null; if (!ships && !pickup) return null;
return ( return (
@ -40,6 +64,35 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
</Button> </Button>
)} )}
{meetSpots.length > 0 && (
<div className="space-y-1.5">
<p className="text-[11px] text-gray-500">Or meet at a nearby public spot:</p>
{meetSpots.map(({ spot, km }) => (
<Button
key={spot.id}
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-left hover:bg-white/5"
onClick={() =>
navigate(
dm(
`Hi! I'd like to buy “${item.title}”. Want to meet at ${spot.title}? /spot/${spot.pubkey}/${spot.identifier}`,
),
)
}
>
<MapPin className="h-3.5 w-3.5 shrink-0 text-[#ccff00]" />
<span className="truncate">{spot.title}</span>
{Number.isFinite(km) && (
<span className="ml-auto shrink-0 text-[10px] text-gray-500">
{km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`}
</span>
)}
</Button>
))}
</div>
)}
{ships && ( {ships && (
<> <>
<Button <Button

View file

@ -16,6 +16,7 @@ import { EscrowRequestDialog } from '@/components/EscrowRequestDialog';
import { EscrowPanel } from '@/components/EscrowPanel'; import { EscrowPanel } from '@/components/EscrowPanel';
import { PayWithSats } from '@/components/PayWithSats'; import { PayWithSats } from '@/components/PayWithSats';
import { HandoffHelper } from '@/components/HandoffHelper'; import { HandoffHelper } from '@/components/HandoffHelper';
import { ShippingStatusCard } from '@/components/ShippingStatusCard';
import { useUsdToSats } from '@/hooks/useExchangeRate'; import { useUsdToSats } from '@/hooks/useExchangeRate';
import { parseEscrowToken } from '@/lib/cashuEscrow'; import { parseEscrowToken } from '@/lib/cashuEscrow';
import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react'; import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react';
@ -271,6 +272,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
<HandoffHelper item={item} /> <HandoffHelper item={item} />
)} )}
{/* Shipping logistics — self-reported, both sides. */}
{user && !isWTB && (item.fulfilment === 'ships' || item.fulfilment === 'both') && (
<ShippingStatusCard item={item} />
)}
{/* Mounted only while open — keeps the wallet hooks off every grid card. */} {/* Mounted only while open — keeps the wallet hooks off every grid card. */}
{showPay && ( {showPay && (
<PayWithSats <PayWithSats

View file

@ -0,0 +1,129 @@
/**
* ShippingStatusCard — optional shipping logistics for a `ships` deal.
*
* Self-reported (a claim, never proof): the seller marks **shipped**, the buyer
* marks **received**, either can flag an **issue**. `p`-tagged to the
* counterparty so the push service can notify while the app is closed. No
* address is ever stored — only a coarse status and an optional tracking ref.
*/
import { useState } from 'react';
import { Package, PackageCheck, AlertTriangle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useListingTrade } from '@/hooks/useTradeAttestations';
import { useShipmentStatus, useSetShipmentStatus } from '@/hooks/useShipmentStatus';
import { useToast } from '@/hooks/useToast';
import type { MarketplaceItem } from '@/lib/types';
export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
const { user } = useCurrentUser();
const { toast } = useToast();
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
const { data: statuses = [] } = useShipmentStatus(listingRef);
const { setStatus, isPending } = useSetShipmentStatus();
const [tracking, setTracking] = useState('');
if (!user) return null;
const shipping = item.fulfilment === 'ships' || item.fulfilment === 'both';
if (!shipping) return null;
const isSeller = user.pubkey === item.pubkey;
// Seller → the buyer they named on their own attestation; buyer → the seller.
const counterparty = isSeller
? trade?.attestations
.find((ev) => ev.pubkey === user.pubkey)
?.tags.find(([t]) => t === 'p')?.[1]
: item.pubkey;
const shipped = statuses.some((s) => s.status === 'shipped');
const received = statuses.some((s) => s.status === 'received');
const issue = statuses.some((s) => s.status === 'issue');
const mark = async (status: 'shipped' | 'received' | 'issue') => {
if (!counterparty) {
toast({
title: 'No counterparty yet',
description: 'The seller should record the sale (naming the buyer) first.',
variant: 'destructive',
});
return;
}
try {
await setStatus({ listing: listingRef, counterparty, status, ref: tracking });
toast({
title:
status === 'shipped' ? 'Marked as shipped' : status === 'received' ? 'Marked as received' : 'Issue reported',
description:
status === 'received'
? 'Thanks — this does not release escrow; settle directly with the seller.'
: 'The other side has been notified.',
});
} catch (e) {
toast({
title: 'Could not update',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
}
};
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">
<Package className="h-3.5 w-3.5 text-[#ff6600]" /> Shipping
{shipped && <span className="text-[#ccff00]">· shipped</span>}
{received && <span className="text-[#ccff00]">· received</span>}
{issue && <span className="text-amber-400">· issue</span>}
</p>
<Input
value={tracking}
onChange={(e) => setTracking(e.target.value)}
placeholder="Tracking number (optional)"
className="h-9 border-white/15 bg-white/5 text-sm text-white"
/>
<div className="flex flex-wrap gap-2">
{isSeller && (
<Button
size="sm"
onClick={() => mark('shipped')}
disabled={isPending}
className="gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
I've shipped it
</Button>
)}
{!isSeller && (
<Button
size="sm"
onClick={() => mark('received')}
disabled={isPending}
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PackageCheck className="h-3.5 w-3.5" />}
I received it
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => mark('issue')}
disabled={isPending}
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
>
<AlertTriangle className="h-3.5 w-3.5" /> Report an issue
</Button>
</div>
<p className="text-[10px] leading-relaxed text-gray-600">
Self-reported — a logistics note, <strong className="text-gray-400">not proof</strong>. Marking
“received” does not release escrow. ⚠️ Don't photograph a parcel with an address visible, and
only share a tracking number if you're comfortable with it being public.
</p>
</div>
);
}

View file

@ -0,0 +1,103 @@
/**
* Shipment status (kind 34967, addressable) — optional, **self-reported**
* logistics for a shipping deal: the seller marks **shipped**, the buyer marks
* **received**, either can flag an **issue**.
*
* Design notes (privacy / notifications / fraud):
* - The app still never stores an address. The event carries only a coarse
* status and an OPTIONAL tracking reference — never an address.
* - It is `p`-tagged to the counterparty, so the push service can notify
* (`34967` is in its watched kinds) WITHOUT decrypting anything.
* - ⚠️ It is a **claim, not proof**: marking "received" does NOT release escrow
* and does not verify anything. Escrow remains the protection.
* - ⚠️ Re-publishing the same `d` replaces the author's own status (per party).
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { onlyVerified } from '@/lib/verifyEvents';
export const SHIPMENT_STATUS_KIND = 34967;
export type ShipmentStatus = 'shipped' | 'received' | 'issue';
export interface ShipmentStatusEvent {
pubkey: string;
status: ShipmentStatus;
/** Optional tracking reference (NOT an address). */
ref?: string;
createdAt: number;
}
const STATUSES: ShipmentStatus[] = ['shipped', 'received', 'issue'];
function parse(ev: NostrEvent): ShipmentStatusEvent | null {
const raw = ev.tags.find(([n]) => n === 'status')?.[1];
if (!raw || !STATUSES.includes(raw as ShipmentStatus)) return null;
return {
pubkey: ev.pubkey,
status: raw as ShipmentStatus,
ref: ev.tags.find(([n]) => n === 'proof')?.[2],
createdAt: ev.created_at,
};
}
/** Latest shipment status per author, for a listing. */
export function useShipmentStatus(listing: { pubkey: string; identifier: string } | undefined) {
const { nostr } = useNostr();
const coord = listing ? `30402:${listing.pubkey}:${listing.identifier}` : undefined;
return useQuery<ShipmentStatusEvent[]>({
queryKey: ['shipment-status', coord],
enabled: !!coord,
staleTime: 60 * 1000,
queryFn: async (c) => {
if (!coord) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const events = onlyVerified(
await nostr.query([{ kinds: [SHIPMENT_STATUS_KIND], '#a': [coord], limit: 50 }], { signal }),
);
const byAuthor = new Map<string, ShipmentStatusEvent>();
for (const ev of events) {
const parsed = parse(ev);
if (!parsed) continue;
const prev = byAuthor.get(ev.pubkey);
if (!prev || parsed.createdAt > prev.createdAt) byAuthor.set(ev.pubkey, parsed);
}
return [...byAuthor.values()];
},
});
}
export function useSetShipmentStatus() {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending } = useNostrPublish();
const setStatus = async ({
listing,
counterparty,
status,
ref,
}: {
listing: { pubkey: string; identifier: string };
counterparty: string;
status: ShipmentStatus;
ref?: string;
}) => {
if (!user) throw new Error('Log in to update shipping.');
const tags: string[][] = [
// One slot per author → re-publishing replaces only MY status.
['d', `ship:${listing.identifier}:${user.pubkey}`],
['a', `30402:${listing.pubkey}:${listing.identifier}`],
['p', counterparty],
['status', status],
['alt', `SkateSpots: shipment ${status}`],
];
if (ref?.trim()) tags.push(['proof', 'ref', ref.trim()]);
await publish({ kind: SHIPMENT_STATUS_KIND, content: '', tags });
};
return { setStatus, isPending };
}