feat: marketplace UX and resilience pass

This commit is contained in:
SkateSpots 2026-11-01 20:05:21 +07:00
parent 3f28c3fdb4
commit 327e81f155
9 changed files with 912 additions and 235 deletions

View file

@ -79,7 +79,6 @@ const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage")); const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage"));
const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage")); const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage"));
const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage")); const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage"));
const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage"));
const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage")); const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage"));
const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage")); const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage"));
const NotFound = lazyWithRetry(() => import("./pages/NotFound")); const NotFound = lazyWithRetry(() => import("./pages/NotFound"));
@ -200,7 +199,7 @@ export function AppRouter() {
<Route path="/support" element={<Navigate to="/supporters" replace />} /> <Route path="/support" element={<Navigate to="/supporters" replace />} />
<Route path="/donate" element={<DonatePage />} /> <Route path="/donate" element={<DonatePage />} />
{/* Marketplace */} {/* Marketplace */}
<Route path="/marketplace-listings" element={<MarketplacePage />} /> <Route path="/marketplace-listings" element={<Navigate to="/marketplace" replace />} />
{/* Newsletter */} {/* Newsletter */}
<Route path="/newsletter" element={<NewsletterPage />} /> <Route path="/newsletter" element={<NewsletterPage />} />
{/* Trick Glossary */} {/* Trick Glossary */}

View file

@ -1,10 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -52,6 +53,13 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
const [showMarkSold, setShowMarkSold] = useState(false); const [showMarkSold, setShowMarkSold] = useState(false);
const npub = nip19.npubEncode(item.pubkey); const npub = nip19.npubEncode(item.pubkey);
const listingPath = `/listing/${item.pubkey}/${item.identifier}`;
// Keep shared links canonical in the native WebView too (capacitor://localhost
// is not a shareable origin).
const listingUrl = `https://skate-spots.org${listingPath}`;
const contactSeller = () => navigate(
`/messages/${npub}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`,
);
const isOwnListingCheck = user?.pubkey === item.pubkey; const isOwnListingCheck = user?.pubkey === item.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey); const isAdmin = isAdminPubkey(user?.pubkey);
@ -127,21 +135,28 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
return ( return (
<Card <Card
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] h-full ${ className={`group relative h-full border-2 bg-black/90 transition-all hover:scale-[1.02] ${
isWTB isWTB
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]' ? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]' : 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
}`} }`}
onClick={() => navigate(`/listing/${item.pubkey}/${item.identifier}`)}
> >
<CardContent className="p-0"> {/* Stretched link: real link semantics, context menu, middle-click and
keyboard activation. Interactive controls above re-enable pointer events. */}
<Link
to={listingPath}
aria-label={t('marketUi.card.viewTitle', { title: item.title })}
className="absolute inset-0 z-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
/>
<CardContent className="pointer-events-none relative z-10 p-0">
{/* Image */} {/* Image */}
<div className="relative aspect-square overflow-hidden rounded-t-lg"> <div className="relative aspect-square overflow-hidden rounded-t-lg">
{primaryImage ? ( {primaryImage ? (
<img <ImageWithFallback
src={primaryImage} src={primaryImage}
alt={item.title} alt={item.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105" className="h-full w-full object-cover transition-transform group-hover:scale-105"
fallback={<span className="text-6xl">{isWTB ? '🔍' : '🛹'}</span>}
/> />
) : ( ) : (
<div <div
@ -198,7 +213,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
{/* More options menu — owners manage their listing (mark sold / {/* More options menu — owners manage their listing (mark sold /
relist / delete), non-owners can report, admins can take down. */} relist / delete), non-owners can report, admins can take down. */}
{user && ( {user && (
<div className="absolute bottom-2 right-2 z-20"> <div className="pointer-events-auto absolute bottom-2 right-2 z-20">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@ -358,7 +373,8 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Badge <Badge
variant="outline" variant="outline"
className={`text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`} onClick={() => navigate(listingPath)}
className={`pointer-events-auto cursor-pointer text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`}
> >
{trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />} {trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />}
{trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />} {trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />}
@ -378,7 +394,10 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1"> <div
className="pointer-events-auto flex cursor-pointer items-center gap-1"
onClick={() => navigate(listingPath)}
>
{sellerRating.rating > 0 ? ( {sellerRating.rating > 0 ? (
<span className="text-sm">{sellerRating.display}</span> <span className="text-sm">{sellerRating.display}</span>
) : ( ) : (
@ -403,14 +422,14 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className={`w-full mt-3 gap-2 ${ className={`pointer-events-auto mt-3 w-full gap-2 ${
isWTB isWTB
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10' ? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10' : 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
}`} }`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate(`/messages/${npub}`); contactSeller();
}} }}
> >
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />

View file

@ -44,6 +44,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const listingRef = { pubkey: item.pubkey, identifier: item.identifier }; const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`; const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
const listingPath = `/listing/${item.pubkey}/${item.identifier}`;
const listingUrl = `https://skate-spots.org${listingPath}`;
const contactSeller = () => navigate(
`/messages/${nip19.npubEncode(item.pubkey)}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`,
);
const { data: trade } = useListingTrade(listingRef, user?.pubkey); const { data: trade } = useListingTrade(listingRef, user?.pubkey);
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey); const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation(); const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
@ -305,7 +310,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10' ? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10' : 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
}`} }`}
onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)} onClick={contactSeller}
> >
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />
{isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')} {isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}

View file

@ -1447,6 +1447,8 @@ marketUi: {
noneFiltered: 'No gear matches your current filters. Try clearing them or searching something else.', noneFiltered: 'No gear matches your current filters. Try clearing them or searching something else.',
noneFirst: 'Be the first to list your gear — decks, trucks, wheels, shoes, anything skate!', noneFirst: 'Be the first to list your gear — decks, trucks, wheels, shoes, anything skate!',
listYourGear: 'List Your Gear', listYourGear: 'List Your Gear',
activeFilters: 'Active filters',
priceRateNote: 'Fiat prices use a live BTC rate. Listings without an available rate sort last.',
faqLink: 'Have questions? Check the FAQ →', faqLink: 'Have questions? Check the FAQ →',
}, },
hub: { hub: {
@ -1532,6 +1534,8 @@ marketUi: {
deleteListing: 'Delete listing', deleteListing: 'Delete listing',
makeOffer: 'Make an offer', makeOffer: 'Make an offer',
shareListing: 'Share listing', shareListing: 'Share listing',
viewOptions: 'View options',
manageListing: 'Manage listing',
unknown: 'Unknown', unknown: 'Unknown',
linkCopied: 'Link copied', linkCopied: 'Link copied',
linkCopiedBody: 'Share it with a skater.', linkCopiedBody: 'Share it with a skater.',
@ -1569,6 +1573,10 @@ marketUi: {
publishFailed: 'Failed to publish', publishFailed: 'Failed to publish',
loginRequired: 'Login Required', loginRequired: 'Login Required',
loginRequiredBody: 'You need to log in to list gear.', loginRequiredBody: 'You need to log in to list gear.',
draftRestored: 'Draft restored',
draftRestoredBody: 'We restored your unsaved listing. Nothing is published until you press the button below.',
discardDraft: 'Discard draft',
draftDiscarded: 'Draft discarded',
backToStash: 'Back to Stash', backToStash: 'Back to Stash',
backToSpot: 'Back to {{title}}', backToSpot: 'Back to {{title}}',
backToMarketplace: 'Back to Marketplace', backToMarketplace: 'Back to Marketplace',
@ -1642,6 +1650,14 @@ marketUi: {
productPhoto: 'Product {{n}}', productPhoto: 'Product {{n}}',
upload: 'Upload', upload: 'Upload',
photosNote: 'Add up to 5 photos. First image will be the main photo.', photosNote: 'Add up to 5 photos. First image will be the main photo.',
cover: 'Cover',
makeCover: 'Make cover',
movePhotoLeft: 'Move photo left',
movePhotoRight: 'Move photo right',
removePhoto: 'Remove photo {{n}}',
previewTitle: 'Live preview',
previewNote: 'Preview only — nothing is published until you press the button below.',
previewEmpty: 'Add a title, category and description to see the preview.',
pickupLocationTitle: 'Pickup Location', pickupLocationTitle: 'Pickup Location',
pickupLocationHint: 'Link to a skate spot for easy meetups', pickupLocationHint: 'Link to a skate spot for easy meetups',
searchSpots: 'Search spots...', searchSpots: 'Search spots...',
@ -1708,6 +1724,7 @@ marketUi: {
relistedBody: 'Your listing is active again.', relistedBody: 'Your listing is active again.',
listingOptions: 'Listing options', listingOptions: 'Listing options',
report: 'Report', report: 'Report',
contactDm: 'Hi! I\'m interested in "{{title}}". {{url}}',
removeAdmin: 'Remove (admin)', removeAdmin: 'Remove (admin)',
postage: 'postage', postage: 'postage',
newSeller: 'New seller', newSeller: 'New seller',

View file

@ -1447,6 +1447,8 @@ marketUi: {
noneFiltered: 'Ningún equipo coincide con tus filtros actuales. Prueba a borrarlos o busca otra cosa.', noneFiltered: 'Ningún equipo coincide con tus filtros actuales. Prueba a borrarlos o busca otra cosa.',
noneFirst: 'Sé el primero en publicar tu equipo: tablas, trucks, ruedas, zapatos, ¡lo que sea de skate!', noneFirst: 'Sé el primero en publicar tu equipo: tablas, trucks, ruedas, zapatos, ¡lo que sea de skate!',
listYourGear: 'Publica tu equipo', listYourGear: 'Publica tu equipo',
activeFilters: 'Filtros activos',
priceRateNote: 'Los precios en moneda fiat usan una tasa BTC en vivo. Los anuncios sin tasa disponible van al final.',
faqLink: '¿Tienes dudas? Consulta las preguntas frecuentes →', faqLink: '¿Tienes dudas? Consulta las preguntas frecuentes →',
}, },
hub: { hub: {
@ -1532,6 +1534,8 @@ marketUi: {
deleteListing: 'Eliminar anuncio', deleteListing: 'Eliminar anuncio',
makeOffer: 'Hacer una oferta', makeOffer: 'Hacer una oferta',
shareListing: 'Compartir anuncio', shareListing: 'Compartir anuncio',
viewOptions: 'Ver opciones',
manageListing: 'Gestionar anuncio',
unknown: 'Desconocido', unknown: 'Desconocido',
linkCopied: 'Enlace copiado', linkCopied: 'Enlace copiado',
linkCopiedBody: 'Compártelo con un skater.', linkCopiedBody: 'Compártelo con un skater.',
@ -1569,6 +1573,10 @@ marketUi: {
publishFailed: 'No se pudo publicar', publishFailed: 'No se pudo publicar',
loginRequired: 'Inicio de sesión obligatorio', loginRequired: 'Inicio de sesión obligatorio',
loginRequiredBody: 'Necesitas iniciar sesión para publicar equipo.', loginRequiredBody: 'Necesitas iniciar sesión para publicar equipo.',
draftRestored: 'Borrador restaurado',
draftRestoredBody: 'Hemos restaurado tu anuncio sin publicar. Nada se publica hasta que pulses el botón de abajo.',
discardDraft: 'Descartar borrador',
draftDiscarded: 'Borrador descartado',
backToStash: 'Volver al Alijo', backToStash: 'Volver al Alijo',
backToSpot: 'Volver a {{title}}', backToSpot: 'Volver a {{title}}',
backToMarketplace: 'Volver al Mercado', backToMarketplace: 'Volver al Mercado',
@ -1642,6 +1650,14 @@ marketUi: {
productPhoto: 'Producto {{n}}', productPhoto: 'Producto {{n}}',
upload: 'Subir', upload: 'Subir',
photosNote: 'Añade hasta 5 fotos. La primera imagen será la principal.', photosNote: 'Añade hasta 5 fotos. La primera imagen será la principal.',
cover: 'Portada',
makeCover: 'Usar como portada',
movePhotoLeft: 'Mover foto a la izquierda',
movePhotoRight: 'Mover foto a la derecha',
removePhoto: 'Eliminar foto {{n}}',
previewTitle: 'Vista previa en vivo',
previewNote: 'Solo vista previa: nada se publica hasta que pulses el botón de abajo.',
previewEmpty: 'Añade título, categoría y descripción para ver la vista previa.',
pickupLocationTitle: 'Punto de recogida', pickupLocationTitle: 'Punto de recogida',
pickupLocationHint: 'Vincula un spot de skate para quedar fácilmente', pickupLocationHint: 'Vincula un spot de skate para quedar fácilmente',
searchSpots: 'Buscar spots...', searchSpots: 'Buscar spots...',
@ -1708,6 +1724,7 @@ marketUi: {
relistedBody: 'Tu anuncio vuelve a estar activo.', relistedBody: 'Tu anuncio vuelve a estar activo.',
listingOptions: 'Opciones del anuncio', listingOptions: 'Opciones del anuncio',
report: 'Denunciar', report: 'Denunciar',
contactDm: '¡Hola! Me interesa "{{title}}". {{url}}',
removeAdmin: 'Retirar (admin)', removeAdmin: 'Retirar (admin)',
postage: 'envío', postage: 'envío',
newSeller: 'Vendedor nuevo', newSeller: 'Vendedor nuevo',

View file

@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -24,9 +25,64 @@ import { SpamFeePayment } from '@/components/SpamFeePayment';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote'; import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types'; import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys'; import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys';
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react'; import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap, ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
const LISTING_DRAFT_KEY = 'skatespots:create-listing-draft:v1';
interface ListingDraft {
listingType: 'selling' | 'wtb';
fulfilment: 'pickup' | 'ships' | 'both';
shippingWho: 'buyer' | 'seller' | 'split';
requireEscrow: boolean;
category: string;
title: string;
summary: string;
description: string;
condition: string;
priceType: string;
price: string;
currency: string;
size: string;
truckHeight: string;
wheelHardness: string;
images: string[];
availability: string;
expiration: number;
selectedSpotId: string | null;
}
function readListingDraft(): ListingDraft | null {
try {
const raw = localStorage.getItem(LISTING_DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<ListingDraft>;
if (!parsed || typeof parsed !== 'object') return null;
return {
listingType: parsed.listingType === 'wtb' ? 'wtb' : 'selling',
fulfilment: parsed.fulfilment === 'ships' || parsed.fulfilment === 'both' ? parsed.fulfilment : 'pickup',
shippingWho: parsed.shippingWho === 'seller' || parsed.shippingWho === 'split' ? parsed.shippingWho : 'buyer',
requireEscrow: !!parsed.requireEscrow,
category: typeof parsed.category === 'string' ? parsed.category : '',
title: typeof parsed.title === 'string' ? parsed.title : '',
summary: typeof parsed.summary === 'string' ? parsed.summary : '',
description: typeof parsed.description === 'string' ? parsed.description : '',
condition: typeof parsed.condition === 'string' ? parsed.condition : '',
priceType: typeof parsed.priceType === 'string' ? parsed.priceType : 'fixed',
price: typeof parsed.price === 'string' ? parsed.price : '',
currency: typeof parsed.currency === 'string' ? parsed.currency : 'sats',
size: typeof parsed.size === 'string' ? parsed.size : '',
truckHeight: typeof parsed.truckHeight === 'string' ? parsed.truckHeight : '',
wheelHardness: typeof parsed.wheelHardness === 'string' ? parsed.wheelHardness : '',
images: Array.isArray(parsed.images) ? parsed.images.filter((v): v is string => typeof v === 'string') : [],
availability: typeof parsed.availability === 'string' ? parsed.availability : '',
expiration: typeof parsed.expiration === 'number' && parsed.expiration > 0 ? parsed.expiration : 30,
selectedSpotId: typeof parsed.selectedSpotId === 'string' ? parsed.selectedSpotId : null,
};
} catch {
return null;
}
}
export default function CreateListingPage() { export default function CreateListingPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -41,6 +97,9 @@ export default function CreateListingPage() {
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast(); const { toast } = useToast();
// Restore a draft synchronously so the form never mounts empty and then jumps.
const [initialDraft] = useState<ListingDraft | null>(readListingDraft);
const [, setDraft] = useLocalStorage<ListingDraft | null>(LISTING_DRAFT_KEY, null);
// Get spot reference from URL parameter // Get spot reference from URL parameter
const spotParam = searchParams.get('spot'); // Format: "pubkey:identifier" const spotParam = searchParams.get('spot'); // Format: "pubkey:identifier"
@ -64,31 +123,75 @@ export default function CreateListingPage() {
// Where to return after publishing / when going back (e.g. /marketplace). // Where to return after publishing / when going back (e.g. /marketplace).
const returnTo = searchParams.get('returnTo') || ''; const returnTo = searchParams.get('returnTo') || '';
// Form state - initialize from URL params if coming from stash // Form state — URL/deep-link values win, then the local draft, then defaults.
const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling'); const [listingType, setListingType] = useState<'selling' | 'wtb'>(initialDraft?.listingType ?? 'selling');
const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>('pickup'); const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>(initialDraft?.fulfilment ?? 'pickup');
const [shippingWho, setShippingWho] = useState<'buyer' | 'seller' | 'split'>('buyer'); const [shippingWho, setShippingWho] = useState<'buyer' | 'seller' | 'split'>(initialDraft?.shippingWho ?? 'buyer');
const { prefs: escrowPrefs } = useEscrowPrefs(); const { prefs: escrowPrefs } = useEscrowPrefs();
const [requireEscrow, setRequireEscrow] = useState(escrowPrefs.requireEscrow); const [requireEscrow, setRequireEscrow] = useState(initialDraft?.requireEscrow ?? escrowPrefs.requireEscrow);
const [category, setCategory] = useState(searchParams.get('category') || ''); const [category, setCategory] = useState(searchParams.get('category') || initialDraft?.category || '');
const [title, setTitle] = useState(searchParams.get('title') || ''); const [title, setTitle] = useState(searchParams.get('title') || initialDraft?.title || '');
const [description, setDescription] = useState(searchParams.get('description') || ''); const [description, setDescription] = useState(searchParams.get('description') || initialDraft?.description || '');
const [summary, setSummary] = useState(searchParams.get('brand') ? `Brand: ${searchParams.get('brand')}` : ''); const [summary, setSummary] = useState(searchParams.get('brand') ? `Brand: ${searchParams.get('brand')}` : (initialDraft?.summary ?? ''));
const [condition, setCondition] = useState<string>(searchParams.get('condition') || ''); const [condition, setCondition] = useState<string>(searchParams.get('condition') || initialDraft?.condition || '');
const [priceType, setPriceType] = useState<string>(searchParams.get('priceType') || 'fixed'); const [priceType, setPriceType] = useState<string>(searchParams.get('priceType') || initialDraft?.priceType || 'fixed');
const [price, setPrice] = useState(searchParams.get('price') || ''); const [price, setPrice] = useState(searchParams.get('price') || initialDraft?.price || '');
const [currency, setCurrency] = useState(searchParams.get('currency') || 'sats'); // Default sats; deep-links (DIY calculator) can preselect const [currency, setCurrency] = useState(searchParams.get('currency') || initialDraft?.currency || 'sats'); // Default sats; deep-links (DIY calculator) can preselect
const [size, setSize] = useState(searchParams.get('size') || ''); const [size, setSize] = useState(searchParams.get('size') || initialDraft?.size || '');
const [truckHeight, setTruckHeight] = useState(''); const [truckHeight, setTruckHeight] = useState(initialDraft?.truckHeight ?? '');
const [wheelHardness, setWheelHardness] = useState(''); const [wheelHardness, setWheelHardness] = useState(initialDraft?.wheelHardness ?? '');
const [images, setImages] = useState<string[]>(() => { const [images, setImages] = useState<string[]>(() => {
const imagesParam = searchParams.get('images'); const imagesParam = searchParams.get('images');
return imagesParam ? imagesParam.split(',').filter(Boolean) : []; return imagesParam ? imagesParam.split(',').filter(Boolean) : (initialDraft?.images ?? []);
}); });
const [availability, setAvailability] = useState(''); const [availability, setAvailability] = useState(initialDraft?.availability ?? '');
const [expiration, setExpiration] = useState(30); const [expiration, setExpiration] = useState(initialDraft?.expiration ?? 30);
const [selectedSpotId, setSelectedSpotId] = useState<string | null>(spotParam || null); const [selectedSpotId, setSelectedSpotId] = useState<string | null>(spotParam || initialDraft?.selectedSpotId || null);
const [spotSearchQuery, setSpotSearchQuery] = useState(''); const [spotSearchQuery, setSpotSearchQuery] = useState('');
const [showDraftNotice, setShowDraftNotice] = useState(!!initialDraft);
// Keep the form recoverable across refresh/accidental navigation. Do not
// persist the anti-spam fee state; that has to be re-proven per submission.
useEffect(() => {
const hasContent = Boolean(
title.trim() ||
description.trim() ||
summary.trim() ||
images.length ||
category ||
selectedSpotId ||
price.trim(),
);
if (!hasContent) {
setDraft(null);
return;
}
setDraft({
listingType,
fulfilment,
shippingWho,
requireEscrow,
category,
title,
summary,
description,
condition,
priceType,
price,
currency,
size,
truckHeight,
wheelHardness,
images,
availability,
expiration,
selectedSpotId,
});
}, [
listingType, fulfilment, shippingWho, requireEscrow, category, title, summary,
description, condition, priceType, price, currency, size, truckHeight,
wheelHardness, images, availability, expiration, selectedSpotId, setDraft,
]);
// Check if user has NIP-05 verification (or is platform owner who bypasses this) // Check if user has NIP-05 verification (or is platform owner who bypasses this)
const hasNip05 = !!metadata?.nip05 || restrictions.isPlatformOwner; const hasNip05 = !!metadata?.nip05 || restrictions.isPlatformOwner;
@ -168,11 +271,69 @@ export default function CreateListingPage() {
setImages((prev) => prev.filter((_, i) => i !== index)); setImages((prev) => prev.filter((_, i) => i !== index));
}; };
const makeCover = (index: number) => {
setImages((prev) => {
if (index <= 0 || index >= prev.length) return prev;
const next = [...prev];
const [cover] = next.splice(index, 1);
return [cover, ...next];
});
};
const moveImage = (index: number, direction: -1 | 1) => {
setImages((prev) => {
const target = index + direction;
if (target < 0 || target >= prev.length) return prev;
const next = [...prev];
[next[index], next[target]] = [next[target], next[index]];
return next;
});
};
const discardDraft = () => {
setDraft(null);
setShowDraftNotice(false);
setListingType('selling');
setFulfilment('pickup');
setShippingWho('buyer');
setRequireEscrow(escrowPrefs.requireEscrow);
setCategory(searchParams.get('category') || '');
setTitle(searchParams.get('title') || '');
setSummary(searchParams.get('brand') ? `Brand: ${searchParams.get('brand')}` : '');
setDescription(searchParams.get('description') || '');
setCondition(searchParams.get('condition') || '');
setPriceType(searchParams.get('priceType') || 'fixed');
setPrice(searchParams.get('price') || '');
setCurrency(searchParams.get('currency') || 'sats');
setSize(searchParams.get('size') || '');
setTruckHeight('');
setWheelHardness('');
setImages(() => {
const imagesParam = searchParams.get('images');
return imagesParam ? imagesParam.split(',').filter(Boolean) : [];
});
setAvailability('');
setExpiration(30);
setSelectedSpotId(spotParam || null);
setSpotSearchQuery('');
toast({ title: t('marketUi.create.draftDiscarded') });
};
// Get category-specific requirements // Get category-specific requirements
const categoryRequirements = category ? CATEGORY_REQUIRED_FIELDS[category] : null; const categoryRequirements = category ? CATEGORY_REQUIRED_FIELDS[category] : null;
const requiresSize = categoryRequirements?.size && listingType === 'selling'; const requiresSize = categoryRequirements?.size && listingType === 'selling';
const requiresCondition = categoryRequirements?.condition && listingType === 'selling'; const requiresCondition = categoryRequirements?.condition && listingType === 'selling';
const previewCategory = category
? (GEAR_CATEGORY_KEYS[category] ? t(GEAR_CATEGORY_KEYS[category]) : GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES])
: null;
const previewCondition = condition
? (GEAR_CONDITION_KEYS[condition] ? t(GEAR_CONDITION_KEYS[condition]) : GEAR_CONDITIONS[condition]?.label)
: null;
const previewPrice = priceType === 'fixed' && price.trim()
? `${price.trim()} ${currency}`
: (PRICE_TYPE_KEYS[priceType] ? t(PRICE_TYPE_KEYS[priceType]) : PRICE_TYPES[priceType as keyof typeof PRICE_TYPES]?.label);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -333,6 +494,9 @@ export default function CreateListingPage() {
recordListingCreation(setLastListingRecord, selectedSpot?.pubkey); recordListingCreation(setLastListingRecord, selectedSpot?.pubkey);
} }
// The draft is now published; do not offer it again next time.
setDraft(null);
toast({ toast({
title: listingType === 'wtb' ? t('marketUi.create.wtbPosted') : t('marketUi.create.listingCreated'), title: listingType === 'wtb' ? t('marketUi.create.wtbPosted') : t('marketUi.create.listingCreated'),
description: listingType === 'wtb' ? t('marketUi.create.wtbPostedBody') : t('marketUi.create.listingCreatedBody'), description: listingType === 'wtb' ? t('marketUi.create.wtbPostedBody') : t('marketUi.create.listingCreatedBody'),
@ -392,6 +556,20 @@ export default function CreateListingPage() {
/> />
</div> </div>
{showDraftNotice && (
<Card className="mb-6 border-2 border-blue-500/40 bg-blue-950/20">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<div className="flex-1">
<h3 className="font-bold text-blue-300">{t('marketUi.create.draftRestored')}</h3>
<p className="text-xs leading-relaxed text-gray-400">{t('marketUi.create.draftRestoredBody')}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={discardDraft} className="border-white/20">
{t('marketUi.create.discardDraft')}
</Button>
</CardContent>
</Card>
)}
{/* Coming from Stash indicator */} {/* Coming from Stash indicator */}
{fromStash && ( {fromStash && (
<Card className="mb-6 bg-purple-950/30 border-2 border-purple-500/50"> <Card className="mb-6 bg-purple-950/30 border-2 border-purple-500/50">
@ -915,18 +1093,65 @@ export default function CreateListingPage() {
key={index} key={index}
className="relative aspect-square rounded-lg overflow-hidden bg-gray-800" className="relative aspect-square rounded-lg overflow-hidden bg-gray-800"
> >
<img <ImageWithFallback
src={imageUrl} src={imageUrl}
alt={t('marketUi.create.productPhoto', { n: index + 1 })} alt={t('marketUi.create.productPhoto', { n: index + 1 })}
className="w-full h-full object-cover" className="h-full w-full object-cover"
/> />
{index === 0 && (
<Badge className="absolute left-2 top-2 gap-1 border-0 bg-orange-500 text-white">
<Star className="h-3 w-3" /> {t('marketUi.create.cover')}
</Badge>
)}
<button <button
type="button" type="button"
onClick={() => removeImage(index)} onClick={() => removeImage(index)}
className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500" aria-label={t('marketUi.create.removePhoto', { n: index + 1 })}
className="absolute right-2 top-2 rounded-full bg-black/80 p-1 hover:bg-red-500"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between gap-1">
{index > 0 ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 bg-black/75 text-white hover:bg-black"
onClick={() => makeCover(index)}
aria-label={t('marketUi.create.makeCover')}
title={t('marketUi.create.makeCover')}
>
<Star className="h-3.5 w-3.5" />
</Button>
) : <span />}
<div className="flex gap-1">
{index > 0 && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 bg-black/75 text-white hover:bg-black"
onClick={() => moveImage(index, -1)}
aria-label={t('marketUi.create.movePhotoLeft')}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
)}
{index < images.length - 1 && (
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 bg-black/75 text-white hover:bg-black"
onClick={() => moveImage(index, 1)}
aria-label={t('marketUi.create.movePhotoRight')}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div> </div>
))} ))}
{images.length < 5 && ( {images.length < 5 && (
@ -1078,6 +1303,54 @@ export default function CreateListingPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Live preview — deliberately lightweight: no publish, no duplicate
event state. It catches missing/ugly listing content before submit. */}
<Card className="mb-6 bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle>{t('marketUi.create.previewTitle')}</CardTitle>
<CardDescription className="text-gray-400">{t('marketUi.create.previewNote')}</CardDescription>
</CardHeader>
<CardContent>
{!title.trim() && !description.trim() && images.length === 0 ? (
<p className="text-sm text-gray-500">{t('marketUi.create.previewEmpty')}</p>
) : (
<div className="space-y-3">
<div className="flex gap-3">
{images[0] ? (
<ImageWithFallback
src={images[0]}
alt={title || t('marketUi.create.productPhoto', { n: 1 })}
className="h-24 w-24 shrink-0 rounded-lg border border-white/10 object-cover"
/>
) : (
<div className="flex h-24 w-24 shrink-0 items-center justify-center rounded-lg border border-dashed border-gray-700 bg-gray-800 text-3xl">
{listingType === 'wtb' ? '🔍' : '🛹'}
</div>
)}
<div className="min-w-0">
<h3 className="line-clamp-2 font-black uppercase text-orange-400">
{title.trim() || '—'}
</h3>
<div className="mt-1 flex flex-wrap gap-1.5">
{previewCategory && <Badge variant="outline" className="border-gray-700 text-[10px] text-gray-300">{previewCategory}</Badge>}
{previewCondition && <Badge variant="outline" className="border-gray-700 text-[10px] text-gray-300">{previewCondition}</Badge>}
{size && <Badge variant="outline" className="border-gray-700 text-[10px] text-gray-300">{size}</Badge>}
</div>
{previewPrice && <p className="mt-2 font-bold text-white">{previewPrice}</p>}
{summary.trim() && <p className="mt-1 line-clamp-2 text-xs text-gray-400">{summary}</p>}
</div>
</div>
{description.trim() && (
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-gray-300">{description}</p>
)}
{images.length > 1 && (
<p className="text-[10px] text-gray-500">{t('marketUi.create.photosNote')}</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -11,6 +11,7 @@ import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { useMarketplaceItems } from '@/hooks/useMarketplace'; import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
@ -22,12 +23,18 @@ import { GEAR_CATEGORIES } from '@/lib/types';
import { GEAR_CATEGORY_KEYS } from '@/lib/labelKeys'; import { GEAR_CATEGORY_KEYS } from '@/lib/labelKeys';
import { getLastLocation } from '@/lib/lastLocation'; import { getLastLocation } from '@/lib/lastLocation';
import { itemDistanceKm } from '@/lib/sortOptions'; import { itemDistanceKm } from '@/lib/sortOptions';
import { useBtcRates } from '@/hooks/useExchangeRate';
import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle, MapPin, Bookmark, X } from 'lucide-react'; import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle, MapPin, Bookmark, X } from 'lucide-react';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
type SortOption = 'newest' | 'price_low' | 'price_high' | 'trust' | 'distance'; type SortOption = 'newest' | 'price_low' | 'price_high' | 'trust' | 'distance';
type TrustFilter = 'all' | 'verified' | 'trusted' | 'following'; type TrustFilter = 'all' | 'verified' | 'trusted' | 'following';
type FulfilmentFilter = 'all' | 'pickup' | 'ships';
const SORT_OPTIONS: SortOption[] = ['newest', 'price_low', 'price_high', 'trust', 'distance'];
const TRUST_OPTIONS: TrustFilter[] = ['all', 'verified', 'trusted', 'following'];
const FULFILMENT_OPTIONS: FulfilmentFilter[] = ['all', 'pickup', 'ships'];
interface SavedSearch { interface SavedSearch {
id: string; id: string;
@ -49,21 +56,68 @@ export default function GearSwapPage() {
}); });
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { data: items, isLoading, error, refetch } = useMarketplaceItems(200); const [fetchLimit, setFetchLimit] = useState(200);
const { data: items, isLoading, error, refetch } = useMarketplaceItems(fetchLimit);
const { data: followList } = useFollowList(user?.pubkey); const { data: followList } = useFollowList(user?.pubkey);
const { data: btcRates } = useBtcRates();
// Filter state // Filters live in the URL so refresh, Back/Forward, deep links and saved
const [searchQuery, setSearchQuery] = useState(''); // searches all preserve the buyer's context.
const [selectedCategory, setSelectedCategory] = useState<string>('all'); const [searchParams, setSearchParams] = useSearchParams();
const [sortBy, setSortBy] = useState<SortOption>('newest'); const [filtersOpen, setFiltersOpen] = useState(false);
const [fulfilmentFilter, setFulfilmentFilter] = useState<'all' | 'pickup' | 'ships'>('all');
const [trustFilter, setTrustFilter] = useState<TrustFilter>('all');
const [showOnlyWTB, setShowOnlyWTB] = useState(false);
const [showOnlySelling, setShowOnlySelling] = useState(false);
const [savedSearches, setSavedSearches] = useLocalStorage<SavedSearch[]>('skatespots:gear-saved-searches', []); const [savedSearches, setSavedSearches] = useLocalStorage<SavedSearch[]>('skatespots:gear-saved-searches', []);
const [saveSearchOpen, setSaveSearchOpen] = useState(false); const [saveSearchOpen, setSaveSearchOpen] = useState(false);
const [saveSearchName, setSaveSearchName] = useState(''); const [saveSearchName, setSaveSearchName] = useState('');
const searchQuery = searchParams.get('q') ?? '';
const selectedCategory = searchParams.get('category') ?? 'all';
const effectiveCategory = selectedCategory === 'all' || Object.prototype.hasOwnProperty.call(GEAR_CATEGORIES, selectedCategory)
? selectedCategory
: 'all';
const sortParam = searchParams.get('sort');
const sortBy: SortOption = SORT_OPTIONS.includes(sortParam as SortOption)
? (sortParam as SortOption)
: 'newest';
const fulfilmentParam = searchParams.get('fulfilment');
const fulfilmentFilter: FulfilmentFilter = FULFILMENT_OPTIONS.includes(fulfilmentParam as FulfilmentFilter)
? (fulfilmentParam as FulfilmentFilter)
: 'all';
const trustParam = searchParams.get('trust');
const trustFilter: TrustFilter = TRUST_OPTIONS.includes(trustParam as TrustFilter)
? (trustParam as TrustFilter)
: 'all';
const listingType = searchParams.get('type');
const showOnlyWTB = listingType === 'wtb';
const showOnlySelling = listingType === 'selling';
const setFilterParams = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(updates)) {
if (!value || (key !== 'q' && value === 'all') || (key === 'sort' && value === 'newest')) {
next.delete(key);
} else {
next.set(key, value);
}
}
setSearchParams(next, { replace: true });
};
const setSearchQuery = (value: string) => setFilterParams({ q: value });
const setSelectedCategory = (value: string) => setFilterParams({ category: value });
const setSortBy = (value: SortOption) => setFilterParams({ sort: value });
const setFulfilmentFilter = (value: FulfilmentFilter) => setFilterParams({ fulfilment: value });
const setTrustFilter = (value: TrustFilter) => setFilterParams({ trust: value });
const toggleSelling = () => setFilterParams({ type: showOnlySelling ? null : 'selling' });
const toggleWTB = () => setFilterParams({ type: showOnlyWTB ? null : 'wtb' });
const clearFilters = () => setFilterParams({
q: null,
category: null,
sort: null,
fulfilment: null,
trust: null,
type: null,
});
// Get unique seller pubkeys for batch queries // Get unique seller pubkeys for batch queries
const sellerPubkeys = useMemo(() => { const sellerPubkeys = useMemo(() => {
if (!items) return []; if (!items) return [];
@ -111,9 +165,9 @@ export default function GearSwapPage() {
} }
// Category filter // Category filter
if (selectedCategory !== 'all') { if (effectiveCategory !== 'all') {
filtered = filtered.filter(item => filtered = filtered.filter(item =>
item.category?.includes(selectedCategory) item.category?.includes(effectiveCategory)
); );
} }
@ -150,19 +204,37 @@ export default function GearSwapPage() {
}); });
} }
// Sort // Sort. Prices are normalized to sats before comparing: "100 USD" and
// "100,000 sats" must not be ordered as raw numbers. Unknown currencies or
// missing rates sort last instead of pretending to be zero.
const priceToSats = (item: (typeof filtered)[number]): number | null => {
const amount = Number(item.price?.amount);
if (!Number.isFinite(amount) || amount <= 0) return null;
const currency = (item.price?.currency || 'USD').toUpperCase();
if (currency === 'SAT' || currency === 'SATS') return amount;
if (currency === 'BTC') return amount * 100_000_000;
const rate = btcRates?.[currency];
return rate && rate > 0 ? (amount / rate) * 100_000_000 : null;
};
switch (sortBy) { switch (sortBy) {
case 'price_low': case 'price_low':
filtered.sort((a, b) => { filtered.sort((a, b) => {
const aPrice = a.price?.amount ? parseFloat(a.price.amount) : Infinity; const aPrice = priceToSats(a);
const bPrice = b.price?.amount ? parseFloat(b.price.amount) : Infinity; const bPrice = priceToSats(b);
if (aPrice == null && bPrice == null) return b.createdAt - a.createdAt;
if (aPrice == null) return 1;
if (bPrice == null) return -1;
return aPrice - bPrice; return aPrice - bPrice;
}); });
break; break;
case 'price_high': case 'price_high':
filtered.sort((a, b) => { filtered.sort((a, b) => {
const aPrice = a.price?.amount ? parseFloat(a.price.amount) : 0; const aPrice = priceToSats(a);
const bPrice = b.price?.amount ? parseFloat(b.price.amount) : 0; const bPrice = priceToSats(b);
if (aPrice == null && bPrice == null) return b.createdAt - a.createdAt;
if (aPrice == null) return 1;
if (bPrice == null) return -1;
return bPrice - aPrice; return bPrice - aPrice;
}); });
break; break;
@ -198,16 +270,22 @@ export default function GearSwapPage() {
} }
return filtered; return filtered;
}, [items, searchQuery, selectedCategory, sortBy, trustFilter, showOnlyWTB, showOnlySelling, fulfilmentFilter, trustScores, followingSet]); }, [items, searchQuery, effectiveCategory, sortBy, trustFilter, showOnlyWTB, showOnlySelling, fulfilmentFilter, trustScores, followingSet, btcRates]);
const applySavedSearch = (saved: SavedSearch) => { const applySavedSearch = (saved: SavedSearch) => {
setSearchQuery(saved.query); const next = new URLSearchParams(searchParams);
setSelectedCategory(saved.category); const put = (key: string, value: string | null) => {
setSortBy(saved.sort); if (!value || (key !== 'q' && value === 'all')) next.delete(key);
setFulfilmentFilter(saved.fulfilment); else next.set(key, value);
setTrustFilter(saved.trust); };
setShowOnlyWTB(saved.wtb); put('q', saved.query);
setShowOnlySelling(saved.selling); put('category', saved.category);
put('sort', saved.sort === 'newest' ? null : saved.sort);
put('fulfilment', saved.fulfilment);
put('trust', saved.trust);
put('type', saved.wtb ? 'wtb' : saved.selling ? 'selling' : null);
setSearchParams(next, { replace: true });
setFiltersOpen(false);
}; };
const saveCurrentSearch = () => { const saveCurrentSearch = () => {
@ -243,6 +321,203 @@ export default function GearSwapPage() {
})), })),
]; ];
const trustLabels: Record<Exclude<TrustFilter, 'all'>, string> = {
verified: t('marketUi.gearSwap.verifiedOnly'),
trusted: t('marketUi.gearSwap.trustedPlus'),
following: t('marketUi.gearSwap.followingOnly'),
};
const categoryLabel = categoryOptions.find((option) => option.value === effectiveCategory)?.label;
const activeFilters: Array<{ key: string; label: string; onRemove: () => void }> = [];
if (searchQuery.trim()) {
activeFilters.push({
key: 'q',
label: `${t('common.search')}: ${searchQuery.trim()}`,
onRemove: () => setSearchQuery(''),
});
}
if (effectiveCategory !== 'all' && categoryLabel) {
activeFilters.push({ key: 'category', label: categoryLabel, onRemove: () => setSelectedCategory('all') });
}
if (showOnlySelling) {
activeFilters.push({
key: 'type',
label: t('marketUi.gearSwap.forSale'),
onRemove: () => setFilterParams({ type: null }),
});
} else if (showOnlyWTB) {
activeFilters.push({
key: 'type',
label: t('marketUi.gearSwap.wtb'),
onRemove: () => setFilterParams({ type: null }),
});
}
if (fulfilmentFilter !== 'all') {
activeFilters.push({
key: 'fulfilment',
label: fulfilmentFilter === 'pickup'
? t('marketUi.gearSwap.localPickup')
: t('marketUi.gearSwap.ships'),
onRemove: () => setFulfilmentFilter('all'),
});
}
if (trustFilter !== 'all') {
activeFilters.push({
key: 'trust',
label: trustLabels[trustFilter],
onRemove: () => setTrustFilter('all'),
});
}
const hasActiveFilters = activeFilters.length > 0;
const filterControls = (
<>
{/* Category Pills */}
<div className="mb-4 flex flex-wrap gap-2">
{categoryOptions.map(({ value, label }) => (
<Button
key={value}
variant={effectiveCategory === value ? 'default' : 'outline'}
size="sm"
aria-pressed={effectiveCategory === value}
className={
effectiveCategory === value
? 'min-h-9 bg-orange-500 hover:bg-orange-600 text-white'
: 'min-h-9 border-gray-600 hover:bg-gray-800'
}
onClick={() => setSelectedCategory(value)}
>
{label}
</Button>
))}
</div>
{/* Filter Row */}
<div className="flex flex-wrap items-center gap-3">
{/* Listing Type */}
<div className="flex items-center gap-2">
<Button
variant={showOnlySelling ? 'default' : 'outline'}
size="sm"
aria-pressed={showOnlySelling}
onClick={toggleSelling}
className={
showOnlySelling
? 'min-h-9 bg-orange-500/80 hover:bg-orange-500'
: 'min-h-9 border-gray-600 text-gray-400 hover:bg-gray-800'
}
>
{t('marketUi.gearSwap.forSale')}
</Button>
<Button
variant={showOnlyWTB ? 'default' : 'outline'}
size="sm"
aria-pressed={showOnlyWTB}
onClick={toggleWTB}
className={
showOnlyWTB
? 'min-h-9 bg-blue-500/80 hover:bg-blue-500'
: 'min-h-9 border-gray-600 text-gray-400 hover:bg-gray-800'
}
>
<Search className="h-3 w-3 mr-1" />
{t('marketUi.gearSwap.wtb')}
</Button>
</div>
<div className="border-l border-gray-700 h-6 mx-1" />
{/* Trust Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-9 gap-2 border-gray-600 text-gray-300 hover:bg-gray-800"
>
<ShieldCheck className="h-4 w-4" />
{t('marketUi.gearSwap.trust')}
{trustFilter !== 'all' && (
<Badge className="ml-1 bg-orange-500/20 text-orange-400 text-xs">
{trustFilter}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-[2200] w-48">
<DropdownMenuLabel>{t('marketUi.gearSwap.filterByTrust')}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={trustFilter === 'all'}
onCheckedChange={() => setTrustFilter('all')}
>
<Users className="h-4 w-4 mr-2" />
{t('marketUi.gearSwap.allSellers')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustFilter === 'verified'}
onCheckedChange={() => setTrustFilter('verified')}
>
<UserCheck className="h-4 w-4 mr-2 text-green-400" />
{t('marketUi.gearSwap.verifiedOnly')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustFilter === 'trusted'}
onCheckedChange={() => setTrustFilter('trusted')}
>
<ShieldCheck className="h-4 w-4 mr-2 text-blue-400" />
{t('marketUi.gearSwap.trustedPlus')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustFilter === 'following'}
onCheckedChange={() => setTrustFilter('following')}
disabled={!user}
>
<Users className="h-4 w-4 mr-2 text-purple-400" />
{t('marketUi.gearSwap.followingOnly')}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
{/* Fulfilment */}
<Select
value={fulfilmentFilter}
onValueChange={(v) => setFulfilmentFilter(v as FulfilmentFilter)}
>
<SelectTrigger className="h-10 w-40 bg-gray-900 border-gray-700" aria-label={t('marketUi.gearSwap.pickupOrShip')}>
<MapPin className="h-4 w-4 mr-2" />
<SelectValue placeholder={t('marketUi.gearSwap.pickupOrShip')} />
</SelectTrigger>
<SelectContent className="z-[2200]">
<SelectItem value="all">{t('marketUi.gearSwap.pickupOrShip')}</SelectItem>
<SelectItem value="pickup">📍 {t('marketUi.gearSwap.localPickup')}</SelectItem>
<SelectItem value="ships">📦 {t('marketUi.gearSwap.ships')}</SelectItem>
</SelectContent>
</Select>
{/* Sort */}
<Select
value={sortBy}
onValueChange={(v) => setSortBy(v as SortOption)}
>
<SelectTrigger className="h-10 w-40 bg-gray-900 border-gray-700" aria-label={t('marketUi.gearSwap.sortBy')}>
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectValue placeholder={t('marketUi.gearSwap.sortBy')} />
</SelectTrigger>
<SelectContent className="z-[2200]">
<SelectItem value="newest">{t('marketUi.gearSwap.newest')}</SelectItem>
<SelectItem value="price_low">{t('marketUi.gearSwap.priceLowHigh')}</SelectItem>
<SelectItem value="price_high">{t('marketUi.gearSwap.priceHighLow')}</SelectItem>
<SelectItem value="trust">{t('marketUi.gearSwap.trustScore')}</SelectItem>
<SelectItem value="distance">{t('marketUi.gearSwap.nearest')}</SelectItem>
</SelectContent>
</Select>
</div>
</>
);
return ( return (
<> <>
<div className="container px-4 py-8 max-w-7xl"> <div className="container px-4 py-8 max-w-7xl">
@ -288,162 +563,94 @@ export default function GearSwapPage() {
<div className="relative mb-4"> <div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500" />
<Input <Input
aria-label={t('common.search')}
placeholder={t('marketUi.gearSwap.searchPlaceholder')} placeholder={t('marketUi.gearSwap.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-gray-900 border-gray-700 h-12 text-lg" className="h-12 bg-gray-900 border-gray-700 pl-10 pr-11 text-lg"
/> />
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
aria-label={t('common.clear')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-2 text-gray-500 hover:bg-white/5 hover:text-white"
>
<X className="h-4 w-4" />
</button>
)}
</div> </div>
{/* Category Pills */} {/* Mobile filters: keep the full category wall out of the feed. */}
<div className="flex flex-wrap gap-2 mb-4"> <div className="mb-3 md:hidden">
{categoryOptions.map(({ value, label }) => ( <Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
<Button <SheetTrigger asChild>
key={value} <Button variant="outline" className="min-h-10 w-full justify-between border-gray-600">
variant={selectedCategory === value ? 'default' : 'outline'} <span className="flex items-center gap-2">
size="sm" <Filter className="h-4 w-4" />
className={ {t('common.filter')}
selectedCategory === value </span>
? 'bg-orange-500 hover:bg-orange-600 text-white' {activeFilters.length > 0 && (
: 'border-gray-600 hover:bg-gray-800' <Badge className="bg-orange-500/20 text-orange-300">{activeFilters.length}</Badge>
}
onClick={() => setSelectedCategory(value)}
>
{label}
</Button>
))}
</div>
{/* Filter Row */}
<div className="flex flex-wrap items-center gap-3">
{/* Condition / Listing Type */}
<div className="flex items-center gap-2">
<Button
variant={showOnlySelling ? 'default' : 'outline'}
size="sm"
onClick={() => {
setShowOnlySelling(!showOnlySelling);
if (!showOnlySelling) setShowOnlyWTB(false);
}}
className={
showOnlySelling
? 'bg-orange-500/80 hover:bg-orange-500'
: 'border-gray-600 text-gray-400 hover:bg-gray-800'
}
>
{t('marketUi.gearSwap.forSale')}
</Button>
<Button
variant={showOnlyWTB ? 'default' : 'outline'}
size="sm"
onClick={() => {
setShowOnlyWTB(!showOnlyWTB);
if (!showOnlyWTB) setShowOnlySelling(false);
}}
className={
showOnlyWTB
? 'bg-blue-500/80 hover:bg-blue-500'
: 'border-gray-600 text-gray-400 hover:bg-gray-800'
}
>
<Search className="h-3 w-3 mr-1" />
{t('marketUi.gearSwap.wtb')}
</Button>
</div>
<div className="border-l border-gray-700 h-6 mx-1" />
{/* Trust Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-gray-600 text-gray-300 hover:bg-gray-800"
>
<ShieldCheck className="h-4 w-4" />
{t('marketUi.gearSwap.trust')}
{trustFilter !== 'all' && (
<Badge className="ml-1 bg-orange-500/20 text-orange-400 text-xs">
{trustFilter}
</Badge>
)} )}
</Button> </Button>
</DropdownMenuTrigger> </SheetTrigger>
<DropdownMenuContent className="w-48"> <SheetContent
<DropdownMenuLabel>{t('marketUi.gearSwap.filterByTrust')}</DropdownMenuLabel> side="bottom"
<DropdownMenuSeparator /> className="max-h-[85dvh] overflow-y-auto border-orange-500/30 bg-black pb-[max(1rem,env(safe-area-inset-bottom))]"
<DropdownMenuCheckboxItem
checked={trustFilter === 'all'}
onCheckedChange={() => setTrustFilter('all')}
> >
<Users className="h-4 w-4 mr-2" /> <SheetHeader className="text-left">
{t('marketUi.gearSwap.allSellers')} <SheetTitle className="flex items-center gap-2 text-white">
</DropdownMenuCheckboxItem> <Filter className="h-4 w-4 text-orange-400" />
<DropdownMenuCheckboxItem {t('common.filter')}
checked={trustFilter === 'verified'} </SheetTitle>
onCheckedChange={() => setTrustFilter('verified')} <SheetDescription>{t('marketUi.gearSwap.subtitle')}</SheetDescription>
</SheetHeader>
<div className="py-2">{filterControls}</div>
<SheetFooter className="flex-row gap-2 border-t border-white/10 pt-3">
<Button variant="outline" className="flex-1 border-white/15" onClick={clearFilters}>
{t('common.clearAll')}
</Button>
<Button
className="flex-1 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
onClick={() => setFiltersOpen(false)}
> >
<UserCheck className="h-4 w-4 mr-2 text-green-400" /> {t('marketUi.gearSwap.listingsFound', { count: filteredItems.length })}
{t('marketUi.gearSwap.verifiedOnly')} </Button>
</DropdownMenuCheckboxItem> </SheetFooter>
<DropdownMenuCheckboxItem </SheetContent>
checked={trustFilter === 'trusted'} </Sheet>
onCheckedChange={() => setTrustFilter('trusted')}
>
<ShieldCheck className="h-4 w-4 mr-2 text-blue-400" />
{t('marketUi.gearSwap.trustedPlus')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustFilter === 'following'}
onCheckedChange={() => setTrustFilter('following')}
disabled={!user}
>
<Users className="h-4 w-4 mr-2 text-purple-400" />
{t('marketUi.gearSwap.followingOnly')}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
{/* Fulfilment */}
<Select
value={fulfilmentFilter}
onValueChange={(v) => setFulfilmentFilter(v as 'all' | 'pickup' | 'ships')}
>
<SelectTrigger className="w-40 bg-gray-900 border-gray-700">
<MapPin className="h-4 w-4 mr-2" />
<SelectValue placeholder={t('marketUi.gearSwap.pickupOrShip')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('marketUi.gearSwap.pickupOrShip')}</SelectItem>
<SelectItem value="pickup">📍 {t('marketUi.gearSwap.localPickup')}</SelectItem>
<SelectItem value="ships">📦 {t('marketUi.gearSwap.ships')}</SelectItem>
</SelectContent>
</Select>
{/* Sort */}
<Select
value={sortBy}
onValueChange={(v) => setSortBy(v as SortOption)}
>
<SelectTrigger className="w-40 bg-gray-900 border-gray-700">
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectValue placeholder={t('marketUi.gearSwap.sortBy')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">{t('marketUi.gearSwap.newest')}</SelectItem>
<SelectItem value="price_low">{t('marketUi.gearSwap.priceLowHigh')}</SelectItem>
<SelectItem value="price_high">{t('marketUi.gearSwap.priceHighLow')}</SelectItem>
<SelectItem value="trust">{t('marketUi.gearSwap.trustScore')}</SelectItem>
<SelectItem value="distance">{t('marketUi.gearSwap.nearest')}</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="hidden md:block">{filterControls}</div>
</CardContent> </CardContent>
</Card> </Card>
{activeFilters.length > 0 && (
<div className="mb-4 flex flex-wrap items-center gap-2" aria-label={t('marketUi.gearSwap.activeFilters')}>
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">
{t('marketUi.gearSwap.activeFilters')}
</span>
{activeFilters.map((filter) => (
<button
key={filter.key}
type="button"
onClick={filter.onRemove}
className="min-h-10 max-w-full truncate rounded-full border border-orange-500/30 bg-orange-500/10 px-2.5 py-1 text-xs font-bold text-orange-200 hover:bg-orange-500/15"
>
{filter.label} <X className="ml-1 inline h-3 w-3 align-[-1px]" />
</button>
))}
<button
type="button"
onClick={clearFilters}
className="min-h-10 px-1 text-xs font-bold text-gray-400 underline underline-offset-2 hover:text-white"
>
{t('common.clearAll')}
</button>
</div>
)}
{savedSearches.length > 0 && ( {savedSearches.length > 0 && (
<div className="mb-4 flex flex-wrap items-center gap-2"> <div className="mb-4 flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-gray-500"> <span className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-gray-500">
@ -463,7 +670,7 @@ export default function GearSwapPage() {
onClick={() => setSavedSearches((current) => current.filter((item) => item.id !== saved.id))} onClick={() => setSavedSearches((current) => current.filter((item) => item.id !== saved.id))}
aria-label={t('marketplaceExtrasUi.deleteSearch')} aria-label={t('marketplaceExtrasUi.deleteSearch')}
title={t('marketplaceExtrasUi.deleteSearch')} title={t('marketplaceExtrasUi.deleteSearch')}
className="min-h-0 px-1.5 py-1 text-orange-300/60 hover:bg-red-500/20 hover:text-red-300" className="flex h-10 w-10 items-center justify-center text-orange-300/60 hover:bg-red-500/20 hover:text-red-300"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
@ -475,7 +682,7 @@ export default function GearSwapPage() {
{/* Results Count */} {/* Results Count */}
{!isLoading && ( {!isLoading && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2"> <div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400" role="status" aria-live="polite">
{filteredItems.length === 1 {filteredItems.length === 1
? t('marketUi.gearSwap.listingFound', { count: filteredItems.length }) ? t('marketUi.gearSwap.listingFound', { count: filteredItems.length })
: t('marketUi.gearSwap.listingsFound', { count: filteredItems.length })} : t('marketUi.gearSwap.listingsFound', { count: filteredItems.length })}
@ -495,6 +702,12 @@ export default function GearSwapPage() {
</div> </div>
)} )}
{(sortBy === 'price_low' || sortBy === 'price_high') && (
<p className="mb-3 text-[11px] leading-relaxed text-gray-500">
{t('marketUi.gearSwap.priceRateNote')}
</p>
)}
{/* Loading State */} {/* Loading State */}
{isLoading && ( {isLoading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@ -535,20 +748,16 @@ export default function GearSwapPage() {
<ShoppingCart className="h-16 w-16 mx-auto mb-4 text-gray-600" /> <ShoppingCart className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<h3 className="text-xl font-bold text-white mb-2">{t('marketUi.gearSwap.noneTitle')}</h3> <h3 className="text-xl font-bold text-white mb-2">{t('marketUi.gearSwap.noneTitle')}</h3>
<p className="text-gray-400 mb-6 max-w-md mx-auto"> <p className="text-gray-400 mb-6 max-w-md mx-auto">
{searchQuery || selectedCategory !== 'all' || trustFilter !== 'all' {hasActiveFilters
? t('marketUi.gearSwap.noneFiltered') ? t('marketUi.gearSwap.noneFiltered')
: t('marketUi.gearSwap.noneFirst')} : t('marketUi.gearSwap.noneFirst')}
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3"> <div className="flex flex-col sm:flex-row items-center justify-center gap-3">
{(searchQuery || selectedCategory !== 'all' || trustFilter !== 'all') && ( {hasActiveFilters && (
<Button <Button
variant="outline" variant="outline"
className="gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10" className="gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
onClick={() => { onClick={clearFilters}
setSearchQuery('');
setSelectedCategory('all');
setTrustFilter('all');
}}
> >
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
{t('common.clearFilters')} {t('common.clearFilters')}
@ -570,6 +779,7 @@ export default function GearSwapPage() {
{/* Listings Grid */} {/* Listings Grid */}
{!isLoading && !error && filteredItems.length > 0 && ( {!isLoading && !error && filteredItems.length > 0 && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<LazyMount key={item.id} className="h-full"> <LazyMount key={item.id} className="h-full">
@ -582,6 +792,19 @@ export default function GearSwapPage() {
</LazyMount> </LazyMount>
))} ))}
</div> </div>
{items && items.length >= fetchLimit && (
<div className="mt-6 flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setFetchLimit((current) => current + 200)}
className="gap-2 border-orange-500/40 text-orange-300 hover:bg-orange-500/10"
>
{t('common.showMore')}
</Button>
</div>
)}
</>
)} )}
</div> </div>

View file

@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox'; import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
import { TrustBadge } from '@/components/TrustBadge'; import { TrustBadge } from '@/components/TrustBadge';
@ -18,6 +19,7 @@ import { ClinkPayButton } from '@/components/ClinkPayButton';
import { OfferDialog } from '@/components/OfferDialog'; import { OfferDialog } from '@/components/OfferDialog';
import { MarkSoldDialog } from '@/components/MarkSoldDialog'; import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { ReportDialog } from '@/components/ReportDialog';
import { useMarketplaceItem } from '@/hooks/useMarketplace'; import { useMarketplaceItem } from '@/hooks/useMarketplace';
import { useMarkListingSold } from '@/hooks/useMarkListingSold'; import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -42,6 +44,7 @@ import {
Handshake, Handshake,
Share2, Share2,
ShieldAlert, ShieldAlert,
Flag,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -70,6 +73,8 @@ export default function ListingDetailPage() {
const [showMarkSold, setShowMarkSold] = useState(false); const [showMarkSold, setShowMarkSold] = useState(false);
const [showDelete, setShowDelete] = useState(false); const [showDelete, setShowDelete] = useState(false);
const [showOffer, setShowOffer] = useState(false); const [showOffer, setShowOffer] = useState(false);
const [showReport, setShowReport] = useState(false);
const [mobileActionsOpen, setMobileActionsOpen] = useState(false);
useSeoMeta({ useSeoMeta({
title: item ? `${item.title} — SkateSpots Marketplace` : 'Listing — SkateSpots', title: item ? `${item.title} — SkateSpots Marketplace` : 'Listing — SkateSpots',
@ -147,7 +152,7 @@ export default function ListingDetailPage() {
const handleShare = async () => { const handleShare = async () => {
try { try {
await navigator.clipboard.writeText(window.location.href); await navigator.clipboard.writeText(`https://skate-spots.org/listing/${item.pubkey}/${item.identifier}`);
toast({ title: t('marketUi.listingDetail.linkCopied'), description: t('marketUi.listingDetail.linkCopiedBody') }); toast({ title: t('marketUi.listingDetail.linkCopied'), description: t('marketUi.listingDetail.linkCopiedBody') });
} catch { } catch {
toast({ title: t('marketUi.listingDetail.copyFailed'), variant: 'destructive' }); toast({ title: t('marketUi.listingDetail.copyFailed'), variant: 'destructive' });
@ -169,7 +174,7 @@ export default function ListingDetailPage() {
return ( return (
<> <>
<div className="container mx-auto max-w-5xl px-4 py-6"> <div className="container mx-auto max-w-5xl px-4 py-6 pb-28 lg:pb-6">
<button <button
onClick={() => navigate('/marketplace')} onClick={() => navigate('/marketplace')}
className="mb-4 inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors" className="mb-4 inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors"
@ -246,6 +251,20 @@ export default function ListingDetailPage() {
<Tag className="mr-1 h-3 w-3" /> {categoryLabel} <Tag className="mr-1 h-3 w-3" /> {categoryLabel}
</Badge> </Badge>
)} )}
{item.fulfilment && (
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
{item.fulfilment === 'pickup'
? `📍 ${t('marketUi.create.pickup')}`
: item.fulfilment === 'ships'
? `📦 ${t('marketUi.create.ships')}`
: `📍📦 ${t('marketUi.create.both')}`}
</Badge>
)}
{item.shipping && (
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
{t('marketUi.card.postage')}: {item.shipping}
</Badge>
)}
</div> </div>
{/* Description */} {/* Description */}
@ -321,7 +340,7 @@ export default function ListingDetailPage() {
{/* Actions */} {/* Actions */}
{isOwnListing ? ( {isOwnListing ? (
<Card className="bg-black/90 border-white/10"> <Card id="listing-actions" className="bg-black/90 border-white/10">
<CardContent className="space-y-2 p-4"> <CardContent className="space-y-2 p-4">
{isSold ? ( {isSold ? (
<Button <Button
@ -353,7 +372,7 @@ export default function ListingDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<Card className="bg-black/90 border-white/10"> <Card id="listing-actions" className="bg-black/90 border-white/10">
<CardContent className="p-4"> <CardContent className="p-4">
{showSafetyNote ? ( {showSafetyNote ? (
<div className="mb-3 space-y-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3"> <div className="mb-3 space-y-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
@ -397,6 +416,16 @@ export default function ListingDetailPage() {
> >
<Share2 className="h-4 w-4" /> {t('marketUi.listingDetail.shareListing')} <Share2 className="h-4 w-4" /> {t('marketUi.listingDetail.shareListing')}
</Button> </Button>
{user && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowReport(true)}
className="w-full gap-2 text-gray-500 hover:bg-red-500/10 hover:text-red-400"
>
<Flag className="h-4 w-4" /> {t('marketUi.card.report')}
</Button>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -410,6 +439,90 @@ export default function ListingDetailPage() {
</div> </div>
</div> </div>
{/* Mobile sticky CTA: keeps the price and the next step visible while the
listing is scrolled. It jumps to the real actions rather than creating
a second set of payment/offer state. */}
<div className="fixed inset-x-0 bottom-16 z-40 border-t border-white/10 bg-black/95 px-3 py-2 backdrop-blur md:bottom-0 lg:hidden">
<div className="mx-auto flex max-w-5xl items-center gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-base font-black text-orange-400">{priceDisplay}</p>
<p className="truncate text-[10px] uppercase tracking-wide text-gray-500">
{isSold ? t('marketplace.sold') : isWTB ? t('marketUi.gearSwap.wtb') : t('marketUi.listingDetail.viewOptions')}
</p>
</div>
<Button
className="shrink-0 bg-orange-500 font-bold text-black hover:bg-orange-400"
onClick={() => {
if (isOwnListing) {
document.getElementById('listing-actions')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
setMobileActionsOpen(true);
}
}}
>
{isOwnListing ? t('marketUi.listingDetail.manageListing') : t('marketUi.listingDetail.viewOptions')}
</Button>
</div>
</div>
{!isOwnListing && (
<Sheet open={mobileActionsOpen} onOpenChange={setMobileActionsOpen}>
<SheetContent
side="bottom"
className="max-h-[85dvh] overflow-y-auto border-orange-500/30 bg-black pb-[max(1rem,env(safe-area-inset-bottom))]"
>
<SheetHeader className="text-left">
<SheetTitle className="flex items-center gap-2 text-white">
<Handshake className="h-4 w-4 text-orange-400" />
{t('marketUi.listingDetail.viewOptions')}
</SheetTitle>
<SheetDescription>{item.title}</SheetDescription>
</SheetHeader>
<div className="space-y-2 py-2">
{showSafetyNote ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-200/90">
{isNewSeller ? t('marketUi.card.newSeller') : t('marketTerms.highValueTitle')} — {t('marketTerms.safetyBody')}
</div>
) : null}
<ListingTradeActions item={item} />
{isValidNoffer(sellerNoffer) && (
<ClinkPayButton
noffer={sellerNoffer}
recipientName={displayName}
className="w-full justify-center"
/>
)}
{user && item.priceType !== 'fixed' && (
<Button
variant="outline"
size="sm"
onClick={() => {
setMobileActionsOpen(false);
setShowOffer(true);
}}
className="w-full gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
>
<Handshake className="h-4 w-4" /> {t('marketUi.listingDetail.makeOffer')}
</Button>
)}
{user && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setMobileActionsOpen(false);
setShowReport(true);
}}
className="w-full gap-2 text-gray-400 hover:bg-red-500/10 hover:text-red-400"
>
<Flag className="h-4 w-4" /> {t('marketUi.card.report')}
</Button>
)}
</div>
</SheetContent>
</Sheet>
)}
<ImageLightbox <ImageLightbox
images={lightboxImages} images={lightboxImages}
index={lightboxIndex} index={lightboxIndex}
@ -446,6 +559,17 @@ export default function ListingDetailPage() {
redirectTo="/marketplace" redirectTo="/marketplace"
/> />
)} )}
{!isOwnListing && (
<ReportDialog
open={showReport}
onOpenChange={setShowReport}
targetType="listing"
targetPubkey={item.pubkey}
targetEventId={item.id}
targetTitle={item.title}
/>
)}
</> </>
); );
} }

View file

@ -52,12 +52,12 @@ export default function MarketplaceHubPage() {
onClick={() => setTab(id)} onClick={() => setTab(id)}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
'flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wide transition-colors', 'flex min-h-10 shrink-0 items-center gap-1 rounded-lg px-2.5 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-colors sm:gap-1.5 sm:px-3 sm:text-xs',
active ? 'bg-[#ccff00] text-black' : 'text-gray-400 hover:bg-white/5 hover:text-white', active ? 'bg-[#ccff00] text-black' : 'text-gray-400 hover:bg-white/5 hover:text-white',
)} )}
> >
<Icon className="h-3.5 w-3.5" /> <Icon className="hidden h-3.5 w-3.5 sm:block" />
{t(labelKey)} <span className="whitespace-nowrap">{t(labelKey)}</span>
</button> </button>
); );
})} })}