import { useEffect, useRef, useMemo, useCallback } from 'react'; import L from 'leaflet'; import 'leaflet.markercluster'; import ngeohash from 'ngeohash'; import { useNavigate } from 'react-router-dom'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { MapPin } from 'lucide-react'; import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent, SkateCrew } from '@/lib/types'; import { SPOT_TYPE_LABELS, formatPrice } from '@/lib/types'; import type { SpotWeatherData } from '@/hooks/useSpotWeather'; import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces'; import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; /** * SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping. * Every value that originates from a Nostr event (spot/tour/crew/event fields) * or a third-party API (BTC Map places) is UNTRUSTED and must never be placed * into `innerHTML`. These helpers build popup rows with `textContent` (which * cannot execute markup) and validate URLs with `sanitizeUrl` before they touch * an `href`/`src`. See .agents/skills/nostr-security. */ /** * Escape a value for safe interpolation into an innerHTML template literal. * Weather/air-quality fields come from an external API, so they're untrusted โ€” * a compromised or malicious upstream could otherwise inject HTML/JS. */ function escapeHtml(value: unknown): string { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** A popup detail row: an emoji/icon prefix + untrusted text set via textContent. */ function popupRow(prefix: string, text: string): HTMLElement { const row = document.createElement('div'); row.className = 'flex items-center gap-2'; const label = document.createElement('span'); // Prefix is always a hardcoded emoji/label โ€” safe as textContent too. label.textContent = prefix; const value = document.createElement('span'); value.textContent = text; // untrusted โ€” textContent neutralizes any markup row.appendChild(label); row.appendChild(value); return row; } // Fix default marker icon issues with webpack/vite const defaultIcon = L.icon({ iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], }); L.Marker.prototype.options.icon = defaultIcon; /** Emoji glyph per spot category โ€” turns the map into a readable language. */ /** A single generic glyph for every skate-spot pin (kept intentionally simple). */ const SPOT_GLYPH = '๐Ÿ›น'; /** * Crisp skate-spot marker: a solid neon teardrop with a TIGHT glow halo (the * glow is an accent, not the whole blob), a generic skate glyph inside, and โ€” * for covered/indoor spots โ€” a small roof "cap" so dry spots literally stand * out on a rainy day. * * `weatherColor`/`weatherIcon` (when the weather layer is on) still override the * fill + add the condition badge, exactly as before. */ const createSkateSpotIcon = (opts?: { weatherColor?: string; weatherIcon?: string; shelter?: 'outdoor' | 'partial' | 'covered' | 'indoor'; }) => { const { weatherColor, weatherIcon, shelter } = opts ?? {}; const fill = weatherColor ? `background: ${weatherColor};` : 'background: linear-gradient(135deg, #ff0040, #ff6600);'; const glowColor = weatherColor || '#ff0040'; const glyph = SPOT_GLYPH; const isSheltered = shelter === 'covered' || shelter === 'indoor'; // Roof cap for covered/indoor spots โ€” a small cyan "dry" marker that sits // above the pin so it reads even when zoomed out. const roofCap = isSheltered ? `
` : ''; return L.divIcon({ className: 'skate-spot-marker', html: `
${roofCap}
${weatherIcon ? `
${weatherIcon}
` : ''} ${glyph}
`, iconSize: [36, 36], iconAnchor: [18, 36], popupAnchor: [0, -36], }); }; // Custom shop marker icon (for shops) const createShopIcon = () => L.divIcon({ className: 'skate-shop-marker', html: `
`, iconSize: [36, 36], iconAnchor: [18, 36], popupAnchor: [0, -36], }); // "You are here" user-location marker โ€” SkateSpots logo in a circular badge // with a pulsing accuracy halo (Google-Maps-style, brand-flavored). const createUserLocationIcon = () => { // BASE_URL resolves correctly for both web ('/') and the Capacitor APK ('./'). // Reuse the small (~24 KB) brand logo for the marker instead of the old // 2.9 MB user-location.png โ€” same artwork, ~99% smaller payload. const logoUrl = `${import.meta.env.BASE_URL}images/logo.png`; return L.divIcon({ className: 'user-location-marker', html: `
`, iconSize: [64, 64], iconAnchor: [32, 32], // center anchored (it marks a point, not a pin drop) popupAnchor: [0, -20], }); }; // Dual-purpose marker (shop + spot) const createDualIcon = (hasGear?: boolean, gearCount?: number) => L.divIcon({ className: 'dual-marker', html: `
${hasGear && gearCount ? `
${gearCount}
` : ''}
`, iconSize: [44, 44], iconAnchor: [22, 44], popupAnchor: [0, -44], }); // Custom tour marker icon (cyan color with route/person icon) const createTourIcon = () => L.divIcon({ className: 'tour-marker', html: `
๐Ÿ›น
`, iconSize: [40, 40], iconAnchor: [20, 40], popupAnchor: [0, -40], }); // Custom event marker icon (lime โ€” matches the Events section + Add Event action) const createEventIcon = () => L.divIcon({ className: 'event-marker', html: `
`, iconSize: [38, 38], iconAnchor: [19, 38], popupAnchor: [0, -38], }); // Custom crew marker icon (teal/cyan โ€” crew home turf) const createCrewIcon = (logo?: string) => { // SECURITY: crew.image is untrusted. Only embed it when it's a valid http(s) // URL; sanitizeUrl returns a parsed href with quotes percent-encoded, so it // cannot break out of the double-quoted src="" attribute below. const safeLogo = sanitizeUrl(logo); return L.divIcon({ className: 'crew-marker', html: `
${safeLogo ? ` ` : ` `}
`, iconSize: [42, 42], iconAnchor: [21, 42], popupAnchor: [0, -42], }); }; // Custom BTC Map marker icon (for Bitcoin-accepting places) const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({ className: 'btcmap-marker', html: `
${isBoosted ? `
โญ
` : ''}
`, iconSize: [32, 32], iconAnchor: [16, 32], popupAnchor: [0, -32], }); // Custom marketplace marker icon (for gear swap) const createMarketplaceIcon = (count?: number) => L.divIcon({ className: 'marketplace-marker', html: `
${count && count > 1 ? `
${count}
` : ''}
`, iconSize: [36, 36], iconAnchor: [18, 36], popupAnchor: [0, -36], }); interface SpotMapProps { spots: SkateSpot[]; marketplaceItems?: MarketplaceItem[]; tours?: SkateTour[]; events?: SkateEvent[]; crews?: SkateCrew[]; btcMapPlaces?: BtcMapPlace[]; center?: [number, number]; zoom?: number; onSpotClick?: (spot: SkateSpot) => void; selectedSpot?: SkateSpot | null; onMapClick?: (lat: number, lng: number) => void; onMapMove?: (center: { lat: number; lon: number }) => void; enableClickToAdd?: boolean; className?: string; weatherEnabled?: boolean; weatherData?: Map; showSpots?: boolean; showShops?: boolean; showGear?: boolean; showTours?: boolean; showEvents?: boolean; showCrews?: boolean; showBtcMap?: boolean; flyTo?: { lat: number; lng: number; zoom?: number } | null; /** The current user's location โ€” renders a "you are here" pin when set. */ userLocation?: { lat: number; lng: number } | null; } function createPopupContent( spot: SkateSpot, onViewDetails: () => void, weatherData?: SpotWeatherData ): HTMLElement { const container = document.createElement('div'); container.className = 'p-2 min-w-[200px]'; // Title const title = document.createElement('h3'); title.className = 'font-bold text-lg mb-1'; title.textContent = spot.title; container.appendChild(title); // Location โ€” SVG is static markup, but spot.location is UNTRUSTED, so it is // written via textContent (never innerHTML) to neutralize any injected markup. if (spot.location) { const location = document.createElement('p'); location.className = 'text-sm text-gray-500 mb-2 flex items-center gap-1'; const pin = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); pin.setAttribute('class', 'h-3 w-3'); pin.setAttribute('viewBox', '0 0 24 24'); pin.setAttribute('fill', 'none'); pin.setAttribute('stroke', 'currentColor'); pin.setAttribute('stroke-width', '2'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z'); const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', '12'); circle.setAttribute('cy', '10'); circle.setAttribute('r', '3'); pin.appendChild(path); pin.appendChild(circle); const text = document.createElement('span'); text.textContent = spot.location; location.appendChild(pin); location.appendChild(text); container.appendChild(location); } // Weather section (if enabled and available) if (weatherData) { const weatherSection = document.createElement('div'); weatherSection.className = 'mb-3 p-2 rounded-lg bg-gray-50 border border-gray-200'; // Current conditions const currentWeather = document.createElement('div'); currentWeather.className = 'flex items-center justify-between mb-2'; currentWeather.innerHTML = `
${escapeHtml(weatherData.weather.weatherIcon)}
${escapeHtml(weatherData.weather.temperature)}ยฐC
${escapeHtml(weatherData.weather.weatherDescription)}
${escapeHtml(weatherData.skateability.label)}
${escapeHtml(weatherData.skateability.score)}/100
`; weatherSection.appendChild(currentWeather); // Details const details = document.createElement('div'); details.className = 'text-xs space-y-1 text-gray-600'; details.innerHTML = `
๐Ÿ’จ Wind: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}
๐Ÿ’ง Humidity: ${escapeHtml(weatherData.weather.humidity)}%
${weatherData.airQuality ? `
๐ŸŒซ๏ธ Air Quality: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})
` : ''} `; weatherSection.appendChild(details); // Forecast (if available) if (weatherData.forecast?.daily && weatherData.forecast.daily.length > 0) { const forecastDiv = document.createElement('div'); forecastDiv.className = 'mt-2 pt-2 border-t border-gray-200'; const forecastTitle = document.createElement('div'); forecastTitle.className = 'text-xs font-semibold mb-1 text-gray-700'; forecastTitle.textContent = '3-Day Forecast'; forecastDiv.appendChild(forecastTitle); const forecastGrid = document.createElement('div'); forecastGrid.className = 'grid grid-cols-3 gap-1 text-xs text-center'; weatherData.forecast.daily.forEach((day) => { const dayDiv = document.createElement('div'); dayDiv.className = 'p-1'; const date = new Date(day.date); const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }); dayDiv.innerHTML = `
${escapeHtml(dayName)}
${escapeHtml(day.weatherIcon)}
${escapeHtml(day.maxTemp)}ยฐ/${escapeHtml(day.minTemp)}ยฐ
${day.precipitationProbability > 0 ? `
${escapeHtml(day.precipitationProbability)}% ๐ŸŒง๏ธ
` : ''} `; forecastGrid.appendChild(dayDiv); }); forecastDiv.appendChild(forecastGrid); weatherSection.appendChild(forecastDiv); } container.appendChild(weatherSection); } // Types if (spot.types.length > 0) { const typesContainer = document.createElement('div'); typesContainer.className = 'flex flex-wrap gap-1 mb-3'; spot.types.slice(0, 3).forEach((type) => { const badge = document.createElement('span'); badge.className = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-gray-100 text-gray-800'; badge.textContent = SPOT_TYPE_LABELS[type] || type; typesContainer.appendChild(badge); }); container.appendChild(typesContainer); } // Button const button = document.createElement('button'); button.className = 'w-full inline-flex items-center justify-center rounded-md text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 h-8 px-3 transition-colors'; button.textContent = 'View Details'; button.onclick = (e) => { e.preventDefault(); onViewDetails(); }; container.appendChild(button); return container; } // Enhanced popup with marketplace items section function createEnhancedPopupContent( spot: SkateSpot, marketplaceItems: MarketplaceItem[], onViewDetails: () => void, weatherData?: SpotWeatherData ): HTMLElement { const container = document.createElement('div'); container.className = 'p-2 min-w-[250px] max-w-[350px]'; // Location name header const header = document.createElement('div'); header.className = 'mb-3 pb-2 border-b border-gray-700'; const locationName = document.createElement('h3'); locationName.className = 'font-bold text-lg text-white'; locationName.textContent = `๐Ÿ“ ${spot.title}`; header.appendChild(locationName); container.appendChild(header); // Spot/Shop Section const spotSection = document.createElement('div'); spotSection.className = 'mb-3'; const isShop = spot.types.includes('shop'); const spotIcon = isShop ? '๐Ÿช' : '๐Ÿ›น'; const spotLabel = isShop ? 'SKATE SHOP' : 'SKATE SPOT'; const spotTitle = document.createElement('div'); spotTitle.className = 'font-bold text-sm text-gray-200 mb-2'; spotTitle.textContent = `${spotIcon} ${spotLabel}`; spotSection.appendChild(spotTitle); // Spot types if (spot.types.length > 0) { const typesContainer = document.createElement('div'); typesContainer.className = 'flex flex-wrap gap-1 mb-2'; spot.types.slice(0, 3).forEach((type) => { const badge = document.createElement('span'); badge.className = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-gray-800 text-gray-200 border-gray-600'; badge.textContent = SPOT_TYPE_LABELS[type] || type; typesContainer.appendChild(badge); }); spotSection.appendChild(typesContainer); } // View spot button const spotButton = document.createElement('button'); spotButton.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-red-500 text-white hover:bg-red-600 h-7 px-3 transition-colors mt-2'; spotButton.textContent = 'View Details โ†’'; spotButton.onclick = (e) => { e.preventDefault(); onViewDetails(); }; spotSection.appendChild(spotButton); container.appendChild(spotSection); // Marketplace Section (if items exist) if (marketplaceItems.length > 0) { const separator = document.createElement('div'); separator.className = 'border-t border-gray-700 my-3'; container.appendChild(separator); const gearSection = document.createElement('div'); const gearTitle = document.createElement('div'); gearTitle.className = 'font-bold text-sm text-orange-400 mb-2'; gearTitle.textContent = `๐Ÿ›’ GEAR FOR SALE (${marketplaceItems.length})`; gearSection.appendChild(gearTitle); // Show up to 3 items const itemsList = document.createElement('div'); itemsList.className = 'space-y-2 mb-2'; marketplaceItems.slice(0, 3).forEach((item) => { const itemDiv = document.createElement('div'); itemDiv.className = 'text-xs text-gray-300'; const price = formatPrice(item.price, item.priceType); itemDiv.textContent = `โ€ข ${item.title} - ${price}`; itemsList.appendChild(itemDiv); }); gearSection.appendChild(itemsList); // View all gear button const gearButton = document.createElement('button'); gearButton.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-orange-500 text-white hover:bg-orange-600 h-7 px-3 transition-colors'; gearButton.textContent = marketplaceItems.length > 3 ? `View All ${marketplaceItems.length} Items โ†’` : 'View Gear โ†’'; gearButton.onclick = (e) => { e.preventDefault(); onViewDetails(); // Goes to spot page which has gear section }; gearSection.appendChild(gearButton); container.appendChild(gearSection); } return container; } export function SpotMap({ spots, marketplaceItems = [], tours = [], events = [], crews = [], btcMapPlaces = [], center = [40.7128, -74.006], // Default to NYC zoom = 3, onSpotClick, selectedSpot, onMapClick, onMapMove, enableClickToAdd = false, className, weatherEnabled = false, weatherData, showSpots = true, showShops = true, showGear = true, showTours = true, showEvents = false, showCrews = false, showBtcMap = false, flyTo = null, userLocation = null, }: SpotMapProps) { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const markersRef = useRef([]); const btcMapMarkersRef = useRef([]); const userMarkerRef = useRef(null); // MarkerClusterGroup for spot/shop/gear/tour markers // eslint-disable-next-line @typescript-eslint/no-explicit-any const clusterGroupRef = useRef(null); const onMapMoveRef = useRef(onMapMove); const navigate = useNavigate(); // Keep the ref updated with latest callback useEffect(() => { onMapMoveRef.current = onMapMove; }, [onMapMove]); // Group all entities by location (geohash-8 for ~40m grouping) // This ensures nearby but distinct spots each get their own marker const locationGroups = useMemo(() => { const groups = new Map(); // Add spots if (showSpots || showShops) { spots?.filter((spot) => spot.geohash).forEach((spot) => { const key = spot.geohash.slice(0, 8); const { latitude, longitude } = ngeohash.decode(spot.geohash); if (!groups.has(key)) { groups.set(key, { geohash: key, lat: latitude, lng: longitude, spots: [], marketplaceItems: [], }); } const isShop = spot.types.includes('shop'); if ((isShop && showShops) || (!isShop && showSpots)) { groups.get(key)!.spots.push(spot); } }); } // Add marketplace items (but NOT at shop-only locations) if (showGear) { marketplaceItems?.filter((item) => item.geohash).forEach((item) => { const key = item.geohash!.slice(0, 8); if (!groups.has(key)) { const { latitude, longitude } = ngeohash.decode(item.geohash!); groups.set(key, { geohash: key, lat: latitude, lng: longitude, spots: [], marketplaceItems: [], }); } const group = groups.get(key)!; // IMPORTANT: Don't add gear to shop-only locations (conflict of interest) // Only add gear if: // 1. No spots at this location (standalone gear listing), OR // 2. There's at least one regular (non-shop) spot at this location const hasShopOnly = group.spots.length > 0 && group.spots.every(s => s.types.includes('shop')); if (!hasShopOnly) { group.marketplaceItems.push(item); } }); } return Array.from(groups.values()).filter( group => group.spots.length > 0 || group.marketplaceItems.length > 0 ); }, [spots, marketplaceItems, showSpots, showShops, showGear]); const handleViewDetails = useCallback((spot: SkateSpot) => { // Check if this is a shop by looking at types const isShop = spot.types.includes('shop'); const route = isShop ? '/shop' : '/spot'; navigate(`${route}/${spot.pubkey}/${spot.identifier}`); }, [navigate]); // Initialize map useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return; const map = L.map(mapRef.current, { center, zoom, scrollWheelZoom: true, attributionControl: false, // Prevent zooming out past a single world view (no grey space, no tile duplication) minZoom: 2, // Clamp panning so the world fills the viewport โ€” no grey bands above/below maxBounds: [[-90, -Infinity], [90, Infinity]], maxBoundsViscosity: 1.0, }); // Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS // filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches // the app's dark theme without depending on a keyed provider like CARTO. L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '', subdomains: 'abc', minZoom: 2, maxZoom: 19, className: 'skatespots-dark-tiles', }).addTo(map); // Add click handler if enabled if (enableClickToAdd && onMapClick) { map.on('click', (e) => { onMapClick(e.latlng.lat, e.latlng.lng); }); } // Add moveend handler for BTC Map updates map.on('moveend', () => { const center = map.getCenter(); onMapMoveRef.current?.({ lat: center.lat, lon: center.lng }); }); mapInstanceRef.current = map; // Create and add the marker cluster group for spot/shop/gear/tour markers. // Clusters break apart naturally as the user zooms in, keeping the map // clean even when hundreds of spots are close together. // eslint-disable-next-line @typescript-eslint/no-explicit-any const clusterGroup = (L as any).markerClusterGroup({ maxClusterRadius: 60, // px โ€” tighter clustering for dense cities showCoverageOnHover: false, // don't draw polygons on hover spiderfyOnMaxZoom: true, // spread out overlapping pins at max zoom removeOutsideVisibleBounds: true, iconCreateFunction: (cluster: any) => { const count = cluster.getChildCount(); return L.divIcon({ html: `
${count}
`, className: 'skate-cluster-icon', iconSize: [42, 42], iconAnchor: [21, 21], }); }, }); clusterGroup.addTo(map); clusterGroupRef.current = clusterGroup; // Fix map size after initialization and ensure tiles are loaded setTimeout(() => { map.invalidateSize(); // Force a redraw to ensure everything is ready map.whenReady(() => { // Trigger initial map move event const center = map.getCenter(); onMapMoveRef.current?.({ lat: center.lat, lon: center.lng }); }); }, 100); return () => { map.remove(); mapInstanceRef.current = null; }; }, []); // Fix map size when container resizes useEffect(() => { const map = mapInstanceRef.current; if (!map || !mapRef.current) return; const resizeObserver = new ResizeObserver(() => { map.invalidateSize(); }); resizeObserver.observe(mapRef.current); return () => { resizeObserver.disconnect(); }; }, []); // Update center when selectedSpot changes useEffect(() => { const map = mapInstanceRef.current; if (!map) return; if (selectedSpot && selectedSpot.geohash) { const { latitude, longitude } = ngeohash.decode(selectedSpot.geohash); map.setView([latitude, longitude], 15, { animate: true }); } }, [selectedSpot]); // Fly to location when flyTo prop changes useEffect(() => { const map = mapInstanceRef.current; if (!map || !flyTo) return; map.flyTo([flyTo.lat, flyTo.lng], flyTo.zoom ?? 14, { animate: true, duration: 1.2 }); }, [flyTo]); // Render / update the "you are here" user-location marker. useEffect(() => { const map = mapInstanceRef.current; if (!map) return; if (!userLocation) { // No location โ€” remove any existing marker. if (userMarkerRef.current) { userMarkerRef.current.remove(); userMarkerRef.current = null; } return; } if (userMarkerRef.current) { // Move the existing marker to the new position. userMarkerRef.current.setLatLng([userLocation.lat, userLocation.lng]); } else { // Create it, on top of everything else. userMarkerRef.current = L.marker([userLocation.lat, userLocation.lng], { icon: createUserLocationIcon(), zIndexOffset: 1000, interactive: true, keyboard: false, }) .addTo(map) .bindPopup('You are here'); } }, [userLocation]); // Update markers useEffect(() => { const map = mapInstanceRef.current; if (!map) return; // Wait for map to be fully loaded before adding markers if (!map.getContainer()) return; // Clear existing markers from cluster group and array if (clusterGroupRef.current) { clusterGroupRef.current.clearLayers(); } markersRef.current.forEach((marker) => marker.remove()); markersRef.current = []; // Ensure we have location data before adding markers if (locationGroups.length === 0) return; // Add new markers for each location group locationGroups.forEach((group) => { const hasSpots = group.spots.length > 0; // Determine primary spot (for popup) const primarySpot = group.spots[0]; // Check if primary spot is a shop const isShop = primarySpot?.types.includes('shop'); // Check if location has BOTH shop type AND other skate spot types (like park, street, etc.) const hasNonShopTypes = primarySpot?.types.some(t => t !== 'shop'); // IMPORTANT: Don't show gear at shop-only locations (conflict of interest) // Only show gear at regular skate spots or dual shop+spot locations const canShowGear = hasNonShopTypes || !isShop; const hasGear = canShowGear && group.marketplaceItems.length > 0; const gearCount = hasGear ? group.marketplaceItems.length : 0; // Determine icon based on what's at this location let icon: L.DivIcon; if (isShop && hasNonShopTypes) { // Location has shop type AND other spot types (e.g., park, street) - use dual icon icon = createDualIcon(hasGear, gearCount); } else if (isShop) { // Shop only - just shop type, no other spot types icon = createShopIcon(); } else if (hasSpots && !hasGear) { // Regular spot only const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined; icon = createSkateSpotIcon({ weatherColor: spotWeather?.skateability.color, weatherIcon: spotWeather?.weather.weatherIcon, shelter: primarySpot?.shelter, }); } else if (!hasSpots && hasGear) { // Gear only (no spot) icon = createMarketplaceIcon(gearCount); } else { // Spot + gear - regular spot icon const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined; icon = createSkateSpotIcon({ weatherColor: spotWeather?.skateability.color, weatherIcon: spotWeather?.weather.weatherIcon, shelter: primarySpot?.shelter, }); } const marker = L.marker([group.lat, group.lng], { icon, }); // Create popup if (primarySpot) { const spotWeather = weatherEnabled && weatherData ? weatherData.get(primarySpot.geohash) : undefined; // Use enhanced popup if there are marketplace items, otherwise use standard const popupContent = hasGear ? createEnhancedPopupContent(primarySpot, group.marketplaceItems, () => handleViewDetails(primarySpot), spotWeather) : createPopupContent(primarySpot, () => handleViewDetails(primarySpot), spotWeather); marker.bindPopup(popupContent, { maxWidth: 350, className: 'custom-popup', }); marker.on('click', () => { onSpotClick?.(primarySpot); }); } else if (hasGear && !hasSpots) { // Gear-only location (no spot) - create simple gear popup const container = document.createElement('div'); container.className = 'p-2 min-w-[200px]'; const title = document.createElement('h3'); title.className = 'font-bold text-sm text-orange-400 mb-2'; title.textContent = `๐Ÿ›’ GEAR FOR SALE (${gearCount})`; container.appendChild(title); const itemsList = document.createElement('div'); itemsList.className = 'space-y-1 mb-3'; group.marketplaceItems.slice(0, 3).forEach((item) => { const itemDiv = document.createElement('div'); itemDiv.className = 'text-xs text-gray-300'; const price = formatPrice(item.price, item.priceType); itemDiv.textContent = `โ€ข ${item.title} - ${price}`; itemsList.appendChild(itemDiv); }); container.appendChild(itemsList); const button = document.createElement('button'); button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-orange-500 text-white hover:bg-orange-600 h-7 px-3 transition-colors'; button.textContent = 'View Items โ†’'; button.onclick = (e) => { e.preventDefault(); // Navigate to marketplace or first item's linked spot const firstItem = group.marketplaceItems[0]; if (firstItem?.spotReference) { const [, pubkey, identifier] = firstItem.spotReference.split(':'); navigate(`/spot/${pubkey}/${identifier}`); } else { navigate('/marketplace'); } }; container.appendChild(button); marker.bindPopup(container, { maxWidth: 300, className: 'custom-popup', }); } // Add to cluster group (enables auto-clustering at high densities) if (clusterGroupRef.current) { clusterGroupRef.current.addLayer(marker); } else { marker.addTo(map); } markersRef.current.push(marker); }); // Add tour markers (separate from spot/shop markers) if (showTours && tours.length > 0) { tours.forEach((tour) => { // Use meeting point coords if available, otherwise decode geohash let lat: number, lng: number; if (tour.meetingPointCoords) { lat = tour.meetingPointCoords.lat; lng = tour.meetingPointCoords.lon; } else if (tour.geohash) { const decoded = ngeohash.decode(tour.geohash); lat = decoded.latitude; lng = decoded.longitude; } else { return; // Skip tours without location } const tourMarker = L.marker([lat, lng], { icon: createTourIcon(), }); // Create tour popup const container = document.createElement('div'); container.className = 'p-3 min-w-[250px] max-w-[300px]'; // Tour header โ€” icon block is static; tour.title is UNTRUSTED (textContent). const header = document.createElement('div'); header.className = 'flex items-start gap-2 mb-3'; const tourIconBox = document.createElement('div'); tourIconBox.className = 'w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center flex-shrink-0'; tourIconBox.innerHTML = '๐Ÿ›น'; const tourHeadText = document.createElement('div'); tourHeadText.className = 'flex-1 min-w-0'; const tourTitle = document.createElement('h3'); tourTitle.className = 'font-bold text-base text-white leading-tight'; tourTitle.textContent = tour.title; const tourKicker = document.createElement('p'); tourKicker.className = 'text-xs text-cyan-400 font-medium'; tourKicker.textContent = 'LOCAL TOUR'; tourHeadText.appendChild(tourTitle); tourHeadText.appendChild(tourKicker); header.appendChild(tourIconBox); header.appendChild(tourHeadText); container.appendChild(header); // Tour details โ€” untrusted strings go through popupRow (textContent). const details = document.createElement('div'); details.className = 'space-y-1.5 mb-3 text-xs text-gray-300'; if (tour.location) { details.appendChild(popupRow('๐Ÿ“', tour.location)); } if (tour.meetingPoint) { details.appendChild(popupRow('๐Ÿšฉ', `Meet at: ${tour.meetingPoint}`)); } if (tour.duration) { details.appendChild(popupRow('โฑ๏ธ', `${tour.duration} hours`)); } if (tour.skillLevel) { const skillEmoji = tour.skillLevel === 'beginner' ? '๐ŸŒฑ' : tour.skillLevel === 'intermediate' ? '๐Ÿ”ฅ' : tour.skillLevel === 'advanced' ? '๐Ÿ’€' : '๐Ÿ›น'; details.appendChild(popupRow(skillEmoji, `${tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1)} level`)); } if (tour.price) { details.appendChild(popupRow('โšก', `${tour.price.toLocaleString()} sats`)); } container.appendChild(details); // Spot types badges if (tour.spotTypes.length > 0) { const badges = document.createElement('div'); badges.className = 'flex flex-wrap gap-1 mb-3'; tour.spotTypes.slice(0, 4).forEach((type) => { const badge = document.createElement('span'); badge.className = 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'; badge.textContent = type; badges.appendChild(badge); }); container.appendChild(badges); } // View button const button = document.createElement('button'); button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-cyan-500 text-white hover:bg-cyan-600 h-8 px-3 transition-colors'; button.textContent = 'View Tour โ†’'; button.onclick = (e) => { e.preventDefault(); navigate(`/tour/${tour.pubkey}/${tour.identifier}`); }; container.appendChild(button); tourMarker.bindPopup(container, { maxWidth: 320, className: 'custom-popup tour-popup', }); // Add tour markers to cluster group as well if (clusterGroupRef.current) { clusterGroupRef.current.addLayer(tourMarker); } else { tourMarker.addTo(map); } markersRef.current.push(tourMarker); }); } // Add event markers (upcoming, geo-located events โ€” lime pins) if (showEvents && events.length > 0) { const nowSec = Math.floor(Date.now() / 1000); events.forEach((evt) => { // Only plot events with a valid geohash that haven't ended yet if (!evt.geohash) return; if ((evt.end ?? evt.start) < nowSec) return; const decoded = ngeohash.decode(evt.geohash); const eventMarker = L.marker([decoded.latitude, decoded.longitude], { icon: createEventIcon(), }); const container = document.createElement('div'); container.className = 'p-3 min-w-[240px] max-w-[300px]'; // Header โ€” icon block is static; evt.title is UNTRUSTED (textContent). const header = document.createElement('div'); header.className = 'flex items-start gap-2 mb-3'; const evtIconBox = document.createElement('div'); evtIconBox.className = 'w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0'; evtIconBox.innerHTML = '๐Ÿ“…'; const evtHeadText = document.createElement('div'); evtHeadText.className = 'flex-1 min-w-0'; const evtTitle = document.createElement('h3'); evtTitle.className = 'font-bold text-base text-white leading-tight'; evtTitle.textContent = evt.title; const evtKicker = document.createElement('p'); evtKicker.className = 'text-xs text-[#a3cc00] font-medium'; evtKicker.textContent = 'UPCOMING EVENT'; evtHeadText.appendChild(evtTitle); evtHeadText.appendChild(evtKicker); header.appendChild(evtIconBox); header.appendChild(evtHeadText); container.appendChild(header); // Details โ€” date is app-generated; evt.location is UNTRUSTED (textContent). const details = document.createElement('div'); details.className = 'space-y-1.5 mb-3 text-xs text-gray-300'; const startDate = new Date(evt.start * 1000); const dateStr = startDate.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); const timeStr = startDate.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); details.appendChild(popupRow('๐Ÿ—“๏ธ', `${dateStr} ยท ${timeStr}`)); if (evt.location) { details.appendChild(popupRow('๐Ÿ“', evt.location)); } container.appendChild(details); // View button const button = document.createElement('button'); button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 h-8 px-3 transition-colors'; button.textContent = 'View Event โ†’'; button.onclick = (e) => { e.preventDefault(); navigate(`/event/${evt.pubkey}/${evt.identifier}`); }; container.appendChild(button); eventMarker.bindPopup(container, { maxWidth: 320, className: 'custom-popup event-popup', }); if (clusterGroupRef.current) { clusterGroupRef.current.addLayer(eventMarker); } else { eventMarker.addTo(map); } markersRef.current.push(eventMarker); }); } // Add crew markers (crew home turf โ€” teal pins) if (showCrews && crews.length > 0) { crews.forEach((crew) => { if (!crew.geohash) return; // Only plot crews with a home turf const decoded = ngeohash.decode(crew.geohash); const crewMarker = L.marker([decoded.latitude, decoded.longitude], { icon: createCrewIcon(crew.image), }); const container = document.createElement('div'); container.className = 'p-3 min-w-[240px] max-w-[300px]'; // Header โ€” crew.image + crew.name are UNTRUSTED. const header = document.createElement('div'); header.className = 'flex items-start gap-2 mb-3'; const crewIconBox = document.createElement('div'); crewIconBox.className = 'w-10 h-10 rounded-lg bg-teal-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden'; const safeCrewImg = sanitizeUrl(crew.image); if (safeCrewImg) { const img = document.createElement('img'); img.src = safeCrewImg; // validated http(s) URL img.className = 'w-full h-full object-cover'; // Fallback to emoji on load error โ€” no inline handler / no innerHTML. img.onerror = () => { crewIconBox.textContent = ''; const fallback = document.createElement('span'); fallback.className = 'text-lg'; fallback.textContent = '๐Ÿค˜'; crewIconBox.appendChild(fallback); }; crewIconBox.appendChild(img); } else { const emoji = document.createElement('span'); emoji.className = 'text-lg'; emoji.textContent = '๐Ÿค˜'; crewIconBox.appendChild(emoji); } const crewHeadText = document.createElement('div'); crewHeadText.className = 'flex-1 min-w-0'; const crewName = document.createElement('h3'); crewName.className = 'font-bold text-base text-white leading-tight'; crewName.textContent = crew.name; const crewKicker = document.createElement('p'); crewKicker.className = 'text-xs text-teal-400 font-medium'; crewKicker.textContent = 'SKATE CREW'; crewHeadText.appendChild(crewName); crewHeadText.appendChild(crewKicker); header.appendChild(crewIconBox); header.appendChild(crewHeadText); container.appendChild(header); // Details โ€” crew.location is UNTRUSTED; counts/founded via popupRow too. const details = document.createElement('div'); details.className = 'space-y-1.5 mb-3 text-xs text-gray-300'; if (crew.location) { details.appendChild(popupRow('๐Ÿ“', crew.location)); } if (crew.members.length > 0) { details.appendChild(popupRow('๐Ÿ‘ฅ', `${crew.members.length} member${crew.members.length === 1 ? '' : 's'}`)); } if (crew.founded) { details.appendChild(popupRow('๐Ÿ“…', `Since ${crew.founded}`)); } container.appendChild(details); // Vibe tags if (crew.vibes.length > 0) { const badges = document.createElement('div'); badges.className = 'flex flex-wrap gap-1 mb-3'; crew.vibes.slice(0, 4).forEach((vibe) => { const badge = document.createElement('span'); badge.className = 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-teal-500/20 text-teal-300 border border-teal-500/30'; badge.textContent = vibe; badges.appendChild(badge); }); container.appendChild(badges); } // View button const button = document.createElement('button'); button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-teal-500 text-white hover:bg-teal-600 h-8 px-3 transition-colors'; button.textContent = 'View Crew โ†’'; button.onclick = (e) => { e.preventDefault(); navigate(`/crew/${crew.pubkey}/${crew.identifier}`); }; container.appendChild(button); crewMarker.bindPopup(container, { maxWidth: 320, className: 'custom-popup crew-popup', }); if (clusterGroupRef.current) { clusterGroupRef.current.addLayer(crewMarker); } else { crewMarker.addTo(map); } markersRef.current.push(crewMarker); }); } }, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]); // Update BTC Map markers useEffect(() => { const map = mapInstanceRef.current; if (!map) return; // Clear existing BTC Map markers btcMapMarkersRef.current.forEach((marker) => marker.remove()); btcMapMarkersRef.current = []; // Don't add markers if BTC Map is disabled if (!showBtcMap || btcMapPlaces.length === 0) return; btcMapPlaces.forEach((place) => { const boosted = isBoosted(place.boosted_until); const recentlyVerified = isRecentlyVerified(place.verified_at); const marker = L.marker([place.lat, place.lon], { icon: createBtcMapIcon(boosted), }); // Create popup content const container = document.createElement('div'); container.className = 'p-3 min-w-[220px] max-w-[300px]'; // Header with Bitcoin icon โ€” place.name (from BTC Map API) is UNTRUSTED. const header = document.createElement('div'); header.className = 'flex items-start gap-2 mb-2'; const btcIconBox = document.createElement('div'); btcIconBox.className = 'w-8 h-8 rounded-lg bg-[#f7931a]/20 flex items-center justify-center flex-shrink-0'; btcIconBox.innerHTML = 'โ‚ฟ'; const btcHeadText = document.createElement('div'); btcHeadText.className = 'flex-1 min-w-0'; const btcName = document.createElement('h3'); btcName.className = 'font-bold text-sm text-white leading-tight'; btcName.textContent = place.name; const btcKicker = document.createElement('p'); btcKicker.className = 'text-[10px] text-[#f7931a] font-medium uppercase'; btcKicker.textContent = 'Accepts Bitcoin'; btcHeadText.appendChild(btcName); btcHeadText.appendChild(btcKicker); header.appendChild(btcIconBox); header.appendChild(btcHeadText); container.appendChild(header); // Badges row โ€” static markup only (no untrusted data). const badges = document.createElement('div'); badges.className = 'flex flex-wrap gap-1 mb-2'; if (boosted) { badges.innerHTML += `โญ Boosted`; } if (recentlyVerified) { badges.innerHTML += `โœ“ Verified`; } if (badges.innerHTML) { container.appendChild(badges); } // Details โ€” every place field is UNTRUSTED (textContent / sanitized tel). const details = document.createElement('div'); details.className = 'space-y-1 mb-3 text-xs text-gray-300'; if (place.address) { details.appendChild(popupRow('๐Ÿ“', place.address)); } if (place.opening_hours) { details.appendChild(popupRow('๐Ÿ•', place.opening_hours)); } if (place.phone) { const phoneRow = document.createElement('div'); phoneRow.className = 'flex items-start gap-1.5'; const phoneIcon = document.createElement('span'); phoneIcon.textContent = '๐Ÿ“ž'; const phoneLink = document.createElement('a'); // tel: sanitized to digits/+/-/()/space only, then set via textContent. const digits = place.phone.replace(/[^0-9+\-\s().]/g, ''); phoneLink.href = `tel:${digits}`; phoneLink.className = 'text-[#f7931a] hover:underline'; phoneLink.textContent = place.phone; phoneRow.appendChild(phoneIcon); phoneRow.appendChild(phoneLink); details.appendChild(phoneRow); } if (place.description) { const desc = document.createElement('div'); desc.className = 'text-gray-400 text-[11px] mt-2 line-clamp-2'; desc.textContent = place.description; details.appendChild(desc); } container.appendChild(details); // Action buttons const actions = document.createElement('div'); actions.className = 'flex gap-2'; // place.website is UNTRUSTED โ€” only render the link for valid http(s) URLs. const safeWebsite = sanitizeUrl(place.website); if (safeWebsite) { const websiteBtn = document.createElement('a'); websiteBtn.href = safeWebsite; websiteBtn.target = '_blank'; websiteBtn.rel = 'noopener noreferrer'; websiteBtn.className = 'flex-1 inline-flex items-center justify-center rounded-md text-xs font-medium bg-[#f7931a] text-white hover:bg-[#f7931a]/90 h-7 px-2 transition-colors'; websiteBtn.textContent = 'Website'; actions.appendChild(websiteBtn); } const btcMapBtn = document.createElement('a'); btcMapBtn.href = `https://btcmap.org/merchant/${place.id}`; btcMapBtn.target = '_blank'; btcMapBtn.rel = 'noopener noreferrer'; btcMapBtn.className = `${safeWebsite ? '' : 'flex-1 '}inline-flex items-center justify-center rounded-md text-xs font-medium bg-gray-700 text-white hover:bg-gray-600 h-7 px-2 transition-colors`; btcMapBtn.textContent = 'BTC Map โ†’'; actions.appendChild(btcMapBtn); container.appendChild(actions); marker.bindPopup(container, { maxWidth: 320, className: 'custom-popup btcmap-popup', }); marker.addTo(map); btcMapMarkersRef.current.push(marker); }); }, [btcMapPlaces, showBtcMap]); return (
); }