- 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.
427 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|