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).
216 lines
12 KiB
Markdown
216 lines
12 KiB
Markdown
# 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.
|
|
- Marketplace hub tabs are now URL-addressable (`?tab=browse|selling|trades|reputation`)
|
|
— refresh-stable, shareable, and back/forward friendly.
|
|
- `ListingOffersPanel` shows 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, matching `MIDDLE_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 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.
|
|
|
|
---
|
|
|
|
## 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.
|