/** * 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 }; }