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
|
||||
(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`)
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -142,7 +142,8 @@ const relaySockets = new Map<string, WebSocket>();
|
|||
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';
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</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 && (
|
||||
<>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { EscrowRequestDialog } from '@/components/EscrowRequestDialog';
|
|||
import { EscrowPanel } from '@/components/EscrowPanel';
|
||||
import { PayWithSats } from '@/components/PayWithSats';
|
||||
import { HandoffHelper } from '@/components/HandoffHelper';
|
||||
import { ShippingStatusCard } from '@/components/ShippingStatusCard';
|
||||
import { useUsdToSats } from '@/hooks/useExchangeRate';
|
||||
import { parseEscrowToken } from '@/lib/cashuEscrow';
|
||||
import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react';
|
||||
|
|
@ -271,6 +272,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
<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. */}
|
||||
{showPay && (
|
||||
<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