Inspired by BitChat's location-channel concept, reframed for skaters and kept fully SkateSpots-native (own kind + tag conventions, no interop). - New ephemeral kind 24899 tagged with a geohash `g` (documented in NIP.md) - useSpotChat / useSendSpotChat hooks (polled, community-relay aware) - SpotChat component: live message list, composer, author profiles, Block/Neighborhood/City precision toggle, and light IRC-style flavor (/me action messages, /who participant count) - Spot detail pages now show a per-spot block-level channel - Map gains a "Local Chat" sheet anchored to the map center with the precision selector Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1815 lines
82 KiB
TypeScript
1815 lines
82 KiB
TypeScript
import { useState, useMemo, useEffect, lazy, Suspense } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { Layout } from '@/components/Layout';
|
||
|
||
// Lazy-load the map — Leaflet is large (~150KB) and only needed on this page
|
||
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
|
||
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||
import { WeatherToggle } from '@/components/WeatherToggle';
|
||
import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
||
import { SortDropdown, type SortOption, calculateDistance } from '@/components/SortDropdown';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from '@/components/ui/sheet';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { Progress } from '@/components/ui/progress';
|
||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
import { LoginArea } from '@/components/auth/LoginArea';
|
||
|
||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||
import { useShops } from '@/hooks/useShops';
|
||
import { useMarketplaceItems } from '@/hooks/useMarketplace';
|
||
import { useTours } from '@/hooks/useTours';
|
||
import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents';
|
||
import { useCrews } from '@/hooks/useCrews';
|
||
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useFollowing } from '@/hooks/useFollowing';
|
||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||
import { useMultipleSpotWeather } from '@/hooks/useSpotWeather';
|
||
import { useComments } from '@/hooks/useComments';
|
||
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
import { useTranslation } from '@/contexts/LanguageContext';
|
||
import { SPOT_CATEGORIES, SPOT_TYPE_LABELS } from '@/lib/types';
|
||
import type { SkateSpot, SkateShop, SkateTour } from '@/lib/types';
|
||
import ngeohash from 'ngeohash';
|
||
import {
|
||
Search,
|
||
Plus,
|
||
List,
|
||
Map,
|
||
X,
|
||
ShoppingBag,
|
||
Route,
|
||
Backpack,
|
||
Package,
|
||
Target,
|
||
Star,
|
||
Zap,
|
||
Lock,
|
||
Users,
|
||
ArrowRightLeft,
|
||
Crown,
|
||
Shield,
|
||
Trash2,
|
||
AlertTriangle,
|
||
Bitcoin,
|
||
SlidersHorizontal,
|
||
Check,
|
||
Camera,
|
||
Navigation,
|
||
ExternalLink,
|
||
MapPin,
|
||
LocateFixed,
|
||
LocateOff,
|
||
Store,
|
||
Info,
|
||
Layers,
|
||
Calendar,
|
||
MessageCircle,
|
||
} from 'lucide-react';
|
||
import { CameraCapture } from '@/components/CameraCapture';
|
||
import { SpotChat } from '@/components/SpotChat';
|
||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
|
||
type ViewMode = 'map' | 'list';
|
||
|
||
// Compact stash item card for the drawer
|
||
function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) {
|
||
const category = STASH_CATEGORIES[item.category];
|
||
const condition = CONDITION_LABELS[item.condition];
|
||
const rarity = (item.rarity || category.rarity) as keyof typeof RARITY_TIERS;
|
||
const rarityTier = RARITY_TIERS[rarity];
|
||
const isWishlist = item.itemType === 'wtb';
|
||
const hasImage = item.images && item.images.length > 0;
|
||
|
||
return (
|
||
<div className={cn(
|
||
'relative p-3 rounded-lg border-2 bg-black/60 transition-all hover:bg-black/80',
|
||
isWishlist ? 'border-cyan-500/40' : `border-${rarity === 'common' ? 'gray' : rarity === 'uncommon' ? 'green' : rarity === 'rare' ? 'blue' : rarity === 'epic' ? 'purple' : 'yellow'}-500/40`
|
||
)} style={{ borderColor: rarityTier.color + '40' }}>
|
||
{/* Wishlist badge */}
|
||
{isWishlist && (
|
||
<Badge className="absolute -top-2 -left-1 bg-cyan-500 text-black text-[9px] px-1.5 py-0">
|
||
<Target className="h-2.5 w-2.5 mr-0.5" />
|
||
Wishlist
|
||
</Badge>
|
||
)}
|
||
|
||
<div className="flex items-start gap-2">
|
||
{/* Image or emoji */}
|
||
{hasImage ? (
|
||
<div className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 border border-white/10">
|
||
<img src={item.images![0]} alt={item.title} className="w-full h-full object-cover" />
|
||
</div>
|
||
) : (
|
||
<span className="text-2xl">{category.emoji}</span>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<h4 className="font-bold text-sm text-white truncate">{item.title}</h4>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{item.brand && <span>{item.brand} • </span>}
|
||
{item.size && <span>{item.size}</span>}
|
||
</p>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
{!isWishlist && (
|
||
<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-700'
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{(item.minPrice || item.maxPrice) && (
|
||
<span className="text-[10px] text-amber-400 flex items-center gap-0.5">
|
||
<Zap className="h-2.5 w-2.5" />
|
||
{isWishlist ? `≤${item.maxPrice?.toLocaleString()}` : item.minPrice?.toLocaleString()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col items-end gap-1">
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[8px] px-1 py-0"
|
||
style={{ borderColor: rarityTier.color, color: rarityTier.color }}
|
||
>
|
||
{rarityTier.label}
|
||
</Badge>
|
||
<button
|
||
onClick={onDelete}
|
||
className="p-1 rounded hover:bg-red-500/20 text-gray-500 hover:text-red-400 transition-colors"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Stash drawer component
|
||
function StashDrawer() {
|
||
const { user } = useCurrentUser();
|
||
const { items, stats, isLoading, addItem, removeItem, isEncryptionAvailable } = useStash();
|
||
const author = useAuthor(user?.pubkey || '');
|
||
const metadata = author.data?.metadata;
|
||
const displayName = metadata?.name ?? genUserName(user?.pubkey || '');
|
||
const avatar = metadata?.picture;
|
||
const usdValue = useSatsToUsd(stats.totalValue);
|
||
|
||
const stashItems = items.filter(i => i.itemType !== 'wtb');
|
||
const wishlistItems = items.filter(i => i.itemType === 'wtb');
|
||
|
||
// Not logged in
|
||
if (!user) {
|
||
return (
|
||
<div className="p-6 text-center">
|
||
<Backpack className="h-12 w-12 mx-auto mb-4 text-[#ff6600]/50" />
|
||
<h3 className="font-bold text-lg mb-2">Your Private Stash</h3>
|
||
<p className="text-sm text-muted-foreground mb-4">
|
||
Track your gear, set prices, and find trades with other skaters.
|
||
</p>
|
||
<LoginArea className="justify-center" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// No encryption
|
||
if (!isEncryptionAvailable) {
|
||
return (
|
||
<div className="p-6 text-center">
|
||
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" />
|
||
<h3 className="font-bold text-lg mb-2">Encryption Required</h3>
|
||
<p className="text-sm text-muted-foreground mb-4">
|
||
Use a signer with NIP-44 support (Alby, nos2x, Amber).
|
||
</p>
|
||
<Link to="/signers">
|
||
<Button variant="outline" size="sm">Learn More</Button>
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Profile header */}
|
||
<div className="p-4 bg-gradient-to-r from-[#ff6600]/20 to-[#ff0040]/20 border-b border-[#ff6600]/30">
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="h-12 w-12 border-2 border-[#ff6600]/50">
|
||
<AvatarImage src={avatar} alt={displayName} />
|
||
<AvatarFallback className="bg-[#ff6600]/20 text-[#ff6600] font-bold">
|
||
{displayName.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-bold text-white truncate">{displayName}</h3>
|
||
<p className="text-xs text-muted-foreground">{stats.total} items</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="flex items-center gap-1 text-amber-400">
|
||
<Zap className="h-3.5 w-3.5" />
|
||
<span className="font-bold text-sm">
|
||
{stats.totalValue > 999 ? Math.floor(stats.totalValue / 1000) + 'k' : stats.totalValue}
|
||
</span>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{usdValue ? `~$${usdValue.toFixed(0)}` : 'sats'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick stats */}
|
||
<div className="grid grid-cols-3 gap-2 p-3 border-b border-white/10">
|
||
<div className="text-center p-2 rounded bg-black/40">
|
||
<div className="text-lg font-black text-[#ff0040]">{stashItems.length}</div>
|
||
<div className="text-[9px] text-muted-foreground uppercase">Items</div>
|
||
</div>
|
||
<div className="text-center p-2 rounded bg-black/40">
|
||
<div className="text-lg font-black text-cyan-400">{wishlistItems.length}</div>
|
||
<div className="text-[9px] text-muted-foreground uppercase">Wishlist</div>
|
||
</div>
|
||
<div className="text-center p-2 rounded bg-black/40">
|
||
<div className="text-lg font-black text-purple-400">{Object.keys(stats.byCategory).length}</div>
|
||
<div className="text-[9px] text-muted-foreground uppercase">Types</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Items list */}
|
||
<div className="flex-1 overflow-auto p-3 space-y-2">
|
||
{isLoading ? (
|
||
Array.from({ length: 3 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-20 w-full bg-white/5" />
|
||
))
|
||
) : items.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<Package className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" />
|
||
<p className="text-sm text-muted-foreground mb-3">Your stash is empty</p>
|
||
<Link to="/stash">
|
||
<Button size="sm" className="bg-[#ff6600] hover:bg-[#ff6600]/90">
|
||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||
Add First Item
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Wishlist items first */}
|
||
{wishlistItems.length > 0 && (
|
||
<div className="mb-3">
|
||
<h4 className="text-[10px] font-bold uppercase text-cyan-400 mb-2 flex items-center gap-1">
|
||
<Target className="h-3 w-3" />
|
||
Wishlist ({wishlistItems.length})
|
||
</h4>
|
||
{wishlistItems.slice(0, 3).map(item => (
|
||
<MiniStashCard
|
||
key={item.id}
|
||
item={item}
|
||
onDelete={() => removeItem.mutate(item.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Stash items */}
|
||
{stashItems.length > 0 && (
|
||
<div>
|
||
<h4 className="text-[10px] font-bold uppercase text-[#ff6600] mb-2 flex items-center gap-1">
|
||
<Package className="h-3 w-3" />
|
||
My Gear ({stashItems.length})
|
||
</h4>
|
||
<div className="space-y-2">
|
||
{stashItems.slice(0, 5).map(item => (
|
||
<MiniStashCard
|
||
key={item.id}
|
||
item={item}
|
||
onDelete={() => removeItem.mutate(item.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer actions */}
|
||
<div className="p-3 border-t border-white/10 space-y-2">
|
||
<Link to="/stash" className="block">
|
||
<Button className="w-full bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 font-bold">
|
||
<Backpack className="h-4 w-4 mr-2" />
|
||
Open Full Stash
|
||
</Button>
|
||
</Link>
|
||
<div className="flex gap-2">
|
||
<Link to="/stash" className="flex-1">
|
||
<Button variant="outline" size="sm" className="w-full border-[#ff6600]/50 text-[#ff6600] hover:bg-[#ff6600]/10">
|
||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||
Add Item
|
||
</Button>
|
||
</Link>
|
||
<Link to="/stash" className="flex-1">
|
||
<Button variant="outline" size="sm" className="w-full border-cyan-500/50 text-cyan-400 hover:bg-cyan-500/10">
|
||
<Target className="h-3.5 w-3.5 mr-1" />
|
||
Wishlist
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Detect iOS/macOS for smarter maps ordering
|
||
function isApplePlatform(): boolean {
|
||
if (typeof navigator === 'undefined') return false;
|
||
return /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent);
|
||
}
|
||
|
||
// Universal directions picker — works on iOS, Android, desktop
|
||
function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
const onApple = isApplePlatform();
|
||
|
||
const allOptions = [
|
||
{
|
||
name: 'Apple Maps',
|
||
sub: 'iOS & macOS',
|
||
href: `https://maps.apple.com/?ll=${lat},${lon}&q=${label}&dirflg=d`,
|
||
emoji: '🍎',
|
||
appleOnly: true,
|
||
},
|
||
{
|
||
name: 'Google Maps',
|
||
sub: 'Opens in browser or app',
|
||
href: `https://maps.google.com/?q=${lat},${lon}`,
|
||
emoji: '🔵',
|
||
appleOnly: false,
|
||
},
|
||
{
|
||
name: 'OpenStreetMap',
|
||
sub: 'Privacy-friendly, no account',
|
||
href: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=17`,
|
||
emoji: '🗺️',
|
||
appleOnly: false,
|
||
},
|
||
{
|
||
name: 'Waze',
|
||
sub: 'Community-powered navigation',
|
||
href: `https://waze.com/ul?ll=${lat},${lon}&navigate=yes`,
|
||
emoji: '🔸',
|
||
appleOnly: false,
|
||
},
|
||
];
|
||
|
||
// On Apple platforms: Apple Maps first. On others: Google first, no Apple.
|
||
const options = onApple
|
||
? allOptions
|
||
: allOptions.filter(o => !o.appleOnly);
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
className="h-12 w-full font-bold text-sm border-white/20 hover:bg-white/10 rounded-xl"
|
||
onClick={() => setOpen(true)}
|
||
>
|
||
<Navigation className="h-4 w-4 mr-2 text-[#ccff00]" />
|
||
Directions
|
||
</Button>
|
||
|
||
{open && (
|
||
<>
|
||
{/* Backdrop */}
|
||
<div className="fixed inset-0 z-[2000]" onClick={() => setOpen(false)} />
|
||
{/* Menu — fixed, centered, never clips behind Sheet stacking context */}
|
||
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[2001] bg-black/97 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-[min(18rem,calc(100vw-2rem))] backdrop-blur animate-in fade-in zoom-in-95 duration-150">
|
||
<div className="px-4 py-3 border-b border-white/8">
|
||
<p className="text-xs font-black uppercase tracking-widest text-white/50">Open with</p>
|
||
</div>
|
||
{options.map(opt => (
|
||
<a
|
||
key={opt.name}
|
||
href={opt.href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={() => setOpen(false)}
|
||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/8 transition-colors"
|
||
>
|
||
<span className="text-xl w-7 text-center">{opt.emoji}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-bold text-white">{opt.name}</div>
|
||
<div className="text-[10px] text-white/40">{opt.sub}</div>
|
||
</div>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Bottom sheet shown when a map marker is tapped
|
||
function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => void }) {
|
||
const navigate = useNavigate();
|
||
const author = useAuthor(spot.pubkey);
|
||
const metadata = author.data?.metadata;
|
||
const displayName = metadata?.name ?? genUserName(spot.pubkey);
|
||
const isShop = spot.types.includes('shop');
|
||
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
|
||
|
||
return (
|
||
<div className="flex flex-col">
|
||
{/* Drag handle */}
|
||
<div className="flex justify-center pt-3 pb-1">
|
||
<div className="w-10 h-1 rounded-full bg-white/20" />
|
||
</div>
|
||
|
||
<div className="px-4 pb-2">
|
||
{/* Header row */}
|
||
<div className="flex items-start gap-3 mb-3">
|
||
{/* Thumbnail */}
|
||
<div className="w-16 h-16 rounded-xl overflow-hidden bg-white/5 flex-shrink-0 border border-white/10">
|
||
{spot.images[0] ? (
|
||
<img src={spot.images[0]} alt={spot.title} className="w-full h-full object-cover" />
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center text-2xl">
|
||
{isShop ? '🏪' : '🛹'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-0.5">
|
||
<h2 className="font-black text-white text-lg leading-tight truncate">{spot.title}</h2>
|
||
</div>
|
||
{spot.location && (
|
||
<p className="text-xs text-muted-foreground flex items-center gap-1 truncate">
|
||
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||
{spot.location}
|
||
</p>
|
||
)}
|
||
{/* Type badges */}
|
||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||
{spot.types.slice(0, 3).map((type) => (
|
||
<span
|
||
key={type}
|
||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-white/10 text-white/70"
|
||
>
|
||
{SPOT_TYPE_LABELS[type] || type}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Close */}
|
||
<button
|
||
onClick={onClose}
|
||
className="p-1.5 rounded-full hover:bg-white/10 text-white/40 hover:text-white transition-colors flex-shrink-0 -mt-0.5"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
{spot.description && (
|
||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{spot.description}</p>
|
||
)}
|
||
|
||
{/* Author */}
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<Avatar className="h-5 w-5">
|
||
<AvatarImage src={metadata?.picture} />
|
||
<AvatarFallback className="text-[8px] bg-white/10">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||
</Avatar>
|
||
<span className="text-xs text-muted-foreground">Added by <span className="text-white/70">{displayName}</span></span>
|
||
</div>
|
||
|
||
{/* CTA buttons */}
|
||
<div className="grid grid-cols-2 gap-2 pb-safe pb-4">
|
||
<Button
|
||
onClick={() => navigate(route)}
|
||
className="h-12 font-bold text-sm bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 rounded-xl"
|
||
>
|
||
<ExternalLink className="h-4 w-4 mr-2" />
|
||
View Details
|
||
</Button>
|
||
{spot.geohash && (() => {
|
||
const { latitude, longitude } = ngeohash.decode(spot.geohash);
|
||
const lat = latitude.toFixed(6);
|
||
const lon = longitude.toFixed(6);
|
||
const label = encodeURIComponent(spot.title);
|
||
return (
|
||
<div className="relative">
|
||
<DirectionsMenu lat={lat} lon={lon} label={label} />
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function MapPage() {
|
||
const { t } = useTranslation();
|
||
|
||
useSeoMeta({
|
||
title: 'Skate Spots Map - SkateSpots',
|
||
description: 'Explore skateboarding spots around the world on our interactive map.',
|
||
});
|
||
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
const { user } = useCurrentUser();
|
||
const { data: followingList } = useFollowing(user?.pubkey);
|
||
const followCount = followingList?.length ?? 0;
|
||
const { data: spots, isLoading: spotsLoading } = useSkateSpots(200);
|
||
const { data: shops, isLoading: shopsLoading } = useShops(200);
|
||
const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200);
|
||
const { data: tours, isLoading: toursLoading } = useTours(100);
|
||
// Upcoming events for the (opt-in) Events map layer
|
||
const { data: upcomingEvents } = useUpcomingSkateEvents(100);
|
||
// Crews for the (opt-in) Crews map layer
|
||
const { data: crews } = useCrews(200);
|
||
|
||
// Check if coming with ?view=gear to focus on marketplace
|
||
const viewParam = searchParams.get('view');
|
||
const focusGear = viewParam === 'gear';
|
||
|
||
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null);
|
||
const [weatherEnabled, setWeatherEnabled] = useState(false);
|
||
const [weatherFilter, setWeatherFilter] = useState<'all' | 'good' | 'perfect'>('all');
|
||
// Smart WoT default:
|
||
// - Logged out or <5 follows → 'all' (show everything, don't punish new/anon users)
|
||
// - Logged in with ≥5 follows → 'trusted' (sort trusted first, badge unverified)
|
||
// User can always override with the TrustFilter dropdown
|
||
const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all';
|
||
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||
const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(null);
|
||
|
||
// Map entity type filters - if ?view=gear, only show gear initially
|
||
const [showSpots, setShowSpots] = useState(!focusGear);
|
||
const [showShops, setShowShops] = useState(!focusGear);
|
||
const [showGear, setShowGear] = useState(true);
|
||
const [showTours, setShowTours] = useState(!focusGear);
|
||
// Events layer — off by default; users opt in via the Layers control
|
||
const [showEvents, setShowEvents] = useState(false);
|
||
// Crews layer — off by default; users opt in via the Layers control
|
||
const [showCrews, setShowCrews] = useState(false);
|
||
const [showBtcMap, setShowBtcMap] = useState(false);
|
||
|
||
// Mobile filters sheet
|
||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||
|
||
// Welcome banner — show once to new/logged-out visitors, dismissible
|
||
const [welcomeDismissed, setWelcomeDismissed] = useLocalStorage('skatespots_welcome_dismissed', false);
|
||
const showWelcomeBanner = !user && !welcomeDismissed;
|
||
|
||
// Track Sasquatch panel open state so the Add Spot FAB can step aside
|
||
const [sasquatchPanelOpen, setSasquatchPanelOpen] = useState(false);
|
||
useEffect(() => {
|
||
const handler = (e: Event) => {
|
||
setSasquatchPanelOpen((e as CustomEvent<{ open: boolean }>).detail.open);
|
||
};
|
||
window.addEventListener('sasquatch:panelchange', handler);
|
||
return () => window.removeEventListener('sasquatch:panelchange', handler);
|
||
}, []);
|
||
|
||
// Mobile "Add" speed-dial (fans out Add Spot / Shop / Tour / Quick Snap)
|
||
const [addDialOpen, setAddDialOpen] = useState(false);
|
||
// Mobile layers popover
|
||
const [layersOpen, setLayersOpen] = useState(false);
|
||
|
||
// Quick camera capture
|
||
const [showQuickCamera, setShowQuickCamera] = useState(false);
|
||
const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile();
|
||
const { toast } = useToast();
|
||
|
||
// BTC Map center for fetching places
|
||
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({ lat: 40.7128, lon: -74.006 });
|
||
|
||
// Near Me fly-to state
|
||
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
||
const [nearMeLoading, setNearMeLoading] = useState(false);
|
||
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
|
||
const [showLocationHelp, setShowLocationHelp] = useState(false);
|
||
|
||
|
||
|
||
const handleNearMe = () => {
|
||
// Clear previous error
|
||
setNearMeError(null);
|
||
|
||
if (!navigator.geolocation) {
|
||
setNearMeError('unavailable');
|
||
toast({
|
||
title: 'Location not available',
|
||
description: 'Your browser does not support geolocation. Try Chrome or Safari.',
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
setNearMeLoading(true);
|
||
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 14 });
|
||
setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude });
|
||
setNearMeLoading(false);
|
||
setNearMeError(null);
|
||
},
|
||
(err) => {
|
||
setNearMeLoading(false);
|
||
if (err.code === err.PERMISSION_DENIED) {
|
||
setNearMeError('denied');
|
||
setShowLocationHelp(true);
|
||
} else if (err.code === err.TIMEOUT) {
|
||
setNearMeError('timeout');
|
||
toast({
|
||
title: 'Location timed out',
|
||
description: 'Could not get your location in time. Check your signal and try again.',
|
||
variant: 'destructive',
|
||
});
|
||
} else {
|
||
setNearMeError('unavailable');
|
||
toast({
|
||
title: 'Location unavailable',
|
||
description: 'Your location could not be determined. Make sure location services are enabled.',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }
|
||
);
|
||
};
|
||
|
||
const categoryKeys = Object.keys(SPOT_CATEGORIES);
|
||
|
||
// Fetch BTC Map places when enabled
|
||
const { data: btcMapPlaces, isLoading: btcMapLoading, error: btcMapError } = useBtcMapPlaces({
|
||
lat: mapCenter.lat,
|
||
lon: mapCenter.lon,
|
||
radiusKm: 50, // 50km radius
|
||
enabled: showBtcMap,
|
||
});
|
||
|
||
// Debug logging for BTC Map
|
||
useEffect(() => {
|
||
if (showBtcMap) {
|
||
console.log('[MapPage] BTC Map enabled, center:', mapCenter, 'loading:', btcMapLoading, 'places:', btcMapPlaces?.length, 'error:', btcMapError);
|
||
}
|
||
}, [showBtcMap, mapCenter, btcMapLoading, btcMapPlaces, btcMapError]);
|
||
|
||
const isLoading = spotsLoading || shopsLoading || marketplaceLoading || toursLoading;
|
||
|
||
// Get user's location for proximity sorting
|
||
useMemo(() => {
|
||
if (sortBy === 'proximity' && !userLocation) {
|
||
navigator.geolocation?.getCurrentPosition(
|
||
(position) => {
|
||
setUserLocation({
|
||
lat: position.coords.latitude,
|
||
lon: position.coords.longitude,
|
||
});
|
||
console.log('[MapPage] User location:', position.coords);
|
||
},
|
||
(error) => {
|
||
console.warn('[MapPage] Geolocation error:', error);
|
||
}
|
||
);
|
||
}
|
||
}, [sortBy, userLocation]);
|
||
|
||
// Combine spots and shops into unified list for map display
|
||
const allLocations = useMemo(() => {
|
||
const spotList = (spots || []).map(spot => ({ ...spot, isShop: false }));
|
||
const shopList = (shops || []).map(shop => ({
|
||
...shop,
|
||
title: shop.name,
|
||
types: ['shop' as const],
|
||
isShop: true,
|
||
accessType: undefined,
|
||
contactInfo: undefined,
|
||
}));
|
||
return [...spotList, ...shopList];
|
||
}, [spots, shops]);
|
||
|
||
// Fetch trust scores for all creators
|
||
const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]);
|
||
const { data: trustScores } = useBatchTrustScores(locationPubkeys);
|
||
|
||
const getTrustScore = (pubkey: string) => trustScores?.get(pubkey);
|
||
|
||
// Fetch comments for popularity sorting
|
||
const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]);
|
||
const { data: allComments } = useComments(spotIds);
|
||
|
||
const getCommentCount = (eventId: string) => {
|
||
return allComments?.filter((c) => {
|
||
const eTag = c.tags.find(([tag]) => tag === 'e')?.[1];
|
||
return eTag === eventId;
|
||
}).length || 0;
|
||
};
|
||
|
||
// Get geohashes for weather fetching (only non-shop spots)
|
||
const spotGeohashes = useMemo(() => {
|
||
const geohashes = allLocations
|
||
.filter(spot => !spot.isShop && spot.geohash)
|
||
.map(spot => spot.geohash);
|
||
|
||
console.log('[MapPage] Spot geohashes:', geohashes);
|
||
console.log('[MapPage] Weather enabled:', weatherEnabled);
|
||
return geohashes;
|
||
}, [allLocations]);
|
||
|
||
// Fetch weather data for all spots
|
||
const { data: weatherData, isLoading: weatherLoading } = useMultipleSpotWeather(
|
||
spotGeohashes,
|
||
weatherEnabled
|
||
);
|
||
|
||
const handleMapClick = (lat: number, lng: number) => {
|
||
if (!user) return;
|
||
// Navigate to create spot with coordinates pre-filled
|
||
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`);
|
||
};
|
||
|
||
// Handle quick camera capture - upload and navigate to create spot
|
||
const handleQuickCameraCapture = async (file: File) => {
|
||
try {
|
||
const tags = await uploadFile(file);
|
||
const url = tags[0]?.[1];
|
||
if (url) {
|
||
setShowQuickCamera(false);
|
||
// Store the captured image URL in sessionStorage for the create spot page
|
||
sessionStorage.setItem('quickCaptureImage', url);
|
||
// Get current location and navigate to create spot
|
||
if (navigator.geolocation) {
|
||
navigator.geolocation.getCurrentPosition(
|
||
(position) => {
|
||
navigate(`/create-spot?lat=${position.coords.latitude.toFixed(6)}&lng=${position.coords.longitude.toFixed(6)}&quickCapture=true`);
|
||
},
|
||
() => {
|
||
// If geolocation fails, still navigate but without coords
|
||
navigate('/create-spot?quickCapture=true');
|
||
}
|
||
);
|
||
} else {
|
||
navigate('/create-spot?quickCapture=true');
|
||
}
|
||
toast({
|
||
title: 'Photo captured!',
|
||
description: 'Now add the spot details.',
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Upload failed',
|
||
description: error instanceof Error ? error.message : 'Failed to upload photo',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
// Filter combined spots and shops
|
||
const filteredSpots = useMemo(() => {
|
||
let filtered = allLocations.filter((item) => {
|
||
// Entity type filter (showSpots/showShops toggles)
|
||
if (item.isShop && !showShops) return false;
|
||
if (!item.isShop && !showSpots) return false;
|
||
|
||
// Search filter
|
||
if (searchQuery) {
|
||
const query = searchQuery.toLowerCase();
|
||
const matchesTitle = item.title.toLowerCase().includes(query);
|
||
const matchesLocation = item.location?.toLowerCase().includes(query);
|
||
const matchesDescription = item.description?.toLowerCase().includes(query);
|
||
if (!matchesTitle && !matchesLocation && !matchesDescription) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Category filter
|
||
if (selectedCategories.length > 0) {
|
||
const hasMatchingCategory = item.types.some((type) =>
|
||
selectedCategories.includes(type)
|
||
);
|
||
if (!hasMatchingCategory) return false;
|
||
}
|
||
|
||
// Weather filter (only applies when weather is enabled AND filter is not 'all')
|
||
if (weatherEnabled && weatherFilter !== 'all' && weatherData && !item.isShop && item.geohash) {
|
||
const spotWeather = weatherData.get(item.geohash);
|
||
if (spotWeather) {
|
||
if (weatherFilter === 'perfect' && spotWeather.skateability.score < 85) {
|
||
return false;
|
||
}
|
||
if (weatherFilter === 'good' && spotWeather.skateability.score < 70) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
// Apply trust filter — when 'trusted', sort trusted content to the top
|
||
// rather than hiding untrusted content (avoids penalising new users/anon browsers)
|
||
// Strict filtering (verified/following) still hides low-trust content as expected
|
||
if (trustScores && trustFilter !== 'all') {
|
||
if (trustFilter === 'trusted') {
|
||
// Soft mode: sort trusted first, keep everything visible
|
||
filtered.sort((a, b) => {
|
||
const scoreA = getTrustScore(a.pubkey) ?? -1;
|
||
const scoreB = getTrustScore(b.pubkey) ?? -1;
|
||
const trustedA = scoreA >= 60 ? 1 : 0;
|
||
const trustedB = scoreB >= 60 ? 1 : 0;
|
||
return trustedB - trustedA;
|
||
});
|
||
} else {
|
||
// Strict modes (verified, following) — actually filter
|
||
filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore);
|
||
}
|
||
}
|
||
|
||
// Apply sorting
|
||
filtered.sort((a, b) => {
|
||
switch (sortBy) {
|
||
case 'trust': {
|
||
const scoreA = getTrustScore(a.pubkey) || 0;
|
||
const scoreB = getTrustScore(b.pubkey) || 0;
|
||
if (scoreB !== scoreA) return scoreB - scoreA; // Highest trust first
|
||
return b.event.created_at - a.event.created_at; // Then newest
|
||
}
|
||
case 'proximity': {
|
||
if (!userLocation || !a.geohash || !b.geohash) return 0;
|
||
const posA = ngeohash.decode(a.geohash);
|
||
const posB = ngeohash.decode(b.geohash);
|
||
const distA = calculateDistance(userLocation.lat, userLocation.lon, posA.latitude, posA.longitude);
|
||
const distB = calculateDistance(userLocation.lat, userLocation.lon, posB.latitude, posB.longitude);
|
||
return distA - distB; // Closest first
|
||
}
|
||
case 'popular': {
|
||
const commentsA = getCommentCount(a.event.id);
|
||
const commentsB = getCommentCount(b.event.id);
|
||
if (commentsB !== commentsA) return commentsB - commentsA; // Most comments first
|
||
return b.event.created_at - a.event.created_at;
|
||
}
|
||
case 'recent':
|
||
default:
|
||
return b.event.created_at - a.event.created_at; // Newest first
|
||
}
|
||
});
|
||
|
||
return filtered;
|
||
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, trustScores, sortBy, userLocation, allComments]);
|
||
|
||
const toggleCategory = (category: string) => {
|
||
setSelectedCategories((prev) =>
|
||
prev.includes(category)
|
||
? prev.filter((c) => c !== category)
|
||
: [...prev, category]
|
||
);
|
||
};
|
||
|
||
const clearFilters = () => {
|
||
setSearchQuery('');
|
||
setSelectedCategories([]);
|
||
setWeatherFilter('all');
|
||
setTrustFilter('all');
|
||
setSortBy('recent');
|
||
};
|
||
|
||
const hasActiveFilters = searchQuery || selectedCategories.length > 0 || weatherFilter !== 'all' || trustFilter !== 'all' || sortBy !== 'recent';
|
||
|
||
// Count active layer filters for mobile badge
|
||
const activeLayerCount = [showSpots, showShops, showGear, showTours, showBtcMap].filter(Boolean).length;
|
||
// Count of the primary layers active — shown as a badge on the Layers button
|
||
const mobileLayerCount = [showSpots, showShops, showGear, showTours, showEvents, showCrews].filter(Boolean).length;
|
||
const totalPrimaryLayers = 6;
|
||
const activeCategoryCount = selectedCategories.length;
|
||
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
|
||
|
||
return (
|
||
<Layout noPadding>
|
||
{/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */}
|
||
{/* 100dvh handles iOS Safari URL bar shrink correctly */}
|
||
<div className="h-[calc(100dvh-var(--header-mobile)-var(--bottom-nav))] md:h-[calc(100vh-4rem)] flex flex-col">
|
||
{/* ── Toolbar ── */}
|
||
<div className="border-b border-white/8 bg-black/95 backdrop-blur">
|
||
<div className="px-3 py-2">
|
||
{/* Single row: search pill + view toggle + filter button */}
|
||
<div className="flex items-center gap-2">
|
||
{/* Search pill */}
|
||
<div className="relative flex-1 min-w-0">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
|
||
<Input
|
||
placeholder={t('mapPage.searchPlaceholder')}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-9 h-9 bg-white/5 border-white/10 text-white placeholder:text-gray-600 rounded-full text-sm focus:border-[#ff0040]/50 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all"
|
||
/>
|
||
{searchQuery && (
|
||
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* View toggle — compact pill */}
|
||
<div className="flex items-center rounded-full border border-white/10 bg-white/4 p-0.5 flex-shrink-0">
|
||
<button
|
||
className={cn('h-8 w-8 rounded-full flex items-center justify-center transition-all touch-manipulation active:scale-90',
|
||
viewMode === 'map' ? 'bg-[#ff0040] shadow-lg shadow-[#ff0040]/30' : 'text-gray-500 hover:text-gray-300')}
|
||
onClick={() => setViewMode('map')}
|
||
aria-label="Map view"
|
||
>
|
||
<Map className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
className={cn('h-8 w-8 rounded-full flex items-center justify-center transition-all touch-manipulation active:scale-90',
|
||
viewMode === 'list' ? 'bg-[#ff0040] shadow-lg shadow-[#ff0040]/30' : 'text-gray-500 hover:text-gray-300')}
|
||
onClick={() => setViewMode('list')}
|
||
aria-label="List view"
|
||
>
|
||
<List className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Filter button — mobile */}
|
||
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||
<SheetTrigger asChild>
|
||
<button
|
||
className={cn(
|
||
'relative h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 border transition-all touch-manipulation active:scale-90 md:hidden',
|
||
totalActiveFilters > 0
|
||
? 'bg-[#ff0040]/20 border-[#ff0040]/60 text-[#ff0040]'
|
||
: 'bg-white/5 border-white/10 text-gray-400 hover:text-gray-200 hover:border-white/20'
|
||
)}
|
||
aria-label="Filters"
|
||
>
|
||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||
{totalActiveFilters > 0 && (
|
||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 text-[9px] font-black bg-[#ff0040] text-white rounded-full flex items-center justify-center leading-none">
|
||
{totalActiveFilters}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</SheetTrigger>
|
||
<SheetContent side="bottom" className="h-[82vh] max-h-[680px] rounded-t-2xl px-4 flex flex-col">
|
||
<SheetHeader className="pb-2 flex-shrink-0">
|
||
<SheetTitle className="flex items-center justify-between text-base">
|
||
<span>Filters</span>
|
||
{hasActiveFilters && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-red-400 hover:text-red-300 h-8 px-2"
|
||
onClick={() => {
|
||
clearFilters();
|
||
}}
|
||
>
|
||
<X className="h-3 w-3 mr-1" />
|
||
Clear
|
||
</Button>
|
||
)}
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
|
||
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 -mx-4 px-4 pb-2 scroll-container">
|
||
{/* Layer toggles */}
|
||
<div>
|
||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Show on Map</h3>
|
||
<div className="grid grid-cols-3 gap-1.5">
|
||
<Button
|
||
variant={showSpots ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'justify-center gap-1 h-9 text-xs px-2',
|
||
showSpots ? 'bg-red-500 hover:bg-red-600' : ''
|
||
)}
|
||
onClick={() => setShowSpots(!showSpots)}
|
||
>
|
||
🛹 Spots
|
||
</Button>
|
||
<Button
|
||
variant={showShops ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'justify-center gap-1 h-9 text-xs px-2',
|
||
showShops ? 'bg-purple-600 hover:bg-purple-700' : ''
|
||
)}
|
||
onClick={() => setShowShops(!showShops)}
|
||
>
|
||
🏪 Shops
|
||
</Button>
|
||
<Button
|
||
variant={showGear ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'justify-center gap-1 h-9 text-xs px-2',
|
||
showGear ? 'bg-orange-500 hover:bg-orange-600' : ''
|
||
)}
|
||
onClick={() => setShowGear(!showGear)}
|
||
>
|
||
🛒 Gear
|
||
</Button>
|
||
<Button
|
||
variant={showTours ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'justify-center gap-1 h-9 text-xs px-2',
|
||
showTours ? 'bg-cyan-500 hover:bg-cyan-600' : ''
|
||
)}
|
||
onClick={() => setShowTours(!showTours)}
|
||
>
|
||
🗺️ Tours
|
||
</Button>
|
||
<Button
|
||
variant={showEvents ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'justify-center gap-1 h-9 text-xs px-2',
|
||
showEvents ? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90' : ''
|
||
)}
|
||
onClick={() => setShowEvents(!showEvents)}
|
||
>
|
||
📅 Events
|
||
</Button>
|
||
<Button
|
||
variant={showCrews ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'justify-center gap-1 h-9 text-xs px-2',
|
||
showCrews ? 'bg-teal-500 hover:bg-teal-600' : ''
|
||
)}
|
||
onClick={() => setShowCrews(!showCrews)}
|
||
>
|
||
🤘 Crews
|
||
</Button>
|
||
<Button
|
||
variant={showBtcMap ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'justify-center gap-1 h-9 text-xs px-2 col-span-2',
|
||
showBtcMap ? 'bg-[#f7931a] hover:bg-[#f7931a]/90' : 'border-[#f7931a]/50 text-[#f7931a]'
|
||
)}
|
||
onClick={() => setShowBtcMap(!showBtcMap)}
|
||
>
|
||
<Bitcoin className="h-3 w-3" /> Bitcoin
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Spot categories */}
|
||
<div>
|
||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Spot Type</h3>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{Object.entries(SPOT_CATEGORIES).map(([key, cat]) => (
|
||
<Button
|
||
key={key}
|
||
variant={selectedCategories.includes(key) ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'gap-1 h-8 text-xs px-2',
|
||
selectedCategories.includes(key)
|
||
? 'bg-[#ff0040] hover:bg-[#ff0040]/90'
|
||
: ''
|
||
)}
|
||
onClick={() => toggleCategory(key)}
|
||
>
|
||
{cat.emoji} {cat.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sort & Trust */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Sort By</h3>
|
||
<SortDropdown
|
||
value={sortBy}
|
||
onChange={setSortBy}
|
||
contentType="spots"
|
||
showProximity={!!userLocation}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Trust Level</h3>
|
||
<TrustFilter
|
||
value={trustFilter}
|
||
onChange={setTrustFilter}
|
||
compact
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weather toggle */}
|
||
<div>
|
||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Weather</h3>
|
||
<WeatherToggle
|
||
enabled={weatherEnabled}
|
||
onToggle={() => {
|
||
const newWeatherEnabled = !weatherEnabled;
|
||
setWeatherEnabled(newWeatherEnabled);
|
||
if (!newWeatherEnabled) {
|
||
setWeatherFilter('all');
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Quick actions for logged in users.
|
||
Note: Add Spot / Shop / Tour / Quick Snap live in the
|
||
map's "Add" speed-dial FAB — here we only surface the
|
||
shortcuts that aren't already on the map (Marketplace,
|
||
Stash) to avoid duplication. */}
|
||
{user && (
|
||
<div>
|
||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Quick Actions</h3>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
<Link to="/marketplace" onClick={() => setMobileFiltersOpen(false)}>
|
||
<Button size="sm" variant="outline" className="w-full gap-1 border-orange-500/50 text-orange-400 font-bold h-9 text-xs">
|
||
<ArrowRightLeft className="h-3 w-3" />
|
||
Buy/Sell Gear
|
||
</Button>
|
||
</Link>
|
||
<Link to="/stash" onClick={() => setMobileFiltersOpen(false)}>
|
||
<Button size="sm" variant="outline" className="w-full gap-1 border-[#ff6600]/50 text-[#ff6600] font-bold h-9 text-xs">
|
||
<Backpack className="h-3 w-3" />
|
||
Stash
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Show Results button — sticks to bottom, never overlaps content */}
|
||
<div className="flex-shrink-0 pt-3 pb-2 border-t safe-area-bottom bg-background">
|
||
<Button
|
||
className="w-full h-12 font-bold text-base"
|
||
onClick={() => setMobileFiltersOpen(false)}
|
||
>
|
||
Show Results
|
||
</Button>
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
{/* Desktop: Weather toggle */}
|
||
<div className="hidden md:block">
|
||
<WeatherToggle
|
||
enabled={weatherEnabled}
|
||
onToggle={() => {
|
||
const newWeatherEnabled = !weatherEnabled;
|
||
setWeatherEnabled(newWeatherEnabled);
|
||
if (!newWeatherEnabled) {
|
||
setWeatherFilter('all');
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: Entity toggles + Sort/Filter + Add buttons (DESKTOP ONLY) */}
|
||
<div className="hidden md:flex items-center gap-2 mt-2 overflow-x-auto scrollbar-hide -mx-3 px-3 sm:mx-0 sm:px-0">
|
||
{/* Layers — grouped popover (matches the mobile Layers control).
|
||
Replaces four separate entity-toggle buttons to declutter the
|
||
desktop toolbar while keeping the entity color language. */}
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-1.5 font-bold text-xs px-2.5 h-8 flex-shrink-0 border-gray-700 hover:border-white/30 touch-manipulation active:scale-95"
|
||
>
|
||
<Layers className="h-3.5 w-3.5" />
|
||
<span>Layers</span>
|
||
<span className="ml-0.5 min-w-[18px] h-[18px] px-1 text-[10px] font-black bg-[#ccff00] text-black rounded-full flex items-center justify-center leading-none">
|
||
{mobileLayerCount}
|
||
</span>
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" className="w-52 p-2 bg-black/95 border-white/15 backdrop-blur-xl">
|
||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p>
|
||
<div className="space-y-0.5">
|
||
{[
|
||
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
|
||
{ key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
|
||
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
|
||
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
|
||
].map(({ key, label, dot, active, toggle }) => (
|
||
<button
|
||
key={key}
|
||
onClick={toggle}
|
||
className={cn(
|
||
'w-full flex items-center gap-2.5 px-1.5 py-2 rounded-lg text-sm font-semibold transition-colors',
|
||
active ? 'text-white hover:bg-white/5' : 'text-gray-500 hover:bg-white/5'
|
||
)}
|
||
>
|
||
<span
|
||
className={cn('w-2.5 h-2.5 rounded-full flex-shrink-0 transition-opacity', active ? 'opacity-100' : 'opacity-30')}
|
||
style={{ backgroundColor: dot }}
|
||
/>
|
||
<span className="flex-1 text-left">{label}</span>
|
||
<span
|
||
className={cn(
|
||
'w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border transition-colors',
|
||
active ? 'bg-[#ccff00] border-[#ccff00]' : 'border-white/20'
|
||
)}
|
||
>
|
||
{active && <Check className="h-3 w-3 text-black" strokeWidth={3} />}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
{/* BTC Map toggle */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant={showBtcMap ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'gap-1 font-bold text-xs px-2.5 h-8 flex-shrink-0 touch-manipulation active:scale-95',
|
||
showBtcMap ? 'bg-[#f7931a] hover:bg-[#f7931a]/90 text-white' : 'border-[#f7931a]/50 text-[#f7931a] hover:bg-[#f7931a]/10'
|
||
)}
|
||
onClick={() => setShowBtcMap(!showBtcMap)}
|
||
>
|
||
<Bitcoin className="h-3.5 w-3.5" />
|
||
<span className="hidden xs:inline">SPEDN ✊</span>
|
||
{btcMapLoading && showBtcMap && (
|
||
<span className="ml-1 h-3 w-3 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||
)}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="max-w-[200px]">
|
||
<p className="text-xs">
|
||
{showBtcMap
|
||
? `Showing ${btcMapPlaces?.length || 0} Bitcoin-accepting places nearby`
|
||
: 'Show places where you can spend your sats (via BTC Map)'
|
||
}
|
||
</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* Stash drawer */}
|
||
<Sheet>
|
||
<SheetTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-1 font-bold text-xs px-2.5 h-8 flex-shrink-0 touch-manipulation active:scale-95 border-[#ff6600]/50 text-[#ff6600] hover:bg-[#ff6600]/10 hover:border-[#ff6600]"
|
||
>
|
||
<Backpack className="h-3.5 w-3.5" />
|
||
<span className="hidden xs:inline">Stash</span>
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side="right" className="w-[340px] sm:w-[400px] p-0 bg-black/95 border-l-2 border-[#ff6600]/30">
|
||
<SheetHeader className="p-4 pb-0">
|
||
<SheetTitle className="flex items-center gap-2 text-white">
|
||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#ff6600] to-[#ff0040] flex items-center justify-center">
|
||
<Backpack className="h-4 w-4 text-white" />
|
||
</div>
|
||
My Stash
|
||
<Badge variant="outline" className="ml-auto border-green-500/50 text-green-400 text-[10px]">
|
||
<Shield className="h-2.5 w-2.5 mr-1" />
|
||
Encrypted
|
||
</Badge>
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
<StashDrawer />
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
{/* Separator */}
|
||
<div className="w-px h-6 bg-gray-700 flex-shrink-0 hidden sm:block" />
|
||
|
||
{/* Sort and Filter */}
|
||
<SortDropdown
|
||
value={sortBy}
|
||
onChange={setSortBy}
|
||
contentType="spots"
|
||
showProximity={!!userLocation}
|
||
/>
|
||
<TrustFilter
|
||
value={trustFilter}
|
||
onChange={setTrustFilter}
|
||
compact
|
||
/>
|
||
|
||
{/* Clear filters */}
|
||
{hasActiveFilters && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0 flex-shrink-0 touch-manipulation active:scale-95"
|
||
onClick={clearFilters}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
|
||
{/* Spacer */}
|
||
<div className="flex-1 min-w-2" />
|
||
|
||
{/* Local Chat - live geohash channel for wherever the map is centered */}
|
||
<Sheet>
|
||
<SheetTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="gap-1.5 border-2 border-purple-500/50 hover:bg-purple-500/10 text-purple-400 font-bold h-8 px-2 sm:px-3 flex-shrink-0"
|
||
>
|
||
<MessageCircle className="h-4 w-4" />
|
||
<span className="hidden md:inline">Local Chat</span>
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent
|
||
side="right"
|
||
className="w-full sm:w-[420px] p-0 flex flex-col"
|
||
>
|
||
<SheetHeader className="p-4 pb-2">
|
||
<SheetTitle className="flex items-center gap-2">
|
||
<MessageCircle className="h-5 w-5 text-purple-400" />
|
||
Local Chat
|
||
</SheetTitle>
|
||
<p className="text-xs text-muted-foreground text-left">
|
||
Live chat for wherever the map is centered. Pick how wide the channel reaches.
|
||
</p>
|
||
</SheetHeader>
|
||
<div className="flex-1 min-h-0 p-4 pt-0">
|
||
<SpotChat
|
||
key={ngeohash.encode(mapCenter.lat, mapCenter.lon, 8)}
|
||
geohash={ngeohash.encode(mapCenter.lat, mapCenter.lon, 8)}
|
||
showPrecisionToggle
|
||
defaultPrecision="neighborhood"
|
||
className="h-full"
|
||
/>
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
{/* Add buttons - icons only on mobile */}
|
||
{user ? (
|
||
<>
|
||
<Link to="/create-spot" className="flex-shrink-0">
|
||
<Button size="sm" className="gap-1.5 bg-[#ff0040] hover:bg-[#ff0040]/90 border-2 border-[#ff0040] font-bold h-8 px-2 sm:px-3">
|
||
<Plus className="h-4 w-4" />
|
||
<span className="hidden md:inline">{t('home.addSpot')}</span>
|
||
</Button>
|
||
</Link>
|
||
<Link to="/create-shop" className="flex-shrink-0 hidden sm:block">
|
||
<Button size="sm" variant="outline" className="gap-1.5 border-2 border-[#ccff00]/50 hover:bg-[#ccff00]/10 text-[#ccff00] font-bold h-8 px-2 sm:px-3">
|
||
<Plus className="h-4 w-4" />
|
||
<span className="hidden md:inline">{t('shops.addShop')}</span>
|
||
</Button>
|
||
</Link>
|
||
<Link to="/marketplace" className="flex-shrink-0 hidden sm:block">
|
||
<Button size="sm" variant="outline" className="gap-1.5 border-2 border-orange-500/50 hover:bg-orange-500/10 text-orange-400 font-bold h-8 px-2 sm:px-3">
|
||
<ShoppingBag className="h-4 w-4" />
|
||
<span className="hidden md:inline">Buy/Sell Gear</span>
|
||
</Button>
|
||
</Link>
|
||
<Link to="/create-tour" className="flex-shrink-0 hidden sm:block">
|
||
<Button size="sm" variant="outline" className="gap-1.5 border-2 border-cyan-500/50 hover:bg-cyan-500/10 text-cyan-400 font-bold h-8 px-2 sm:px-3">
|
||
<Route className="h-4 w-4" />
|
||
<span className="hidden md:inline">Offer Tour</span>
|
||
</Button>
|
||
</Link>
|
||
</>
|
||
) : (
|
||
<Badge variant="outline" className="border-yellow-500/50 text-yellow-400 text-[10px] px-2 flex-shrink-0">
|
||
🔒 {t('errors.loginRequired')}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{/* Row 3: Category filters (DESKTOP ONLY) */}
|
||
<div className="hidden md:flex items-center gap-1.5 mt-2 overflow-x-auto scrollbar-hide -mx-3 px-3 sm:mx-0 sm:px-0 pb-0.5">
|
||
{Object.entries(SPOT_CATEGORIES).map(([key, cat]) => (
|
||
<Button
|
||
key={key}
|
||
variant={selectedCategories.includes(key) ? 'default' : 'outline'}
|
||
size="sm"
|
||
className={cn(
|
||
'gap-1 font-bold text-xs flex-shrink-0 px-2.5 h-8 touch-manipulation active:scale-95',
|
||
selectedCategories.includes(key)
|
||
? 'bg-[#ff0040] hover:bg-[#ff0040]/90'
|
||
: 'border-gray-700 hover:border-[#ff0040]/50'
|
||
)}
|
||
onClick={() => toggleCategory(key)}
|
||
>
|
||
{cat.emoji} {cat.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Welcome Banner — shown once to logged-out visitors */}
|
||
{showWelcomeBanner && (
|
||
<div className="relative z-10 bg-gradient-to-r from-[#ff0040]/90 via-black/95 to-[#ccff00]/20 border-b border-[#ff0040]/40 backdrop-blur">
|
||
<div className="container px-4 py-2.5 flex items-center gap-3">
|
||
<span className="text-xl flex-shrink-0">🛹</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">New here?</p>
|
||
<p className="text-xs text-white/70 leading-tight">Discover skate spots, events & the community.</p>
|
||
</div>
|
||
<Link
|
||
to="/onboarding"
|
||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-[#ccff00] text-black text-xs font-black uppercase tracking-wide hover:bg-[#ccff00]/90 active:scale-95 transition-transform"
|
||
>
|
||
Get Started
|
||
</Link>
|
||
<button
|
||
onClick={() => setWelcomeDismissed(true)}
|
||
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 text-white/40 hover:text-white transition-colors"
|
||
aria-label="Dismiss"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Content */}
|
||
<div className="flex-1 relative">
|
||
{viewMode === 'map' ? (
|
||
<>
|
||
<Suspense fallback={
|
||
<div className="h-full bg-black/50 flex items-center justify-center">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-6 h-6 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin" />
|
||
<span className="text-sm text-white font-bold">Loading map...</span>
|
||
</div>
|
||
</div>
|
||
}>
|
||
<SpotMap
|
||
spots={filteredSpots}
|
||
marketplaceItems={marketplaceItems || []}
|
||
tours={tours || []}
|
||
btcMapPlaces={btcMapPlaces || []}
|
||
selectedSpot={selectedSpot}
|
||
onSpotClick={setSelectedSpot}
|
||
onMapClick={handleMapClick}
|
||
onMapMove={setMapCenter}
|
||
enableClickToAdd={!!user}
|
||
className="h-full"
|
||
weatherEnabled={weatherEnabled}
|
||
weatherData={weatherData}
|
||
events={upcomingEvents || []}
|
||
crews={crews || []}
|
||
showSpots={showSpots}
|
||
showShops={showShops}
|
||
showGear={showGear}
|
||
showTours={showTours}
|
||
showEvents={showEvents}
|
||
showCrews={showCrews}
|
||
showBtcMap={showBtcMap}
|
||
flyTo={flyTo}
|
||
/>
|
||
</Suspense>
|
||
{/* Loading indicator — slim top edge bar */}
|
||
{isLoading && (
|
||
<div className="absolute top-0 left-0 right-0 z-[1000] h-0.5">
|
||
<div className="h-full bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00] animate-pulse" />
|
||
</div>
|
||
)}
|
||
|
||
|
||
|
||
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom
|
||
nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
|
||
<div
|
||
className="absolute left-1.5 z-[999] pointer-events-none bottom-[calc(5rem+env(safe-area-inset-bottom,0px)+0.25rem)] md:bottom-1.5"
|
||
>
|
||
<div className="bg-black/40 backdrop-blur-sm px-1 py-px rounded text-[8px] text-gray-500/80 leading-none pointer-events-auto">
|
||
<a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer" className="hover:text-gray-300 transition-colors">CARTO</a>
|
||
{' · '}
|
||
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="hover:text-gray-300 transition-colors">OSM</a>
|
||
{showBtcMap && (
|
||
<>{' · '}<a href="https://btcmap.org" target="_blank" rel="noopener noreferrer" className="text-[#f7931a]/60 hover:text-[#f7931a] transition-colors">BTC Map</a></>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Location permission help dialog */}
|
||
{showLocationHelp && (
|
||
<div className="absolute inset-0 z-[2000] flex items-end justify-center pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:items-center md:pb-0 px-4">
|
||
<div
|
||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||
onClick={() => setShowLocationHelp(false)}
|
||
/>
|
||
<div className="relative w-full max-w-sm rounded-2xl bg-[#111] border border-red-500/30 shadow-2xl p-5 space-y-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center flex-shrink-0">
|
||
<LocateOff className="h-5 w-5 text-red-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-black text-white uppercase tracking-wide">Location Blocked</p>
|
||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||
Your browser has blocked location access for this site. Here's how to re-enable it:
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="rounded-xl border border-white/8 bg-white/[0.03] divide-y divide-white/5 overflow-hidden">
|
||
{[
|
||
{ platform: '📱 Android Chrome', steps: 'Tap the lock icon in the address bar → Site settings → Location → Allow' },
|
||
{ platform: '🍎 iOS Safari', steps: 'Settings app → Safari → Location → Allow (or tap the "AA" button in Safari\'s address bar)' },
|
||
{ platform: '💻 Desktop Chrome', steps: 'Click the lock icon → Site settings → Location → Allow' },
|
||
{ platform: '🦊 Firefox', steps: 'Click the lock icon → Connection secure → More info → Permissions → Access your location → Allow' },
|
||
].map(({ platform, steps }) => (
|
||
<div key={platform} className="px-3 py-2.5">
|
||
<p className="text-xs font-bold text-white mb-0.5">{platform}</p>
|
||
<p className="text-[11px] text-muted-foreground leading-snug">{steps}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
setShowLocationHelp(false);
|
||
setNearMeError(null);
|
||
}}
|
||
className="w-full py-3 rounded-xl bg-white/8 hover:bg-white/12 border border-white/10 text-sm font-bold text-white transition-all active:scale-[0.98]"
|
||
>
|
||
Got it
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Mobile map controls (redesigned) ──────────────────────────
|
||
Right edge holds just TWO small controls: Near Me + Layers.
|
||
Primary "Add" action is a speed-dial FAB (bottom-right). This
|
||
replaces the old 12-item vertical strip. */}
|
||
<div
|
||
className={cn(
|
||
"fixed right-3 z-[1000] md:hidden flex flex-col items-center gap-2 transition-all duration-200",
|
||
mobileFiltersOpen || selectedSpot || addDialOpen ? "opacity-0 pointer-events-none translate-x-2" : "opacity-100 translate-x-0"
|
||
)}
|
||
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 6.5rem)' }}
|
||
>
|
||
{/* Near Me */}
|
||
<button
|
||
onClick={nearMeError === 'denied' ? () => setShowLocationHelp(true) : handleNearMe}
|
||
disabled={nearMeLoading}
|
||
className={cn(
|
||
'h-11 w-11 rounded-full flex items-center justify-center shadow-lg border backdrop-blur transition-all touch-manipulation active:scale-90',
|
||
nearMeError === 'denied'
|
||
? 'bg-red-900/80 border-red-500/30'
|
||
: 'bg-black/70 border-white/15 hover:border-white/30'
|
||
)}
|
||
aria-label="Find spots near me"
|
||
>
|
||
{nearMeLoading ? (
|
||
<span className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white animate-spin" />
|
||
) : nearMeError === 'denied' ? (
|
||
<LocateOff className="h-4 w-4 text-red-400" />
|
||
) : (
|
||
<LocateFixed className="h-4 w-4 text-[#ccff00]" />
|
||
)}
|
||
</button>
|
||
|
||
{/* Layers popover — labelled toggles with color dots */}
|
||
<Popover open={layersOpen} onOpenChange={setLayersOpen}>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
className={cn(
|
||
'relative h-11 w-11 rounded-full flex items-center justify-center shadow-lg border backdrop-blur transition-all touch-manipulation active:scale-90',
|
||
layersOpen
|
||
? 'bg-[#ff0040]/20 border-[#ff0040]/60'
|
||
: 'bg-black/70 border-white/15 hover:border-white/30'
|
||
)}
|
||
aria-label="Map layers"
|
||
>
|
||
<Layers className={cn('h-4 w-4', layersOpen ? 'text-[#ff0040]' : 'text-gray-200')} />
|
||
{mobileLayerCount < totalPrimaryLayers && (
|
||
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[9px] font-black bg-[#ccff00] text-black rounded-full flex items-center justify-center leading-none border-2 border-black">
|
||
{mobileLayerCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
side="left"
|
||
align="end"
|
||
className="w-52 p-2 bg-black/95 border-white/15 backdrop-blur-xl"
|
||
>
|
||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p>
|
||
<div className="space-y-0.5">
|
||
{[
|
||
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
|
||
{ key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
|
||
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
|
||
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
|
||
].map(({ key, label, dot, active, toggle }) => (
|
||
<button
|
||
key={key}
|
||
onClick={toggle}
|
||
className={cn(
|
||
'w-full flex items-center gap-2.5 px-1.5 py-2 rounded-lg text-sm font-semibold transition-colors touch-manipulation',
|
||
active ? 'text-white hover:bg-white/5' : 'text-gray-500 hover:bg-white/5'
|
||
)}
|
||
>
|
||
<span
|
||
className={cn('w-2.5 h-2.5 rounded-full flex-shrink-0 transition-opacity', active ? 'opacity-100' : 'opacity-30')}
|
||
style={{ backgroundColor: dot }}
|
||
/>
|
||
<span className="flex-1 text-left">{label}</span>
|
||
<span
|
||
className={cn(
|
||
'w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border transition-colors',
|
||
active ? 'bg-[#ccff00] border-[#ccff00]' : 'border-white/20'
|
||
)}
|
||
>
|
||
{active && <Check className="h-3 w-3 text-black" strokeWidth={3} />}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* ── Add speed-dial FAB (mobile, logged in) ──────────────────── */}
|
||
{user && (
|
||
<div
|
||
className={cn(
|
||
'fixed right-4 z-[1001] md:hidden flex flex-col items-end gap-2.5 transition-all duration-200',
|
||
mobileFiltersOpen || selectedSpot ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||
)}
|
||
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 1rem)' }}
|
||
>
|
||
{/* Fan-out actions */}
|
||
{addDialOpen && (
|
||
<>
|
||
{[
|
||
{ label: 'Add Spot', to: '/create-spot', bg: 'bg-[#ff0040]', icon: <MapPin className="h-4 w-4 text-white" /> },
|
||
{ label: 'Add Shop', to: '/create-shop', bg: 'bg-[#9333ea]', icon: <Store className="h-4 w-4 text-white" /> },
|
||
{ label: 'Add Event', to: '/create-event', bg: 'bg-[#ccff00]', icon: <Calendar className="h-4 w-4 text-black" /> },
|
||
{ label: 'Sell Gear', to: '/create-listing', bg: 'bg-[#f97316]', icon: <ShoppingBag className="h-4 w-4 text-white" /> },
|
||
{ label: 'Offer Tour', to: '/create-tour', bg: 'bg-[#06b6d4]', icon: <Route className="h-4 w-4 text-white" /> },
|
||
].map((a, i) => (
|
||
<Link
|
||
key={a.to}
|
||
to={a.to}
|
||
onClick={() => setAddDialOpen(false)}
|
||
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||
style={{ animationDelay: `${i * 40}ms` }}
|
||
>
|
||
<span className="px-2.5 py-1 rounded-lg bg-black/85 backdrop-blur text-white text-xs font-bold shadow-lg border border-white/10">
|
||
{a.label}
|
||
</span>
|
||
<span className={cn('h-11 w-11 rounded-full flex items-center justify-center shadow-lg', a.bg)}>
|
||
{a.icon}
|
||
</span>
|
||
</Link>
|
||
))}
|
||
{/* Quick Snap */}
|
||
<button
|
||
onClick={() => { setAddDialOpen(false); setShowQuickCamera(true); }}
|
||
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||
style={{ animationDelay: '200ms' }}
|
||
>
|
||
<span className="px-2.5 py-1 rounded-lg bg-black/85 backdrop-blur text-white text-xs font-bold shadow-lg border border-white/10">
|
||
Quick Snap
|
||
</span>
|
||
<span className="h-11 w-11 rounded-full flex items-center justify-center shadow-lg bg-black/80 border border-white/20">
|
||
<Camera className="h-4 w-4 text-[#ccff00]" />
|
||
</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Main FAB */}
|
||
<button
|
||
onClick={() => setAddDialOpen(v => !v)}
|
||
className={cn(
|
||
'h-14 w-14 rounded-full flex items-center justify-center shadow-2xl transition-all duration-300 touch-manipulation active:scale-90',
|
||
addDialOpen
|
||
? 'bg-white rotate-45'
|
||
: 'bg-[#ff0040] shadow-[#ff0040]/40 hover:bg-[#ff0040]/90'
|
||
)}
|
||
aria-label={addDialOpen ? 'Close add menu' : 'Add spot, shop, tour, or snap'}
|
||
>
|
||
<Plus className={cn('h-7 w-7 transition-colors', addDialOpen ? 'text-black' : 'text-white')} strokeWidth={2.5} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tap-catcher to close the speed-dial */}
|
||
{addDialOpen && (
|
||
<div
|
||
className="fixed inset-0 z-[1000] md:hidden"
|
||
onClick={() => setAddDialOpen(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="h-full overflow-auto">
|
||
<div className="container px-4 py-6">
|
||
{isLoading ? (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<SpotCardSkeleton key={i} />
|
||
))}
|
||
</div>
|
||
) : filteredSpots.length > 0 ? (
|
||
<>
|
||
<p className="text-sm text-muted-foreground mb-4">
|
||
{t('mapPage.locationsFound', { count: filteredSpots.length })}
|
||
{showSpots && !showShops && ` (${t('mapPage.spotsOnly')})`}
|
||
{!showSpots && showShops && ` (${t('mapPage.shopsOnly')})`}
|
||
{showSpots && showShops && ` (${t('mapPage.spotsAndShops')})`}
|
||
</p>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 stagger-children">
|
||
{filteredSpots.map((spot) => (
|
||
<SpotCard key={spot.id} spot={spot} />
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="text-center py-12">
|
||
<Map className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||
<p className="text-muted-foreground mb-4">
|
||
{hasActiveFilters
|
||
? t('mapPage.noSpotsMatch')
|
||
: t('mapPage.noSpotsYet')}
|
||
</p>
|
||
{hasActiveFilters ? (
|
||
<Button variant="outline" onClick={clearFilters}>
|
||
{t('common.clearFilters')}
|
||
</Button>
|
||
) : (
|
||
user && (
|
||
<Link to="/create-spot">
|
||
<Button className="gap-2">
|
||
<Plus className="h-4 w-4" />
|
||
{t('mapPage.addFirstSpot')}
|
||
</Button>
|
||
</Link>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick Camera Capture Dialog */}
|
||
<CameraCapture
|
||
open={showQuickCamera}
|
||
onOpenChange={setShowQuickCamera}
|
||
onCapture={handleQuickCameraCapture}
|
||
isUploading={isUploadingQuickPhoto}
|
||
/>
|
||
|
||
{/* Spot Bottom Sheet — slides up when a marker is tapped, map stays visible */}
|
||
<Sheet open={!!selectedSpot} onOpenChange={(open) => { if (!open) setSelectedSpot(null); }}>
|
||
<SheetContent
|
||
side="bottom"
|
||
className="h-auto max-h-[55vh] rounded-t-2xl px-0 pb-0 bg-black/95 border-t border-white/10"
|
||
>
|
||
{selectedSpot && <SpotBottomSheet spot={selectedSpot} onClose={() => setSelectedSpot(null)} />}
|
||
</SheetContent>
|
||
</Sheet>
|
||
</Layout>
|
||
);
|
||
}
|