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:
parent
16c787d3f3
commit
4b1c025901
7 changed files with 330 additions and 3 deletions
27
NIP.md
27
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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
129
src/components/ShippingStatusCard.tsx
Normal file
129
src/components/ShippingStatusCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/hooks/useShipmentStatus.ts
Normal file
103
src/hooks/useShipmentStatus.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue