SkateSpots/src/pages/StashPage.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00

1528 lines
87 KiB
TypeScript

import { useState, useMemo, useRef } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
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,
Check,
Download,
Upload,
HardDrive,
} from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
/** Sort order for the "Condition" option (best → worst). */
const CONDITION_SORT_RANK: Record<string, number> = { new: 0, like_new: 1, good: 2, well_used: 3 };
// ─── 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, selectable = false, selected = false, onToggleSelect }: {
item: StashItem; onEdit: () => void; onDelete: () => void; onListOnMarketplace?: () => void; isWTB?: boolean;
selectable?: boolean; selected?: boolean; onToggleSelect?: () => void;
}) {
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
onClick={selectable ? onToggleSelect : undefined}
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],
selectable && 'cursor-pointer',
selected && 'ring-2 ring-[#ccff00] border-[#ccff00]/60',
)}>
<div className={cn('absolute left-0 top-0 bottom-0 w-1 rounded-l-xl bg-gradient-to-b', RARITY_COLORS[rarity])} />
{selectable && (
<span className={cn('flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2', selected ? 'border-[#ccff00] bg-[#ccff00] text-black' : 'border-white/30')}>
{selected && <Check className="h-3 w-3" />}
</span>
)}
<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">
{!selectable && (
<>
<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, selectable = false, selected = false, onToggleSelect }: {
item: StashItem; onEdit: () => void; onDelete: () => void; onListOnMarketplace?: () => void; isWTB?: boolean;
selectable?: boolean; selected?: boolean; onToggleSelect?: () => void;
}) {
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
onClick={selectable ? onToggleSelect : undefined}
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', selected && 'ring-2 ring-[#ccff00] border-[#ccff00]/60')}>
<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>
)}
{selectable && (
<div className="absolute bottom-2 left-2 z-20">
<span className={cn('flex h-6 w-6 items-center justify-center rounded-full border-2', selected ? 'border-[#ccff00] bg-[#ccff00] text-black' : 'border-white/50 bg-black/50')}>
{selected && <Check className="h-3.5 w-3.5" />}
</span>
</div>
)}
<div className={cn('absolute inset-0 bg-black/85 transition-all duration-200 flex flex-col items-center justify-center gap-2 backdrop-blur-sm', selectable ? 'hidden' : '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', // A want is meant to be seen — default it public so it reaches the marketplace.
visibility: isWTB ? 'public' : '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: isWTB ? 'public' : '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' && (isWTB
? 'Appears in the Marketplace as a “wanted” ad, so sellers can find you.'
: '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' && (formData.itemType === 'wtb'
? 'Appears in the Marketplace as a “wanted” ad, so sellers can find you.'
: '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() {
const { t } = useTranslation();
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, importItems, bulkSetVisibility, bulkSetForSale, bulkRemove, isEncryptionAvailable } = useStash();
const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Backup & transfer: your stash is encrypted on Nostr, but a local copy you
// control means a dead relay (or a new account) can't lose your gear list. ──
const handleExport = () => {
const payload = {
app: 'SkateSpots',
type: 'stash-export',
version: 1,
exportedAt: new Date().toISOString(),
items,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `skatespots-stash-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast({ title: t('walletUi.stash.exported'), description: t('walletUi.stash.exportedDesc', { count: items.length }) });
};
const handleImportFile = async (file: File) => {
try {
const parsed = JSON.parse(await file.text()) as unknown;
const raw = Array.isArray(parsed) ? parsed : (parsed as { items?: unknown })?.items;
if (!Array.isArray(raw)) throw new Error(t('walletUi.stash.noItemsInFile'));
const cleaned = raw.filter(
(i): i is Omit<StashItem, 'id' | 'addedAt'> =>
!!i &&
typeof i === 'object' &&
typeof (i as { title?: unknown }).title === 'string' &&
typeof (i as { category?: unknown }).category === 'string',
);
if (cleaned.length === 0) throw new Error(t('walletUi.stash.noValidItems'));
const res = await importItems.mutateAsync(cleaned);
toast({
title: t('walletUi.stash.imported', { count: res.added }),
description: res.skipped > 0 ? t('walletUi.stash.importSkipped', { count: res.skipped }) : t('walletUi.stash.importAdded'),
});
} catch (e) {
toast({
title: t('walletUi.stash.importFailed'),
description: e instanceof Error ? e.message : t('walletUi.stash.couldNotRead'),
variant: 'destructive',
});
}
};
const backupCard = (
<div className="space-y-3 rounded-2xl border border-white/6 bg-white/2 p-4">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-[#ff6600]" />
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">{t('walletUi.stash.backupTransfer')}</span>
</div>
<p className="text-[11px] leading-snug text-gray-500">
{t('walletUi.stash.backupDesc')}
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1 gap-1.5 border-white/15"
onClick={handleExport}
disabled={items.length === 0}
>
<Download className="h-3.5 w-3.5" /> {t('walletUi.stash.export')}
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 gap-1.5 border-white/15"
onClick={() => fileInputRef.current?.click()}
disabled={importItems.isPending}
>
{importItems.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{t('walletUi.stash.import')}
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleImportFile(f);
e.target.value = '';
}}
/>
</div>
);
// Stash writes must run against a fresh relay copy — the multi-device merge
// depends on it. So an unreachable relay fails the action loudly instead of
// queuing a pre-signed blob that could overwrite another device's newer edit.
const onStashError = (e: unknown) => {
toast({
title: t('walletUi.stash.updateFailed'),
description: e instanceof Error ? e.message : t('walletUi.newsletter.tryAgain'),
variant: 'destructive',
});
};
// Add: save to encrypted stash, then publish a public event if it's public
// (gear → showcase/listing; a WANT → a marketplace want ad).
const handleAddItem = (item: Omit<StashItem, 'id' | 'addedAt'>) => {
addItem.mutate(item, {
onSuccess: (created) => {
if (created.visibility === 'public') {
syncShowcase(created).catch(() => {});
}
},
onError: onStashError,
});
};
// 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';
if (isPublic) {
syncShowcase(updated).catch(() => {});
} else if (wasPublic) {
// No longer public → pull the public event down.
unpublishShowcase(updated.id).catch(() => {});
}
},
onError: onStashError,
});
};
// 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(() => {});
}
},
onError: onStashError,
});
setPendingDeleteId(null);
};
// ── Bulk selection (multi-select delete / visibility / for-sale) ──────────
const [selecting, setSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const toggleSelect = (id: string) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const exitSelecting = () => {
setSelecting(false);
setSelectedIds(new Set());
};
const handleBulkVisibility = (visibility: StashItem['visibility']) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
bulkSetVisibility.mutate(
{ ids, visibility },
{
onSuccess: ({ changed }) => {
// Keep the public showcase in step with the new visibility.
for (const it of changed) {
if (it.visibility === 'public') syncShowcase(it).catch(() => {});
else unpublishShowcase(it.id).catch(() => {});
}
toast({ title: t('walletUi.stash.updatedCount', { count: changed.length }) });
exitSelecting();
},
onError: onStashError,
},
);
};
const handleBulkForSale = (forSale: boolean) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
bulkSetForSale.mutate(
{ ids, forSale },
{
onSuccess: ({ changed }) => {
for (const it of changed) {
if (it.visibility === 'public') syncShowcase(it).catch(() => {});
}
toast({ title: forSale ? t('walletUi.stash.markedForSale', { count: changed.length }) : t('walletUi.stash.unmarkedCount', { count: changed.length }) });
exitSelecting();
},
onError: onStashError,
},
);
};
const handleBulkDelete = () => {
const ids = [...selectedIds];
if (ids.length === 0) return;
bulkRemove.mutate(ids, {
onSuccess: ({ removed }) => {
for (const it of removed) {
if (it.visibility === 'public') unpublishShowcase(it.id).catch(() => {});
}
toast({ title: t('walletUi.stash.removedCount', { count: removed.length }) });
setBulkDeleteOpen(false);
exitSelecting();
},
onError: onStashError,
});
};
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState<string>('all');
const [filterLocation, setFilterLocation] = useState<string>('all');
const [filterSort, setFilterSort] = useState<'recent' | 'value' | 'condition' | 'az'>('recent');
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) &&
!item.size?.toLowerCase().includes(q)
) {
return false;
}
}
if (filterCategory !== 'all' && item.category !== filterCategory) return false;
if (filterLocation !== 'all' && item.storageLocation !== filterLocation) return false;
return true;
})
.sort((a, b) => {
switch (filterSort) {
case 'value':
return (b.minPrice ?? b.maxPrice ?? 0) - (a.minPrice ?? a.maxPrice ?? 0);
case 'condition':
return (CONDITION_SORT_RANK[a.condition] ?? 9) - (CONDITION_SORT_RANK[b.condition] ?? 9);
case 'az':
return a.title.localeCompare(b.title);
case 'recent':
default:
return b.addedAt - a.addedAt;
}
});
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;
// Public items are published as separate NIP-99 listings — show which ones are
// visible to others, with a link to the real profile view.
const publicItems = stashItems.filter((i) => i.visibility === 'public');
const profileHref = user?.pubkey ? `/${nip19.npubEncode(user.pubkey)}` : '/';
const publicCard = publicItems.length > 0 && (
<div className="space-y-3 rounded-2xl border border-emerald-500/20 bg-gradient-to-br from-emerald-500/8 to-transparent p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-emerald-400" />
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-300">Public stash</span>
</div>
<span className="text-2xl font-black text-emerald-400">{publicItems.length}</span>
</div>
<div className="flex flex-wrap gap-1">
{publicItems.slice(0, 4).map((i) => (
<span key={i.id} className="max-w-[9rem] truncate rounded-full bg-white/6 px-2 py-0.5 text-[10px] text-gray-300">{i.title}</span>
))}
{publicItems.length > 4 && <span className="text-[10px] text-gray-500">+{publicItems.length - 4}</span>}
</div>
<p className="text-[11px] leading-snug text-gray-500">Anyone can see these on your profile.</p>
<Link to={profileHref} className="block">
<Button size="sm" variant="outline" className="w-full gap-1.5 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/10">
<Globe className="h-3.5 w-3.5" /> See what others see
</Button>
</Link>
</div>
);
// ── Not logged in ──
if (!user) {
return (
<>
<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' }}>{t('walletUi.stash.title')}</h1>
<p className="text-gray-300 mb-8 max-w-lg mx-auto text-lg">{t('walletUi.stash.loginDesc')}</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">{t('walletUi.stash.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">{t('walletUi.stash.syncsEverywhere')}</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">{t('walletUi.stash.p2pTrading')}</p></div>
</div>
<LoginArea className="justify-center" />
</div>
</div>
</div>
</>
);
}
// ── No encryption ──
if (!isEncryptionAvailable) {
return (
<>
<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">{t('walletUi.stash.encryptionRequired')}</h2>
<p className="text-gray-300 mb-6 max-w-md mx-auto">{t('walletUi.stash.encryptionRequiredDesc')}</p>
<Button variant="outline" asChild><Link to="/signers">{t('walletUi.stash.learnSigners')}</Link></Button>
</CardContent>
</Card>
</div>
</>
);
}
return (
<>
<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">{t('walletUi.stash.title')}</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" />{t('walletUi.stash.encrypted')}</span>
<span className="text-white/15 text-[10px]">·</span>
<span className="text-[10px] text-[#ccff00]/70 whitespace-nowrap">{t('walletUi.stash.syncsEverywhere')}</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">
{/* Bulk select */}
{stats.total > 0 && (
<button
type="button"
onClick={() => (selecting ? exitSelecting() : setSelecting(true))}
className={cn(
'flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors',
selecting
? 'border-[#ccff00]/50 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-gray-400 hover:text-white',
)}
>
{selecting ? <X className="h-3.5 w-3.5" /> : <Check className="h-3.5 w-3.5" />}
{selecting ? t('common.cancel') : t('walletUi.stash.select')}
</button>
)}
{/* 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' ? t('walletUi.stash.stash') : t('walletUi.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={t('common.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={t('walletUi.stash.allCategories')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('walletUi.stash.allCategories')}</SelectItem>
{Object.entries(STASH_CATEGORIES).map(([key, cat]) => <SelectItem key={key} value={key}>{cat.emoji} {cat.label}</SelectItem>)}
</SelectContent>
</Select>
<Select value={filterSort} onValueChange={(v) => setFilterSort(v as typeof filterSort)}>
<SelectTrigger className="w-[110px] h-8 bg-white/4 border-white/8 text-sm flex-shrink-0" aria-label="Sort"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="recent">{t('walletUi.stash.sortNewest')}</SelectItem>
<SelectItem value="value">{t('walletUi.stash.sortValue')}</SelectItem>
<SelectItem value="condition">{t('walletUi.stash.sortCondition')}</SelectItem>
<SelectItem value="az">{t('walletUi.stash.sortAz')}</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">{t('walletUi.stash.wishlistEmpty')}</p>
<p className="text-sm text-muted-foreground mb-7 max-w-xs leading-relaxed">{t('walletUi.stash.wishlistEmptyDesc')}</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">{t('walletUi.stash.stashEmpty')}</p>
<p className="text-muted-foreground mb-8 max-w-sm leading-relaxed">{t('walletUi.stash.stashEmptyDesc')}</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">{t('walletUi.stash.noMatch')}</p>
<p className="text-muted-foreground text-sm mb-4">{t('walletUi.stash.noMatchDesc')}</p>
<button onClick={() => { setSearchQuery(''); setFilterCategory('all'); setFilterLocation('all'); }} className="text-xs text-[#ff6600] hover:underline font-black uppercase tracking-wider">{t('common.clearFilters')}</button>
</>
)}
</div>
) : viewMode === 'list' ? (
<div className="space-y-2">
{filteredItems.map((item) => (
<StashListRow key={item.id} item={item} isWTB={item.itemType === 'wtb'}
selectable={selecting} selected={selectedIds.has(item.id)} onToggleSelect={() => toggleSelect(item.id)}
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'}
selectable={selecting} selected={selectedIds.has(item.id)} onToggleSelect={() => toggleSelect(item.id)}
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">Public wants are posted to the Marketplace as “wanted” ads, so sellers can find you.</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>
)}
{/* Public stash preview */}
{publicCard}
{/* Backup & transfer */}
{backupCard}
{/* 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>
)}
{/* Backup & transfer — mobile (the sidebar is desktop-only). */}
<div className="mt-4 space-y-4 lg:hidden">
{publicCard}
{backupCard}
</div>
</div>
{/* ── Bulk action bar ── */}
{selecting && (
<div
className="fixed inset-x-0 z-40 border-t border-white/10 bg-black/95 px-3 py-2.5 backdrop-blur-md"
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px))' }}
>
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-2">
<span className="text-sm font-bold text-white">{t('walletUi.stash.selectedCount', { count: selectedIds.size })}</span>
<button
type="button"
onClick={() => setSelectedIds(new Set(filteredItems.map((i) => i.id)))}
className="text-xs font-bold text-[#ccff00] hover:underline"
>
{t('walletUi.stash.selectAll')}
</button>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" className="h-8 gap-1.5 border-white/15" disabled={selectedIds.size === 0} onClick={() => handleBulkVisibility('public')}>
<Globe className="h-3.5 w-3.5" /> {t('walletUi.stash.visibilityPublic')}
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1.5 border-white/15" disabled={selectedIds.size === 0} onClick={() => handleBulkVisibility('private')}>
<Lock className="h-3.5 w-3.5" /> {t('walletUi.stash.visibilityPrivate')}
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1.5 border-green-500/30 text-green-400 hover:bg-green-500/10" disabled={selectedIds.size === 0} onClick={() => handleBulkForSale(true)}>
<Zap className="h-3.5 w-3.5" /> {t('walletUi.stash.forSale')}
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1.5 border-red-500/30 text-red-400 hover:bg-red-500/10" disabled={selectedIds.size === 0} onClick={() => setBulkDeleteOpen(true)}>
<Trash2 className="h-3.5 w-3.5" /> {t('walletUi.stash.remove')}
</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={exitSelecting}>{t('common.done')}</Button>
</div>
</div>
</div>
)}
{/* Bulk delete confirmation */}
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('walletUi.stash.bulkDeleteTitle', { count: selectedIds.size })}
</AlertDialogTitle>
<AlertDialogDescription>
{t('walletUi.stash.bulkDeleteDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDelete} className="bg-red-500 text-white hover:bg-red-600">
{t('walletUi.stash.remove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 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>{t('walletUi.stash.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{pendingDeleteItem
? <>{t('walletUi.stash.deleteDesc', { title: pendingDeleteItem.title })}{pendingDeleteItem.visibility === 'public' ? t('walletUi.stash.deleteDescPublic') : ''}{t('walletUi.stash.deleteUndone')}</>
: t('walletUi.stash.deleteDescGeneric')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmRemoveItem}
className="bg-red-600 hover:bg-red-700 text-white"
>
{t('walletUi.stash.remove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}