SkateSpots/src/hooks/useShipmentStatus.ts
SkateSpots 4b1c025901 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.
2026-10-01 17:01:57 +07:00

103 lines
3.7 KiB
TypeScript

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