# 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('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/` 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. --- ## Recommended order of work 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-label`s, 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.