SkateSpots/src/pages/MapPage.tsx
shakespeare.diy 3e6fc305ac map: shrink filter pills to h-7 w-7, hide right column + Near Me when filter sheet open
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-23 21:07:57 +07:00

1473 lines
62 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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 { 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 { 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,
} from 'lucide-react';
import { CameraCapture } from '@/components/CameraCapture';
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>
);
}
// Universal directions picker — works on GrapheneOS, iOS, any browser
function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label: string }) {
const [open, setOpen] = useState(false);
const options = [
{
name: 'Maps (device default)',
sub: 'Opens your default maps app',
href: `geo:${lat},${lon}?q=${lat},${lon}(${label})`,
emoji: '📍',
},
{
name: 'OpenStreetMap',
sub: 'Privacy-friendly, no account',
href: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=17`,
emoji: '🗺️',
},
{
name: 'Google Maps',
sub: 'Opens in browser or app',
href: `https://maps.google.com/?q=${lat},${lon}`,
emoji: '🔵',
},
{
name: 'Apple Maps',
sub: 'iOS & macOS',
href: `https://maps.apple.com/?ll=${lat},${lon}&q=${label}`,
emoji: '🍎',
},
];
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 */}
<div className="absolute bottom-14 right-0 z-[2001] bg-black/95 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-64 backdrop-blur">
<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);
// 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);
const [showBtcMap, setShowBtcMap] = useState(false);
// Mobile filters sheet
const [mobileFiltersOpen, setMobileFiltersOpen] = 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 handleNearMe = () => {
if (!navigator.geolocation) return;
setNearMeLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 });
setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude });
setNearMeLoading(false);
},
() => setNearMeLoading(false),
{ enableHighAccuracy: true, timeout: 8000 }
);
};
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;
const activeCategoryCount = selectedCategories.length;
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
return (
<Layout>
{/* Account for mobile header (56px) + bottom nav (~72px+safe-area) on mobile, desktop header (64px) only on desktop */}
{/* Using 100dvh for mobile to handle iOS Safari URL bar correctly */}
<div className="h-[calc(100dvh-3.5rem-72px)] md:h-[calc(100vh-4rem)] flex flex-col">
{/* Toolbar */}
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container px-3 sm:px-4 py-2 sm:py-3">
{/* Row 1: Search + View Toggle + Filters (mobile) */}
<div className="flex items-center gap-2">
{/* Search */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('mapPage.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-10"
/>
</div>
{/* View toggle - Map / List */}
<div className="flex items-center rounded-lg border bg-muted p-0.5 flex-shrink-0">
<Button
variant={viewMode === 'map' ? 'default' : 'ghost'}
size="sm"
className="h-9 w-9 sm:w-auto sm:px-3 p-0"
onClick={() => setViewMode('map')}
>
<Map className="h-4 w-4" />
<span className="hidden sm:inline ml-2">{t('mapPage.mapView')}</span>
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="h-9 w-9 sm:w-auto sm:px-3 p-0"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
<span className="hidden sm:inline ml-2">{t('mapPage.listView')}</span>
</Button>
</div>
{/* Mobile: Filters button that opens sheet */}
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-10 px-3 gap-2 md:hidden flex-shrink-0 relative"
>
<SlidersHorizontal className="h-4 w-4" />
<span className="sr-only">Filters</span>
{totalActiveFilters > 0 && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-bold bg-[#ff0040] text-white rounded-full flex items-center justify-center">
{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={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 */}
{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="/create-spot" onClick={() => setMobileFiltersOpen(false)}>
<Button size="sm" className="w-full gap-1 bg-[#ff0040] hover:bg-[#ff0040]/90 font-bold h-9 text-xs">
<Plus className="h-3 w-3" />
Add Spot
</Button>
</Link>
<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>
<Link to="/create-tour" onClick={() => setMobileFiltersOpen(false)}>
<Button size="sm" variant="outline" className="w-full gap-1 border-cyan-500/50 text-cyan-400 font-bold h-9 text-xs">
<Route className="h-3 w-3" />
Tour
</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">
{/* Entity toggles */}
<Button
variant={showSpots ? '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',
showSpots ? 'bg-red-500 hover:bg-red-600' : 'border-gray-700'
)}
onClick={() => setShowSpots(!showSpots)}
>
🛹 <span className="hidden xs:inline">{t('mapPage.spots')}</span>
</Button>
<Button
variant={showShops ? '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',
showShops ? 'bg-purple-600 hover:bg-purple-700' : 'border-gray-700'
)}
onClick={() => setShowShops(!showShops)}
>
🏪 <span className="hidden xs:inline">{t('mapPage.shops')}</span>
</Button>
<Button
variant={showGear ? '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',
showGear ? 'bg-orange-500 hover:bg-orange-600' : 'border-gray-700'
)}
onClick={() => setShowGear(!showGear)}
>
<ShoppingBag className="h-3.5 w-3.5" />
<span className="hidden xs:inline">{t('marketplace.gear')}</span>
</Button>
<Button
variant={showTours ? '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',
showTours ? 'bg-cyan-500 hover:bg-cyan-600' : 'border-gray-700'
)}
onClick={() => setShowTours(!showTours)}
>
<Route className="h-3.5 w-3.5" />
<span className="hidden xs:inline">Local Tours</span>
</Button>
{/* 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" />
{/* 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>
{/* 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}
showSpots={showSpots}
showShops={showShops}
showGear={showGear}
showTours={showTours}
showBtcMap={showBtcMap}
flyTo={flyTo}
/>
</Suspense>
{/* Loading indicator */}
{isLoading && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000] bg-black/90 backdrop-blur px-4 py-2 rounded-lg shadow-lg border border-[#ff0040]/30">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-[#ff0040] border-t-transparent" />
<span className="text-sm text-white font-bold">{t('common.loading')}</span>
</div>
</div>
)}
{/* Near Me button + attribution — floating top-right, hidden when any overlay is open */}
<div className={cn(
"absolute top-4 right-4 z-[1000] flex flex-col items-end gap-1.5 transition-opacity duration-150",
mobileFiltersOpen ? "opacity-0 pointer-events-none" : "opacity-100"
)}>
<Button
onClick={handleNearMe}
disabled={nearMeLoading}
size="sm"
className="h-10 w-10 rounded-full bg-black/80 hover:bg-black border border-white/20 shadow-lg backdrop-blur p-0"
title="Near me"
>
{nearMeLoading ? (
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
) : (
<Target className="h-4 w-4 text-[#ccff00]" />
)}
</Button>
{/* Map attribution */}
<div className="bg-black/70 backdrop-blur px-2 py-1 rounded text-[10px] text-gray-400 text-right leading-snug">
©{' '}
<a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer" className="text-gray-300 hover:underline">CARTO</a>
{' · '}
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="text-gray-300 hover:underline">OSM</a>
{showBtcMap && (
<>{' · '}<a href="https://btcmap.org" target="_blank" rel="noopener noreferrer" className="text-[#f7931a] hover:underline">BTC Map</a></>
)}
</div>
</div>
{/* Right-side control column — filter pills + Quick Snap (mobile only, hidden when overlays open) */}
<div className={cn(
"absolute bottom-24 right-3 z-[1000] md:hidden flex flex-col items-center gap-1.5 transition-opacity duration-150",
mobileFiltersOpen ? "opacity-0 pointer-events-none" : "opacity-100"
)}>
{/* Layer filter pills — compact icon-only */}
{[
{ key: 'spots', label: '🛹', active: showSpots, toggle: () => setShowSpots(v => !v), activeColor: 'bg-red-500/90' },
{ key: 'shops', label: '🏪', active: showShops, toggle: () => setShowShops(v => !v), activeColor: 'bg-purple-600/90' },
{ key: 'gear', label: '🛒', active: showGear, toggle: () => setShowGear(v => !v), activeColor: 'bg-orange-500/90' },
{ key: 'tours', label: '🗺️', active: showTours, toggle: () => setShowTours(v => !v), activeColor: 'bg-cyan-500/90' },
].map(({ key, label, active, toggle, activeColor }) => (
<button
key={key}
onClick={toggle}
className={cn(
'h-7 w-7 rounded-full text-xs flex items-center justify-center shadow-md border transition-all',
active
? `${activeColor} border-white/20 shadow-black/30`
: 'bg-black/60 border-white/8 opacity-35'
)}
title={key}
>
{label}
</button>
))}
{/* Divider */}
{user && <div className="w-4 h-px bg-white/15 my-0.5" />}
{/* Quick Snap */}
{user && (
<>
<Button
onClick={() => setShowQuickCamera(true)}
className="h-12 w-12 rounded-full bg-[#ff0040] hover:bg-[#ff0040]/90 shadow-lg shadow-[#ff0040]/25 border-2 border-white/20"
>
<Camera className="h-5 w-5" />
</Button>
<span className="text-[10px] text-white/60 font-bold whitespace-nowrap">
Snap
</span>
</>
)}
</div>
</>
) : (
<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>
);
}