feat: marketplace UX and resilience pass
This commit is contained in:
parent
3f28c3fdb4
commit
327e81f155
9 changed files with 912 additions and 235 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Reference in a new issue