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).
This commit is contained in:
SkateSpots 2026-10-09 21:04:52 +07:00
parent 0d15a7ce3d
commit 5f8975d631
6 changed files with 30 additions and 7 deletions

View file

@ -18,10 +18,16 @@ exploration passes and should be confirmed before acting.
- 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,
and signed roles/ban lists.
signed roles/ban lists, and the nav terminology cleanup.
---

View file

@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import {
useNavPreferences,
MIDDLE_SLOTS,
DEFAULT_TAB_IDS,
type NavTab,
} from '@/hooks/useNavPreferences';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -69,7 +70,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
const handleReset = () => {
reset();
// reset() writes defaults; reseed draft from the same default source.
setDraft(['lab', 'events', 'crews']);
setDraft([...DEFAULT_TAB_IDS]);
};
// Resolve draft ids to tabs for the preview & reorder list.

View file

@ -62,7 +62,7 @@ function amountLabel(m: MarketplaceOffer, t: TFunc): string {
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
const { t } = useTranslation();
const { user, metadata } = useCurrentUser();
const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
const { data, isError } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
const { respondToOffer, counterOffer } = useOfferActions();
const { mutateAsync: sendDM } = useSendDirectMessage();
const { toast } = useToast();
@ -73,7 +73,13 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
const [payFor, setPayFor] = useState<OfferThread | null>(null);
const [paidThreads, setPaidThreads] = useState<Set<string>>(() => new Set());
if (!user || !data) return null;
if (!user) return null;
// A relay outage must not look like "no offers" — surface it instead of hiding
// the panel entirely.
if (isError) {
return <p className="text-sm text-muted-foreground">{t('marketUi.offers.loadFailed')}</p>;
}
if (!data) return null;
const mine = data.threads.filter((t) => t.buyer === user.pubkey || t.seller === user.pubkey);
if (mine.length === 0) return null;

View file

@ -1779,6 +1779,7 @@ marketUi: {
open: 'Open',
noAmount: 'no amount',
titleOwner: 'Offers ({{count}})',
loadFailed: 'Could not load offers. Check your connection and try again.',
titleBuyer: 'Your negotiations',
you: 'You',
them: 'Them',

View file

@ -1779,6 +1779,7 @@ marketUi: {
open: 'Abierta',
noAmount: 'sin importe',
titleOwner: 'Ofertas ({{count}})',
loadFailed: 'No se pudieron cargar las ofertas. Comprueba tu conexión e inténtalo de nuevo.',
titleBuyer: 'Tus negociaciones',
you: 'Tú',
them: 'Ellos',

View file

@ -1,5 +1,4 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { Store, Handshake, Star, Package } from 'lucide-react';
import GearSwapPage from '@/pages/GearSwapPage';
import TradesPage from '@/pages/TradesPage';
@ -27,7 +26,16 @@ type TabId = (typeof TABS)[number]['id'];
export default function MarketplaceHubPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const [tab, setTab] = useState<TabId>('browse');
// The active tab lives in the URL (?tab=…) so it survives refresh, is
// shareable, and works with back/forward. Unknown values fall back to Browse.
const [searchParams, setSearchParams] = useSearchParams();
const requested = searchParams.get('tab');
const tab: TabId = (TABS.some((entry) => entry.id === requested) ? requested : 'browse') as TabId;
const setTab = (id: TabId) => {
const next = new URLSearchParams(searchParams);
next.set('tab', id);
setSearchParams(next, { replace: true });
};
return (
<>