From 0d15a7ce3d2ad00bf759b54bad7846d4aefe3855 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 9 Oct 2026 20:21:04 +0700 Subject: [PATCH] fix(marketplace): close the P0 audit gaps + borrow the guidelines page P0 fixes from UX_AUDIT_2026-10-09.md: - useMarketplaceItem now filters admin-hidden listings and skips showcase-only (not-for-sale) items, so a direct /listing/... URL cannot surface removed or non-sale content. - Listing detail hides Make an offer for logged-out users and for fixed-price listings (Buy is the single primary CTA there). Borrowed from the Plebeian market review: a /marketplace-guidelines page (peer-to-peer, buyer-beware, no checkout, prohibited items, soft moderation), linked from the marketplace hub; new marketTerms en/es keys. Records the remaining borrowables in the audit doc. --- UX_AUDIT_2026-10-09.md | 46 ++++++++++++- scripts/smoke-test.mjs | 1 + src/AppRouter.tsx | 2 + src/hooks/useMarketplace.ts | 21 +++++- src/i18n/en.ts | 21 ++++++ src/i18n/es.ts | 21 ++++++ src/pages/ListingDetailPage.tsx | 18 ++--- src/pages/MarketplaceGuidelinesPage.tsx | 91 +++++++++++++++++++++++++ src/pages/MarketplaceHubPage.tsx | 9 +++ 9 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 src/pages/MarketplaceGuidelinesPage.tsx diff --git a/UX_AUDIT_2026-10-09.md b/UX_AUDIT_2026-10-09.md index 4b0a3ee..684deb2 100644 --- a/UX_AUDIT_2026-10-09.md +++ b/UX_AUDIT_2026-10-09.md @@ -10,6 +10,19 @@ exploration passes and should be confirmed before acting. > flags `/marketplace-listings`, but several of its line references no longer > apply). Treat this file as current. +## Fixes landed (2026-10-09) + +- `useMarketplaceItem` now filters admin-hidden listings and skips showcase-only + ("not for sale") items — a direct `/listing/...` URL can no longer surface + removed or non-sale content. +- Listing detail: "Make an offer" is hidden for logged-out users and for + fixed-price listings (the Buy button is the single primary CTA there). +- New `/marketplace-guidelines` page, linked from the marketplace hub. + +Deferred (own passes): the grid "Mark as sold" -> `MarkSoldDialog` change (touches +reputation publishing and lives in an emoji-bearing file), payment-UX hardening, +and signed roles/ban lists. + --- ## 1. Marketplace flow — the sequence and where it breaks @@ -165,6 +178,33 @@ Seller block (`ListingDetailPage.tsx:272-301`) then actions. destructive confirms on `AlertDialog`, add `aria-label`s, and move PyramidPage to the batch trust hook. -Nothing above was changed in this pass — this is the audit only. Items marked -**[reported]** should be re-checked in the source before a fix, since explorer -line references can drift. +Items marked **[reported]** should be re-checked in the source before a fix, +since explorer line references can drift. The P0 items 1-2 landed on 2026-10-09 +(see "Fixes landed" above); the rest remain. + +## 4. Borrowables from the Plebeian market repo (2026-10-09) + +Reviewed `PlebeianApp/market` (GPL-3.0; ours is AGPL-3.0 — borrow ideas and +reimplement, do not paste files). Their "marketplace" is largely an app-resource +market (paid NIP-05 / vanity URLs / badges proven by zap receipts), not a P2P +gear flow, so most of it is not our shape. + +Adopted: +- The guidelines-page shape -> our `/marketplace-guidelines`. + +Worth doing later: +- Payment-UX hardening (from their lightning-payment-flow doc): only the active + processor generates/monitors; duplicate-success guard; "payment likely + succeeded" on timeout; manual preimage entry validated before recording a trade + proof. Applies to `PayWithSats` / offer payment. +- Roles + ban list as signed lists (their SPEC): app roles as NIP-51 kind 30000 + (`d=admins|editors`) and an instance ban list as kind 10000, replacing + hardcoded `ADMIN_PUBKEYS` (matches the AGENTS "swap admin keys" door). Own pass. +- Data-layer rules (their `src/AGENTS.md`): protocol rules out of UI; never + collapse payment state into a single boolean; wallet/auth state is sensitive. + Encode in our AGENTS and apply incrementally. +- Zap-receipt validation checklist (dedupe, recency, embedded zap request, amount + match) to harden our Pro/fee verification. + +Not borrowing: cart / multi-invoice checkout ("pay all with NWC"); the NIP-89 +31990 app-config framework; NDK/Applesauce; their Playwright suite. diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index f36a776..9ae404a 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -128,6 +128,7 @@ const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: exp const ROUTES = [ '/', '/marketplace', + '/marketplace-guidelines', '/listing/abc/def', '/trades', '/lab', diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 6af5077..cac29e4 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -69,6 +69,7 @@ const LicensePage = lazyWithRetry(() => import("./pages/LicensePage")); const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage")); const TrustPage = lazyWithRetry(() => import("./pages/TrustPage")); const NetworkAlertsPage = lazyWithRetry(() => import("./pages/NetworkAlertsPage")); +const MarketplaceGuidelinesPage = lazyWithRetry(() => import("./pages/MarketplaceGuidelinesPage")); const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage")); const DonatePage = lazyWithRetry(() => import("./pages/DonatePage")); const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage")); @@ -138,6 +139,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/hooks/useMarketplace.ts b/src/hooks/useMarketplace.ts index 3a5d7df..5325b20 100644 --- a/src/hooks/useMarketplace.ts +++ b/src/hooks/useMarketplace.ts @@ -92,15 +92,29 @@ export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, }); } -/** Fetch a single marketplace listing by author + `d` (including sold/inactive). */ +/** + * Fetch a single marketplace listing by author + `d` (including sold/inactive). + * Admin-hidden listings resolve to null and showcase-only ("not for sale") items + * are skipped, so a direct `/listing/...` URL cannot surface content the + * marketplace has removed or that was never for sale. + */ export function useMarketplaceItem(pubkey?: string, identifier?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['marketplace-item', pubkey, identifier, getQueryKeySuffix()], enabled: !!pubkey && !!identifier, staleTime: STALE_TIME, gcTime: GC_TIME, + select: (item: MarketplaceItem | null) => + item && + !hidden.has({ + coord: `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`, + id: item.id, + }) + ? item + : null, queryFn: async (): Promise => { if (!pubkey || !identifier) return null; const signal = AbortSignal.timeout(TIMEOUT); @@ -109,7 +123,10 @@ export function useMarketplaceItem(pubkey?: string, identifier?: string) { { signal, requireAny: true }, ); const newest = [...events].sort((a, b) => b.created_at - a.created_at)[0]; - return newest ? parseMarketplaceItem(newest) : null; + if (!newest) return null; + // Showcase-only (public Stash gear, NOT for sale) never reaches a listing URL. + if (newest.tags.some((t) => t[0] === 'showcase' && t[1] === 'true')) return null; + return parseMarketplaceItem(newest); }, }); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ce17dd9..80743df 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -7439,6 +7439,27 @@ walletUi: { povDesc: 'A score is relative to who is looking. A rank from one provider or one person is not comparable to another, and we never present one as an objective fact.', settingsCta: 'Manage in Settings', }, + marketTerms: { + link: 'Marketplace guidelines', + seoTitle: 'Marketplace guidelines', + seoDescription: 'How SkateSpots gear listings work: peer-to-peer, buyer beware, and the rules.', + title: 'Marketplace guidelines', + intro: 'SkateSpots is a peer-to-peer gear board. You deal directly with the other person: we do not hold funds, ship items, or resolve disputes.', + howTitle: 'How it works', + howBody: 'List gear, contact the seller in a private message, and settle directly (cash, Lightning, or a swap). There is no cart or checkout.', + rulesTitle: 'What is not allowed', + rulesBody: 'No illegal, dangerous, or adult content; no drugs, weapons, counterfeit or stolen goods. Keep listings about skate gear and related items.', + respectTitle: 'Be respectful', + respectBody: 'No abuse, harassment, threats, or hate. Moderators can hide listings or accounts.', + trustTitle: 'Trust is a guide, not a guarantee', + trustBody: 'Verification marks and trade reputation are signals from the network, not promises. Do your own checks before you trade.', + trustLink: 'How trust works', + privacyLink: 'What leaves your device', + safetyTitle: 'Trade safely', + safetyBody: 'Meet in public for handovers, use escrow or a good-faith deposit for shipped deals, and never send money outside the app.', + moderationTitle: 'Moderation is soft and reversible', + moderationBody: 'Hidden content stays on relays and can be restored; disputes between people are yours to resolve.', + }, }; export default { ...en, ...batch3En, ...batch4En, ...legacyEn }; diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 9dc1505..6509b2d 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -7439,6 +7439,27 @@ walletUi: { povDesc: 'Una puntuación es relativa a quién mira. Una clasificación de un proveedor o una persona no es comparable con otra, y nunca la presentamos como un hecho objetivo.', settingsCta: 'Gestionar en Ajustes', }, + marketTerms: { + link: 'Normas del mercadillo', + seoTitle: 'Normas del mercadillo', + seoDescription: 'Cómo funcionan los anuncios de SkateSpots: entre particulares, con precaución y con reglas.', + title: 'Normas del mercadillo', + intro: 'SkateSpots es un tablón de material entre particulares. Tratas directamente con la otra persona: no custodianos dinero, no enviamos artículos ni resolvemos disputas.', + howTitle: 'Cómo funciona', + howBody: 'Publica material, contacta con el vendedor por mensaje privado y cerrad directamente (efectivo, Lightning o un cambio). No hay carrito ni pago en la app.', + rulesTitle: 'Qué no está permitido', + rulesBody: 'Nada ilegal, peligroso o para adultos; ni drogas, armas, falsificaciones o artículos robados. Mantén los anuncios sobre material de skate y afines.', + respectTitle: 'Respeto', + respectBody: 'Sin abusos, acoso, amenazas ni odio. La moderación puede ocultar anuncios o cuentas.', + trustTitle: 'La confianza es una guía, no una garantía', + trustBody: 'Las marcas de verificación y la reputación son señales de la red, no promesas. Comprueba antes de comerciar.', + trustLink: 'Cómo funciona la confianza', + privacyLink: 'Qué sale de tu dispositivo', + safetyTitle: 'Comercia con seguridad', + safetyBody: 'Quedad en lugares públicos para las entregas, usa escrow o una señal de buena fe en envíos, y nunca envíes dinero fuera de la app.', + moderationTitle: 'La moderación es suave y reversible', + moderationBody: 'El contenido oculto sigue en los relays y puede restaurarse; las disputas entre personas las resolvéis vosotros.', + }, }; export default { ...es, ...batch3Es, ...batch4Es, ...legacyEs }; diff --git a/src/pages/ListingDetailPage.tsx b/src/pages/ListingDetailPage.tsx index 2487f42..1d89eb8 100644 --- a/src/pages/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage.tsx @@ -337,14 +337,16 @@ export default function ListingDetailPage() { - + {user && item.priceType !== 'fixed' && ( + + )}