The 'New here?' banner was a later sibling of the absolute toolbar inside the same map container, so it became the first in-flow child at y=0 and the floating toolbar painted over it — the search bar covered the banner. Move the banner to be the container's first child (above the toolbar) and have the toolbar drop into flow when the banner is shown, so it can't cover it while still floating over the map otherwise. Desktop is unchanged (md:static). Docs: AGENTS.md map-layout note.
2125 lines
99 KiB
TypeScript
2125 lines
99 KiB
TypeScript
import { useState, useMemo, useEffect, lazy, Suspense, useCallback } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||
|
||
// 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 { RouteDialog } from '@/components/RouteDialog';
|
||
import { TrustFilter } from '@/components/TrustFilter';
|
||
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
|
||
import { SortDropdown, type SortOption } from '@/components/SortDropdown';
|
||
import { calculateDistance } from '@/lib/sortOptions';
|
||
import { useZapTotals, zapTargetKey } from '@/hooks/useZapTotals';
|
||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
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 { useRelayStatus } from '@/hooks/useRelayStatus';
|
||
import { useAutoHiddenSpots } from '@/hooks/useAutoHiddenSpots';
|
||
import { useOnlineStatus } from '@/hooks/useOfflineCache';
|
||
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 { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
||
import { useMultipleSpotWeather } from '@/hooks/useSpotWeather';
|
||
import { useCommentCounts } from '@/hooks/useCommentCounts';
|
||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||
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/language';
|
||
import { SPOT_CATEGORIES, SPOT_TYPE_LABELS, SHELTER_INFO, SKATE_RELAY } from '@/lib/types';
|
||
import type { SkateSpot } from '@/lib/types';
|
||
import ngeohash from 'ngeohash';
|
||
import {
|
||
Search,
|
||
Plus,
|
||
List,
|
||
Map,
|
||
X,
|
||
ShoppingBag,
|
||
Route,
|
||
Backpack,
|
||
Package,
|
||
Target,
|
||
Star,
|
||
Zap,
|
||
ArrowRightLeft,
|
||
Shield,
|
||
Trash2,
|
||
AlertTriangle,
|
||
Bitcoin,
|
||
SlidersHorizontal,
|
||
Check,
|
||
Navigation,
|
||
ExternalLink,
|
||
MapPin,
|
||
LocateFixed,
|
||
LocateOff,
|
||
Store,
|
||
Layers,
|
||
Calendar,
|
||
MessageCircle,
|
||
ChevronDown,
|
||
Smartphone,
|
||
WifiOff,
|
||
RefreshCw,
|
||
} from 'lucide-react';
|
||
import { SpotChat } from '@/components/SpotChat';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { cn } from '@/lib/utils';
|
||
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
|
||
import { getLastLocation } from '@/lib/lastLocation';
|
||
|
||
|
||
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',
|
||
// Wishlist keeps a fixed cyan border; gear uses its rarity colour (applied
|
||
// via inline style below, since Tailwind's JIT can't see dynamically
|
||
// interpolated `border-${colour}` class names).
|
||
isWishlist && 'border-cyan-500/40',
|
||
)}
|
||
style={isWishlist ? undefined : { 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">
|
||
<ImageWithFallback 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, 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>
|
||
);
|
||
}
|
||
|
||
// In-app directions — opens the same RouteDialog used on the detail pages.
|
||
// Deliberately NO external map handoff: routing stays inside SkateSpots.
|
||
function DirectionsButton({
|
||
lat,
|
||
lon,
|
||
label,
|
||
onDirections,
|
||
}: {
|
||
lat: string;
|
||
lon: string;
|
||
label: string;
|
||
/**
|
||
* When provided, the PARENT owns the RouteDialog. Rendering a Radix Dialog
|
||
* inside the spot's Radix Sheet stacked two modals (the sheet covered the
|
||
* route), so the map passes this and renders the dialog itself; the sheet
|
||
* then closes and the route gets the whole screen.
|
||
*/
|
||
onDirections?: (target: { lat: number; lng: number; label: string }) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const destination = useMemo(() => {
|
||
const la = parseFloat(lat);
|
||
const lo = parseFloat(lon);
|
||
return Number.isFinite(la) && Number.isFinite(lo) ? { lat: la, lng: lo } : null;
|
||
}, [lat, lon]);
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
className="h-12 w-full font-bold text-sm border-white/20 hover:bg-white/10 rounded-xl"
|
||
onClick={() => {
|
||
if (!destination) return;
|
||
if (onDirections) onDirections({ ...destination, label: label || 'Spot' });
|
||
else setOpen(true);
|
||
}}
|
||
disabled={!destination}
|
||
>
|
||
<Navigation className="h-4 w-4 mr-2 text-[#ccff00]" />
|
||
Directions
|
||
</Button>
|
||
{!onDirections && (
|
||
<RouteDialog
|
||
open={open}
|
||
onOpenChange={setOpen}
|
||
destination={destination}
|
||
destinationLabel={label || 'Spot'}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Bottom sheet shown when a map marker is tapped
|
||
function SpotBottomSheet({
|
||
spot,
|
||
onClose,
|
||
onDirections,
|
||
}: {
|
||
spot: SkateSpot;
|
||
onClose: () => void;
|
||
onDirections: (target: { lat: number; lng: number; label: string }) => 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] ? (
|
||
<ImageWithFallback 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.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
|
||
<span
|
||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||
style={{
|
||
color: SHELTER_INFO[spot.shelter].color,
|
||
backgroundColor: `${SHELTER_INFO[spot.shelter].color}25`,
|
||
}}
|
||
>
|
||
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].short}
|
||
</span>
|
||
)}
|
||
{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 = spot.title;
|
||
return (
|
||
<div className="relative">
|
||
<DirectionsButton lat={lat} lon={lon} label={label} onDirections={onDirections} />
|
||
</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, refetch: refetchSpots } = useSkateSpots(2000);
|
||
const { data: shops, isLoading: shopsLoading } = useShops(500);
|
||
|
||
// Wi-Fi present ≠ relay reachable (captive portal / relay outage). The utility
|
||
// prefers saved spots over a blank map, so we detect that honestly and say so.
|
||
// ⚠️ Two different signals on purpose:
|
||
// • deviceOffline — the DEVICE has no connection, so the pins really are
|
||
// the saved cache. This is what the "showing saved spots" banner means.
|
||
// • relayUnreachable — the canonical community relay didn't answer its probe.
|
||
// The map reads from several relays, so this can be true while fresh data
|
||
// loads fine; use it only for the "nothing at all" card, never the banner
|
||
// (that mismatch is why the banner used to stick around online).
|
||
const online = useOnlineStatus();
|
||
const relay = useRelayStatus(SKATE_RELAY);
|
||
const deviceOffline = !online;
|
||
const relayUnreachable = !online || relay.status === 'offline';
|
||
// Dismissable for the session; re-armed when the connection comes back.
|
||
const [offlineBannerDismissed, setOfflineBannerDismissed] = useState(false);
|
||
useEffect(() => {
|
||
if (online) setOfflineBannerDismissed(false);
|
||
}, [online]);
|
||
|
||
// Spots reported "not a skate spot" by ≥3 distinct accounts with NO community
|
||
// signals are hidden from the map pending a human (see useAutoHiddenSpots).
|
||
const autoHideInput = useMemo(
|
||
() => (spots ?? []).map((s) => ({ id: s.id, pubkey: s.pubkey, identifier: s.identifier })),
|
||
[spots],
|
||
);
|
||
const { autoHiddenIds } = useAutoHiddenSpots(autoHideInput);
|
||
|
||
// Tell the cold-start splash that real content has arrived, so it can reveal
|
||
// the map already populated (rather than dismissing on bare connection). We
|
||
// signal readiness when the spots query has genuinely resolved — either it
|
||
// streamed in some spots, or it finished loading (empty relay / offline). With
|
||
// IndexedDB persistence this often fires on the very first frame (cache hit),
|
||
// so returning users get an instant, already-populated reveal. The splash
|
||
// keeps its own hard cap, so this can only ever make the reveal feel readier.
|
||
useEffect(() => {
|
||
const hasSpots = Array.isArray(spots) && spots.length > 0;
|
||
if (hasSpots || !spotsLoading) {
|
||
window.dispatchEvent(new Event('skatespots:content-ready'));
|
||
}
|
||
}, [spots, spotsLoading]);
|
||
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);
|
||
// Directions target lives at page level (NOT inside the spot Sheet) so the
|
||
// RouteDialog is never nested inside another modal.
|
||
const [routeTarget, setRouteTarget] = useState<{ lat: number; lng: number; label: string } | 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');
|
||
// Seed from the cached last-known location so the "you are here" badge and
|
||
// proximity sorting are available instantly; a fresh fix updates it.
|
||
// Mobile-only: Near Me is a mobile affordance, so desktop shows no location
|
||
// pin (or proximity sort).
|
||
const isMobile = useIsMobile();
|
||
const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(() => {
|
||
if (!isMobile) return null;
|
||
const cached = getLastLocation();
|
||
return cached ? { lat: cached.lat, lon: cached.lng } : 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 [, 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 / Event / Gear / Tour)
|
||
const [addDialOpen, setAddDialOpen] = useState(false);
|
||
// Mobile layers popover
|
||
const [layersOpen, setLayersOpen] = useState(false);
|
||
|
||
const { toast } = useToast();
|
||
|
||
// Optimistic initial view: if we have a cached last-known location, open the
|
||
// map ALREADY centered on the user's area (and zoomed to city level) instead
|
||
// of the default world view. Resolved once at mount — a fresh fix or a manual
|
||
// pan supersedes it. Makes the map feel "already yours" the moment it opens.
|
||
const [initialView] = useState(() => {
|
||
const cached = getLastLocation();
|
||
return cached
|
||
? { center: [cached.lat, cached.lng] as [number, number], zoom: 12, lat: cached.lat, lon: cached.lng }
|
||
: { center: [40.7128, -74.006] as [number, number], zoom: 3, lat: 40.7128, lon: -74.006 };
|
||
});
|
||
const initialCenter = initialView.center;
|
||
const initialZoom = initialView.zoom;
|
||
|
||
// BTC Map center for fetching places — seeded from the cached location so
|
||
// location-aware layers (BTC Map, weather) query the right area immediately.
|
||
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({
|
||
lat: initialView.lat,
|
||
lon: initialView.lon,
|
||
});
|
||
|
||
// 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 = async () => {
|
||
// Clear previous error
|
||
setNearMeError(null);
|
||
setNearMeLoading(true);
|
||
// "Near Me" should also MEAN "show me what's near" — surface the closest
|
||
// spots first instead of leaving the list on Most Recent.
|
||
setSortBy('proximity');
|
||
|
||
// OPTIMISTIC: if we already know roughly where they are, fly there IMMEDIATELY
|
||
// so the map reacts on tap. The fresh fix below refines the exact position.
|
||
const cached = getLastLocation();
|
||
if (cached) {
|
||
setFlyTo({ lat: cached.lat, lng: cached.lng, zoom: 13 });
|
||
}
|
||
|
||
// Getting a GPS lock (especially first-time / on ROMs without Google's
|
||
// fused provider) can take a while — let the user know it's working.
|
||
const locatingToast = toast({
|
||
title: cached ? 'Refining your location…' : 'Locating you…',
|
||
description: cached
|
||
? 'Centered on your last spot — dialing in a fresh GPS fix.'
|
||
: 'Getting a GPS fix. This can take up to a minute the first time.',
|
||
duration: 60000,
|
||
});
|
||
|
||
try {
|
||
const { lat, lng } = await getCurrentPosition();
|
||
locatingToast.dismiss();
|
||
setFlyTo({ lat, lng, zoom: 14 });
|
||
setUserLocation({ lat, lon: lng });
|
||
setNearMeLoading(false);
|
||
setNearMeError(null);
|
||
toast({
|
||
title: 'Found you',
|
||
description: 'Centered the map on your location.',
|
||
duration: 3000,
|
||
});
|
||
} catch (err) {
|
||
locatingToast.dismiss();
|
||
setNearMeLoading(false);
|
||
const kind = err instanceof GeolocationError ? err.kind : 'unavailable';
|
||
|
||
// Raw diagnostic text so we can see exactly what failed on-device
|
||
// (no adb needed). Shown appended to the toast + logged.
|
||
const rawMsg = err instanceof Error ? err.message : String(err);
|
||
console.warn('[NearMe] geolocation failed:', kind, rawMsg, err);
|
||
const diag = rawMsg ? ` [${rawMsg}]` : '';
|
||
|
||
if (kind === 'denied') {
|
||
setNearMeError('denied');
|
||
if (isNativeGeolocation()) {
|
||
// Native app: it's an OS permission, not a browser setting.
|
||
toast({
|
||
title: 'Location permission needed',
|
||
description:
|
||
'Allow location for SkateSpots in your device Settings → Apps → SkateSpots → Permissions, then tap Near Me again.' +
|
||
diag,
|
||
variant: 'destructive',
|
||
duration: 12000,
|
||
});
|
||
} else {
|
||
// Web: show the browser re-enable help sheet.
|
||
setShowLocationHelp(true);
|
||
}
|
||
} else if (kind === 'timeout') {
|
||
setNearMeError('timeout');
|
||
toast({
|
||
title: 'Location timed out',
|
||
description:
|
||
(isNativeGeolocation()
|
||
? "Couldn't get a location fix in time. If you're indoors, GPS can't see satellites — enable Network Location (Settings → Location) or step outside, then try again."
|
||
: 'Could not get your location in time. Check your signal and try again.') + diag,
|
||
variant: 'destructive',
|
||
duration: 12000,
|
||
});
|
||
} else if (kind === 'no_provider') {
|
||
setNearMeError('unavailable');
|
||
toast({
|
||
title: 'No location provider',
|
||
description:
|
||
(isNativeGeolocation()
|
||
? "Your phone isn't providing a location. On privacy-focused ROMs (e.g. GrapheneOS) enable Network Location under Settings → Location, or make sure GPS is on and try outdoors."
|
||
: 'No location provider is available. Enable location services and try again.') +
|
||
diag,
|
||
variant: 'destructive',
|
||
duration: 14000,
|
||
});
|
||
} else {
|
||
setNearMeError('unavailable');
|
||
toast({
|
||
title: 'Location unavailable',
|
||
description:
|
||
(isNativeGeolocation()
|
||
? 'Turn on Location/GPS in your phone settings, allow location for SkateSpots (Settings → Apps → SkateSpots → Permissions), then try again.'
|
||
: 'Your location could not be determined. Make sure location services are enabled.') +
|
||
diag,
|
||
variant: 'destructive',
|
||
duration: 12000,
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
|
||
// Fetch BTC Map places when enabled
|
||
const { data: btcMapPlaces, isLoading: btcMapLoading } = useBtcMapPlaces({
|
||
lat: mapCenter.lat,
|
||
lon: mapCenter.lon,
|
||
radiusKm: 50, // 50km radius
|
||
enabled: showBtcMap,
|
||
});
|
||
|
||
const isLoading = spotsLoading || shopsLoading || marketplaceLoading || toursLoading;
|
||
|
||
// Get user's location for proximity sorting
|
||
useEffect(() => {
|
||
if (sortBy === 'proximity' && !userLocation) {
|
||
getCurrentPosition()
|
||
.then(({ lat, lng }) => setUserLocation({ lat, lon: lng }))
|
||
.catch((error) => console.warn('[MapPage] Geolocation error:', error));
|
||
}
|
||
}, [sortBy, userLocation]);
|
||
|
||
// Silent background warm-up (once, on mount): quietly refresh the location fix
|
||
// so it's cached and ready the instant the user taps "Near Me". This never
|
||
// moves the map or shows a prompt-driven toast — it just updates the badge /
|
||
// cache in the background. Skipped on native, where it would surface an OS
|
||
// permission prompt out of context (native users trigger it via "Near Me").
|
||
useEffect(() => {
|
||
if (isNativeGeolocation()) return;
|
||
let cancelled = false;
|
||
|
||
const warm = () =>
|
||
getCurrentPosition()
|
||
.then(({ lat, lng }) => {
|
||
if (!cancelled) setUserLocation({ lat, lon: lng });
|
||
})
|
||
.catch(() => {
|
||
// Silent — user hasn't asked to be located yet.
|
||
});
|
||
|
||
// Only warm up when permission is ALREADY granted — otherwise this fires the
|
||
// browser's "Allow location?" prompt the moment someone opens /map.
|
||
if (typeof navigator !== 'undefined' && navigator.permissions?.query) {
|
||
navigator.permissions
|
||
.query({ name: 'geolocation' as PermissionName })
|
||
.then((status) => {
|
||
if (!cancelled && status.state === 'granted') warm();
|
||
})
|
||
.catch(() => {
|
||
/* permissions API unavailable — don't prompt */
|
||
});
|
||
}
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
// Combine spots and shops into unified list for map display
|
||
const allLocations = useMemo(() => {
|
||
const spotList = (spots || [])
|
||
.filter((spot) => !autoHiddenIds.has(spot.id))
|
||
.map(spot => ({ ...spot, isShop: false }));
|
||
const shopList = (shops || []).map(shop => ({
|
||
...shop,
|
||
title: shop.name,
|
||
// SkateSpot-shaped fields so shops can share the unified spot list/map.
|
||
tags: [] as string[],
|
||
category: 'shop',
|
||
types: ['shop' as const],
|
||
isShop: true,
|
||
accessType: undefined,
|
||
contactInfo: undefined,
|
||
}));
|
||
return [...spotList, ...shopList];
|
||
}, [spots, shops, autoHiddenIds]);
|
||
|
||
// Fetch trust scores for all creators
|
||
const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]);
|
||
const { data: trustScores } = useBatchTrustScores(locationPubkeys);
|
||
|
||
const getTrustScore = useCallback((pubkey: string) => trustScores?.get(pubkey), [trustScores]);
|
||
|
||
// Fetch community status reports for all spots (not shops), batched in one query.
|
||
const statusSpotRefs = useMemo(
|
||
() => (spots || []).map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
|
||
[spots],
|
||
);
|
||
const { getStatus: getSpotStatus } = useBatchSpotStatus(statusSpotRefs);
|
||
|
||
// Fetch comments for popularity sorting
|
||
const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]);
|
||
const { data: commentCounts } = useCommentCounts(spotIds);
|
||
|
||
const getCommentCount = useCallback((eventId: string) => {
|
||
return commentCounts?.get(eventId) ?? 0;
|
||
}, [commentCounts]);
|
||
|
||
// Batched zap totals for "Most Zapped" sorting (ONE query for every location).
|
||
const locationEvents = useMemo(() => allLocations.map((l) => l.event), [allLocations]);
|
||
const { data: zapTotals } = useZapTotals(locationEvents);
|
||
const getZapTotal = useCallback(
|
||
(event: (typeof locationEvents)[number]) => zapTotals?.get(zapTargetKey(event))?.totalSats ?? 0,
|
||
[zapTotals],
|
||
);
|
||
|
||
// 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);
|
||
|
||
return geohashes;
|
||
}, [allLocations]);
|
||
|
||
// Fetch weather data for all spots
|
||
const { data: weatherData } = 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)}`);
|
||
};
|
||
|
||
// 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.
|
||
// • 'trusted' (soft): keep everything visible but float trusted content to
|
||
// the top. This must be folded into the MAIN sort below as its primary
|
||
// key — running it as a separate .sort() first is pointless because the
|
||
// sort() that follows would immediately re-order everything and wipe it
|
||
// out (which it silently did for the default trusted+recent combo).
|
||
// • 'verified'/'following' (strict): actually hide low-trust content.
|
||
const softTrust = trustFilter === 'trusted';
|
||
if (trustScores && trustFilter !== 'all' && !softTrust) {
|
||
filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore);
|
||
}
|
||
|
||
// Trust tier (1 = trusted, 0 = not) used as the primary sort key in soft mode.
|
||
const trustTier = (pubkey: string) => ((getTrustScore(pubkey) ?? -1) >= 60 ? 1 : 0);
|
||
|
||
// Apply sorting. In soft-trust mode, trusted content sorts first and the
|
||
// chosen `sortBy` becomes the tiebreaker WITHIN each trust tier.
|
||
filtered.sort((a, b) => {
|
||
if (softTrust && trustScores) {
|
||
const tierDiff = trustTier(b.pubkey) - trustTier(a.pubkey);
|
||
if (tierDiff !== 0) return tierDiff;
|
||
}
|
||
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 'zaps': {
|
||
const zapsA = getZapTotal(a.event);
|
||
const zapsB = getZapTotal(b.event);
|
||
if (zapsB !== zapsA) return zapsB - zapsA; // Most zapped 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, getTrustScore, trustScores, sortBy, userLocation, getCommentCount, getZapTotal]);
|
||
|
||
// Spots shown as MAP MARKERS. Hide spots the community has confirmed as
|
||
// "gone" (demolished) so they don't clutter the map — they still appear
|
||
// (dimmed, with a badge) in the card list below so the record isn't lost.
|
||
const mapSpots = useMemo(() => {
|
||
return filteredSpots.filter((item) => {
|
||
if (item.isShop) return true;
|
||
const status = getSpotStatus(item.pubkey, item.identifier);
|
||
return status?.status !== 'gone';
|
||
});
|
||
}, [filteredSpots, getSpotStatus]);
|
||
|
||
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
|
||
// 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 (
|
||
<>
|
||
{/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */}
|
||
{/* 100dvh handles iOS Safari URL bar shrink correctly */}
|
||
<div className="relative h-[calc(100dvh-var(--header-mobile)-var(--bottom-nav))] md:h-[calc(100vh-4rem)] flex flex-col">
|
||
{/* Welcome Banner — shown once to logged-out visitors. It lives ABOVE
|
||
the toolbar (both in flow) because the toolbar floats over the map:
|
||
as a later sibling the banner would sit at the container's top and
|
||
the floating toolbar would paint over it. */}
|
||
{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>
|
||
{/* Desktop-only: grab the mobile app via Zapstore */}
|
||
<a
|
||
href="https://zapstore.dev/apps/org.skatespots.app"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="hidden md:inline-flex flex-shrink-0 items-center gap-1.5 px-3 py-1.5 rounded-full border-2 border-[#ccff00]/50 text-[#ccff00] text-xs font-black uppercase tracking-wide hover:bg-[#ccff00]/10 active:scale-95 transition-all"
|
||
>
|
||
<Smartphone className="h-3.5 w-3.5" />
|
||
Get on Zapstore
|
||
</a>
|
||
<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>
|
||
)}
|
||
|
||
{/* ── Toolbar ──
|
||
Mobile: floats OVER the map (absolute) so the map fills the whole
|
||
screen; the gradient fades into the map and the transparent wrapper
|
||
passes touches through to it. Desktop keeps the in-flow bar. With the
|
||
welcome banner present it drops into flow so it can't cover it. */}
|
||
<div className={cn(
|
||
'pointer-events-none inset-x-0 top-0 z-[1000]',
|
||
showWelcomeBanner
|
||
? 'relative border-b border-white/8 bg-black/95'
|
||
: 'absolute bg-gradient-to-b from-black/85 via-black/55 to-transparent pb-5',
|
||
'md:static md:z-auto md:border-b md:border-white/8 md:bg-black/95 md:pb-0 md:backdrop-blur',
|
||
)}>
|
||
<div className="pointer-events-auto 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-black/50 backdrop-blur-md border-white/15 md:bg-white/5 md:border-white/10 text-white placeholder:text-gray-500 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/15 bg-black/50 backdrop-blur-md md:bg-white/4 md:border-white/10 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]/25 border-[#ff0040]/60 text-[#ff0040]'
|
||
: 'bg-black/50 backdrop-blur-md border-white/15 text-gray-300 md:bg-white/5 md:border-white/10 md: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" /> Spedn
|
||
</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 / Event / Gear / Tour 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} places where you can Spedn your sats nearby`
|
||
: 'Show places where you can Spedn 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.
|
||
Mobile only: on desktop the toolbar is dense and DMs/crew chat cover
|
||
messaging, so we hide the standalone Local Chat trigger at md+. */}
|
||
<Sheet>
|
||
<SheetTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="md:hidden gap-1.5 border-2 border-purple-500/50 hover:bg-purple-500/10 text-purple-400 font-bold h-8 px-2 flex-shrink-0"
|
||
>
|
||
<MessageCircle className="h-4 w-4" />
|
||
</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 ? (
|
||
<>
|
||
{/* Primary action: add a spot (this is a map, after all) */}
|
||
<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>
|
||
{/* Secondary add actions consolidated into one menu to de-clutter */}
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="flex-shrink-0 gap-1 border-2 border-gray-700 hover:border-[#ccff00]/50 font-bold h-8 px-2 sm:px-3"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
<span className="hidden md:inline">More</span>
|
||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="end" className="w-52 p-1.5">
|
||
<Link to="/create-shop" className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium hover:bg-[#ccff00]/10 text-[#ccff00] transition-colors">
|
||
<Store className="h-4 w-4" /> {t('shops.addShop')}
|
||
</Link>
|
||
<Link to="/marketplace" className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium hover:bg-orange-500/10 text-orange-400 transition-colors">
|
||
<ShoppingBag className="h-4 w-4" /> Buy/Sell Gear
|
||
</Link>
|
||
<Link to="/create-tour" className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium hover:bg-cyan-500/10 text-cyan-400 transition-colors">
|
||
<Route className="h-4 w-4" /> Offer Tour
|
||
</Link>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</>
|
||
) : (
|
||
<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>
|
||
|
||
{/* Main Content */}
|
||
{/* `isolate` creates a stacking context so the map's internal z-indexes
|
||
(Leaflet panes, our overlays up to z-[2000]) stay INSIDE the map. Without
|
||
it they competed with the root and the © OpenStreetMap attribution
|
||
(z-999) painted on top of the Sasquatch panel (z-40). */}
|
||
<div className="relative isolate flex-1">
|
||
{viewMode === 'map' ? (
|
||
<>
|
||
<Suspense fallback={
|
||
<div className="h-full bg-black/20 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={mapSpots}
|
||
center={initialCenter}
|
||
zoom={initialZoom}
|
||
marketplaceItems={marketplaceItems || []}
|
||
tours={tours || []}
|
||
btcMapPlaces={btcMapPlaces || []}
|
||
selectedSpot={selectedSpot}
|
||
onSpotClick={setSelectedSpot}
|
||
onMapClick={handleMapClick}
|
||
onMapMove={setMapCenter}
|
||
enableClickToAdd={!!user}
|
||
className="h-full ss-map-floating-toolbar"
|
||
weatherEnabled={weatherEnabled}
|
||
weatherData={weatherData}
|
||
events={upcomingEvents || []}
|
||
crews={crews || []}
|
||
showSpots={showSpots}
|
||
showShops={showShops}
|
||
showGear={showGear}
|
||
showTours={showTours}
|
||
showEvents={showEvents}
|
||
showCrews={showCrews}
|
||
showBtcMap={showBtcMap}
|
||
flyTo={flyTo}
|
||
userLocation={isMobile && userLocation ? { lat: userLocation.lat, lng: userLocation.lon } : null}
|
||
/>
|
||
</Suspense>
|
||
{/* Loading indicator — a slim, honest top bar. The map stays
|
||
visible underneath and spots stream in; we NEVER cover the map
|
||
with fake placeholder pins. An animated sweep sits on top of a
|
||
faint track so it reads as "working", plus a small labeled pill
|
||
during a genuine cold start (no markers yet) so the user knows
|
||
we're talking to the relays. */}
|
||
{isLoading && (
|
||
<>
|
||
<div className="absolute top-0 left-0 right-0 z-[1001] h-[3px] overflow-hidden bg-white/5">
|
||
<div className="ss-loadbar h-full w-1/3 bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00]" />
|
||
</div>
|
||
{mapSpots.length === 0 && (
|
||
<div className="absolute top-[4.5rem] left-1/2 -translate-x-1/2 z-[1001] md:top-3">
|
||
<div className="flex items-center gap-2 rounded-full bg-black/60 px-3.5 py-1.5 backdrop-blur-md ring-1 ring-white/10 shadow-lg">
|
||
<span className="h-3.5 w-3.5 rounded-full border-2 border-[#ccff00]/30 border-t-[#ccff00] animate-spin" />
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/70">
|
||
Loading spots
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
|
||
|
||
{/* Offline / relay-down banner. The map keeps showing the
|
||
last-known spots from the local cache — this just says so
|
||
honestly, and offers a retry. Shown only once we know the relay
|
||
is unreachable, so a slow cold start never flashes it. */}
|
||
{!isLoading && deviceOffline && !offlineBannerDismissed && allLocations.length > 0 && (
|
||
<div className="absolute top-[4.5rem] left-1/2 z-[1002] -translate-x-1/2 md:top-3">
|
||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-400/30 bg-black/75 px-3 py-1.5 shadow-lg backdrop-blur-md">
|
||
<WifiOff className="h-3.5 w-3.5 text-amber-400" />
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-amber-200/90">
|
||
Offline · showing saved spots
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
refetchSpots();
|
||
relay.recheck();
|
||
}}
|
||
className="ml-0.5 rounded-full p-1 text-amber-200/80 transition-colors hover:bg-white/10 hover:text-white"
|
||
aria-label="Try again"
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOfflineBannerDismissed(true)}
|
||
className="rounded-full p-1 text-amber-200/60 transition-colors hover:bg-white/10 hover:text-white"
|
||
aria-label="Dismiss"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty state — the active filters hid everything. Without this
|
||
the map just looks blank/unloaded. Only shown when the
|
||
spot/shop layers are on (other layers have their own markers). */}
|
||
{!isLoading && (showSpots || showShops) && filteredSpots.length === 0 && allLocations.length > 0 && (
|
||
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
|
||
<div className="pointer-events-auto max-w-xs rounded-2xl border border-white/10 bg-black/85 p-5 text-center shadow-2xl backdrop-blur-md">
|
||
<p className="font-black uppercase tracking-wide text-white">No spots match</p>
|
||
<p className="mt-1 text-xs text-gray-400">Try a different search or clear your filters.</p>
|
||
<Button size="sm" className="mt-3" onClick={clearFilters}>
|
||
Clear filters
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Offline with nothing cached yet — don't pretend the map is
|
||
empty ("be the first to map your park"): say what's actually
|
||
wrong and offer a retry. */}
|
||
{!isLoading && relayUnreachable && (showSpots || showShops) && allLocations.length === 0 && (
|
||
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
|
||
<div className="pointer-events-auto max-w-sm rounded-2xl border border-amber-400/25 bg-black/85 p-6 text-center shadow-2xl backdrop-blur-md">
|
||
<WifiOff className="mx-auto h-7 w-7 text-amber-400" />
|
||
<p className="mt-2 font-black uppercase tracking-wide text-white">
|
||
Can't reach the relays
|
||
</p>
|
||
<p className="mt-1 text-xs leading-relaxed text-gray-400">
|
||
{online
|
||
? "You're online, but the community relay isn't answering right now."
|
||
: "You're offline."}{' '}
|
||
Spots you've already seen are saved on this device and will show here.
|
||
</p>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="mt-4 gap-2 border-white/20"
|
||
onClick={() => {
|
||
refetchSpots();
|
||
relay.recheck();
|
||
}}
|
||
>
|
||
<RefreshCw className="h-4 w-4" />
|
||
Try again
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Nothing mapped here AT ALL — the honest empty map. This is the
|
||
density problem, not a filter problem, so the fix on offer is
|
||
"add the first one" rather than "clear your filters". */}
|
||
{!isLoading && !relayUnreachable && (showSpots || showShops) && allLocations.length === 0 && (
|
||
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
|
||
<div className="pointer-events-auto max-w-sm rounded-2xl border-2 border-[#ccff00]/25 bg-black/85 p-6 text-center shadow-2xl backdrop-blur-md">
|
||
<p className="text-2xl">🛹</p>
|
||
<p className="mt-2 font-black uppercase tracking-wide text-white">
|
||
No spots mapped yet
|
||
</p>
|
||
<p className="mt-1 text-xs leading-relaxed text-gray-400">
|
||
Be the first to map your park — it takes a minute, it's yours, and the next
|
||
skater who rolls through will thank you.
|
||
</p>
|
||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center">
|
||
<Link to="/create-spot">
|
||
<Button
|
||
size="sm"
|
||
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Add the first spot
|
||
</Button>
|
||
</Link>
|
||
<Button size="sm" variant="outline" className="gap-2 border-white/20" onClick={handleNearMe} disabled={nearMeLoading}>
|
||
<LocateFixed className="h-4 w-4" />
|
||
{nearMeLoading ? 'Locating…' : 'Find spots near me'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</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/55 backdrop-blur-sm px-1.5 py-0.5 rounded text-[9px] font-medium text-gray-200/90 leading-none pointer-events-auto">
|
||
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">© OpenStreetMap</a>
|
||
{showBtcMap && (
|
||
<>{' · '}<a href="https://btcmap.org" target="_blank" rel="noopener noreferrer" className="text-[#f7931a] hover:text-[#ffb04d] 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={() => {
|
||
// On native a denial is an OS permission the user can grant
|
||
// and retry; only the web needs the browser re-enable sheet.
|
||
if (nearMeError === 'denied' && !isNativeGeolocation()) {
|
||
setShowLocationHelp(true);
|
||
return;
|
||
}
|
||
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>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{/* 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, event, gear, or tour'}
|
||
>
|
||
<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}
|
||
status={spot.isShop ? null : getSpotStatus(spot.pubkey, spot.identifier)}
|
||
/>
|
||
))}
|
||
</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>
|
||
|
||
{/* 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"
|
||
hideClose
|
||
className="h-auto max-h-[55vh] rounded-t-2xl px-0 pb-0 bg-black/95 border-t border-white/10"
|
||
>
|
||
<SheetTitle className="sr-only">{selectedSpot?.title ?? 'Spot details'}</SheetTitle>
|
||
{selectedSpot && (
|
||
<SpotBottomSheet
|
||
spot={selectedSpot}
|
||
onClose={() => setSelectedSpot(null)}
|
||
onDirections={(target) => {
|
||
// Close the sheet first — the route dialog takes over.
|
||
setSelectedSpot(null);
|
||
setRouteTarget(target);
|
||
}}
|
||
/>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
{/* In-app route — page-level so it never stacks inside the spot Sheet. */}
|
||
<RouteDialog
|
||
open={!!routeTarget}
|
||
onOpenChange={(open) => { if (!open) setRouteTarget(null); }}
|
||
destination={routeTarget ? { lat: routeTarget.lat, lng: routeTarget.lng } : null}
|
||
destinationLabel={routeTarget?.label ?? 'Spot'}
|
||
onDestinationChange={(pick) => setRouteTarget({ lat: pick.lat, lng: pick.lng, label: pick.label })}
|
||
/>
|
||
</>
|
||
);
|
||
}
|