From 4b1c02590136ead53d8a610aa4b4f3c69a19cb0b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 17:01:57 +0700 Subject: [PATCH] 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:: 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. --- NIP.md | 27 ++++++ push-service/README.md | 5 +- push-service/server.ts | 10 +- src/components/HandoffHelper.tsx | 53 ++++++++++ src/components/ListingTradeActions.tsx | 6 ++ src/components/ShippingStatusCard.tsx | 129 +++++++++++++++++++++++++ src/hooks/useShipmentStatus.ts | 103 ++++++++++++++++++++ 7 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 src/components/ShippingStatusCard.tsx create mode 100644 src/hooks/useShipmentStatus.ts diff --git a/NIP.md b/NIP.md index 901f35a..aaa2f79 100644 --- a/NIP.md +++ b/NIP.md @@ -3314,6 +3314,33 @@ answered (they have a `31871` for that listing). - Consumers **trust-weight counterparties** with the existing web-of-trust score (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::"], + ["a", "30402::"], + ["p", ""], + ["status", "shipped|received|issue"], + ["proof", "ref", ""], + ["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`) A `31872` may carry `["cashu_token", ""]`. We use Cashu **P2PK with a diff --git a/push-service/README.md b/push-service/README.md index 6301550..c9c6a51 100644 --- a/push-service/README.md +++ b/push-service/README.md @@ -15,8 +15,9 @@ only *"this pubkey has something, at time T."* recipient, and checks the marker. It ALSO pushes **marketplace events** addressed to subscribers — offers - (`30403`, the whole negotiation), trade requests (`31872`) and confirmations - (`31871`) — via `#p` filters built from the current subscription set (so it + (`30403`, the whole negotiation), trade requests (`31872`), confirmations + (`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 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 diff --git a/push-service/server.ts b/push-service/server.ts index a6b7694..a42b2ab 100644 --- a/push-service/server.ts +++ b/push-service/server.ts @@ -142,7 +142,8 @@ const relaySockets = new Map(); const OFFER_KIND = 30403; // offer / counter / accepted / declined / withdrawn const TRADE_CONFIRM_KIND = 31871; // counterparty recorded the 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[] { return [...new Set(Object.values(state.subscriptions).map((s) => s.pubkey))]; @@ -152,6 +153,13 @@ function subscribedPubkeys(): string[] { function marketBody(ev: NostrEvent): string { 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 === 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'; if (status === 'accepted') return 'Your offer was accepted'; if (status === 'declined') return 'Your offer was declined'; diff --git a/src/components/HandoffHelper.tsx b/src/components/HandoffHelper.tsx index b4df7dd..c490697 100644 --- a/src/components/HandoffHelper.tsx +++ b/src/components/HandoffHelper.tsx @@ -4,10 +4,14 @@ * 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 { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; +import ngeohash from 'ngeohash'; import { Package, MapPin, Lock, ShieldCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { useSkateSpots } from '@/hooks/useSkateSpots'; +import { itemDistanceKm } from '@/lib/sortOptions'; import type { MarketplaceItem } from '@/lib/types'; 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 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; return ( @@ -40,6 +64,35 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) { )} + {meetSpots.length > 0 && ( +
+

Or meet at a nearby public spot:

+ {meetSpots.map(({ spot, km }) => ( + + ))} +
+ )} + {ships && ( <> + )} + {!isSeller && ( + + )} + + + +

+ Self-reported — a logistics note, not proof. 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. +

+ + ); +} diff --git a/src/hooks/useShipmentStatus.ts b/src/hooks/useShipmentStatus.ts new file mode 100644 index 0000000..f8a0b67 --- /dev/null +++ b/src/hooks/useShipmentStatus.ts @@ -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({ + 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(); + 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 }; +}