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

View file

@ -1,10 +1,11 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -52,6 +53,13 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
const [showMarkSold, setShowMarkSold] = useState(false);
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 isAdmin = isAdminPubkey(user?.pubkey);
@ -127,21 +135,28 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
return (
<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
? '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)]'
}`}
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 */}
<div className="relative aspect-square overflow-hidden rounded-t-lg">
{primaryImage ? (
<img
<ImageWithFallback
src={primaryImage}
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
@ -198,7 +213,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
{/* More options menu — owners manage their listing (mark sold /
relist / delete), non-owners can report, admins can take down. */}
{user && (
<div className="absolute bottom-2 right-2 z-20">
<div className="pointer-events-auto absolute bottom-2 right-2 z-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@ -358,7 +373,8 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<TooltipTrigger asChild>
<Badge
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 === 'trusted' && <ShieldCheck className="h-3 w-3" />}
@ -378,7 +394,10 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<TooltipProvider>
<Tooltip>
<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 ? (
<span className="text-sm">{sellerRating.display}</span>
) : (
@ -403,14 +422,14 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<Button
variant="outline"
size="sm"
className={`w-full mt-3 gap-2 ${
className={`pointer-events-auto mt-3 w-full gap-2 ${
isWTB
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
}`}
onClick={(e) => {
e.stopPropagation();
navigate(`/messages/${npub}`);
contactSeller();
}}
>
<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 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: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
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-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" />
{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.',
noneFirst: 'Be the first to list your gear — decks, trucks, wheels, shoes, anything skate!',
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 →',
},
hub: {
@ -1532,6 +1534,8 @@ marketUi: {
deleteListing: 'Delete listing',
makeOffer: 'Make an offer',
shareListing: 'Share listing',
viewOptions: 'View options',
manageListing: 'Manage listing',
unknown: 'Unknown',
linkCopied: 'Link copied',
linkCopiedBody: 'Share it with a skater.',
@ -1569,6 +1573,10 @@ marketUi: {
publishFailed: 'Failed to publish',
loginRequired: 'Login Required',
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',
backToSpot: 'Back to {{title}}',
backToMarketplace: 'Back to Marketplace',
@ -1642,6 +1650,14 @@ marketUi: {
productPhoto: 'Product {{n}}',
upload: 'Upload',
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',
pickupLocationHint: 'Link to a skate spot for easy meetups',
searchSpots: 'Search spots...',
@ -1708,6 +1724,7 @@ marketUi: {
relistedBody: 'Your listing is active again.',
listingOptions: 'Listing options',
report: 'Report',
contactDm: 'Hi! I\'m interested in "{{title}}". {{url}}',
removeAdmin: 'Remove (admin)',
postage: 'postage',
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.',
noneFirst: 'Sé el primero en publicar tu equipo: tablas, trucks, ruedas, zapatos, ¡lo que sea de skate!',
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 →',
},
hub: {
@ -1532,6 +1534,8 @@ marketUi: {
deleteListing: 'Eliminar anuncio',
makeOffer: 'Hacer una oferta',
shareListing: 'Compartir anuncio',
viewOptions: 'Ver opciones',
manageListing: 'Gestionar anuncio',
unknown: 'Desconocido',
linkCopied: 'Enlace copiado',
linkCopiedBody: 'Compártelo con un skater.',
@ -1569,6 +1573,10 @@ marketUi: {
publishFailed: 'No se pudo publicar',
loginRequired: 'Inicio de sesión obligatorio',
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',
backToSpot: 'Volver a {{title}}',
backToMarketplace: 'Volver al Mercado',
@ -1642,6 +1650,14 @@ marketUi: {
productPhoto: 'Producto {{n}}',
upload: 'Subir',
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',
pickupLocationHint: 'Vincula un spot de skate para quedar fácilmente',
searchSpots: 'Buscar spots...',
@ -1708,6 +1724,7 @@ marketUi: {
relistedBody: 'Tu anuncio vuelve a estar activo.',
listingOptions: 'Opciones del anuncio',
report: 'Denunciar',
contactDm: '¡Hola! Me interesa "{{title}}". {{url}}',
removeAdmin: 'Retirar (admin)',
postage: 'envío',
newSeller: 'Vendedor nuevo',

View file

@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -24,9 +25,64 @@ import { SpamFeePayment } from '@/components/SpamFeePayment';
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 { 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';
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() {
const { t } = useTranslation();
@ -41,6 +97,9 @@ export default function CreateListingPage() {
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
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
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).
const returnTo = searchParams.get('returnTo') || '';
// Form state - initialize from URL params if coming from stash
const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling');
const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>('pickup');
const [shippingWho, setShippingWho] = useState<'buyer' | 'seller' | 'split'>('buyer');
// Form state — URL/deep-link values win, then the local draft, then defaults.
const [listingType, setListingType] = useState<'selling' | 'wtb'>(initialDraft?.listingType ?? 'selling');
const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>(initialDraft?.fulfilment ?? 'pickup');
const [shippingWho, setShippingWho] = useState<'buyer' | 'seller' | 'split'>(initialDraft?.shippingWho ?? 'buyer');
const { prefs: escrowPrefs } = useEscrowPrefs();
const [requireEscrow, setRequireEscrow] = useState(escrowPrefs.requireEscrow);
const [category, setCategory] = useState(searchParams.get('category') || '');
const [title, setTitle] = useState(searchParams.get('title') || '');
const [description, setDescription] = useState(searchParams.get('description') || '');
const [summary, setSummary] = useState(searchParams.get('brand') ? `Brand: ${searchParams.get('brand')}` : '');
const [condition, setCondition] = useState<string>(searchParams.get('condition') || '');
const [priceType, setPriceType] = useState<string>(searchParams.get('priceType') || 'fixed');
const [price, setPrice] = useState(searchParams.get('price') || '');
const [currency, setCurrency] = useState(searchParams.get('currency') || 'sats'); // Default sats; deep-links (DIY calculator) can preselect
const [size, setSize] = useState(searchParams.get('size') || '');
const [truckHeight, setTruckHeight] = useState('');
const [wheelHardness, setWheelHardness] = useState('');
const [requireEscrow, setRequireEscrow] = useState(initialDraft?.requireEscrow ?? escrowPrefs.requireEscrow);
const [category, setCategory] = useState(searchParams.get('category') || initialDraft?.category || '');
const [title, setTitle] = useState(searchParams.get('title') || initialDraft?.title || '');
const [description, setDescription] = useState(searchParams.get('description') || initialDraft?.description || '');
const [summary, setSummary] = useState(searchParams.get('brand') ? `Brand: ${searchParams.get('brand')}` : (initialDraft?.summary ?? ''));
const [condition, setCondition] = useState<string>(searchParams.get('condition') || initialDraft?.condition || '');
const [priceType, setPriceType] = useState<string>(searchParams.get('priceType') || initialDraft?.priceType || 'fixed');
const [price, setPrice] = useState(searchParams.get('price') || initialDraft?.price || '');
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') || initialDraft?.size || '');
const [truckHeight, setTruckHeight] = useState(initialDraft?.truckHeight ?? '');
const [wheelHardness, setWheelHardness] = useState(initialDraft?.wheelHardness ?? '');
const [images, setImages] = useState<string[]>(() => {
const imagesParam = searchParams.get('images');
return imagesParam ? imagesParam.split(',').filter(Boolean) : [];
return imagesParam ? imagesParam.split(',').filter(Boolean) : (initialDraft?.images ?? []);
});
const [availability, setAvailability] = useState('');
const [expiration, setExpiration] = useState(30);
const [selectedSpotId, setSelectedSpotId] = useState<string | null>(spotParam || null);
const [availability, setAvailability] = useState(initialDraft?.availability ?? '');
const [expiration, setExpiration] = useState(initialDraft?.expiration ?? 30);
const [selectedSpotId, setSelectedSpotId] = useState<string | null>(spotParam || initialDraft?.selectedSpotId || null);
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)
const hasNip05 = !!metadata?.nip05 || restrictions.isPlatformOwner;
@ -168,11 +271,69 @@ export default function CreateListingPage() {
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
const categoryRequirements = category ? CATEGORY_REQUIRED_FIELDS[category] : null;
const requiresSize = categoryRequirements?.size && 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) => {
e.preventDefault();
@ -333,6 +494,9 @@ export default function CreateListingPage() {
recordListingCreation(setLastListingRecord, selectedSpot?.pubkey);
}
// The draft is now published; do not offer it again next time.
setDraft(null);
toast({
title: listingType === 'wtb' ? t('marketUi.create.wtbPosted') : t('marketUi.create.listingCreated'),
description: listingType === 'wtb' ? t('marketUi.create.wtbPostedBody') : t('marketUi.create.listingCreatedBody'),
@ -392,6 +556,20 @@ export default function CreateListingPage() {
/>
</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 */}
{fromStash && (
<Card className="mb-6 bg-purple-950/30 border-2 border-purple-500/50">
@ -915,18 +1093,65 @@ export default function CreateListingPage() {
key={index}
className="relative aspect-square rounded-lg overflow-hidden bg-gray-800"
>
<img
<ImageWithFallback
src={imageUrl}
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
type="button"
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" />
</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>
))}
{images.length < 5 && (
@ -1078,6 +1303,54 @@ export default function CreateListingPage() {
</CardContent>
</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 */}
<Button
type="submit"

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from '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 { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@ -11,6 +11,7 @@ import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
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 { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
@ -22,12 +23,18 @@ import { GEAR_CATEGORIES } from '@/lib/types';
import { GEAR_CATEGORY_KEYS } from '@/lib/labelKeys';
import { getLastLocation } from '@/lib/lastLocation';
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 { useTranslation } from '@/contexts/language';
import { useLocalStorage } from '@/hooks/useLocalStorage';
type SortOption = 'newest' | 'price_low' | 'price_high' | 'trust' | 'distance';
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 {
id: string;
@ -49,21 +56,68 @@ export default function GearSwapPage() {
});
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: btcRates } = useBtcRates();
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortOption>('newest');
const [fulfilmentFilter, setFulfilmentFilter] = useState<'all' | 'pickup' | 'ships'>('all');
const [trustFilter, setTrustFilter] = useState<TrustFilter>('all');
const [showOnlyWTB, setShowOnlyWTB] = useState(false);
const [showOnlySelling, setShowOnlySelling] = useState(false);
// Filters live in the URL so refresh, Back/Forward, deep links and saved
// searches all preserve the buyer's context.
const [searchParams, setSearchParams] = useSearchParams();
const [filtersOpen, setFiltersOpen] = useState(false);
const [savedSearches, setSavedSearches] = useLocalStorage<SavedSearch[]>('skatespots:gear-saved-searches', []);
const [saveSearchOpen, setSaveSearchOpen] = useState(false);
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
const sellerPubkeys = useMemo(() => {
if (!items) return [];
@ -111,9 +165,9 @@ export default function GearSwapPage() {
}
// Category filter
if (selectedCategory !== 'all') {
if (effectiveCategory !== 'all') {
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) {
case 'price_low':
filtered.sort((a, b) => {
const aPrice = a.price?.amount ? parseFloat(a.price.amount) : Infinity;
const bPrice = b.price?.amount ? parseFloat(b.price.amount) : Infinity;
const aPrice = priceToSats(a);
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;
});
break;
case 'price_high':
filtered.sort((a, b) => {
const aPrice = a.price?.amount ? parseFloat(a.price.amount) : 0;
const bPrice = b.price?.amount ? parseFloat(b.price.amount) : 0;
const aPrice = priceToSats(a);
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;
});
break;
@ -198,16 +270,22 @@ export default function GearSwapPage() {
}
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) => {
setSearchQuery(saved.query);
setSelectedCategory(saved.category);
setSortBy(saved.sort);
setFulfilmentFilter(saved.fulfilment);
setTrustFilter(saved.trust);
setShowOnlyWTB(saved.wtb);
setShowOnlySelling(saved.selling);
const next = new URLSearchParams(searchParams);
const put = (key: string, value: string | null) => {
if (!value || (key !== 'q' && value === 'all')) next.delete(key);
else next.set(key, value);
};
put('q', saved.query);
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 = () => {
@ -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 (
<>
<div className="container px-4 py-8 max-w-7xl">
@ -288,162 +563,94 @@ export default function GearSwapPage() {
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500" />
<Input
aria-label={t('common.search')}
placeholder={t('marketUi.gearSwap.searchPlaceholder')}
value={searchQuery}
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>
{/* Category Pills */}
<div className="flex flex-wrap gap-2 mb-4">
{categoryOptions.map(({ value, label }) => (
<Button
key={value}
variant={selectedCategory === value ? 'default' : 'outline'}
size="sm"
className={
selectedCategory === value
? 'bg-orange-500 hover:bg-orange-600 text-white'
: 'border-gray-600 hover:bg-gray-800'
}
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>
{/* Mobile filters: keep the full category wall out of the feed. */}
<div className="mb-3 md:hidden">
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
<SheetTrigger asChild>
<Button variant="outline" className="min-h-10 w-full justify-between border-gray-600">
<span className="flex items-center gap-2">
<Filter className="h-4 w-4" />
{t('common.filter')}
</span>
{activeFilters.length > 0 && (
<Badge className="bg-orange-500/20 text-orange-300">{activeFilters.length}</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>{t('marketUi.gearSwap.filterByTrust')}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={trustFilter === 'all'}
onCheckedChange={() => setTrustFilter('all')}
</SheetTrigger>
<SheetContent
side="bottom"
className="max-h-[85dvh] overflow-y-auto border-orange-500/30 bg-black pb-[max(1rem,env(safe-area-inset-bottom))]"
>
<Users className="h-4 w-4 mr-2" />
{t('marketUi.gearSwap.allSellers')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustFilter === 'verified'}
onCheckedChange={() => setTrustFilter('verified')}
<SheetHeader className="text-left">
<SheetTitle className="flex items-center gap-2 text-white">
<Filter className="h-4 w-4 text-orange-400" />
{t('common.filter')}
</SheetTitle>
<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.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 '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>
{t('marketUi.gearSwap.listingsFound', { count: filteredItems.length })}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
<div className="hidden md:block">{filterControls}</div>
</CardContent>
</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 && (
<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">
@ -463,7 +670,7 @@ export default function GearSwapPage() {
onClick={() => setSavedSearches((current) => current.filter((item) => item.id !== saved.id))}
aria-label={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" />
</button>
@ -475,7 +682,7 @@ export default function GearSwapPage() {
{/* Results Count */}
{!isLoading && (
<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
? t('marketUi.gearSwap.listingFound', { count: filteredItems.length })
: t('marketUi.gearSwap.listingsFound', { count: filteredItems.length })}
@ -495,6 +702,12 @@ export default function GearSwapPage() {
</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 */}
{isLoading && (
<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" />
<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">
{searchQuery || selectedCategory !== 'all' || trustFilter !== 'all'
{hasActiveFilters
? t('marketUi.gearSwap.noneFiltered')
: t('marketUi.gearSwap.noneFirst')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
{(searchQuery || selectedCategory !== 'all' || trustFilter !== 'all') && (
{hasActiveFilters && (
<Button
variant="outline"
className="gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
onClick={() => {
setSearchQuery('');
setSelectedCategory('all');
setTrustFilter('all');
}}
onClick={clearFilters}
>
<Filter className="h-4 w-4" />
{t('common.clearFilters')}
@ -570,6 +779,7 @@ export default function GearSwapPage() {
{/* Listings Grid */}
{!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">
{filteredItems.map((item) => (
<LazyMount key={item.id} className="h-full">
@ -582,6 +792,19 @@ export default function GearSwapPage() {
</LazyMount>
))}
</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>

View file

@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
import { TrustBadge } from '@/components/TrustBadge';
@ -18,6 +19,7 @@ import { ClinkPayButton } from '@/components/ClinkPayButton';
import { OfferDialog } from '@/components/OfferDialog';
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { ReportDialog } from '@/components/ReportDialog';
import { useMarketplaceItem } from '@/hooks/useMarketplace';
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -42,6 +44,7 @@ import {
Handshake,
Share2,
ShieldAlert,
Flag,
} from 'lucide-react';
import { useTranslation } from '@/contexts/language';
@ -70,6 +73,8 @@ export default function ListingDetailPage() {
const [showMarkSold, setShowMarkSold] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showOffer, setShowOffer] = useState(false);
const [showReport, setShowReport] = useState(false);
const [mobileActionsOpen, setMobileActionsOpen] = useState(false);
useSeoMeta({
title: item ? `${item.title} — SkateSpots Marketplace` : 'Listing — SkateSpots',
@ -147,7 +152,7 @@ export default function ListingDetailPage() {
const handleShare = async () => {
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') });
} catch {
toast({ title: t('marketUi.listingDetail.copyFailed'), variant: 'destructive' });
@ -169,7 +174,7 @@ export default function ListingDetailPage() {
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
onClick={() => navigate('/marketplace')}
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}
</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>
{/* Description */}
@ -321,7 +340,7 @@ export default function ListingDetailPage() {
{/* Actions */}
{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">
{isSold ? (
<Button
@ -353,7 +372,7 @@ export default function ListingDetailPage() {
</CardContent>
</Card>
) : (
<Card className="bg-black/90 border-white/10">
<Card id="listing-actions" className="bg-black/90 border-white/10">
<CardContent className="p-4">
{showSafetyNote ? (
<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')}
</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>
</Card>
)}
@ -410,6 +439,90 @@ export default function ListingDetailPage() {
</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
images={lightboxImages}
index={lightboxIndex}
@ -446,6 +559,17 @@ export default function ListingDetailPage() {
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)}
aria-pressed={active}
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',
)}
>
<Icon className="h-3.5 w-3.5" />
{t(labelKey)}
<Icon className="hidden h-3.5 w-3.5 sm:block" />
<span className="whitespace-nowrap">{t(labelKey)}</span>
</button>
);
})}