SkateSpots/UX_AUDIT_2026-10-09.md
SkateSpots 5f8975d631 fix(ux): URL-addressable marketplace tabs, offers error state, nav reset
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).
2026-10-09 21:04:52 +07:00

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.