1760 lines
68 KiB
TypeScript
1760 lines
68 KiB
TypeScript
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
||
import { useEffect, useRef, useMemo, useCallback } from 'react';
|
||
import L from 'leaflet';
|
||
import 'leaflet.markercluster';
|
||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||
import ngeohash from 'ngeohash';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent, SkateCrew } from '@/lib/types';
|
||
import { formatPrice } from '@/lib/types';
|
||
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||
import type { SpotWeatherData } from '@/hooks/useSpotWeather';
|
||
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
||
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
|
||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||
import { FORECAST_DAYS } from '@/lib/weather';
|
||
import { useTranslation } from '@/contexts/language';
|
||
|
||
/** Translation function shape, so module-level popup builders can receive `t`. */
|
||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||
|
||
/**
|
||
* The map never shows a *second full copy* of the world. `applyWorldMinZoom`
|
||
* clamps the minimum zoom so the 256·2^z world is at least as large as the
|
||
* viewport. The map can still wrap across the antimeridian (so Hawaii and the
|
||
* Pacific are reachable by dragging), but at that zoom you only ever see a
|
||
* sliver of the adjacent copy — never duplicated continents.
|
||
*/
|
||
function applyWorldMinZoom(map: L.Map): void {
|
||
// The world is a 256·2^z square. To cover the viewport (no grey bands, no
|
||
// repeated continents) pick the smallest integer zoom whose world is at
|
||
// least as large as the larger viewport dimension. Using the container's
|
||
// client size directly avoids the 0×0 size Leaflet reports before layout.
|
||
const el = map.getContainer();
|
||
const width = el.clientWidth || map.getSize().x;
|
||
const height = el.clientHeight || map.getSize().y;
|
||
if (!width || !height) return;
|
||
const min = Math.max(1, Math.ceil(Math.log2(Math.max(width, height) / 256)));
|
||
map.setMinZoom(min);
|
||
if (map.getZoom() < min) map.setZoom(min, { animate: false });
|
||
}
|
||
|
||
/**
|
||
* 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, '"')
|
||
.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: markerIcon,
|
||
iconRetinaUrl: markerIcon2x,
|
||
shadowUrl: markerShadow,
|
||
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 = (picture?: string | null) => {
|
||
// 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`;
|
||
|
||
// Show the user's own Nostr profile picture (their npub's "logo") when set.
|
||
// Layered CSS backgrounds mean a broken/expired avatar URL silently reveals
|
||
// the brand logo underneath — no inline JS (CSP-safe).
|
||
const safeAvatar = picture ? sanitizeUrl(picture) : null;
|
||
const avatarCss = safeAvatar
|
||
? encodeURI(safeAvatar).replace(/[()'"]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
||
: '';
|
||
const backgroundImage = avatarCss ? `url('${avatarCss}'), url('${logoUrl}')` : `url('${logoUrl}')`;
|
||
const backgroundSize = avatarCss ? 'cover, cover' : 'cover';
|
||
|
||
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>
|
||
<!-- avatar / logo badge -->
|
||
<div style="
|
||
position: absolute;
|
||
left: 50%; top: 50%;
|
||
width: 40px; height: 40px;
|
||
margin: -20px 0 0 -20px;
|
||
border-radius: 50%;
|
||
background-image: ${backgroundImage};
|
||
background-size: ${backgroundSize};
|
||
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; zoom?: 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;
|
||
/** The current user's Nostr profile picture, used on the "you are here" pin. */
|
||
userPicture?: string | null;
|
||
/** A pending spot location the user tapped to add (shown before confirming). */
|
||
pendingPin?: { lat: number; lng: number } | null;
|
||
}
|
||
|
||
function createPopupContent(
|
||
spot: SkateSpot,
|
||
onViewDetails: () => void,
|
||
weatherData: SpotWeatherData | undefined,
|
||
t: Translate,
|
||
): 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-300 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-white/[0.04] border border-white/10';
|
||
|
||
// 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-300">${escapeHtml(t(weatherData.weather.weatherDescriptionKey))}</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
|
||
${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(t(weatherData.skateability.labelKey))}
|
||
</div>
|
||
<div class="text-xs text-gray-300">${escapeHtml(t('mapUi.weatherScoreSuffix', { score: weatherData.skateability.score }))}</div>
|
||
</div>
|
||
`;
|
||
weatherSection.appendChild(currentWeather);
|
||
|
||
// Details
|
||
const details = document.createElement('div');
|
||
details.className = 'text-xs space-y-1 text-gray-600';
|
||
details.innerHTML = `
|
||
<div>💨 ${escapeHtml(t('mapUi.wind'))}: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
|
||
<div>💧 ${escapeHtml(t('mapUi.humidity'))}: ${escapeHtml(weatherData.weather.humidity)}%</div>
|
||
${weatherData.airQuality ? `<div>🌫️ ${escapeHtml(t('mapUi.airQuality'))}: ${escapeHtml(t(weatherData.airQuality.aqiLabelKey))} (${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 = t('mapUi.forecastDays', { count: FORECAST_DAYS });
|
||
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(dateLocaleCode(), { 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 = spotTypeLabel(t, 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 = t('mapUi.viewDetails');
|
||
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,
|
||
t: Translate,
|
||
): 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 ? t('mapUi.skateShop') : t('mapUi.skateSpot');
|
||
|
||
const spotTitle = document.createElement('div');
|
||
spotTitle.className = 'font-bold text-sm text-gray-200 mb-2';
|
||
spotTitle.textContent = `${spotIcon} ${spotLabel.toUpperCase()}`;
|
||
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 = spotTypeLabel(t, 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 = t('mapUi.viewDetailsArrow');
|
||
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 = `🛒 ${t('mapUi.gearForSale', { count: marketplaceItems.length }).toUpperCase()}`;
|
||
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, t);
|
||
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 ? t('mapUi.viewAllItems', { count: marketplaceItems.length }) : t('mapUi.viewGear');
|
||
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,
|
||
userPicture = null,
|
||
pendingPin = null,
|
||
}: SpotMapProps) {
|
||
const { t } = useTranslation();
|
||
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);
|
||
const pendingPinMarkerRef = 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);
|
||
// Signature of the marker set currently on the map — lets us skip a full
|
||
// rebuild when the parent hands us new arrays with identical content.
|
||
const markerSigRef = useRef<string>('');
|
||
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). The real limit is computed after mount by
|
||
// `applyWorldMinZoom()` because it depends on the viewport size.
|
||
minZoom: 1,
|
||
// Clamp vertically (no grey above/below the poles) but let the map wrap
|
||
// horizontally at the antimeridian, so the Pacific is reachable.
|
||
maxBounds: [[-85, -Infinity], [85, Infinity]],
|
||
maxBoundsViscosity: 1.0,
|
||
worldCopyJump: true,
|
||
});
|
||
|
||
applyWorldMinZoom(map);
|
||
|
||
// 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.
|
||
// Wrapped in an on-device tile cache so the basemap still draws at the park.
|
||
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '',
|
||
subdomains: 'abc',
|
||
minZoom: 1,
|
||
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, zoom: map.getZoom() });
|
||
});
|
||
|
||
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: { getChildCount: () => number }) => {
|
||
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();
|
||
applyWorldMinZoom(map);
|
||
// 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, zoom: map.getZoom() });
|
||
});
|
||
}, 100);
|
||
|
||
return () => {
|
||
map.remove();
|
||
mapInstanceRef.current = null;
|
||
};
|
||
// Map init runs once on mount; center/zoom/click handlers are applied by
|
||
// later effects, so they must not re-create the Leaflet map.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Fix map size when container resizes
|
||
useEffect(() => {
|
||
const map = mapInstanceRef.current;
|
||
if (!map || !mapRef.current) return;
|
||
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
map.invalidateSize();
|
||
applyWorldMinZoom(map);
|
||
});
|
||
|
||
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 and refresh its icon
|
||
// (the profile picture can arrive after the marker was first created).
|
||
userMarkerRef.current.setLatLng([userLocation.lat, userLocation.lng]);
|
||
userMarkerRef.current.setIcon(createUserLocationIcon(userPicture));
|
||
} else {
|
||
// Create it, on top of everything else.
|
||
userMarkerRef.current = L.marker([userLocation.lat, userLocation.lng], {
|
||
icon: createUserLocationIcon(userPicture),
|
||
zIndexOffset: 1000,
|
||
interactive: true,
|
||
keyboard: false,
|
||
})
|
||
.addTo(map)
|
||
.bindPopup(`<b>${escapeHtml(t('mapUi.youAreHere'))}</b>`);
|
||
}
|
||
}, [userLocation, userPicture, t]);
|
||
|
||
// Render / update the tap-to-add pending pin. The user taps the map, a pin
|
||
// drops here, and MapPage shows an "Add a spot here?" card — only on confirm
|
||
// do we leave the map. Without this a logged-in tap yanked you straight into
|
||
// the create form, which made the map feel broken.
|
||
useEffect(() => {
|
||
const map = mapInstanceRef.current;
|
||
if (!map) return;
|
||
|
||
if (!pendingPin) {
|
||
if (pendingPinMarkerRef.current) {
|
||
pendingPinMarkerRef.current.remove();
|
||
pendingPinMarkerRef.current = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const latlng: [number, number] = [pendingPin.lat, pendingPin.lng];
|
||
if (pendingPinMarkerRef.current) {
|
||
pendingPinMarkerRef.current.setLatLng(latlng);
|
||
} else {
|
||
pendingPinMarkerRef.current = L.marker(latlng, {
|
||
icon: L.divIcon({
|
||
className: '',
|
||
html:
|
||
'<div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-4px)">' +
|
||
'<div style="width:22px;height:22px;border-radius:50%;background:#ff0040;border:3px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.55)"></div>' +
|
||
'<div style="width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-top:11px solid #fff;margin-top:-1px"></div>' +
|
||
'</div>',
|
||
iconSize: [22, 34],
|
||
iconAnchor: [11, 34],
|
||
}),
|
||
zIndexOffset: 1200,
|
||
keyboard: false,
|
||
}).addTo(map);
|
||
}
|
||
map.panTo(latlng, { animate: true });
|
||
}, [pendingPin]);
|
||
|
||
// Update markers
|
||
useEffect(() => {
|
||
const map = mapInstanceRef.current;
|
||
if (!map) return;
|
||
|
||
// Wait for map to be fully loaded before adding markers
|
||
if (!map.getContainer()) return;
|
||
|
||
// Skip the whole rebuild when nothing that affects the rendered markers has
|
||
// actually changed. Every spot/listing/tour/event/crew is an addressable
|
||
// event, so a new event id means an edit — ids + weather colours are enough
|
||
// to detect real changes. Without this, the unrelated identity churn the
|
||
// parent hands us (trust scores, comment/zap totals and re-sorts all produce
|
||
// fresh arrays) tore down and rebuilt every Leaflet marker several times per
|
||
// load, which is what made the pins feel slow.
|
||
const markerSig = [
|
||
`${showSpots}|${showShops}|${showGear}|${showTours}|${showEvents}|${showCrews}|${weatherEnabled}`,
|
||
locationGroups
|
||
.map((group) => {
|
||
const first = group.spots[0];
|
||
const weather =
|
||
weatherEnabled && weatherData && first?.geohash
|
||
? weatherData.get(first.geohash)
|
||
: undefined;
|
||
return [
|
||
group.geohash,
|
||
group.spots.map((s) => s.event.id).join('.'),
|
||
group.marketplaceItems.map((i) => i.event.id).join('.'),
|
||
weather?.skateability.color ?? '',
|
||
weather?.weather.weatherIcon ?? '',
|
||
].join(':');
|
||
})
|
||
.join('|'),
|
||
tours.map((t) => t.id).join('.'),
|
||
events.map((e) => e.id).join('.'),
|
||
crews.map((c) => c.id).join('.'),
|
||
].join('~~');
|
||
if (markerSig === markerSigRef.current) return;
|
||
markerSigRef.current = markerSig;
|
||
|
||
// Clear existing markers from cluster group and array
|
||
if (clusterGroupRef.current) {
|
||
clusterGroupRef.current.clearLayers();
|
||
}
|
||
markersRef.current.forEach((marker) => marker.remove());
|
||
markersRef.current = [];
|
||
|
||
// Markers built this pass; added to the cluster group in batches below.
|
||
const pending: L.Marker[] = [];
|
||
|
||
// 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,
|
||
});
|
||
|
||
// When the host page supplies onSpotClick (MapPage's bottom sheet), let it
|
||
// own the detail UI — binding the Leaflet popup too made BOTH appear at
|
||
// once. Standalone map usages (no handler) keep the popup.
|
||
if (primarySpot) {
|
||
if (!onSpotClick) {
|
||
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), t)
|
||
: createPopupContent(primarySpot, () => handleViewDetails(primarySpot), spotWeather, t);
|
||
|
||
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 = `🛒 ${t('mapUi.gearForSale', { count: gearCount }).toUpperCase()}`;
|
||
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, t);
|
||
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 = t('mapUi.viewItems');
|
||
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',
|
||
});
|
||
}
|
||
|
||
// Queue for a batched add to the cluster group (see flush below).
|
||
pending.push(marker);
|
||
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 = t('mapUi.localTour').toUpperCase();
|
||
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('🚩', t('mapUi.meetAt', { place: tour.meetingPoint })));
|
||
}
|
||
if (tour.duration) {
|
||
details.appendChild(popupRow('⏱️', t('mapUi.hours', { count: tour.duration })));
|
||
}
|
||
if (tour.skillLevel) {
|
||
const skillEmoji = tour.skillLevel === 'beginner' ? '🌱' : tour.skillLevel === 'intermediate' ? '🔥' : tour.skillLevel === 'advanced' ? '💀' : '🛹';
|
||
details.appendChild(popupRow(skillEmoji, t('mapUi.level', { level: tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1) })));
|
||
}
|
||
if (tour.price) {
|
||
details.appendChild(popupRow('⚡', `${tour.price.toLocaleString()} ${t('common.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 = t('mapUi.viewTour');
|
||
button.onclick = (e) => {
|
||
e.preventDefault();
|
||
navigate(`/tour/${tour.pubkey}/${tour.identifier}`);
|
||
};
|
||
container.appendChild(button);
|
||
|
||
tourMarker.bindPopup(container, {
|
||
maxWidth: 320,
|
||
className: 'custom-popup tour-popup',
|
||
});
|
||
|
||
// Queue for a batched add to the cluster group (see flush below).
|
||
pending.push(tourMarker);
|
||
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 = t('mapUi.upcomingEvent').toUpperCase();
|
||
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(dateLocaleCode(), { weekday: 'short', month: 'short', day: 'numeric' });
|
||
const timeStr = startDate.toLocaleTimeString(dateLocaleCode(), { 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 = t('mapUi.viewEvent');
|
||
button.onclick = (e) => {
|
||
e.preventDefault();
|
||
navigate(`/event/${evt.pubkey}/${evt.identifier}`);
|
||
};
|
||
container.appendChild(button);
|
||
|
||
eventMarker.bindPopup(container, {
|
||
maxWidth: 320,
|
||
className: 'custom-popup event-popup',
|
||
});
|
||
|
||
// Queue for a batched add to the cluster group (see flush below).
|
||
pending.push(eventMarker);
|
||
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 = t('mapUi.skateCrew').toUpperCase();
|
||
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('👥', t('mapUi.members', { count: crew.members.length })));
|
||
}
|
||
if (crew.founded) {
|
||
details.appendChild(popupRow('📅', t('mapUi.since', { year: 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 = t('mapUi.viewCrew');
|
||
button.onclick = (e) => {
|
||
e.preventDefault();
|
||
navigate(`/crew/${crew.pubkey}/${crew.identifier}`);
|
||
};
|
||
container.appendChild(button);
|
||
|
||
crewMarker.bindPopup(container, {
|
||
maxWidth: 320,
|
||
className: 'custom-popup crew-popup',
|
||
});
|
||
|
||
// Queue for a batched add to the cluster group (see flush below).
|
||
pending.push(crewMarker);
|
||
markersRef.current.push(crewMarker);
|
||
});
|
||
}
|
||
|
||
// Add the queued markers to the cluster group in batches across animation
|
||
// frames. `addLayers` is the plugin's bulk API — it clusters the batch and
|
||
// refreshes once, instead of the old one-marker-at-a-time `addLayer`, which
|
||
// re-clustered the whole set for every single pin. Batching keeps the main
|
||
// thread responsive (no freeze on a few hundred pins) and lets nearby pins
|
||
// paint first; cancel it if the effect re-runs before it finishes.
|
||
const group = clusterGroupRef.current;
|
||
if (!group) {
|
||
pending.forEach((marker) => marker.addTo(map));
|
||
} else if (pending.length > 0) {
|
||
const CHUNK_SIZE = 150;
|
||
let offset = 0;
|
||
let cancelled = false;
|
||
let raf = 0;
|
||
const flush = () => {
|
||
if (cancelled || !clusterGroupRef.current) return;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(clusterGroupRef.current as any).addLayers(pending.slice(offset, offset + CHUNK_SIZE));
|
||
offset += CHUNK_SIZE;
|
||
if (offset < pending.length) {
|
||
raf = requestAnimationFrame(flush);
|
||
}
|
||
};
|
||
flush();
|
||
return () => {
|
||
cancelled = true;
|
||
if (raf) cancelAnimationFrame(raf);
|
||
};
|
||
}
|
||
}, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, showSpots, showShops, showGear, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate, t]);
|
||
|
||
// 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 = t('mapUi.acceptsBitcoin');
|
||
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">⭐ ${escapeHtml(t('mapUi.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">✓ ${escapeHtml(t('common.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 = t('mapUi.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, t]);
|
||
|
||
return (
|
||
<div className={className}>
|
||
<div className="relative h-full w-full">
|
||
<div ref={mapRef} className="h-full w-full rounded-xl" />
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|