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:
parent
88a2a2ca15
commit
0d15a7ce3d
9 changed files with 217 additions and 13 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,7 @@ 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} />
|
||||||
|
{user && item.priceType !== 'fixed' && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -345,6 +346,7 @@ export default function ListingDetailPage() {
|
||||||
>
|
>
|
||||||
<Handshake className="h-4 w-4" /> {t('marketUi.listingDetail.makeOffer')}
|
<Handshake className="h-4 w-4" /> {t('marketUi.listingDetail.makeOffer')}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
91
src/pages/MarketplaceGuidelinesPage.tsx
Normal file
91
src/pages/MarketplaceGuidelinesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue