SkateSpots/src/components/SpotMap.tsx
shakespeare.diy 358708ee27 perf: shrink map marker payload and extend vendor chunking
C (marker): Reuse the existing ~24 KB logo.png for the "you are here"
map marker instead of the 2.9 MB user-location.png (byte-identical copy
of logo-sticker). Removed the unused 2.9 MB PNG from public/images,
cutting deploy/APK size. Same artwork, ~99% smaller payload.

B (chunking): Extend the existing manualChunks in vite.config.ts to
split the largest page-specific libraries into their own vendor chunks
(hls.js, html5-qrcode, recharts) so they load only on the routes that
use them. Capacitor single-bundle branch and React core grouping left
untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:18:14 +07:00

1601 lines
59 KiB
TypeScript

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/** 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
? `
<div style="
position: absolute;
top: -7px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
width: 18px;
height: 9px;
background: linear-gradient(135deg, #22d3ee, #818cf8);
border-radius: 9px 9px 0 0;
border: 2px solid rgba(255,255,255,0.9);
border-bottom: none;
box-shadow: 0 0 8px rgba(34,211,238,0.8);
"></div>`
: '';
return L.divIcon({
className: 'skate-spot-marker',
html: `
<div class="ss-pin" style="position: relative; width: 36px; height: 36px;">
${roofCap}
<div style="
width: 36px;
height: 36px;
${fill}
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
border: 2.5px solid #fff;
box-shadow: 0 0 0 1px rgba(0,0,0,0.35), 0 0 10px ${glowColor}, 0 3px 6px rgba(0,0,0,0.45);
position: relative;
">
${weatherIcon ? `
<div style="
position: absolute;
top: -8px;
right: -8px;
background: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
transform: rotate(45deg);
">${weatherIcon}</div>
` : ''}
<span style="
transform: rotate(45deg);
font-size: 16px;
line-height: 1;
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.5));
">${glyph}</span>
</div>
</div>
`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
});
};
// Custom shop marker icon (for shops)
const createShopIcon = () => L.divIcon({
className: 'skate-shop-marker',
html: `
<div style="
width: 36px;
height: 36px;
background: linear-gradient(135deg, #ccff00, #00ff88);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(204,255,0,0.5);
border: 2px solid #fff;
">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M3 9h18v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z"/>
<path d="m3 9 2.45-4.9A2 2 0 0 1 7.24 3h9.52a2 2 0 0 1 1.8 1.1L21 9"/>
<path d="M12 3v6"/>
</svg>
</div>
`,
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: `
<div style="position: relative; width: 64px; height: 64px;">
<!-- pulsing accuracy halo -->
<span style="
position: absolute;
left: 50%; top: 50%;
width: 64px; height: 64px;
margin: -32px 0 0 -32px;
border-radius: 50%;
background: rgba(255,0,64,0.25);
animation: ssPulse 2s ease-out infinite;
"></span>
<!-- logo badge -->
<div style="
position: absolute;
left: 50%; top: 50%;
width: 40px; height: 40px;
margin: -20px 0 0 -20px;
border-radius: 50%;
background-image: url('${logoUrl}');
background-size: cover;
background-position: center;
background-color: #fff;
border: 3px solid #ff0040;
box-shadow: 0 3px 10px rgba(0,0,0,0.5);
"></div>
</div>
`,
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: `
<div style="
width: 44px;
height: 44px;
position: relative;
transform: rotate(-45deg);
">
<!-- Main container -->
<div style="
width: 100%;
height: 100%;
background: linear-gradient(135deg, #9333ea 50%, #ff0040 50%);
border-radius: 50% 50% 50% 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(147,51,234,0.5);
border: 2px solid #fff;
">
<!-- Dual icons: shop + spot -->
<div style="transform: rotate(45deg); display: flex; gap: 2px;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9h18v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z"/>
<path d="m3 9 2.45-4.9A2 2 0 0 1 7.24 3h9.52a2 2 0 0 1 1.8 1.1L21 9"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="#fff" stroke="#fff" stroke-width="2">
<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>
</svg>
</div>
</div>
${hasGear && gearCount ? `
<div style="
position: absolute;
bottom: -6px;
right: -6px;
background: #ff6600;
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
border: 2px solid #fff;
transform: rotate(45deg);
">${gearCount}</div>
` : ''}
</div>
`,
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: `
<div style="
width: 40px;
height: 40px;
background: linear-gradient(135deg, #06b6d4, #0891b2);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(6,182,212,0.5);
border: 3px solid #fff;
position: relative;
">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<circle cx="12" cy="8" r="5"/>
<path d="M20 21a8 8 0 0 0-16 0"/>
</svg>
<div style="
position: absolute;
top: -6px;
right: -6px;
background: #fbbf24;
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
border: 2px solid #fff;
transform: rotate(45deg);
">🛹</div>
</div>
`,
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: `
<div style="
width: 38px;
height: 38px;
background: linear-gradient(135deg, #ccff00, #a3cc00);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(204,255,0,0.45);
border: 3px solid #fff;
">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>
</svg>
</div>
`,
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: `
<div style="
width: 42px;
height: 42px;
background: linear-gradient(135deg, #14b8a6, #06b6d4);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(20,184,166,0.55);
border: 3px solid #fff;
overflow: hidden;
">
${safeLogo ? `
<img src="${safeLogo}" style="
width: 100%;
height: 100%;
object-fit: cover;
transform: rotate(45deg) scale(1.5);
" />
` : `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
`}
</div>
`,
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: `
<div style="
width: 32px;
height: 32px;
background: linear-gradient(135deg, #f7931a, #ffb347);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(247,147,26,0.5);
border: 2px solid ${isBoosted ? '#ffd700' : '#fff'};
position: relative;
">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#fff" style="transform: rotate(45deg);">
<path d="M12.5 8.8v-3h2.1c.6 0 1.1.1 1.4.4.4.3.5.7.5 1.2 0 .5-.2.9-.5 1.1-.4.2-.8.3-1.4.3h-2.1zm0 5.4v-3.5h2.3c.7 0 1.2.2 1.6.5.4.3.6.8.6 1.3 0 .5-.2 1-.6 1.3-.4.3-.9.5-1.6.5h-2.3zm-2 2.8V5h4.6c1.2 0 2.1.3 2.8.9.7.6 1 1.4 1 2.4 0 .7-.2 1.3-.5 1.8-.3.5-.8.8-1.4 1 .7.1 1.3.5 1.7 1 .4.5.6 1.2.6 2 0 1.1-.4 2-1.1 2.6-.7.6-1.7.9-3 .9h-4.7z"/>
</svg>
${isBoosted ? `
<div style="
position: absolute;
top: -6px;
right: -6px;
background: #ffd700;
border-radius: 50%;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
border: 2px solid #fff;
transform: rotate(45deg);
">⭐</div>
` : ''}
</div>
`,
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: `
<div style="
width: 36px;
height: 36px;
background: linear-gradient(135deg, #ff6600, #ff9933);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255,102,0,0.5);
border: 2px solid #fff;
position: relative;
">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
<path d="M3 6h18"/>
<path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
${count && count > 1 ? `
<div style="
position: absolute;
top: -8px;
right: -8px;
background: #ff0040;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
border: 2px solid #fff;
transform: rotate(45deg);
">${count}</div>
` : ''}
</div>
`,
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<string, SpotWeatherData>;
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 = `
<div class="flex items-center gap-2">
<span class="text-2xl">${escapeHtml(weatherData.weather.weatherIcon)}</span>
<div>
<div class="font-semibold">${escapeHtml(weatherData.weather.temperature)}°C</div>
<div class="text-xs text-gray-500">${escapeHtml(weatherData.weather.weatherDescription)}</div>
</div>
</div>
<div class="text-right">
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
${escapeHtml(weatherData.skateability.label)}
</div>
<div class="text-xs text-gray-500">${escapeHtml(weatherData.skateability.score)}/100</div>
</div>
`;
weatherSection.appendChild(currentWeather);
// Details
const details = document.createElement('div');
details.className = 'text-xs space-y-1 text-gray-600';
details.innerHTML = `
<div>💨 Wind: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
<div>💧 Humidity: ${escapeHtml(weatherData.weather.humidity)}%</div>
${weatherData.airQuality ? `<div>🌫️ Air Quality: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
`;
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 = `
<div class="font-semibold text-gray-700">${escapeHtml(dayName)}</div>
<div class="text-lg my-1">${escapeHtml(day.weatherIcon)}</div>
<div class="text-gray-600">${escapeHtml(day.maxTemp)}°/${escapeHtml(day.minTemp)}°</div>
${day.precipitationProbability > 0 ? `<div class="text-blue-600">${escapeHtml(day.precipitationProbability)}% 🌧️</div>` : ''}
`;
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<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const markersRef = useRef<L.Marker[]>([]);
const btcMapMarkersRef = useRef<L.Marker[]>([]);
const userMarkerRef = useRef<L.Marker | null>(null);
// MarkerClusterGroup for spot/shop/gear/tour markers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const clusterGroupRef = useRef<any>(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<string, {
geohash: string;
lat: number;
lng: number;
spots: SkateSpot[];
marketplaceItems: MarketplaceItem[];
}>();
// 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: `
<div style="
width: 42px;
height: 42px;
background: linear-gradient(135deg, #ff0040, #ff6600);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 900;
font-size: ${count > 99 ? '11px' : count > 9 ? '13px' : '15px'};
box-shadow: 0 0 0 1px rgba(0,0,0,0.35), 0 0 12px rgba(255,0,64,0.9), 0 3px 8px rgba(0,0,0,0.45);
border: 3px solid #fff;
font-family: Impact, 'Arial Black', sans-serif;
letter-spacing: -0.5px;
">${count}</div>
`,
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('<b>You are here</b>');
}
}, [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 = '<span class="text-lg">🛹</span>';
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 = '<span class="text-lg">📅</span>';
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 = '<span class="text-lg">₿</span>';
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 += `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-yellow-500/20 text-yellow-300 border border-yellow-500/30">⭐ Boosted</span>`;
}
if (recentlyVerified) {
badges.innerHTML += `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-green-500/20 text-green-300 border border-green-500/30">✓ Verified</span>`;
}
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 (
<div className={className}>
<div className="relative h-full w-full">
<div ref={mapRef} className="h-full w-full rounded-xl" />
</div>
</div>
);
}