From 584561e390f32efdfc02cf2187f42d670ff41125 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 23:24:23 +0700 Subject: [PATCH] feat(trades): trusted-trader recommendations (31873) + offer DM nudges - TradeRecommendationsCard on profiles: shows who recommends this trader (kind 31873 'Attestor Recommendation') and lets a logged-in viewer recommend them. New useTraderRecommendations hook. - OfferDialog sends a best-effort NIP-17 DM to the seller when an offer is made; ListingOffersPanel DMs the buyer when the seller accepts/declines. - AGENTS.md notes 31873. Gate: 101 tests, 0 errors/warnings, boot smoke green. --- AGENTS.md | 4 +- src/components/ListingOffersPanel.tsx | 20 +++- src/components/OfferDialog.tsx | 17 +++ src/components/TradeRecommendationsCard.tsx | 114 +++++++++++++++++++ src/hooks/useTraderRecommendations.ts | 119 ++++++++++++++++++++ src/pages/ListingDetailPage.tsx | 1 + src/pages/NIP19Page.tsx | 2 + 7 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 src/components/TradeRecommendationsCard.tsx create mode 100644 src/hooks/useTraderRecommendations.ts diff --git a/AGENTS.md b/AGENTS.md index 127b7a4..b02fe77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,9 @@ session with **no other memory of this project**. So: - `30402` NIP-99 listing = marketplace item, and the **Assertion Event** for trades - `30403` marketplace offer (+ seller response) — see `NIP.md` - `31871` trade attestation (sale/purchase) · `31872` trade confirmation request - → `src/hooks/useTradeAttestations.ts`; reputation UI in `TradeReputationCard` + · `31873` trusted-trader recommendation + → `src/hooks/useTradeAttestations.ts` / `useTraderRecommendations.ts`; + reputation UI in `TradeReputationCard` / `TradeRecommendationsCard` - `35000–35012` role profiles (skater/filmer/coach/…) · `35006` crews · `30003` spot collections - `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates - **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx index b0a6517..c5f50ad 100644 --- a/src/components/ListingOffersPanel.tsx +++ b/src/components/ListingOffersPanel.tsx @@ -7,11 +7,8 @@ import { Badge } from '@/components/ui/badge'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; -import { - useListingOffers, - useOfferActions, - type MarketplaceOffer, -} from '@/hooks/useMarketplaceOffers'; +import { useOfferActions, useListingOffers, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers'; +import { useSendDirectMessage } from '@/hooks/useDirectMessages'; import { genUserName } from '@/lib/genUserName'; import type { MarketplaceItem } from '@/lib/types'; import { Handshake, CheckCircle, X, Loader2 } from 'lucide-react'; @@ -47,6 +44,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i const { user } = useCurrentUser(); const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier }); const { respondToOffer } = useOfferActions(); + const { mutateAsync: sendDM } = useSendDirectMessage(); const { toast } = useToast(); const [busy, setBusy] = useState(null); @@ -56,6 +54,18 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i setBusy(offer.id); try { await respondToOffer(offer, status); + // Best-effort nudge to the buyer via NIP-17 DM. + try { + await sendDM({ + recipients: offer.buyer, + content: + status === 'accepted' + ? '✅ Your offer was accepted! Message the seller to arrange the handoff.' + : '❌ Your offer was declined.', + }); + } catch { + /* DM optional */ + } toast({ title: status === 'accepted' ? 'Offer accepted' : 'Offer declined', description: 'The buyer will see your response.', diff --git a/src/components/OfferDialog.tsx b/src/components/OfferDialog.tsx index 155e863..f828ae7 100644 --- a/src/components/OfferDialog.tsx +++ b/src/components/OfferDialog.tsx @@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { useOfferActions } from '@/hooks/useMarketplaceOffers'; +import { useSendDirectMessage } from '@/hooks/useDirectMessages'; import { useToast } from '@/hooks/useToast'; import { Handshake, Loader2 } from 'lucide-react'; @@ -19,14 +20,17 @@ export function OfferDialog({ open, onOpenChange, listing, + title, sellerName, }: { open: boolean; onOpenChange: (open: boolean) => void; listing: { pubkey: string; identifier: string }; + title: string; sellerName: string; }) { const { createOffer } = useOfferActions(); + const { mutateAsync: sendDM } = useSendDirectMessage(); const { toast } = useToast(); const [amount, setAmount] = useState(''); const [note, setNote] = useState(''); @@ -41,6 +45,19 @@ export function OfferDialog({ setBusy(true); try { await createOffer({ listing, amount: parsed, currency: 'SATS', note }); + + // Best-effort nudge to the seller via NIP-17 DM — never fail the offer. + try { + const amountLine = parsed ? ` for ${parsed.toLocaleString()} sats` : ''; + const noteLine = note.trim() ? `\n\n“${note.trim()}”` : ''; + await sendDM({ + recipients: listing.pubkey, + content: `🛹 New offer on your listing “${title}”${amountLine}.${noteLine}`, + }); + } catch { + /* DM optional (no NIP-44 signer, etc.) */ + } + toast({ title: 'Offer sent', description: `${sellerName} can accept or decline it.`, diff --git a/src/components/TradeRecommendationsCard.tsx b/src/components/TradeRecommendationsCard.tsx new file mode 100644 index 0000000..b3ada2a --- /dev/null +++ b/src/components/TradeRecommendationsCard.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useToast } from '@/hooks/useToast'; +import { + useTraderRecommendations, + useMyRecommendation, + useRecommendationActions, +} from '@/hooks/useTraderRecommendations'; +import { genUserName } from '@/lib/genUserName'; +import { ShieldCheck, Loader2 } from 'lucide-react'; + +function Recommender({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name ?? genUserName(pubkey); + return ( + + + + + {name.slice(0, 2).toUpperCase()} + + + {name} + + ); +} + +/** + * Trusted-trader recommendations for a profile (kind 31873). Shows who vouches + * for this trader, and lets a logged-in viewer add their own recommendation. + */ +export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string; className?: string }) { + const { user } = useCurrentUser(); + const { data: recs, isLoading } = useTraderRecommendations(pubkey); + const { data: mine } = useMyRecommendation(pubkey); + const { recommend } = useRecommendationActions(); + const { toast } = useToast(); + const [busy, setBusy] = useState(false); + + const isSelf = user?.pubkey === pubkey; + const count = recs?.length ?? 0; + + // Nothing to show: no recommendations and the viewer can't add one. + if (!isLoading && count === 0 && (!user || isSelf)) return null; + + const handleRecommend = async () => { + setBusy(true); + try { + await recommend(pubkey); + toast({ title: 'Recommendation published', description: 'Thanks for vouching.' }); + } catch (e) { + toast({ + title: 'Could not recommend', + description: e instanceof Error ? e.message : 'Something went wrong.', + variant: 'destructive', + }); + } finally { + setBusy(false); + } + }; + + return ( + + + + + Trusted trader + + + +

+ {count > 0 ? ( + <> + Recommended by {count} skater + {count === 1 ? '' : 's'}. + + ) : ( + 'No recommendations yet.' + )} +

+ + {count > 0 && ( +
+ {recs!.slice(0, 8).map((r) => ( + + ))} +
+ )} + + {user && !isSelf && ( + mine ? ( +

You recommend this trader ✓

+ ) : ( + + ) + )} +
+
+ ); +} diff --git a/src/hooks/useTraderRecommendations.ts b/src/hooks/useTraderRecommendations.ts new file mode 100644 index 0000000..ab449d6 --- /dev/null +++ b/src/hooks/useTraderRecommendations.ts @@ -0,0 +1,119 @@ +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 { useRelays } from '@/lib/relays'; + +/** + * Trusted-trader recommendations — built on the Attestations NIP's + * **Attestor Recommendation** (kind `31873`). A skater recommends another as a + * trusted marketplace trader, for discovery: profiles show who vouches for them. + * + * d = `:reco:` · p = target · k = `30402` (listings) + */ +export const RECOMMENDATION_KIND = 31873; + +export interface TraderRecommendation { + id: string; + recommender: string; + target: string; + kinds: string[]; + note: string; + createdAt: number; + event: NostrEvent; +} + +function parse(event: NostrEvent): TraderRecommendation | null { + if (event.kind !== RECOMMENDATION_KIND) return null; + const d = event.tags.find(([t]) => t === 'd')?.[1]; + const target = event.tags.find(([t]) => t === 'p')?.[1]; + if (!d || !target) return null; + return { + id: event.id, + recommender: event.pubkey, + target, + kinds: event.tags.filter(([t]) => t === 'k').map(([, v]) => v), + note: event.content, + createdAt: event.created_at, + event, + }; +} + +/** Recommendations received by `pubkey` (newest per recommender). */ +export function useTraderRecommendations(pubkey?: string) { + const { nostr } = useNostr(); + const { read } = useRelays(); + const relayKey = read.slice().sort().join(','); + + return useQuery({ + queryKey: ['trader-recommendations', pubkey, relayKey], + enabled: !!pubkey, + staleTime: 5 * 60 * 1000, + queryFn: async (c) => { + if (!pubkey) return []; + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); + const events = await nostr.query( + [{ kinds: [RECOMMENDATION_KIND], '#p': [pubkey], limit: 200 }], + { signal }, + ); + const byRecommender = new Map(); + for (const rec of events + .map(parse) + .filter((r): r is TraderRecommendation => !!r) + .sort((a, b) => b.createdAt - a.createdAt)) { + if (!byRecommender.has(rec.recommender)) byRecommender.set(rec.recommender, rec); + } + return [...byRecommender.values()]; + }, + }); +} + +/** The current user's own recommendation for `target`, if any. */ +export function useMyRecommendation(target?: string) { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + + return useQuery({ + queryKey: ['my-recommendation', user?.pubkey, target], + enabled: !!user?.pubkey && !!target, + staleTime: 60 * 1000, + queryFn: async (c) => { + if (!user?.pubkey || !target) return null; + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); + const events = await nostr.query( + [{ kinds: [RECOMMENDATION_KIND], authors: [user.pubkey], '#p': [target], limit: 10 }], + { signal }, + ); + return ( + events + .map(parse) + .filter((r): r is TraderRecommendation => !!r) + .sort((a, b) => b.createdAt - a.createdAt)[0] ?? null + ); + }, + }); +} + +export function useRecommendationActions() { + const { user } = useCurrentUser(); + const { mutateAsync: publish } = useNostrPublish(); + + /** Recommend `target` as a trusted trader (re-publishing replaces yours). */ + const recommend = async (target: string, note?: string) => { + if (!user) throw new Error('Log in to recommend a trader.'); + if (target === user.pubkey) throw new Error('You cannot recommend yourself.'); + await publish({ + kind: RECOMMENDATION_KIND, + content: note?.trim() || '', + tags: [ + ['d', `${user.pubkey}:reco:${target}`], + ['p', target], + ['k', '30402'], + ['alt', 'SkateSpots: trusted-trader recommendation'], + ], + }); + }; + + return { recommend }; +} diff --git a/src/pages/ListingDetailPage.tsx b/src/pages/ListingDetailPage.tsx index 0a1f284..0cc45e9 100644 --- a/src/pages/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage.tsx @@ -358,6 +358,7 @@ export default function ListingDetailPage() { open={showOffer} onOpenChange={setShowOffer} listing={{ pubkey: item.pubkey, identifier: item.identifier }} + title={item.title} sellerName={displayName} /> )} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 46e1442..3a5c382 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { ZapButton } from '@/components/ZapButton'; import { ZapBadgesCard } from '@/components/ZapBadgesCard'; import { TradeReputationCard } from '@/components/TradeReputationCard'; +import { TradeRecommendationsCard } from '@/components/TradeRecommendationsCard'; import { ClinkPayButton } from '@/components/ClinkPayButton'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -688,6 +689,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) { {/* Marketplace trade reputation (only renders with trades) */} + {/* Quick Info Card */}