SkateSpots/src/pages/GearSwapPage.tsx
SkateSpots 087f490acc refactor(layout): hoist Layout into the router so the chrome mounts once
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
  <Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
  navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
  a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
  fragments instead of <Layout> (structure-preserving), imports dropped.

Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
2026-09-27 13:41:42 +07:00

427 lines
16 KiB
TypeScript

import { useState, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
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 { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { LazyMount } from '@/components/LazyMount';
import { useBatchSellerSales } from '@/hooks/useSellerSales';
import { useFollowList } from '@/hooks/useFollowList';
import { GearSwapItemCard } from '@/components/GearSwapItemCard';
import { GEAR_CATEGORIES } from '@/lib/types';
import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle } from 'lucide-react';
type SortOption = 'newest' | 'price_low' | 'price_high' | 'trust';
type TrustFilter = 'all' | 'verified' | 'trusted' | 'following';
export default function GearSwapPage() {
useSeoMeta({
title: 'Gear Swap - SkateSpots',
description: 'Buy & sell used skate gear with other skaters. Decks, trucks, wheels, shoes, and more.',
});
const { user } = useCurrentUser();
const { data: items, isLoading, error } = useMarketplaceItems(200);
const { data: followList } = useFollowList(user?.pubkey);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortOption>('newest');
const [trustFilter, setTrustFilter] = useState<TrustFilter>('all');
const [showOnlyWTB, setShowOnlyWTB] = useState(false);
const [showOnlySelling, setShowOnlySelling] = useState(false);
// Get unique seller pubkeys for batch queries
const sellerPubkeys = useMemo(() => {
if (!items) return [];
const pubkeys = new Set(items.map(item => item.pubkey));
return Array.from(pubkeys);
}, [items]);
// Batch fetch trust scores and sales for all sellers
const { data: trustScores } = useBatchTrustScores(sellerPubkeys);
const { data: sellerSales } = useBatchSellerSales(sellerPubkeys);
// Build follow set for quick lookup
const followingSet = useMemo(() => {
if (!followList) return new Set<string>();
return new Set(
followList.tags
.filter(([tag]) => tag === 'p')
.map(([, pubkey]) => pubkey)
);
}, [followList]);
// Filter and sort items
const filteredItems = useMemo(() => {
if (!items) return [];
let filtered = [...items];
// Filter out expired items
const now = Math.floor(Date.now() / 1000);
filtered = filtered.filter(item => {
if (item.expiration && item.expiration < now) return false;
if (item.status && item.status !== 'active') return false;
return true;
});
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(item =>
item.title.toLowerCase().includes(query) ||
item.description?.toLowerCase().includes(query) ||
item.summary?.toLowerCase().includes(query) ||
item.location?.toLowerCase().includes(query)
);
}
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(item =>
item.category?.includes(selectedCategory)
);
}
// Listing type filters
if (showOnlyWTB) {
filtered = filtered.filter(item => item.listingType === 'wtb');
}
if (showOnlySelling) {
filtered = filtered.filter(item => item.listingType !== 'wtb');
}
// Trust filter
if (trustFilter !== 'all' && trustScores) {
filtered = filtered.filter(item => {
const score = trustScores.get(item.pubkey);
switch (trustFilter) {
case 'verified':
return score !== undefined && score >= 90;
case 'trusted':
return score !== undefined && score >= 60;
case 'following':
return followingSet.has(item.pubkey);
default:
return true;
}
});
}
// Sort
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;
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;
return bPrice - aPrice;
});
break;
case 'trust':
filtered.sort((a, b) => {
const aScore = trustScores?.get(a.pubkey) ?? 0;
const bScore = trustScores?.get(b.pubkey) ?? 0;
return bScore - aScore;
});
break;
case 'newest':
default:
filtered.sort((a, b) => b.createdAt - a.createdAt);
break;
}
return filtered;
}, [items, searchQuery, selectedCategory, sortBy, trustFilter, showOnlyWTB, showOnlySelling, trustScores, followingSet]);
const categoryOptions = [
{ value: 'all', label: 'All Categories' },
...Object.entries(GEAR_CATEGORIES).map(([key, label]) => ({
value: key,
label,
})),
];
return (
<>
<div className="container px-4 py-8 max-w-7xl">
<PageHeader
title="Gear Swap"
icon={ShoppingCart}
accent="#ff6600"
subtitle="Buy & sell used skate gear with other skaters"
className="mb-8"
actions={
<Link to="/create-listing?returnTo=/marketplace">
<Button
size="lg"
className="gap-2 bg-orange-500 hover:bg-orange-600 text-white font-bold uppercase shadow-[0_0_20px_rgba(255,102,0,0.3)]"
>
<Plus className="h-5 w-5" />
List Gear
</Button>
</Link>
}
/>
{/* Search & Filters */}
<Card className="bg-black/90 border-2 border-orange-500/20 mb-6">
<CardContent className="p-4">
{/* Search */}
<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
placeholder="Search gear..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-gray-900 border-gray-700 h-12 text-lg"
/>
</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'
}
>
For Sale
</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" />
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" />
Trust
{trustFilter !== 'all' && (
<Badge className="ml-1 bg-orange-500/20 text-orange-400 text-xs">
{trustFilter}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Filter by Trust</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={trustFilter === 'all'}
onCheckedChange={() => setTrustFilter('all')}
>
<Users className="h-4 w-4 mr-2" />
All Sellers
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustFilter === 'verified'}
onCheckedChange={() => setTrustFilter('verified')}
>
<UserCheck className="h-4 w-4 mr-2 text-green-400" />
Verified Only
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustFilter === 'trusted'}
onCheckedChange={() => setTrustFilter('trusted')}
>
<ShieldCheck className="h-4 w-4 mr-2 text-blue-400" />
Trusted+
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={trustFilter === 'following'}
onCheckedChange={() => setTrustFilter('following')}
disabled={!user}
>
<Users className="h-4 w-4 mr-2 text-purple-400" />
Following Only
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
{/* 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="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="price_low">Price: Low → High</SelectItem>
<SelectItem value="price_high">Price: High → Low</SelectItem>
<SelectItem value="trust">Trust Score</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Results Count */}
{!isLoading && (
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-400">
{filteredItems.length} {filteredItems.length === 1 ? 'listing' : 'listings'} found
</p>
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i} className="bg-black/90 border-2 border-orange-500/20">
<CardContent className="p-4">
<Skeleton className="w-full aspect-square rounded-lg mb-3" />
<Skeleton className="h-5 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2 mb-2" />
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Error State */}
{error && (
<Card className="bg-red-950/30 border-2 border-red-500/50">
<CardContent className="py-8 text-center">
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-red-400" />
<h3 className="text-lg font-bold text-red-400 mb-2">Failed to load listings</h3>
<p className="text-gray-400">Please try again later.</p>
</CardContent>
</Card>
)}
{/* Empty State */}
{!isLoading && !error && filteredItems.length === 0 && (
<Card className="bg-black/90 border-2 border-dashed border-orange-500/30">
<CardContent className="py-16 text-center">
<ShoppingCart className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<h3 className="text-xl font-bold text-white mb-2">No listings found</h3>
<p className="text-gray-400 mb-6 max-w-md mx-auto">
{searchQuery || selectedCategory !== 'all' || trustFilter !== 'all'
? 'No gear matches your current filters. Try clearing them or searching something else.'
: 'Be the first to list your gear — decks, trucks, wheels, shoes, anything skate!'}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
{(searchQuery || selectedCategory !== 'all' || trustFilter !== 'all') && (
<Button
variant="outline"
className="gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
onClick={() => {
setSearchQuery('');
setSelectedCategory('all');
setTrustFilter('all');
}}
>
<Filter className="h-4 w-4" />
Clear Filters
</Button>
)}
<Link to="/create-listing">
<Button className="gap-2 bg-orange-500 hover:bg-orange-600">
<Plus className="h-4 w-4" />
List Your Gear
</Button>
</Link>
</div>
<Link to="/faq" className="mt-5 inline-block text-xs text-gray-600 hover:text-gray-400 underline underline-offset-2 transition-colors">
Have questions? Check the FAQ →
</Link>
</CardContent>
</Card>
)}
{/* 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">
<GearSwapItemCard
item={item}
trustScore={trustScores?.get(item.pubkey)}
salesCount={sellerSales?.get(item.pubkey) ?? 0}
isFollowing={followingSet.has(item.pubkey)}
/>
</LazyMount>
))}
</div>
)}
</div>
</>
);
}