Next P1 slice from UX_AUDIT_2026-10-09.md: - Marketplace hub tabs now live in the URL (?tab=...) so refresh, share and back/forward keep the tab; unknown values fall back to Browse. - ListingOffersPanel shows an error state on a relay outage instead of returning null (a failure no longer reads as no offers). - Nav customizer Reset reseeds from DEFAULT_TAB_IDS (4) instead of a hardcoded 3, matching MIDDLE_SLOTS. New marketUi.offers.loadFailed (en/es).
12 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.mdis 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)
useMarketplaceItemnow 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-guidelinespage, linked from the marketplace hub. - Marketplace hub tabs are now URL-addressable (
?tab=browse|selling|trades|reputation) — refresh-stable, shareable, and back/forward friendly. ListingOffersPanelshows an error state on a relay outage instead of hiding itself (a failure no longer looks like "no offers").- Nav customizer Reset now reseeds from
DEFAULT_TAB_IDS(four tabs) instead of a hardcoded three, matchingMIDDLE_SLOTS.
Deferred (own passes): the grid "Mark as sold" -> MarkSoldDialog change (touches
reputation publishing and lives in an emoji-bearing file), payment-UX hardening,
signed roles/ban lists, and the nav terminology cleanup.
1. Marketplace flow — the sequence and where it breaks
Entry points today:
- Nav "Marketplace" ->
/marketplace->MarketplaceHubPage(src/AppRouter.tsx:140). The hub hasBrowse / Selling / My Trades / Reputationtabs. - 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/tradesroute is the only URL-addressable trades view. Set the tab fromuseSearchParams. - [verified] Two parallel listing cards exist.
GearSwapItemCard(grid,GearSwapPage.tsx:464) andMarketplaceItemCard(SpotDetailPage.tsx:828per-spot gear). Only the latter mountsListingTradeActions(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]
isFollowingis 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;
ListingOffersPanelreturns 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:214shows 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-listingroute); 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.handleMarkSoldcalls onlymarkSold(GearSwapItemCard.tsx:113-115); the detail/dashboard paths openMarkSoldDialog, 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 applyfilterAdminHiddenand does not excludeshowcase-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]
SellerDashboardhas no error state — a relay outage looks like "No listings" (SellerDashboard.tsx:76).
2. Cross-cutting bugs / inconsistencies
- [verified] Duplicate/conflicting nav labels.
nav.marketplaceandnav.tradesare 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/tradesroute 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 fromDEFAULT_TAB_IDS. - [reported] Desktop "More" active state only checks
/statsand/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:59confirmed by the "no error read" pattern. - [reported] Destructive-confirmation inconsistency:
window.confirm(DeckCostCalculator.tsx:1144), plainDialog(TradesPage.tsx:319,MyDataTab.tsx:420), no confirmation (MapPage.tsx:305,323), versus the app-standardAlertDialog(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,CommunityStatsPageheading,SkateGlobe,SkatersPageheading. 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
marketplacei18n namespace runs in parallel tomarketUi(en.ts:763vs1452); 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 usesuseBatchTrustScores. - [reported]
ListingTradeActionsmountsuseCashuEscrowper card on the per-spot gear list (SpotDetailPage.tsx:828), duplicating wallet hooks across items.
Recommended order of work
- Marketplace correctness (P0):
- Filter
useMarketplaceItemthroughfilterAdminHiddenand excludeshowcase-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.
- Filter
- Flow integrity (P1): URL-address the hub tabs; fix the back target;
give
ListingOffersPanelan error/empty state; add the missing outage states. - Navigation + terminology (P1): collapse
nav.marketplace/nav.trades, fix the catalog id/href, retire/marketplace-listings, fix the customizer Reset. - Polish (P2): i18n the raw status/currency/shipping strings, standardize
destructive confirms on
AlertDialog, addaria-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 hardcodedADMIN_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.