SkateSpots/UX_AUDIT_2026-10-09.md
SkateSpots 0d15a7ce3d 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.
2026-10-09 20:21:04 +07:00

11 KiB

UI/UX audit — 2026-10-09

Scope: whole app, with a deep focus on the marketplace and its flow sequence. Method: two exploration passes (marketplace flow; app-wide cross-cutting) plus direct verification of the high-impact findings in the source. Items marked [verified] were checked by hand; items marked [reported] come from the exploration passes and should be confirmed before acting.

Note: the older UX_AUDIT.md is pre-refactor and partly stale (it still 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

Entry points today:

  • Nav "Marketplace" -> /marketplace -> MarketplaceHubPage (src/AppRouter.tsx:140). The hub has Browse / Selling / My Trades / Reputation tabs.
  • Deep links: /listing/:pubkey/:identifier (detail), /trades (TradesPage), and the orphan /marketplace-listings (see below).

Step 1 — Browse

MarketplaceHubPage Browse tab renders GearSwapPage (src/pages/MarketplaceHubPage.tsx:59), whose grid uses useMarketplaceItems and GearSwapItemCard.

  • [verified] The hub tabs are local state, not the URL — const [tab, setTab] = useState<TabId>('browse') (MarketplaceHubPage.tsx:29). Refreshing resets to Browse, tabs cannot be deep-linked or shared, and the separate /trades route is the only URL-addressable trades view. Set the tab from useSearchParams.
  • [verified] Two parallel listing cards exist. GearSwapItemCard (grid, GearSwapPage.tsx:464) and MarketplaceItemCard (SpotDetailPage.tsx:828 per-spot gear). Only the latter mounts ListingTradeActions (MarketplaceItemCard.tsx), so buy/offer/escrow behaviour differs by surface — contradicting the "shared by both card and detail" design note.
  • [reported] Anonymous dead ends: the card's Contact button renders when logged out and links to the auth-gated /messages/<npub> with no login affordance (GearSwapItemCard.tsx:414).
  • [reported] isFollowing is a declared but unused prop (GearSwapItemCard.tsx:32), still passed by the grid (GearSwapPage.tsx:468).

Step 2 — Listing detail

Seller block (ListingDetailPage.tsx:272-301) then actions.

  • [verified] A fixed-price listing shows two competing primary CTAs at once: "Buy with sats" (inside ListingTradeActions) and "Make an offer" (ListingDetailPage.tsx:339-347). Only one should be primary per price type.
  • [verified] "Make an offer" is shown to logged-out users. The buyer branch renders for any non-owner regardless of auth; ListingOffersPanel returns null when !user (ListingOffersPanel.tsx:76), so an anonymous visitor sees the offer button, opens it, and the submit throws "Log in to make an offer" (useMarketplaceOffers.ts). Needs a login guard/affordance.
  • [verified] Detail page omits badges the cards show — fulfilment, shipping, escrow-required, distance, PoW, verified-trades (ListingDetailPage.tsx:214 shows only condition/size/category).
  • [verified] Back always goes to /marketplace (ListingDetailPage.tsx:155), dropping the tab/context you came from.
  • [reported] No edit-listing path exists at all (no /edit-listing route); an owner can only Mark sold / Relist / Delete (ListingDetailPage.tsx:304).
  • [reported] Escrow is unreachable on web (native-only gate) while the "ships + escrow" hint still shows (ListingTradeActions.tsx:229,242-251).

Step 3 — Offers and trade completion

ListingOffersPanel is the negotiation thread; accepted offers offer a "Pay with sats" button.

  • [verified] The offers panel disappears entirely when the query has no data (ListingOffersPanel.tsx:76) — on a relay error, or for logged-out users, there is no panel, no error, and no retry.
  • [reported] "Pay with sats" is offered for accepted offers whose seller pointer kind is unsupported (creq/clink), which then throws (usePayPaymentPointer.ts:54,59).
  • [reported] Shipment status can dead-end: "I've shipped" / "I received" silently error until the seller records the buyer (ShippingStatusCard.tsx:46).

Step 4 — Selling

SellerDashboard (hub Selling tab) and the grid.

  • [verified] The grid's owner "Mark as sold" skips the trade flow. GearSwapItemCard.handleMarkSold calls only markSold (GearSwapItemCard.tsx:113-115); the detail/dashboard paths open MarkSoldDialog, which also publishes the kind-31871 attestation (buyer + method + rating). Same user action, no reputation recorded, depending on where it is clicked.
  • [verified] A hidden or showcase-only listing is still reachable by URL. useMarketplaceItem (useMarketplace.ts:96-115) does not apply filterAdminHidden and does not exclude showcase-only items, unlike the list hooks (useMarketplace.ts:19,58). An admin-hidden or not-for-sale listing still renders at /listing/... (moderation gap).
  • [reported] SellerDashboard has no error state — a relay outage looks like "No listings" (SellerDashboard.tsx:76).

2. Cross-cutting bugs / inconsistencies

  • [verified] Duplicate/conflicting nav labels. nav.marketplace and nav.trades are both "Marketplace" (src/i18n/en.ts:75,79); the catalog entry { id: 'trades', href: '/marketplace', labelKey: 'nav.trades' } (useNavPreferences.ts:67) has an id/label/href mismatch, and the real /trades route has no nav entry at all. Pick one word per destination.
  • [verified] Orphan "Marketplace" page. /marketplace-listings -> MarketplacePage (AppRouter.tsx:193) is an external-product promo page (Plebeian Market) with no in-app link — but its name collides with the real marketplace. Rename or remove.
  • [verified] Nav-consistency bug in the nav customizer. MIDDLE_SLOTS = 4 (useNavPreferences.ts:76) but the Reset action hardcodes three tabs (CustomizeNavSheet.tsx:72), diverging from DEFAULT_TAB_IDS.
  • [reported] Desktop "More" active state only checks /stats and /about (Layout.tsx:595), so many destinations never highlight.
  • [reported] Inconsistent 404 presentation: unknown single-segment paths hit /:nip19 (NotFound inside Layout); unknown multi-segment paths hit * (NotFound with no chrome) (AppRouter.tsx:199,206).
  • [reported] Orphan routes with no in-app link: /home, /coaches, /pyramid, /food (AppRouter.tsx:116,120,139,148).
  • [reported] Missing error/outage states on TradesPage, TradeReputationPanel, LabPage, CrewsPage, SkatersPage, SpotCollectionsPage, FoodPage, NewsletterPage, GroupConversationPage, ContactsPage — a relay outage renders the empty state as truth. TradesPage.tsx:59 confirmed by the "no error read" pattern.
  • [reported] Destructive-confirmation inconsistency: window.confirm (DeckCostCalculator.tsx:1144), plain Dialog (TradesPage.tsx:319, MyDataTab.tsx:420), no confirmation (MapPage.tsx:305,323), versus the app-standard AlertDialog (Stash/Collections/SpotDetail).
  • [reported] Icon-only buttons missing aria-label: SpotCollectionsPage, ShopDetailPage, CollectionDetailPage, SpotCard, EventCard, TourCard, MarketplaceItemCard (report menu).
  • [reported] Hardcoded English left in shared UI: DeleteEntityDialog (used by listings/crews/events/tours/coaches), CrossLinkCards, FounderFollow, CommunityStatsPage heading, SkateGlobe, SkatersPage heading. Plus marketplace strings: listing status rendered raw (MarketplaceItemCard.tsx, ListingDetailPage.tsx:203), the shipping value raw (GearSwapItemCard.tsx:334), seller-dashboard price raw (SellerDashboard.tsx:19).
  • [reported] A legacy marketplace i18n namespace runs in parallel to marketUi (en.ts:763 vs 1452); only a few legacy keys are still used, the rest is dead.
  • [reported] Per-row trust query still exists on PyramidPage (PyramidPage.tsx:19) — the same heavy pattern we removed from the marketplace card; every other list uses useBatchTrustScores.
  • [reported] ListingTradeActions mounts useCashuEscrow per card on the per-spot gear list (SpotDetailPage.tsx:828), duplicating wallet hooks across items.

  1. Marketplace correctness (P0):
    • Filter useMarketplaceItem through filterAdminHidden and exclude showcase-only (moderation + "not for sale" reachable by URL).
    • Gate "Make an offer" behind login and show only one primary CTA per price type.
    • Make the grid's "Mark as sold" go through MarkSoldDialog (record the attestation) or remove the quick action.
  2. Flow integrity (P1): URL-address the hub tabs; fix the back target; give ListingOffersPanel an error/empty state; add the missing outage states.
  3. Navigation + terminology (P1): collapse nav.marketplace/nav.trades, fix the catalog id/href, retire /marketplace-listings, fix the customizer Reset.
  4. Polish (P2): i18n the raw status/currency/shipping strings, standardize destructive confirms on AlertDialog, add aria-labels, and move PyramidPage to the batch trust hook.

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.