~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).
1528 lines
87 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|