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.
This commit is contained in:
SkateSpots 2026-10-09 20:21:04 +07:00
parent 88a2a2ca15
commit 0d15a7ce3d
9 changed files with 217 additions and 13 deletions

View file

@ -10,6 +10,19 @@ exploration passes and should be confirmed before acting.
> flags `/marketplace-listings`, but several of its line references no longer > flags `/marketplace-listings`, but several of its line references no longer
> apply). Treat this file as current. > 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 ## 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 destructive confirms on `AlertDialog`, add `aria-label`s, and move PyramidPage
to the batch trust hook. to the batch trust hook.
Nothing above was changed in this pass — this is the audit only. Items marked Items marked **[reported]** should be re-checked in the source before a fix,
**[reported]** should be re-checked in the source before a fix, since explorer since explorer line references can drift. The P0 items 1-2 landed on 2026-10-09
line references can drift. (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.

View file

@ -128,6 +128,7 @@ const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: exp
const ROUTES = [ const ROUTES = [
'/', '/',
'/marketplace', '/marketplace',
'/marketplace-guidelines',
'/listing/abc/def', '/listing/abc/def',
'/trades', '/trades',
'/lab', '/lab',

View file

@ -69,6 +69,7 @@ const LicensePage = lazyWithRetry(() => import("./pages/LicensePage"));
const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage")); const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage"));
const TrustPage = lazyWithRetry(() => import("./pages/TrustPage")); const TrustPage = lazyWithRetry(() => import("./pages/TrustPage"));
const NetworkAlertsPage = lazyWithRetry(() => import("./pages/NetworkAlertsPage")); const NetworkAlertsPage = lazyWithRetry(() => import("./pages/NetworkAlertsPage"));
const MarketplaceGuidelinesPage = lazyWithRetry(() => import("./pages/MarketplaceGuidelinesPage"));
const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage")); const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage"));
const DonatePage = lazyWithRetry(() => import("./pages/DonatePage")); const DonatePage = lazyWithRetry(() => import("./pages/DonatePage"));
const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage")); const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
@ -138,6 +139,7 @@ export function AppRouter() {
<Route path="/onboarding" element={<OnboardingPage />} /> <Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/pyramid" element={<PyramidPage />} /> <Route path="/pyramid" element={<PyramidPage />} />
<Route path="/marketplace" element={<MarketplaceHubPage />} /> <Route path="/marketplace" element={<MarketplaceHubPage />} />
<Route path="/marketplace-guidelines" element={<MarketplaceGuidelinesPage />} />
<Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} /> <Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} />
<Route path="/trades" element={<TradesPage />} /> <Route path="/trades" element={<TradesPage />} />
<Route path="/stats" element={<CommunityStatsPage />} /> <Route path="/stats" element={<CommunityStatsPage />} />

View file

@ -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) { export function useMarketplaceItem(pubkey?: string, identifier?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['marketplace-item', pubkey, identifier, getQueryKeySuffix()], queryKey: ['marketplace-item', pubkey, identifier, getQueryKeySuffix()],
enabled: !!pubkey && !!identifier, enabled: !!pubkey && !!identifier,
staleTime: STALE_TIME, staleTime: STALE_TIME,
gcTime: GC_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<MarketplaceItem | null> => { queryFn: async (): Promise<MarketplaceItem | null> => {
if (!pubkey || !identifier) return null; if (!pubkey || !identifier) return null;
const signal = AbortSignal.timeout(TIMEOUT); const signal = AbortSignal.timeout(TIMEOUT);
@ -109,7 +123,10 @@ export function useMarketplaceItem(pubkey?: string, identifier?: string) {
{ signal, requireAny: true }, { signal, requireAny: true },
); );
const newest = [...events].sort((a, b) => b.created_at - a.created_at)[0]; 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);
}, },
}); });
} }

View file

@ -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.', 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', 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 }; export default { ...en, ...batch3En, ...batch4En, ...legacyEn };

View file

@ -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.', 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', 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 }; export default { ...es, ...batch3Es, ...batch4Es, ...legacyEs };

View file

@ -337,14 +337,16 @@ export default function ListingDetailPage() {
<Card className="bg-black/90 border-white/10"> <Card className="bg-black/90 border-white/10">
<CardContent className="p-4"> <CardContent className="p-4">
<ListingTradeActions item={item} /> <ListingTradeActions item={item} />
<Button {user && item.priceType !== 'fixed' && (
variant="outline" <Button
size="sm" variant="outline"
onClick={() => setShowOffer(true)} size="sm"
className="mt-2 w-full gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10" onClick={() => setShowOffer(true)}
> className="mt-2 w-full gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
<Handshake className="h-4 w-4" /> {t('marketUi.listingDetail.makeOffer')} >
</Button> <Handshake className="h-4 w-4" /> {t('marketUi.listingDetail.makeOffer')}
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

View file

@ -0,0 +1,91 @@
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { Package, ShieldAlert, Handshake, Scale, Users, Ban } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
function Section({
icon: Icon,
color,
title,
children,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-2xl border border-white/8 bg-white/3 p-5">
<div className="mb-3 flex items-center gap-3">
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl"
style={{ background: `${color}1a`, border: `1px solid ${color}40` }}
>
<Icon className="h-5 w-5" style={{ color }} />
</div>
<h2 className="text-lg font-black uppercase tracking-wide text-white">{title}</h2>
</div>
<div className="space-y-3 text-sm leading-relaxed text-gray-400">{children}</div>
</section>
);
}
/**
* Marketplace guidelines. Peer-to-peer, buyer-beware, no checkout, no central
* dispute handling — expectations stated up front, alongside the trust signals
* the app now shows.
*/
export default function MarketplaceGuidelinesPage() {
const { t } = useTranslation();
useSeoMeta({
title: t('marketTerms.seoTitle'),
description: t('marketTerms.seoDescription'),
});
return (
<div className="container max-w-3xl space-y-5 py-10">
<header className="space-y-3">
<div className="flex items-center gap-3">
<Package className="h-8 w-8 text-[#ccff00]" />
<h1 className="text-3xl font-black uppercase tracking-wide text-white">
{t('marketTerms.title')}
</h1>
</div>
<p className="text-gray-400">{t('marketTerms.intro')}</p>
</header>
<Section icon={Handshake} color="#13d2e5" title={t('marketTerms.howTitle')}>
<p>{t('marketTerms.howBody')}</p>
</Section>
<Section icon={Ban} color="#ef4444" title={t('marketTerms.rulesTitle')}>
<p>{t('marketTerms.rulesBody')}</p>
</Section>
<Section icon={Users} color="#6366f1" title={t('marketTerms.respectTitle')}>
<p>{t('marketTerms.respectBody')}</p>
</Section>
<Section icon={Scale} color="#22c55e" title={t('marketTerms.trustTitle')}>
<p>{t('marketTerms.trustBody')}</p>
<p className="space-x-3">
<Link to="/trust" className="text-xs text-[#ccff00] underline">
{t('marketTerms.trustLink')}
</Link>
<Link to="/privacy" className="text-xs text-[#ccff00] underline">
{t('marketTerms.privacyLink')}
</Link>
</p>
</Section>
<Section icon={ShieldAlert} color="#f59e0b" title={t('marketTerms.safetyTitle')}>
<p>{t('marketTerms.safetyBody')}</p>
</Section>
<Section icon={ShieldAlert} color="#8c929e" title={t('marketTerms.moderationTitle')}>
<p>{t('marketTerms.moderationBody')}</p>
</Section>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Store, Handshake, Star, Package } from 'lucide-react'; import { Store, Handshake, Star, Package } from 'lucide-react';
import GearSwapPage from '@/pages/GearSwapPage'; import GearSwapPage from '@/pages/GearSwapPage';
import TradesPage from '@/pages/TradesPage'; import TradesPage from '@/pages/TradesPage';
@ -53,6 +54,14 @@ export default function MarketplaceHubPage() {
); );
})} })}
</div> </div>
<div className="pb-2">
<Link
to="/marketplace-guidelines"
className="text-xs text-gray-500 underline hover:text-gray-300"
>
{t('marketTerms.link')}
</Link>
</div>
</div> </div>
</div> </div>