feat(trades): My Trades page + block-filter DM relays
- New '/trades' page: action-needed (incoming 'confirm this sale' requests as seller, pending confirmations as buyer) with inline actions, a reputation card, and the user's own trade records (role/counterparty/state) linking to each listing. Nav entry (auth-only) + smoke route. - DM inbox relays (kind 10050) are now block-filtered on both read and delivery (closes the last relay caveat). - AGENTS.md updated. Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
parent
78f643d7a9
commit
5b163c632f
6 changed files with 299 additions and 5 deletions
|
|
@ -48,7 +48,8 @@ session with **no other memory of this project**. So:
|
|||
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
||||
— one implementation). Buyer/seller attestations build mutual, WoT-weighted
|
||||
reputation. **No payments are implemented** — "Buy" is intentionally absent;
|
||||
buyers contact the seller and settle off-app.
|
||||
buyers contact the seller and settle off-app. A **`/trades`** page ("My Trades")
|
||||
manages incoming requests, pending confirmations, and the user's trade records.
|
||||
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
|
||||
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
||||
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
||||
|
|
@ -73,8 +74,8 @@ session with **no other memory of this project**. So:
|
|||
applied, local included): listing reports, seller info, skate relays, zaps,
|
||||
Sasquatch purchase, collections, crew form, and DMs. AppProvider's
|
||||
empty-relays defaults check is the only intentional direct reader.
|
||||
- ⚠️ DM **inbox** relays (kind 10050) are delivered-to/read-from but are not
|
||||
yet block-filtered (they're a separate list from the NIP-65 `relays`).
|
||||
- DM **inbox** relays (kind 10050) are also **block-filtered** now (reads and
|
||||
delivery), so the blocklist applies to DMs too.
|
||||
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`).
|
||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ const ROUTES = [
|
|||
'/',
|
||||
'/marketplace',
|
||||
'/listing/abc/def',
|
||||
'/trades',
|
||||
'/leaderboards',
|
||||
'/bounties',
|
||||
'/settings',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const EditEventPage = lazyWithRetry(() => import("./pages/EditEventPage"));
|
|||
const EditTourPage = lazyWithRetry(() => import("./pages/EditTourPage"));
|
||||
const GearSwapPage = lazyWithRetry(() => import("./pages/GearSwapPage"));
|
||||
const ListingDetailPage = lazyWithRetry(() => import("./pages/ListingDetailPage"));
|
||||
const TradesPage = lazyWithRetry(() => import("./pages/TradesPage"));
|
||||
const SpotDetailPage = lazyWithRetry(() => import("./pages/SpotDetailPage"));
|
||||
const ShopDetailPage = lazyWithRetry(() => import("./pages/ShopDetailPage"));
|
||||
const EventDetailPage = lazyWithRetry(() => import("./pages/EventDetailPage"));
|
||||
|
|
@ -121,6 +122,7 @@ export function AppRouter() {
|
|||
<Route path="/pyramid" element={<PyramidPage />} />
|
||||
<Route path="/marketplace" element={<GearSwapPage />} />
|
||||
<Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} />
|
||||
<Route path="/trades" element={<TradesPage />} />
|
||||
<Route path="/stats" element={<CommunityStatsPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/faq" element={<FAQPage />} />
|
||||
|
|
|
|||
|
|
@ -16,8 +16,11 @@ import type { RelayMetadata } from '@/contexts/AppContext';
|
|||
* DM inbox relays (kind 10050), deduped.
|
||||
*/
|
||||
function dmReadRelays(metadata: RelayMetadata): string[] {
|
||||
const blocked = new Set((metadata.blockedRelays ?? []).map((u) => u.toLowerCase()));
|
||||
const read = metadata.relays.filter((r) => r.read).map((r) => r.url);
|
||||
return [...new Set([...(metadata.dmRelays ?? []), ...read])];
|
||||
return [...new Set([...(metadata.dmRelays ?? []), ...read])].filter(
|
||||
(u) => !blocked.has(u.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -209,7 +212,9 @@ export function useSendDirectMessage() {
|
|||
console.warn('[DM] Failed to fetch DM relays, using write relays:', error);
|
||||
}
|
||||
|
||||
const publishRelays = deliverRelays.size > 0 ? [...deliverRelays] : writeRelays;
|
||||
const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase()));
|
||||
const deliverList = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase()));
|
||||
const publishRelays = deliverList.length > 0 ? deliverList : writeRelays;
|
||||
|
||||
// Publish all gift wraps
|
||||
for (const relayUrl of publishRelays) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
MessageCircle,
|
||||
BarChart3,
|
||||
Coins,
|
||||
Handshake,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
|
|
@ -59,6 +60,7 @@ export const NAV_CATALOG: NavTab[] = [
|
|||
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users },
|
||||
{ id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
|
||||
{ id: 'mints', href: '/mints', label: 'Mints', icon: Coins },
|
||||
{ id: 'trades', href: '/trades', label: 'My Trades', icon: Handshake, authOnly: true },
|
||||
{ id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true },
|
||||
{ id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true },
|
||||
{ id: 'contacts', href: '/contacts', label: 'Contacts', icon: Users, authOnly: true },
|
||||
|
|
|
|||
283
src/pages/TradesPage.tsx
Normal file
283
src/pages/TradesPage.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
useIncomingTradeRequests,
|
||||
usePendingTradeConfirmations,
|
||||
useTradeReputation,
|
||||
useTradeAttestation,
|
||||
parseListingCoord,
|
||||
} from '@/hooks/useTradeAttestations';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { Handshake, CheckCircle, Loader2, Package, ArrowRight } from 'lucide-react';
|
||||
|
||||
function PeerName({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||
return (
|
||||
<Link to={`/${nip19.npubEncode(pubkey)}`} className="font-bold text-white hover:text-[#ccff00]">
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function listingUrl(pubkey: string, identifier: string): string {
|
||||
return `/listing/${pubkey}/${identifier}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/trades` — one place to manage marketplace trades: incoming confirmation
|
||||
* requests (as seller), pending confirmations (as buyer), and your own trade
|
||||
* attestation records. Mirrors the DM banners, in a dedicated surface.
|
||||
*/
|
||||
export default function TradesPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { publishTrade, isPending: publishing } = useTradeAttestation();
|
||||
|
||||
const { data: incoming, isLoading: loadingIncoming } = useIncomingTradeRequests(user?.pubkey);
|
||||
const { data: pending, isLoading: loadingPending } = usePendingTradeConfirmations(user?.pubkey);
|
||||
const { data: reputation, isLoading: loadingRep } = useTradeReputation(user?.pubkey);
|
||||
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
useSeoMeta({
|
||||
title: 'My Trades — SkateSpots',
|
||||
description: 'Manage your marketplace trades, confirmations and reputation.',
|
||||
});
|
||||
|
||||
const handleConfirmSale = async (
|
||||
listing: { pubkey: string; identifier: string },
|
||||
requestor: string,
|
||||
requestCoord: string,
|
||||
) => {
|
||||
setBusy(requestCoord);
|
||||
try {
|
||||
await publishTrade({
|
||||
listing,
|
||||
role: 'sold',
|
||||
counterparty: requestor,
|
||||
requestCoord,
|
||||
});
|
||||
toast({ title: 'Sale confirmed', description: 'They can now confirm their side.' });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm the sale',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPurchase = async (
|
||||
listing: { pubkey: string; identifier: string },
|
||||
seller: string,
|
||||
coord: string,
|
||||
) => {
|
||||
setBusy(coord);
|
||||
try {
|
||||
await publishTrade({ listing, role: 'bought', counterparty: seller });
|
||||
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm the trade',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-md px-4 py-16 text-center space-y-4">
|
||||
<Handshake className="mx-auto h-10 w-10 text-gray-600" />
|
||||
<h1 className="text-xl font-black uppercase text-white">My Trades</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Log in to manage your marketplace trades and reputation.
|
||||
</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// Attestations this user authored (their own trade records).
|
||||
const authored = (reputation?.attestations ?? []).filter((e) => e.pubkey === user.pubkey);
|
||||
|
||||
const actionNeeded =
|
||||
(incoming?.length ?? 0) + (pending?.length ?? 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-3 text-3xl font-black uppercase text-white">
|
||||
<Handshake className="h-8 w-8 text-[#ff6600]" /> My Trades
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Confirmations, requests, and the reputation they build. Payment is arranged
|
||||
with the other skater — the app only records the deal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TradeReputationCard pubkey={user.pubkey} />
|
||||
|
||||
{/* Action needed */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
|
||||
Action needed {actionNeeded > 0 && `· ${actionNeeded}`}
|
||||
</h2>
|
||||
|
||||
{(loadingIncoming || loadingPending) && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 w-full rounded-2xl" />
|
||||
<Skeleton className="h-20 w-full rounded-2xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incoming: someone asked you (seller) to record a sale */}
|
||||
{incoming?.map((r) => (
|
||||
<Card key={r.coord} className="border-2 border-[#ff6600]/30 bg-[#ff6600]/5">
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white">
|
||||
<PeerName pubkey={r.requestor} /> asked you to confirm a sale
|
||||
</p>
|
||||
<Link
|
||||
to={listingUrl(r.listing.pubkey, r.listing.identifier)}
|
||||
className="text-xs text-gray-400 hover:text-white"
|
||||
>
|
||||
View listing <ArrowRight className="inline h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConfirmSale(r.listing, r.requestor, r.coord)}
|
||||
disabled={busy === r.coord || publishing}
|
||||
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{busy === r.coord ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
|
||||
Confirm sale
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Pending: a seller recorded a sale you haven't confirmed */}
|
||||
{pending?.map((t) => (
|
||||
<Card key={t.coord} className="border-2 border-[#ccff00]/30 bg-[#ccff00]/5">
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<CheckCircle className="h-5 w-5 shrink-0 text-[#ccff00]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white">
|
||||
<PeerName pubkey={t.seller} /> recorded a sale — confirm your side
|
||||
</p>
|
||||
<Link
|
||||
to={listingUrl(t.listing.pubkey, t.listing.identifier)}
|
||||
className="text-xs text-gray-400 hover:text-white"
|
||||
>
|
||||
View listing <ArrowRight className="inline h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConfirmPurchase(t.listing, t.seller, t.coord)}
|
||||
disabled={busy === t.coord || publishing}
|
||||
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{busy === t.coord ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
|
||||
Confirm
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!loadingIncoming && !loadingPending && actionNeeded === 0 && (
|
||||
<p className="text-sm text-gray-500">Nothing waiting on you right now. 🛹</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Your trade records */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
|
||||
Your trade records
|
||||
</h2>
|
||||
|
||||
{loadingRep && <Skeleton className="h-16 w-full rounded-2xl" />}
|
||||
|
||||
{!loadingRep && authored.length === 0 && (
|
||||
<Card className="border-dashed border-white/15 bg-black/60">
|
||||
<CardContent className="py-10 text-center">
|
||||
<Package className="mx-auto mb-3 h-8 w-8 text-gray-600" />
|
||||
<p className="text-sm text-gray-400">
|
||||
No trades yet. Close a deal and confirm it to start building reputation.
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-4 border-white/15">
|
||||
<Link to="/marketplace">Browse Gear Swap</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{authored.map((ev) => {
|
||||
const coord = ev.tags.find(([t]) => t === 'a')?.[1];
|
||||
const parsed = parseListingCoord(coord);
|
||||
const d = ev.tags.find(([t]) => t === 'd')?.[1] ?? '';
|
||||
const role = d.includes(':trade:sold:') ? 'Sold' : d.includes(':trade:bought:') ? 'Bought' : 'Trade';
|
||||
const state = ev.tags.find(([t]) => t === 's')?.[1] ?? 'valid';
|
||||
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1];
|
||||
return (
|
||||
<Card key={ev.id} className="bg-black/80 border-white/10">
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Badge
|
||||
className={
|
||||
role === 'Sold'
|
||||
? 'bg-orange-500/15 text-orange-400 border border-orange-500/30'
|
||||
: 'bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30'
|
||||
}
|
||||
>
|
||||
{role}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-200">
|
||||
{counterparty ? <>with <PeerName pubkey={counterparty} /></> : 'trade'}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{new Date(ev.created_at * 1000).toLocaleDateString()}
|
||||
{state !== 'valid' && ` · ${state}`}
|
||||
</p>
|
||||
</div>
|
||||
{parsed && (
|
||||
<Link
|
||||
to={listingUrl(parsed.pubkey, parsed.identifier)}
|
||||
className="shrink-0 text-xs text-gray-400 hover:text-white"
|
||||
>
|
||||
View listing <ArrowRight className="inline h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue