From 5b163c632fd06c77e7b10f3fc69072cefd30e11f Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 22:56:52 +0700 Subject: [PATCH] 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. --- AGENTS.md | 7 +- scripts/smoke-test.mjs | 1 + src/AppRouter.tsx | 2 + src/hooks/useDirectMessages.ts | 9 +- src/hooks/useNavPreferences.ts | 2 + src/pages/TradesPage.tsx | 283 +++++++++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 src/pages/TradesPage.tsx diff --git a/AGENTS.md b/AGENTS.md index 422b287..f743fb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 7a20e44..51169c8 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -129,6 +129,7 @@ const ROUTES = [ '/', '/marketplace', '/listing/abc/def', + '/trades', '/leaderboards', '/bounties', '/settings', diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index a191650..307eb20 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index f4e09e3..2d2ad30 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -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) { diff --git a/src/hooks/useNavPreferences.ts b/src/hooks/useNavPreferences.ts index 76efada..fc7b395 100644 --- a/src/hooks/useNavPreferences.ts +++ b/src/hooks/useNavPreferences.ts @@ -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 }, diff --git a/src/pages/TradesPage.tsx b/src/pages/TradesPage.tsx new file mode 100644 index 0000000..d77867d --- /dev/null +++ b/src/pages/TradesPage.tsx @@ -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 ( + + {name} + + ); +} + +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(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 ( + +
+ +

My Trades

+

+ Log in to manage your marketplace trades and reputation. +

+ +
+
+ ); + } + + // 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 ( + +
+
+

+ My Trades +

+

+ Confirmations, requests, and the reputation they build. Payment is arranged + with the other skater — the app only records the deal. +

+
+ + + + {/* Action needed */} +
+

+ Action needed {actionNeeded > 0 && `· ${actionNeeded}`} +

+ + {(loadingIncoming || loadingPending) && ( +
+ + +
+ )} + + {/* Incoming: someone asked you (seller) to record a sale */} + {incoming?.map((r) => ( + + + +
+

+ asked you to confirm a sale +

+ + View listing + +
+ +
+
+ ))} + + {/* Pending: a seller recorded a sale you haven't confirmed */} + {pending?.map((t) => ( + + + +
+

+ recorded a sale — confirm your side +

+ + View listing + +
+ +
+
+ ))} + + {!loadingIncoming && !loadingPending && actionNeeded === 0 && ( +

Nothing waiting on you right now. 🛹

+ )} +
+ + {/* Your trade records */} +
+

+ Your trade records +

+ + {loadingRep && } + + {!loadingRep && authored.length === 0 && ( + + + +

+ No trades yet. Close a deal and confirm it to start building reputation. +

+ +
+
+ )} + + {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 ( + + + + {role} + +
+

+ {counterparty ? <>with : 'trade'} +

+

+ {new Date(ev.created_at * 1000).toLocaleDateString()} + {state !== 'valid' && ` · ${state}`} +

+
+ {parsed && ( + + View listing + + )} +
+
+ ); + })} +
+
+
+ ); +}