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