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' && (
+
+ )}