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:
parent
0d15a7ce3d
commit
5f8975d631
6 changed files with 30 additions and 7 deletions
|
|
@ -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
|
- 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).
|
fixed-price listings (the Buy button is the single primary CTA there).
|
||||||
- New `/marketplace-guidelines` page, linked from the marketplace hub.
|
- 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
|
Deferred (own passes): the grid "Mark as sold" -> `MarkSoldDialog` change (touches
|
||||||
reputation publishing and lives in an emoji-bearing file), payment-UX hardening,
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
useNavPreferences,
|
useNavPreferences,
|
||||||
MIDDLE_SLOTS,
|
MIDDLE_SLOTS,
|
||||||
|
DEFAULT_TAB_IDS,
|
||||||
type NavTab,
|
type NavTab,
|
||||||
} from '@/hooks/useNavPreferences';
|
} from '@/hooks/useNavPreferences';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -69,7 +70,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
reset();
|
reset();
|
||||||
// reset() writes defaults; reseed draft from the same default source.
|
// 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.
|
// Resolve draft ids to tabs for the preview & reorder list.
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ function amountLabel(m: MarketplaceOffer, t: TFunc): string {
|
||||||
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
|
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, metadata } = useCurrentUser();
|
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 { respondToOffer, counterOffer } = useOfferActions();
|
||||||
const { mutateAsync: sendDM } = useSendDirectMessage();
|
const { mutateAsync: sendDM } = useSendDirectMessage();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -73,7 +73,13 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
||||||
const [payFor, setPayFor] = useState<OfferThread | null>(null);
|
const [payFor, setPayFor] = useState<OfferThread | null>(null);
|
||||||
const [paidThreads, setPaidThreads] = useState<Set<string>>(() => new Set());
|
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);
|
const mine = data.threads.filter((t) => t.buyer === user.pubkey || t.seller === user.pubkey);
|
||||||
if (mine.length === 0) return null;
|
if (mine.length === 0) return null;
|
||||||
|
|
|
||||||
|
|
@ -1779,6 +1779,7 @@ marketUi: {
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
noAmount: 'no amount',
|
noAmount: 'no amount',
|
||||||
titleOwner: 'Offers ({{count}})',
|
titleOwner: 'Offers ({{count}})',
|
||||||
|
loadFailed: 'Could not load offers. Check your connection and try again.',
|
||||||
titleBuyer: 'Your negotiations',
|
titleBuyer: 'Your negotiations',
|
||||||
you: 'You',
|
you: 'You',
|
||||||
them: 'Them',
|
them: 'Them',
|
||||||
|
|
|
||||||
|
|
@ -1779,6 +1779,7 @@ marketUi: {
|
||||||
open: 'Abierta',
|
open: 'Abierta',
|
||||||
noAmount: 'sin importe',
|
noAmount: 'sin importe',
|
||||||
titleOwner: 'Ofertas ({{count}})',
|
titleOwner: 'Ofertas ({{count}})',
|
||||||
|
loadFailed: 'No se pudieron cargar las ofertas. Comprueba tu conexión e inténtalo de nuevo.',
|
||||||
titleBuyer: 'Tus negociaciones',
|
titleBuyer: 'Tus negociaciones',
|
||||||
you: 'Tú',
|
you: 'Tú',
|
||||||
them: 'Ellos',
|
them: 'Ellos',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Store, Handshake, Star, Package } from 'lucide-react';
|
import { Store, Handshake, Star, Package } from 'lucide-react';
|
||||||
import GearSwapPage from '@/pages/GearSwapPage';
|
import GearSwapPage from '@/pages/GearSwapPage';
|
||||||
import TradesPage from '@/pages/TradesPage';
|
import TradesPage from '@/pages/TradesPage';
|
||||||
|
|
@ -27,7 +26,16 @@ type TabId = (typeof TABS)[number]['id'];
|
||||||
export default function MarketplaceHubPage() {
|
export default function MarketplaceHubPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useCurrentUser();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue