SkateSpots/src/pages/StashPage.tsx
SkateSpots d1f8870379 chore(lint): remove all explicit any + sweep dead imports
- Fix all 12 no-explicit-any (NPool, NostrEvent[], LabProfile, structural
  Cluster type, explicit SkateSpot cast) -> 0 remaining
- Remove unused imports/vars across 48 files (manual + a verified codemod for
  unused import specifiers only); tsc + tests + boot smoke all green
- Warning backlog: 182 -> 76 (remaining: ~55 no-unused-vars locals,
  14 react-hooks/exhaustive-deps, 7 react-refresh)
- AGENTS.md baseline updated
2026-09-26 21:40:42 +07:00

1164 lines
71 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useRef } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import { Layout } from '@/components/Layout';
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 { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogClose, DialogDescription } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { LoginArea } from '@/components/auth/LoginArea';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
import { useStashShowcaseSync } from '@/hooks/useStashShowcase';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { useUploadFile } from '@/hooks/useUploadFile';
import {
Package,
Plus,
Trash2,
Edit3,
Lock,
Users,
Globe,
Zap,
ArrowRightLeft,
Shield,
Star,
Search,
AlertTriangle,
ShoppingCart,
Heart,
Backpack,
Trophy,
X,
ImagePlus,
Loader2,
Store,
MapPin,
LayoutGrid,
List,
} from 'lucide-react';
import { cn } from '@/lib/utils';
// ─── Rarity helpers ──────────────────────────────────────────────────────────
const RARITY_COLORS = {
common: 'from-gray-500 to-gray-600',
uncommon: 'from-green-500 to-emerald-600',
rare: 'from-blue-500 to-indigo-600',
epic: 'from-purple-500 to-violet-600',
legendary: 'from-yellow-400 to-orange-500',
};
const RARITY_GLOW = {
common: '',
uncommon: 'shadow-[0_0_15px_rgba(34,197,94,0.3)]',
rare: 'shadow-[0_0_20px_rgba(59,130,246,0.4)]',
epic: 'shadow-[0_0_25px_rgba(168,85,247,0.5)]',
legendary: 'shadow-[0_0_30px_rgba(250,204,21,0.6)] animate-pulse',
};
const RARITY_BORDER = {
common: 'border-gray-500/40',
uncommon: 'border-green-500/50',
rare: 'border-blue-500/50',
epic: 'border-purple-500/60',
legendary: 'border-yellow-400/70',
};
// ─── List Row — mobile default ────────────────────────────────────────────────
function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = false }: {
item: StashItem; onEdit: () => void; onDelete: () => void; onListOnMarketplace?: () => void; isWTB?: boolean;
}) {
const category = STASH_CATEGORIES[item.category];
const condition = CONDITION_LABELS[item.condition];
const rarity = (item.rarity || category.rarity) as keyof typeof RARITY_COLORS;
const rarityTier = RARITY_TIERS[rarity];
const usdValue = useSatsToUsd(item.minPrice || item.maxPrice || 0);
const canListOnMarketplace = !isWTB && (item.willingToSell || item.willingToTrade);
return (
<div className={cn(
'relative flex items-center gap-3 p-3 rounded-xl border-2 bg-black/60 transition-all active:scale-[0.99]',
RARITY_BORDER[rarity], RARITY_GLOW[rarity],
)}>
<div className={cn('absolute left-0 top-0 bottom-0 w-1 rounded-l-xl bg-gradient-to-b', RARITY_COLORS[rarity])} />
<div className="w-14 h-14 flex-shrink-0 rounded-lg overflow-hidden bg-black/50 border border-white/10 flex items-center justify-center ml-1">
{item.images?.[0] ? <ImageWithFallback src={item.images[0]} alt={item.title} className="w-full h-full object-cover" fallback={<span className="text-2xl">{category.emoji}</span>} /> : <span className="text-2xl">{category.emoji}</span>}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
{isWTB && <Badge className="bg-cyan-500 text-black text-[9px] px-1 py-0 h-4 font-black">WTB</Badge>}
<h3 className="font-bold text-sm text-white truncate leading-tight" style={{ textShadow: `0 0 8px ${rarityTier.color}30` }}>{item.title}</h3>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground flex-wrap">
{item.brand && <span className="font-medium text-gray-300">{item.brand}</span>}
{item.size && <span className="text-muted-foreground/60">• {item.size}</span>}
{!isWTB && item.storageLocation && item.storageLocation !== 'other' && (
<span className="text-[#ff6600]/80">• {{ home:'🏠',car:'🚗',skatepark:'🛹',warehouse:'🏭',storage_unit:'🔒',van:'🚐',friends:'👫',parents:'👨‍👩‍👦',wardrobe:'🚪',backpack:'🎒',sponsor:'🤝',other:'📦' }[item.storageLocation]}</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
{!isWTB && (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className={cn('h-2.5 w-2.5', i < condition.stars ? 'fill-yellow-400 text-yellow-400' : 'text-gray-500')} />
))}
</div>
)}
{(item.minPrice || item.maxPrice) && (
<span className="text-[11px] font-bold text-amber-400 flex items-center gap-0.5">
<Zap className="h-3 w-3" />{(item.maxPrice || item.minPrice || 0).toLocaleString()}
{usdValue && <span className="text-muted-foreground font-normal">(~${usdValue.toFixed(0)})</span>}
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1.5 flex-shrink-0">
<div>
{item.visibility === 'private' && <Lock className="h-3.5 w-3.5 text-red-400/70" />}
{item.visibility === 'friends' && <Users className="h-3.5 w-3.5 text-blue-400/70" />}
{item.visibility === 'public' && <Globe className="h-3.5 w-3.5 text-green-400/70" />}
</div>
<div className="flex gap-1">
{item.willingToTrade && <span className="text-[9px] font-bold text-cyan-400 border border-cyan-500/40 rounded px-1 py-0">↔</span>}
{item.willingToSell && !isWTB && <span className="text-[9px] font-bold text-green-400 border border-green-500/40 rounded px-1 py-0">⚡</span>}
</div>
<div className="flex gap-1">
<button onClick={(e) => { e.stopPropagation(); onEdit(); }} className="w-7 h-7 rounded-lg bg-white/5 hover:bg-white/15 flex items-center justify-center transition-colors touch-manipulation active:scale-90">
<Edit3 className="h-3.5 w-3.5 text-gray-300" />
</button>
{canListOnMarketplace && onListOnMarketplace && (
<button onClick={(e) => { e.stopPropagation(); onListOnMarketplace(); }} className="w-7 h-7 rounded-lg bg-[#ff6600]/20 hover:bg-[#ff6600]/40 flex items-center justify-center transition-colors touch-manipulation active:scale-90">
<Store className="h-3.5 w-3.5 text-[#ff6600]" />
</button>
)}
<button onClick={(e) => { e.stopPropagation(); onDelete(); }} className="w-7 h-7 rounded-lg bg-red-500/10 hover:bg-red-500/30 flex items-center justify-center transition-colors touch-manipulation active:scale-90">
<Trash2 className="h-3.5 w-3.5 text-red-400" />
</button>
</div>
</div>
</div>
);
}
// ─── Grid Card — desktop default ──────────────────────────────────────────────
function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = false }: {
item: StashItem; onEdit: () => void; onDelete: () => void; onListOnMarketplace?: () => void; isWTB?: boolean;
}) {
const category = STASH_CATEGORIES[item.category];
const condition = CONDITION_LABELS[item.condition];
const rarity = (item.rarity || category.rarity) as keyof typeof RARITY_COLORS;
const usdValue = useSatsToUsd(item.minPrice || item.maxPrice || 0);
const rarityTier = RARITY_TIERS[rarity];
const canListOnMarketplace = !isWTB && (item.willingToSell || item.willingToTrade);
return (
<Card className={cn('relative overflow-hidden transition-all duration-300 hover:scale-[1.02] hover:-translate-y-0.5 cursor-pointer group bg-gradient-to-br from-black/90 to-black/70 border-2', RARITY_GLOW[rarity], RARITY_BORDER[rarity], isWTB && 'ring-2 ring-cyan-500/30')}>
<div className={cn('absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r', RARITY_COLORS[rarity])}>
{rarity !== 'common' && <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" />}
</div>
{isWTB && (
<div className="absolute top-3 left-2 z-10">
<Badge className="bg-cyan-500 text-black text-[10px] font-black uppercase"><Heart className="h-2.5 w-2.5 mr-0.5" />Wishlist</Badge>
</div>
)}
<div className="absolute top-3 right-2 z-10">
<Badge variant="outline" className="text-[9px] px-1.5 py-0.5 backdrop-blur-sm font-bold uppercase tracking-wider"
style={{ borderColor: rarityTier.color, color: rarityTier.color, backgroundColor: rarityTier.color + '15' }}>
{rarityTier.label}
</Badge>
</div>
<div className="h-40 bg-gradient-to-br from-black/50 to-black/30 flex items-center justify-center relative overflow-hidden">
<div className={cn('absolute inset-2 rounded-lg border-2 opacity-20', RARITY_BORDER[rarity])} />
{item.images?.[0] ? (
<ImageWithFallback src={item.images[0]} alt={item.title} className="w-full h-full object-cover" />
) : (
<div className="flex flex-col items-center gap-2">
<span className="text-5xl">{category.emoji}</span>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{category.label}</span>
</div>
)}
<div className="absolute inset-0 bg-black/85 transition-all duration-200 flex flex-col items-center justify-center gap-2 backdrop-blur-sm opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-8 border-white/30" onClick={(e) => { e.stopPropagation(); onEdit(); }}>
<Edit3 className="h-3.5 w-3.5 mr-1" />Edit
</Button>
<Button size="sm" variant="destructive" className="h-8" onClick={(e) => { e.stopPropagation(); onDelete(); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{canListOnMarketplace && onListOnMarketplace && (
<Button size="sm" className="h-8 bg-[#ff6600] hover:bg-[#ff6600]/90 text-black font-bold"
onClick={(e) => { e.stopPropagation(); onListOnMarketplace(); }}>
<Store className="h-3 w-3 mr-1" />List on Marketplace
</Button>
)}
</div>
</div>
<CardContent className="p-3 space-y-2">
<div>
<h3 className="font-bold text-sm text-white line-clamp-1" style={{ textShadow: `0 0 10px ${rarityTier.color}40` }}>{item.title}</h3>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<span>{category.emoji}</span>
{item.brand && <span className="font-medium">{item.brand}</span>}
{item.size && <span className="text-muted-foreground/60">• {item.size}</span>}
</p>
</div>
{!isWTB && item.storageLocation && item.storageLocation !== 'other' && (
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded-full border border-[#ff6600]/30 text-[#ff6600] font-bold uppercase tracking-wide">
{{ home:'🏠 Home',car:'🚗 Car',skatepark:'🛹 Skatepark',warehouse:'🏭 Warehouse',storage_unit:'🔒 Storage Unit',van:'🚐 Van',friends:"👫 Friend's",parents:"👨‍👩‍👦 Parents'",wardrobe:'🚪 Wardrobe',backpack:'🎒 Backpack',sponsor:'🤝 Sponsor',other:'📦 Other' }[item.storageLocation]}
</span>
)}
{!isWTB && (
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className={cn('h-3 w-3', i < condition.stars ? 'fill-yellow-400 text-yellow-400 drop-shadow-[0_0_4px_rgba(250,204,21,0.5)]' : 'text-gray-500')} />
))}
<span className="text-[10px] ml-1 font-medium" style={{ color: condition.color }}>{condition.label.split(' ')[1]}</span>
</div>
)}
<div className="flex items-center justify-between pt-1.5 border-t border-white/8">
{(item.minPrice || item.maxPrice) ? (
<div className="flex items-center gap-1">
<Zap className="h-3.5 w-3.5 text-amber-400" />
<span className="font-bold text-sm text-amber-400">{isWTB ? 'Up to ' : ''}{(item.maxPrice || item.minPrice || 0).toLocaleString()}</span>
<span className="text-[10px] text-muted-foreground">sats</span>
{usdValue && <span className="text-[10px] text-muted-foreground">(~${usdValue.toFixed(0)})</span>}
</div>
) : (
<span className="text-xs text-gray-500">No price set</span>
)}
<div className="flex items-center gap-1">
{item.visibility === 'private' && <Tooltip><TooltipTrigger><Lock className="h-3.5 w-3.5 text-red-400/60" /></TooltipTrigger><TooltipContent>Private</TooltipContent></Tooltip>}
{item.visibility === 'friends' && <Tooltip><TooltipTrigger><Users className="h-3.5 w-3.5 text-blue-400/60" /></TooltipTrigger><TooltipContent>Friends only</TooltipContent></Tooltip>}
{item.willingToTrade && <Badge variant="outline" className="text-[9px] h-5 px-1 border-cyan-500/40 text-cyan-400"><ArrowRightLeft className="h-2.5 w-2.5" /></Badge>}
{item.willingToSell && !isWTB && <Badge variant="outline" className="text-[9px] h-5 px-1 border-green-500/40 text-green-400"><Zap className="h-2.5 w-2.5" /></Badge>}
</div>
</div>
</CardContent>
</Card>
);
}
// ─── Add Item Dialog ──────────────────────────────────────────────────────────
function AddItemDialog({ onAdd, mode = 'stash', trigger }: { onAdd: (item: Omit<StashItem, 'id' | 'addedAt'>) => void; mode?: 'stash' | 'wtb'; trigger?: React.ReactNode }) {
const [open, setOpen] = useState(false);
const isWTB = mode === 'wtb';
const fileInputRef = useRef<HTMLInputElement>(null);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const [formData, setFormData] = useState<Partial<StashItem>>({ category: 'deck', condition: 'good', visibility: 'private', willingToSell: !isWTB, willingToTrade: false, itemType: isWTB ? 'wtb' : 'stash', images: [] });
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const newImages: string[] = [...(formData.images || [])];
for (const file of Array.from(files)) {
const tags = await uploadFile(file);
const urlTag = tags.find(t => t[0] === 'url');
if (urlTag) newImages.push(urlTag[1]);
}
setFormData({ ...formData, images: newImages });
} catch { /* ignore */ }
if (fileInputRef.current) fileInputRef.current.value = '';
};
const removeImage = (index: number) => {
const imgs = [...(formData.images || [])];
imgs.splice(index, 1);
setFormData({ ...formData, images: imgs });
};
const handleSubmit = () => {
if (!formData.title || !formData.category) return;
if (!isWTB && !formData.condition) return;
onAdd({
title: formData.title, category: formData.category as StashItem['category'],
condition: isWTB ? 'good' : (formData.condition as StashItem['condition']),
visibility: formData.visibility || 'private', size: formData.size, brand: formData.brand,
description: formData.description, minPrice: isWTB ? undefined : formData.minPrice,
maxPrice: isWTB ? formData.maxPrice : undefined, willingToSell: isWTB ? false : formData.willingToSell,
willingToTrade: formData.willingToTrade, images: formData.images,
itemType: isWTB ? 'wtb' : 'stash', storageLocation: isWTB ? undefined : formData.storageLocation,
});
setFormData({ category: 'deck', condition: 'good', visibility: 'private', willingToSell: !isWTB, willingToTrade: false, itemType: isWTB ? 'wtb' : 'stash', images: [] });
setOpen(false);
};
const defaultTrigger = isWTB ? (
<Button variant="outline" className="gap-2 border-cyan-500/50 text-cyan-400 hover:bg-cyan-500/10"><Heart className="h-4 w-4" />Add to Wishlist</Button>
) : (
<Button className="gap-2 bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 border-0 shadow-lg shadow-[#ff0040]/20"><Plus className="h-4 w-4" />Add to Stash</Button>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto bg-black/95 border-2 border-[#ff6600]/40">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
{isWTB ? <><Heart className="h-5 w-5 text-cyan-400" />Add to Wishlist</> : <><Package className="h-5 w-5 text-[#ff6600]" />Add Item to Stash</>}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{isWTB ? "Add items you're looking for." : "Add gear to your encrypted private inventory."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{isWTB ? 'Looking for...' : 'Item Name'} *</Label>
<Input placeholder={isWTB ? "e.g. Any 8.0-8.25 deck" : "e.g. Powell Peralta Flight Deck 8.25"} value={formData.title || ''} onChange={(e) => setFormData({ ...formData, title: e.target.value })} className="bg-black/50 border-white/20" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Category *</Label>
<Select value={formData.category} onValueChange={(v) => setFormData({ ...formData, category: v as StashItem['category'] })}>
<SelectTrigger className="bg-black/50 border-white/20"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STASH_CATEGORIES).map(([key, cat]) => <SelectItem key={key} value={key}>{cat.emoji} {cat.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{isWTB ? 'Min. Condition' : 'Condition'} *</Label>
<Select value={formData.condition} onValueChange={(v) => setFormData({ ...formData, condition: v as StashItem['condition'] })}>
<SelectTrigger className="bg-black/50 border-white/20"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(CONDITION_LABELS).map(([key, cond]) => <SelectItem key={key} value={key}>{isWTB ? cond.label + ' or better' : cond.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{isWTB ? 'Preferred Brand' : 'Brand'}</Label>
<Input placeholder={isWTB ? "Any or specific" : "e.g. Powell, Spitfire"} value={formData.brand || ''} onChange={(e) => setFormData({ ...formData, brand: e.target.value })} className="bg-black/50 border-white/20" />
</div>
<div className="space-y-2">
<Label>{isWTB ? 'Size Range' : 'Size'}</Label>
<Input placeholder={isWTB ? "e.g. 8.0-8.25" : "e.g. 8.25, 52mm"} value={formData.size || ''} onChange={(e) => setFormData({ ...formData, size: e.target.value })} className="bg-black/50 border-white/20" />
</div>
</div>
<div className="space-y-2">
<Label>{isWTB ? 'Additional Notes' : 'Description'}</Label>
<Textarea placeholder={isWTB ? "Any specific requirements..." : "Describe the item, any damage..."} value={formData.description || ''} onChange={(e) => setFormData({ ...formData, description: e.target.value })} rows={3} className="bg-black/50 border-white/20" />
</div>
{!isWTB && (
<div className="space-y-2">
<Label>Photos</Label>
<div className="space-y-3">
{formData.images && formData.images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{formData.images.map((url, index) => (
<div key={index} className="relative aspect-square rounded-lg overflow-hidden border border-white/20 group">
<img src={url} alt={`Photo ${index + 1}`} className="w-full h-full object-cover" />
<button type="button" onClick={() => removeImage(index)} className="absolute top-1 right-1 p-1 rounded-full bg-black/80 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"><X className="h-3 w-3" /></button>
</div>
))}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" multiple onChange={handleImageUpload} className="hidden" />
<Button type="button" variant="outline" className="w-full border-dashed border-white/30 hover:border-[#ff6600]/50 hover:bg-[#ff6600]/5" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>
{isUploading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Uploading...</> : <><ImagePlus className="h-4 w-4 mr-2" />Add Photos</>}
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label>{isWTB ? 'Max Budget (sats)' : 'Minimum Price (sats)'}</Label>
<Input type="number" placeholder={isWTB ? "e.g. 100000" : "e.g. 50000"} value={isWTB ? (formData.maxPrice || '') : (formData.minPrice || '')} onChange={(e) => setFormData({ ...formData, [isWTB ? 'maxPrice' : 'minPrice']: parseInt(e.target.value) || undefined })} className="bg-black/50 border-white/20" />
</div>
<div className="space-y-3">
{!isWTB && (
<div className="flex items-center justify-between p-3 rounded-lg bg-green-500/10 border border-green-500/30">
<div className="flex items-center gap-2"><Zap className="h-4 w-4 text-green-400" /><Label>Willing to Sell</Label></div>
<Switch checked={formData.willingToSell} onCheckedChange={(v) => setFormData({ ...formData, willingToSell: v })} />
</div>
)}
<div className="flex items-center justify-between p-3 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
<div className="flex items-center gap-2"><ArrowRightLeft className="h-4 w-4 text-cyan-400" /><Label>{isWTB ? 'Open to Trade' : 'Willing to Trade'}</Label></div>
<Switch checked={formData.willingToTrade} onCheckedChange={(v) => setFormData({ ...formData, willingToTrade: v })} />
</div>
</div>
{!isWTB && (
<div className="space-y-2">
<Label className="flex items-center gap-2"><MapPin className="h-4 w-4 text-[#ff6600]" />Where is this item?</Label>
<div className="grid grid-cols-3 gap-2">
{([ { value: 'home', label: '🏠 Home' }, { value: 'car', label: '🚗 Car' }, { value: 'backpack', label: '🎒 Backpack' }, { value: 'wardrobe', label: '🚪 Wardrobe' }, { value: 'van', label: '🚐 Van' }, { value: 'skatepark', label: '🛹 Skatepark' }, { value: 'friends', label: "👫 Friend's" }, { value: 'parents', label: "👨‍👩‍👦 Parents'" }, { value: 'warehouse', label: '🏭 Warehouse' }, { value: 'storage_unit', label: '🔒 Storage Unit' }, { value: 'sponsor', label: '🤝 Sponsor' }, { value: 'other', label: '📦 Other' }, ] as const).map(({ value, label }) => (
<button key={value} type="button" onClick={() => setFormData({ ...formData, storageLocation: value })}
className={cn('p-2 rounded-lg text-xs font-bold text-center transition-all border', formData.storageLocation === value ? 'bg-[#ff6600] border-[#ff6600] text-black' : 'bg-black/40 border-white/10 text-gray-400 hover:border-[#ff6600]/50 hover:text-white')}>
{label}
</button>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label>Visibility</Label>
<Select value={formData.visibility} onValueChange={(v) => setFormData({ ...formData, visibility: v as StashItem['visibility'] })}>
<SelectTrigger className="bg-black/50 border-white/20"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="private"><div className="flex items-center gap-2"><Lock className="h-3.5 w-3.5 text-red-400" /><span>Private - Only you</span></div></SelectItem>
<SelectItem value="friends"><div className="flex items-center gap-2"><Users className="h-3.5 w-3.5 text-blue-400" /><span>Friends - Mutual follows</span></div></SelectItem>
<SelectItem value="public"><div className="flex items-center gap-2"><Globe className="h-3.5 w-3.5 text-green-400" /><span>Public - Anyone</span></div></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{formData.visibility === 'private' && 'Encrypted — only visible to you.'}
{formData.visibility === 'friends' && 'Kept private for now (friends-only sharing coming soon).'}
{formData.visibility === 'public' && 'Shown in your public Stash on your profile. If it\u2019s also for sale/trade, it appears in the marketplace too.'}
</p>
</div>
</div>
<DialogFooter>
<DialogClose asChild><Button variant="outline" className="border-white/20">Cancel</Button></DialogClose>
<Button onClick={handleSubmit} disabled={!formData.title || isUploading} className={cn(isWTB ? "bg-cyan-500 hover:bg-cyan-600 text-black" : "bg-[#ff6600] hover:bg-[#ff6600]/90")}>
{isWTB ? <><Heart className="h-4 w-4 mr-2" />Add to Wishlist</> : <><Plus className="h-4 w-4 mr-2" />Add Item</>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Edit Item Dialog ─────────────────────────────────────────────────────────
function EditItemDialog({ item, open, onOpenChange, onSave }: { item: StashItem; open: boolean; onOpenChange: (open: boolean) => void; onSave: (item: StashItem) => void }) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const isWTB = item.itemType === 'wtb';
const [formData, setFormData] = useState<StashItem>({ ...item });
useState(() => { setFormData({ ...item }); });
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const newImages: string[] = [...(formData.images || [])];
for (const file of Array.from(files)) {
const tags = await uploadFile(file);
const urlTag = tags.find(t => t[0] === 'url');
if (urlTag) newImages.push(urlTag[1]);
}
setFormData({ ...formData, images: newImages });
} catch { /* ignore */ }
if (fileInputRef.current) fileInputRef.current.value = '';
};
const removeImage = (index: number) => {
const imgs = [...(formData.images || [])];
imgs.splice(index, 1);
setFormData({ ...formData, images: imgs });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto bg-black/95 border-2 border-[#ff6600]/40">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white"><Edit3 className="h-5 w-5 text-[#ff6600]" />Edit Item</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Item Name *</Label>
<Input value={formData.title || ''} onChange={(e) => setFormData({ ...formData, title: e.target.value })} className="bg-black/50 border-white/20" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Category *</Label>
<Select value={formData.category} onValueChange={(v) => setFormData({ ...formData, category: v as StashItem['category'] })}>
<SelectTrigger className="bg-black/50 border-white/20"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STASH_CATEGORIES).map(([key, cat]) => <SelectItem key={key} value={key}>{cat.emoji} {cat.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Condition</Label>
<Select value={formData.condition} onValueChange={(v) => setFormData({ ...formData, condition: v as StashItem['condition'] })}>
<SelectTrigger className="bg-black/50 border-white/20"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(CONDITION_LABELS).map(([key, cond]) => <SelectItem key={key} value={key}>{cond.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Brand</Label><Input value={formData.brand || ''} onChange={(e) => setFormData({ ...formData, brand: e.target.value })} className="bg-black/50 border-white/20" /></div>
<div className="space-y-2"><Label>Size</Label><Input value={formData.size || ''} onChange={(e) => setFormData({ ...formData, size: e.target.value })} className="bg-black/50 border-white/20" /></div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea value={formData.description || ''} onChange={(e) => setFormData({ ...formData, description: e.target.value })} rows={3} className="bg-black/50 border-white/20" />
</div>
{!isWTB && (
<div className="space-y-2">
<Label>Photos</Label>
<div className="space-y-3">
{formData.images && formData.images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{formData.images.map((url, index) => (
<div key={index} className="relative aspect-square rounded-lg overflow-hidden border border-white/20 group">
<img src={url} alt={`Photo ${index + 1}`} className="w-full h-full object-cover" />
<button type="button" onClick={() => removeImage(index)} className="absolute top-1 right-1 p-1 rounded-full bg-black/80 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"><X className="h-3 w-3" /></button>
</div>
))}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" multiple onChange={handleImageUpload} className="hidden" />
<Button type="button" variant="outline" className="w-full border-dashed border-white/30" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>
{isUploading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Uploading...</> : <><ImagePlus className="h-4 w-4 mr-2" />Add Photos</>}
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label>{isWTB ? 'Max Budget (sats)' : 'Minimum Price (sats)'}</Label>
<Input type="number" value={isWTB ? (formData.maxPrice || '') : (formData.minPrice || '')} onChange={(e) => setFormData({ ...formData, [isWTB ? 'maxPrice' : 'minPrice']: parseInt(e.target.value) || undefined })} className="bg-black/50 border-white/20" />
</div>
<div className="space-y-3">
{!isWTB && (
<div className="flex items-center justify-between p-3 rounded-lg bg-green-500/10 border border-green-500/30">
<div className="flex items-center gap-2"><Zap className="h-4 w-4 text-green-400" /><Label>Willing to Sell</Label></div>
<Switch checked={formData.willingToSell} onCheckedChange={(v) => setFormData({ ...formData, willingToSell: v })} />
</div>
)}
<div className="flex items-center justify-between p-3 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
<div className="flex items-center gap-2"><ArrowRightLeft className="h-4 w-4 text-cyan-400" /><Label>Willing to Trade</Label></div>
<Switch checked={formData.willingToTrade} onCheckedChange={(v) => setFormData({ ...formData, willingToTrade: v })} />
</div>
</div>
<div className="space-y-2">
<Label>Visibility</Label>
<Select value={formData.visibility} onValueChange={(v) => setFormData({ ...formData, visibility: v as StashItem['visibility'] })}>
<SelectTrigger className="bg-black/50 border-white/20"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="private"><div className="flex items-center gap-2"><Lock className="h-3.5 w-3.5 text-red-400" /><span>Private</span></div></SelectItem>
<SelectItem value="friends"><div className="flex items-center gap-2"><Users className="h-3.5 w-3.5 text-blue-400" /><span>Friends</span></div></SelectItem>
<SelectItem value="public"><div className="flex items-center gap-2"><Globe className="h-3.5 w-3.5 text-green-400" /><span>Public</span></div></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{formData.visibility === 'private' && 'Encrypted — only visible to you.'}
{formData.visibility === 'friends' && 'Kept private for now (friends-only sharing coming soon).'}
{formData.visibility === 'public' && 'Shown in your public Stash on your profile. If it\u2019s also for sale/trade, it appears in the marketplace too.'}
</p>
</div>
</div>
<DialogFooter>
<DialogClose asChild><Button variant="outline" className="border-white/20">Cancel</Button></DialogClose>
<Button onClick={() => { if (!formData.title || !formData.category) return; onSave(formData); onOpenChange(false); }} disabled={!formData.title || isUploading} className="bg-[#ff6600] hover:bg-[#ff6600]/90">
<Edit3 className="h-4 w-4 mr-2" />Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── List at Spot Dialog ──────────────────────────────────────────────────────
function ListAtSpotDialog({ item, open, onOpenChange, onSelectSpot }: {
item: StashItem; open: boolean; onOpenChange: (open: boolean) => void; onSelectSpot: (spotId: string | null) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md bg-black/95 border-2 border-[#ff6600]/40">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white"><Store className="h-5 w-5 text-[#ff6600]" />List "{item.title}"</DialogTitle>
<DialogDescription className="text-muted-foreground">Where can buyers pick up this item?</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-4 border-[#ff6600]/50 hover:bg-[#ff6600]/10 hover:border-[#ff6600]" onClick={() => onSelectSpot('pick')}>
<MapPin className="h-6 w-6 mr-3 text-[#ff6600]" />
<div className="text-left flex-1"><div className="font-bold text-white">Link to a Skate Spot</div><div className="text-xs text-muted-foreground">Best for local sales • Shows on spot page & map</div></div>
<Badge className="bg-[#ff6600]/20 text-[#ff6600] border-[#ff6600]/50">Recommended</Badge>
</Button>
<Button variant="outline" className="w-full justify-start h-auto py-4 border-gray-600 hover:bg-gray-800" onClick={() => onSelectSpot(null)}>
<Globe className="h-6 w-6 mr-3 text-gray-400" />
<div className="text-left"><div className="font-bold text-white">No Specific Spot</div><div className="text-xs text-muted-foreground">Visible on Marketplace page only</div></div>
</Button>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30 text-xs">
<p className="text-blue-300"><strong>💡 Tip:</strong> Listings linked to spots are easier to find for local skaters.</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function StashPage() {
useSeoMeta({
title: 'My Stash - SkateSpots',
description: 'Manage your private gear inventory. Track what you own, what you want to sell or trade.',
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const { items, stats, isLoading, addItem, updateItem, removeItem, isEncryptionAvailable } = useStash();
const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync();
// Add: save to encrypted stash, then publish a public event if it's public.
const handleAddItem = (item: Omit<StashItem, 'id' | 'addedAt'>) => {
addItem.mutate(item, {
onSuccess: (created) => {
if (created.visibility === 'public' && created.itemType !== 'wtb') {
syncShowcase(created).catch(() => {});
}
},
});
};
// Edit: update the encrypted stash, then reconcile the public event.
const handleUpdateItem = (updated: StashItem) => {
const previous = items.find((i) => i.id === updated.id);
updateItem.mutate(updated, {
onSuccess: () => {
const wasPublic = previous?.visibility === 'public';
const isPublic = updated.visibility === 'public' && updated.itemType !== 'wtb';
if (isPublic) {
syncShowcase(updated).catch(() => {});
} else if (wasPublic) {
// No longer public → pull the public event down.
unpublishShowcase(updated.id).catch(() => {});
}
},
});
};
// Remove: confirm first (deleting gear is destructive and, with Amber, costs a
// signer prompt), then delete from the encrypted stash and clean up any public
// event. `handleRemoveItem` just opens the confirm; `confirmRemoveItem` commits.
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const pendingDeleteItem = pendingDeleteId
? items.find((i) => i.id === pendingDeleteId) ?? null
: null;
const handleRemoveItem = (itemId: string) => {
setPendingDeleteId(itemId);
};
const confirmRemoveItem = () => {
if (!pendingDeleteId) return;
const itemId = pendingDeleteId;
const target = items.find((i) => i.id === itemId);
removeItem.mutate(itemId, {
onSuccess: () => {
if (target?.visibility === 'public') {
unpublishShowcase(itemId).catch(() => {});
}
},
});
setPendingDeleteId(null);
};
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState<string>('all');
const [filterLocation, setFilterLocation] = useState<string>('all');
const [activeTab, setActiveTab] = useState<'stash' | 'wishlist'>('stash');
// Grid default on desktop, list on mobile
const [viewMode, setViewMode] = useState<'list' | 'grid'>(() =>
typeof window !== 'undefined' && window.innerWidth >= 1024 ? 'grid' : 'list'
);
const [editingItem, setEditingItem] = useState<StashItem | null>(null);
const [listingItem, setListingItem] = useState<StashItem | null>(null);
const handleListOnMarketplace = (item: StashItem, spotChoice: string | null) => {
const params = new URLSearchParams();
params.set('fromStash', 'true'); params.set('stashId', item.id); params.set('title', item.title);
params.set('category', item.category); params.set('condition', item.condition);
if (item.size) params.set('size', item.size);
if (item.brand) params.set('brand', item.brand);
if (item.description) params.set('description', item.description);
if (item.minPrice) params.set('price', item.minPrice.toString());
if (item.images && item.images.length > 0) params.set('images', item.images.join(','));
if (item.willingToTrade && !item.willingToSell) params.set('priceType', 'trade');
else if (item.willingToSell) params.set('priceType', 'fixed');
if (spotChoice === 'pick') params.set('pickSpot', 'true');
navigate(`/create-listing?${params.toString()}`);
};
const handleSpotSelection = (spotChoice: string | null) => {
if (listingItem) { handleListOnMarketplace(listingItem, spotChoice); setListingItem(null); }
};
const stashItems = useMemo(() => items.filter(i => i.itemType !== 'wtb'), [items]);
const wishlistItems = useMemo(() => items.filter(i => i.itemType === 'wtb'), [items]);
const displayItems = activeTab === 'stash' ? stashItems : wishlistItems;
const filteredItems = displayItems.filter(item => {
if (searchQuery) {
const q = searchQuery.toLowerCase();
if (!item.title.toLowerCase().includes(q) && !item.brand?.toLowerCase().includes(q) && !item.description?.toLowerCase().includes(q)) return false;
}
if (filterCategory !== 'all' && item.category !== filterCategory) return false;
if (filterLocation !== 'all' && item.storageLocation !== filterLocation) return false;
return true;
});
const hasForSale = stashItems.filter(i => i.willingToSell || i.willingToTrade).length;
const activeCategoryChips = Object.entries(STASH_CATEGORIES).filter(([key]) => displayItems.some(i => i.category === key));
const showCategoryChips = activeCategoryChips.length > 1;
// ── Not logged in ──
if (!user) {
return (
<Layout>
<div className="container px-4 py-12 max-w-4xl">
<div className="relative overflow-hidden rounded-2xl border-2 border-[#ff6600]/50 bg-black/50 backdrop-blur">
<div className="absolute top-0 right-0 w-64 h-64 bg-[#ff6600] opacity-10 rounded-full blur-[80px]" />
<div className="absolute bottom-0 left-0 w-48 h-48 bg-[#ff0040] opacity-10 rounded-full blur-[60px]" />
<div className="relative py-16 px-6 text-center">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-2xl bg-gradient-to-br from-[#ff6600] to-[#ff0040] mb-6 shadow-[0_0_40px_rgba(255,102,0,0.4)]">
<Backpack className="h-12 w-12 text-white" />
</div>
<h1 className="text-4xl md:text-5xl font-black uppercase mb-4" style={{ background: 'linear-gradient(135deg, #ff6600, #ff0040)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>My Stash</h1>
<p className="text-gray-300 mb-8 max-w-lg mx-auto text-lg">Your private gear inventory. Track what you own, set prices, and selectively share with traders.</p>
<div className="grid grid-cols-3 gap-4 max-w-md mx-auto mb-8 text-center">
<div className="p-4 rounded-lg bg-black/40 border border-white/10"><Shield className="h-6 w-6 mx-auto mb-2 text-[#ff6600]" /><p className="text-xs text-gray-400">Encrypted</p></div>
<div className="p-4 rounded-lg bg-black/40 border border-white/10"><ArrowRightLeft className="h-6 w-6 mx-auto mb-2 text-[#ccff00]" /><p className="text-xs text-gray-400">Syncs Everywhere</p></div>
<div className="p-4 rounded-lg bg-black/40 border border-white/10"><ArrowRightLeft className="h-6 w-6 mx-auto mb-2 text-cyan-400" /><p className="text-xs text-gray-400">P2P Trading</p></div>
</div>
<LoginArea className="justify-center" />
</div>
</div>
</div>
</Layout>
);
}
// ── No encryption ──
if (!isEncryptionAvailable) {
return (
<Layout>
<div className="container px-4 py-12 max-w-4xl">
<Card className="bg-black/80 border-2 border-yellow-500/50">
<CardContent className="p-8 text-center">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" />
<h2 className="text-2xl font-black uppercase text-white mb-3">Encryption Required</h2>
<p className="text-gray-300 mb-6 max-w-md mx-auto">Your Stash is encrypted for privacy. Please use a signer that supports NIP-44 encryption (like Alby, nos2x, or Amber).</p>
<Button variant="outline" asChild><Link to="/signers">Learn About Signers</Link></Button>
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<style>{`
@keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
.animate-shimmer { animation: shimmer 2s infinite; }
`}</style>
{/* ── Sticky header ── */}
<div className="sticky top-0 z-30 bg-black/96 backdrop-blur-md border-b border-white/8">
<div className="container px-4 max-w-7xl">
{/* Title row */}
<div className="flex items-center justify-between min-h-14 py-2 gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="relative flex-shrink-0">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-[#ff6600] to-[#ff0040] flex items-center justify-center shadow-[0_0_16px_rgba(255,102,0,0.35)]">
<Backpack className="h-4 w-4 text-white" />
</div>
{stats.total > 0 && (
<div className="absolute -bottom-1 -right-1 min-w-[18px] h-[18px] rounded-full bg-[#ccff00] border-2 border-black flex items-center justify-center px-0.5">
<span className="text-black text-[9px] font-black leading-none">{stats.total}</span>
</div>
)}
</div>
<div className="min-w-0">
<h1 className="text-lg font-black uppercase text-white leading-none tracking-tight">My Stash</h1>
<div className="flex items-center flex-wrap gap-x-2 gap-y-1 mt-1">
<span className="flex items-center gap-1 text-[10px] text-green-400/80 whitespace-nowrap"><Shield className="h-2.5 w-2.5 flex-shrink-0" />Encrypted</span>
<span className="text-white/15 text-[10px]">·</span>
<span className="text-[10px] text-[#ccff00]/70 whitespace-nowrap">Syncs everywhere</span>
{stats.totalValue > 0 && (
<><span className="text-white/15 text-[10px]">·</span>
<span className="flex items-center gap-0.5 text-[10px] text-amber-400/80 whitespace-nowrap"><Zap className="h-2.5 w-2.5 flex-shrink-0" />{stats.totalValue > 999 ? Math.floor(stats.totalValue / 1000) + 'k' : stats.totalValue} sats</span></>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* View toggle — only useful when there are items to view */}
{stats.total > 0 && (
<div className="flex items-center rounded-lg border border-white/10 overflow-hidden">
<button aria-label="List view" onClick={() => setViewMode('list')} className={cn('px-2.5 py-1.5 transition-colors', viewMode === 'list' ? 'bg-white/12 text-white' : 'text-gray-400 hover:text-gray-300')}><List className="h-3.5 w-3.5" /></button>
<button aria-label="Grid view" onClick={() => setViewMode('grid')} className={cn('px-2.5 py-1.5 transition-colors', viewMode === 'grid' ? 'bg-white/12 text-white' : 'text-gray-400 hover:text-gray-300')}><LayoutGrid className="h-3.5 w-3.5" /></button>
</div>
)}
{/* Mobile-only quick add — desktop has sidebar buttons */}
<div className="flex sm:hidden">
<AddItemDialog onAdd={handleAddItem} mode="stash" />
</div>
</div>
</div>
{/* Tab row */}
<div className="flex items-center gap-1 -mb-px">
{(['stash', 'wishlist'] as const).map((tab) => {
const count = tab === 'stash' ? stashItems.length : wishlistItems.length;
const active = activeTab === tab;
return (
<button key={tab} onClick={() => setActiveTab(tab)}
className={cn('flex items-center gap-1.5 px-4 py-2.5 text-sm font-bold border-b-2 transition-all',
active ? (tab === 'stash' ? 'border-[#ff0040] text-white' : 'border-cyan-400 text-white') : 'border-transparent text-gray-500 hover:text-gray-300')}>
{tab === 'stash' ? <Package className="h-3.5 w-3.5" /> : <Heart className="h-3.5 w-3.5" />}
{tab === 'stash' ? 'Stash' : 'Wishlist'}
<span className={cn('text-xs px-1.5 py-0.5 rounded-full font-black', active ? (tab === 'stash' ? 'bg-[#ff0040]/20 text-[#ff0040]' : 'bg-cyan-400/20 text-cyan-400') : 'bg-white/8 text-gray-500')}>{count}</span>
</button>
);
})}
</div>
</div>
</div>
{/* ── Filter bar ── */}
<div className="border-b border-white/6 bg-black/40">
<div className="container px-4 max-w-7xl py-2 flex items-center gap-2 flex-wrap">
<div className="relative flex-1 min-w-[140px] sm:flex-none sm:w-44">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400 pointer-events-none" />
<Input placeholder="Search..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-8 h-8 bg-white/4 border-white/8 text-sm placeholder:text-gray-400 focus:border-[#ff6600]/50" />
</div>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[130px] sm:w-[140px] h-8 bg-white/4 border-white/8 text-sm flex-shrink-0"><SelectValue placeholder="All categories" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{Object.entries(STASH_CATEGORIES).map(([key, cat]) => <SelectItem key={key} value={key}>{cat.emoji} {cat.label}</SelectItem>)}
</SelectContent>
</Select>
{showCategoryChips && (
<div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none">
{activeCategoryChips.map(([key, cat]) => {
const count = displayItems.filter(i => i.category === key).length;
return (
<button key={key} onClick={() => setFilterCategory(filterCategory === key ? 'all' : key)}
className={cn('flex-shrink-0 flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold transition-all border whitespace-nowrap',
filterCategory === key ? 'bg-white text-black border-white' : 'bg-white/4 border-white/8 text-gray-400 hover:border-white/20 hover:text-white')}>
{cat.emoji} {count}
</button>
);
})}
</div>
)}
{activeTab === 'stash' && stashItems.some(i => i.storageLocation && i.storageLocation !== 'other') && (
<div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none">
{([{ value: 'all', label: 'All' }, { value: 'home', label: '🏠' }, { value: 'car', label: '🚗' }, { value: 'backpack', label: '🎒' }, { value: 'skatepark', label: '🛹' }, { value: 'van', label: '🚐' }, { value: 'friends', label: '👫' }] as const).map(({ value, label }) => {
const count = value === 'all' ? stashItems.length : stashItems.filter(i => i.storageLocation === value).length;
if (value !== 'all' && count === 0) return null;
return (
<button key={value} onClick={() => setFilterLocation(value)}
className={cn('flex-shrink-0 px-2.5 py-1 rounded-full text-xs font-bold transition-all border whitespace-nowrap',
filterLocation === value ? 'bg-[#ff6600] border-[#ff6600] text-black' : 'bg-white/4 border-white/8 text-gray-500 hover:border-[#ff6600]/40 hover:text-white')}>
{label}
</button>
);
})}
</div>
)}
<span className="ml-auto text-xs text-gray-500 hidden lg:block flex-shrink-0">{filteredItems.length} item{filteredItems.length !== 1 ? 's' : ''}</span>
</div>
</div>
{/* ── Body ── */}
<div className="container px-4 py-5 max-w-7xl">
<div className="flex gap-7 items-start">
{/* ── Items panel ── */}
<div className="flex-1 min-w-0">
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-xl border border-white/8 bg-white/3 overflow-hidden">
<Skeleton className="h-44 w-full bg-white/5" />
<div className="p-3 space-y-2">
<Skeleton className="h-4 w-3/4 bg-white/5" />
<Skeleton className="h-3 w-1/2 bg-white/5" />
<Skeleton className="h-3 w-1/3 bg-white/5" />
</div>
</div>
))}
</div>
) : filteredItems.length === 0 ? (
/* ── Empty states ── */
<div className="flex flex-col items-center justify-center py-28 text-center">
{activeTab === 'wishlist' ? (
<>
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-cyan-500/20 to-blue-500/10 border border-cyan-500/25 flex items-center justify-center mb-5 shadow-[0_0_40px_rgba(6,182,212,0.12)]">
<Heart className="h-10 w-10 text-cyan-500/60" />
</div>
<p className="font-black text-white text-2xl uppercase tracking-tight mb-2">Wishlist Empty</p>
<p className="text-sm text-muted-foreground mb-7 max-w-xs leading-relaxed">Add gear you're hunting for — we'll match you with friends' stashes.</p>
<AddItemDialog onAdd={handleAddItem} mode="wtb" />
</>
) : stashItems.length === 0 ? (
<>
<div className="relative mb-7">
<div className="absolute inset-0 rounded-full bg-[#ff6600]/15 blur-[60px]" />
<div className="relative w-24 h-24 rounded-3xl bg-gradient-to-br from-[#ff6600]/25 to-[#ff0040]/15 border border-[#ff6600]/30 flex items-center justify-center shadow-[0_0_50px_rgba(255,102,0,0.12)]">
<Backpack className="h-12 w-12 text-[#ff6600]" />
</div>
</div>
<p className="font-black text-white text-3xl uppercase tracking-tight mb-3">Your Stash is Empty</p>
<p className="text-muted-foreground mb-8 max-w-sm leading-relaxed">Start tracking your gear. Decks, trucks, shoes — add it once, access it everywhere via Nostr.</p>
<AddItemDialog onAdd={handleAddItem} />
</>
) : (
<>
<div className="w-14 h-14 rounded-2xl bg-white/4 border border-white/8 flex items-center justify-center mb-4">
<Search className="h-7 w-7 text-gray-400" />
</div>
<p className="text-white font-bold mb-1">No items match</p>
<p className="text-muted-foreground text-sm mb-4">Try adjusting your filters.</p>
<button onClick={() => { setSearchQuery(''); setFilterCategory('all'); setFilterLocation('all'); }} className="text-xs text-[#ff6600] hover:underline font-black uppercase tracking-wider">Clear all filters</button>
</>
)}
</div>
) : viewMode === 'list' ? (
<div className="space-y-2">
{filteredItems.map((item) => (
<StashListRow key={item.id} item={item} isWTB={item.itemType === 'wtb'}
onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} />
))}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredItems.map((item) => (
<StashItemCard key={item.id} item={item} isWTB={item.itemType === 'wtb'}
onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} />
))}
</div>
)}
</div>
{/* ── Sidebar — 300px, desktop only, sticky ── */}
<aside className="hidden lg:flex flex-col w-[300px] flex-shrink-0 gap-4" style={{ position: 'sticky', top: '118px' }}>
{/* Quick actions */}
<div className="flex flex-col gap-2">
<AddItemDialog onAdd={handleAddItem} mode="stash" />
<AddItemDialog onAdd={handleAddItem} mode="wtb" />
</div>
{/* Stats card */}
{stats.total > 0 && (
<div className="rounded-2xl overflow-hidden border border-white/10 bg-gradient-to-b from-white/5 to-white/2">
{/* Card header */}
<div className="px-5 pt-5 pb-4 border-b border-white/8">
<div className="flex items-center justify-between mb-4">
<span className="text-[11px] font-black uppercase tracking-widest text-gray-500">Inventory</span>
<Trophy className="h-3.5 w-3.5 text-amber-400/60" />
</div>
{/* Big stat */}
<div className="flex items-end gap-3">
<div>
<p className="text-5xl font-black text-white leading-none">{stats.total}</p>
<p className="text-xs text-gray-500 mt-1">item{stats.total !== 1 ? 's' : ''} in stash</p>
</div>
{stats.totalValue > 0 && (
<div className="mb-1 pl-3 border-l border-white/10">
<p className="text-xl font-black text-amber-400 leading-none flex items-center gap-1">
<Zap className="h-4 w-4" />
{stats.totalValue > 999 ? (stats.totalValue / 1000).toFixed(1) + 'k' : stats.totalValue}
</p>
<p className="text-xs text-gray-400 mt-0.5">est. sats</p>
</div>
)}
</div>
</div>
{/* Secondary stats row */}
<div className="grid grid-cols-3 divide-x divide-white/6 border-b border-white/8">
{[
{ label: 'Wishlist', value: wishlistItems.length, color: 'text-cyan-400' },
{ label: 'For Sale', value: stashItems.filter(i => i.willingToSell).length, color: 'text-green-400' },
{ label: 'To Trade', value: stashItems.filter(i => i.willingToTrade).length, color: 'text-[#ff6600]' },
].map(({ label, value, color }) => (
<div key={label} className="flex flex-col items-center py-3 px-2 gap-0.5">
<span className={cn('text-lg font-black leading-none', color)}>{value}</span>
<span className="text-[10px] text-gray-400 font-bold">{label}</span>
</div>
))}
</div>
{/* Category breakdown bars */}
{Object.keys(stats.byCategory).length > 0 && (
<div className="px-5 py-4 space-y-2.5">
{Object.entries(stats.byCategory).sort(([, a], [, b]) => b - a).slice(0, 5).map(([cat, count]) => {
const category = STASH_CATEGORIES[cat as keyof typeof STASH_CATEGORIES];
if (!category) return null;
const pct = Math.round((count / stats.total) * 100);
return (
<button key={cat}
onClick={() => setFilterCategory(filterCategory === cat ? 'all' : cat)}
className={cn('w-full flex items-center gap-2.5 group transition-opacity', filterCategory !== 'all' && filterCategory !== cat ? 'opacity-30' : 'opacity-100')}>
<span className="text-sm flex-shrink-0 w-5 text-center">{category.emoji}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-gray-400 font-bold group-hover:text-white transition-colors truncate">{category.label}</span>
<span className="text-[10px] text-gray-400 font-black ml-1">{count}</span>
</div>
<div className="h-1 rounded-full bg-white/6 overflow-hidden">
<div className="h-full rounded-full bg-gradient-to-r from-[#ff6600] to-[#ff0040] transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
</button>
);
})}
</div>
)}
</div>
)}
{/* Marketplace / Sell CTA */}
<div className={cn('rounded-2xl border overflow-hidden',
hasForSale > 0
? 'border-[#ff6600]/40 bg-gradient-to-br from-[#ff6600]/15 via-[#ff3300]/8 to-black/80 shadow-[0_0_40px_rgba(255,102,0,0.08)]'
: 'border-white/8 bg-white/3')}>
<div className="p-5 space-y-4">
<div className="flex items-start gap-3">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5',
hasForSale > 0 ? 'bg-gradient-to-br from-[#ff6600] to-[#ff0040] shadow-lg shadow-[#ff6600]/25' : 'bg-white/8')}>
<Store className={cn('h-5 w-5', hasForSale > 0 ? 'text-white' : 'text-gray-500')} />
</div>
<div className="min-w-0 flex-1">
<p className={cn('text-sm font-black uppercase tracking-wide leading-none', hasForSale > 0 ? 'text-white' : 'text-gray-400')}>
{hasForSale > 0 ? 'Ready to Sell' : 'Marketplace'}
</p>
<p className="text-[11px] text-gray-500 mt-1 leading-snug">
{hasForSale > 0
? `${hasForSale} item${hasForSale > 1 ? 's' : ''} marked for sale or trade`
: 'Toggle "Sell" or "Trade" on any item to list it publicly'}
</p>
</div>
</div>
{hasForSale > 0 && (
<div className="flex gap-1.5">
<div className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-green-500/10 border border-green-500/30">
<Zap className="h-3 w-3 text-green-400" />
<span className="text-[10px] font-black text-green-400">{stashItems.filter(i => i.willingToSell).length} for sale</span>
</div>
<div className="flex items-center gap-1 px-2.5 py-1 rounded-full bg-cyan-500/10 border border-cyan-500/30">
<ArrowRightLeft className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] font-black text-cyan-400">{stashItems.filter(i => i.willingToTrade).length} to trade</span>
</div>
</div>
)}
<Link to="/marketplace">
<Button size="sm" className={cn('w-full gap-2 h-10 font-black text-xs',
hasForSale > 0
? 'bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 text-white border-0 shadow-lg shadow-[#ff6600]/25'
: 'bg-white/6 hover:bg-white/10 text-gray-400 border border-white/8')}>
<ShoppingCart className="h-4 w-4" />
{hasForSale > 0 ? 'Go to Marketplace →' : 'Browse Marketplace'}
</Button>
</Link>
</div>
</div>
{/* Wishlist teaser */}
{wishlistItems.length > 0 && (
<div className="rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/8 to-blue-500/4 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-cyan-400" />
<span className="text-sm font-black text-cyan-300 uppercase tracking-wide">Wishlist</span>
</div>
<span className="text-2xl font-black text-cyan-400">{wishlistItems.length}</span>
</div>
<p className="text-[11px] text-gray-500 leading-relaxed">We'll match your wishlist against friends' stashes automatically.</p>
<button onClick={() => setActiveTab('wishlist')} className="w-full text-xs font-black text-cyan-400 hover:text-cyan-300 transition-colors py-1.5 rounded-lg bg-cyan-500/8 hover:bg-cyan-500/15 border border-cyan-500/20 uppercase tracking-wider">
View Wishlist
</button>
</div>
)}
{/* How it works */}
<div className="rounded-2xl border border-white/6 bg-white/2 p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-3">How it works</p>
<ul className="space-y-2">
{[
{ icon: '✏️', text: 'Edit items with the pen button' },
{ icon: '🏪', text: 'List for sale with the store button' },
{ icon: '📸', text: 'Add photos for better trade offers' },
{ icon: '🔑', text: 'Nostr key syncs to all devices' },
].map(({ icon, text }) => (
<li key={text} className="flex items-start gap-2.5">
<span className="text-sm flex-shrink-0 mt-0.5">{icon}</span>
<span className="text-[11px] text-gray-400 leading-snug">{text}</span>
</li>
))}
</ul>
</div>
</aside>
</div>
{/* ── Mobile marketplace CTA ── */}
{hasForSale > 0 && (
<div className="lg:hidden mt-4">
<Link to="/marketplace">
<div className="flex items-center gap-3 p-4 rounded-2xl bg-gradient-to-r from-[#ff6600]/18 to-[#ff0040]/12 border border-[#ff6600]/30 active:scale-[0.99] transition-transform">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#ff6600] to-[#ff0040] flex items-center justify-center flex-shrink-0 shadow-lg shadow-[#ff6600]/20">
<ShoppingCart className="h-5 w-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-black text-white leading-none">{hasForSale} item{hasForSale > 1 ? 's' : ''} ready to sell</p>
<p className="text-xs text-[#ff6600]/80 mt-0.5">Tap to go to Marketplace →</p>
</div>
</div>
</Link>
</div>
)}
</div>
{/* Mobile FABs */}
<div className="fixed md:hidden z-40 flex flex-col items-end gap-2" style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 0.75rem)', right: '0.75rem' }}>
<AddItemDialog onAdd={handleAddItem} mode="wtb"
trigger={<button className="h-10 w-10 rounded-full bg-cyan-500/90 shadow-lg shadow-cyan-500/20 border border-white/20 flex items-center justify-center active:scale-90 transition-transform touch-manipulation"><Heart className="h-4 w-4 text-black" /></button>}
/>
<AddItemDialog onAdd={handleAddItem} mode="stash"
trigger={<button className="rounded-full bg-gradient-to-br from-[#ff6600] to-[#ff0040] shadow-xl shadow-[#ff0040]/25 border-2 border-white/20 flex items-center justify-center active:scale-90 transition-transform touch-manipulation" style={{ height: '3.25rem', width: '3.25rem' }}><Plus className="h-6 w-6 text-white" strokeWidth={2.5} /></button>}
/>
</div>
{editingItem && (
<EditItemDialog item={editingItem} open={!!editingItem} onOpenChange={(open) => !open && setEditingItem(null)}
onSave={(updatedItem) => { handleUpdateItem(updatedItem); setEditingItem(null); }} />
)}
{listingItem && (
<ListAtSpotDialog item={listingItem} open={!!listingItem} onOpenChange={(open) => !open && setListingItem(null)} onSelectSpot={handleSpotSelection} />
)}
{/* Delete confirmation — deleting gear is destructive (and re-encrypts +
signs, so it costs an Amber prompt); confirm before committing. */}
<AlertDialog open={!!pendingDeleteId} onOpenChange={(open) => !open && setPendingDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove from your stash?</AlertDialogTitle>
<AlertDialogDescription>
{pendingDeleteItem
? <>“{pendingDeleteItem.title}” will be removed from your stash{pendingDeleteItem.visibility === 'public' ? ' and unlisted from your public showcase' : ''}. This can’t be undone.</>
: 'This item will be removed from your stash. This can’t be undone.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmRemoveItem}
className="bg-red-600 hover:bg-red-700 text-white"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}