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.
This commit is contained in:
parent
0956b1368a
commit
584561e390
7 changed files with 271 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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.',
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
114
src/components/TradeRecommendationsCard.tsx
Normal file
114
src/components/TradeRecommendationsCard.tsx
Normal file
|
|
@ -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 (
|
||||
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6 border border-white/10">
|
||||
<AvatarImage src={author.data?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-gray-800 text-[10px] text-gray-400">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-xs text-gray-300 hover:text-white">{name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Card className={`bg-black/90 border-2 border-[#ff6600]/25 ${className ?? ''}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
Trusted trader
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-gray-300">
|
||||
{count > 0 ? (
|
||||
<>
|
||||
Recommended by <span className="font-bold text-white">{count}</span> skater
|
||||
{count === 1 ? '' : 's'}.
|
||||
</>
|
||||
) : (
|
||||
'No recommendations yet.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{count > 0 && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{recs!.slice(0, 8).map((r) => (
|
||||
<Recommender key={r.id} pubkey={r.recommender} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && !isSelf && (
|
||||
mine ? (
|
||||
<p className="text-xs text-[#ccff00]">You recommend this trader ✓</p>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRecommend}
|
||||
disabled={busy}
|
||||
className="w-full gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
|
||||
Recommend as trusted trader
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
119
src/hooks/useTraderRecommendations.ts
Normal file
119
src/hooks/useTraderRecommendations.ts
Normal file
|
|
@ -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 = `<recommender>:reco:<target>` · 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<TraderRecommendation[]>({
|
||||
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<string, TraderRecommendation>();
|
||||
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<TraderRecommendation | null>({
|
||||
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 };
|
||||
}
|
||||
|
|
@ -358,6 +358,7 @@ export default function ListingDetailPage() {
|
|||
open={showOffer}
|
||||
onOpenChange={setShowOffer}
|
||||
listing={{ pubkey: item.pubkey, identifier: item.identifier }}
|
||||
title={item.title}
|
||||
sellerName={displayName}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
<TradeReputationCard pubkey={pubkey} />
|
||||
<TradeRecommendationsCard pubkey={pubkey} />
|
||||
|
||||
{/* Quick Info Card */}
|
||||
<Card className="bg-black/90 border-2 border-gray-800">
|
||||
|
|
|
|||
Loading…
Reference in a new issue