- 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.
103 lines
3.7 KiB
TypeScript
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 };
|
|
}
|