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>
1601 lines
59 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/** A popup detail row: an emoji/icon prefix + untrusted text set via textContent. */
|
|
function popupRow(prefix: string, text: string): HTMLElement {
|
|
const row = document.createElement('div');
|
|
row.className = 'flex items-center gap-2';
|
|
const label = document.createElement('span');
|
|
// Prefix is always a hardcoded emoji/label — safe as textContent too.
|
|
label.textContent = prefix;
|
|
const value = document.createElement('span');
|
|
value.textContent = text; // untrusted — textContent neutralizes any markup
|
|
row.appendChild(label);
|
|
row.appendChild(value);
|
|
return row;
|
|
}
|
|
|
|
// Fix default marker icon issues with webpack/vite
|
|
const defaultIcon = L.icon({
|
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34],
|
|
shadowSize: [41, 41],
|
|
});
|
|
|
|
L.Marker.prototype.options.icon = defaultIcon;
|
|
|
|
/** Emoji glyph per spot category — turns the map into a readable language. */
|
|
/** A single generic glyph for every skate-spot pin (kept intentionally simple). */
|
|
const SPOT_GLYPH = '🛹';
|
|
|
|
/**
|
|
* Crisp skate-spot marker: a solid neon teardrop with a TIGHT glow halo (the
|
|
* glow is an accent, not the whole blob), a generic skate glyph inside, and —
|
|
* for covered/indoor spots — a small roof "cap" so dry spots literally stand
|
|
* out on a rainy day.
|
|
*
|
|
* `weatherColor`/`weatherIcon` (when the weather layer is on) still override the
|
|
* fill + add the condition badge, exactly as before.
|
|
*/
|
|
const createSkateSpotIcon = (opts?: {
|
|
weatherColor?: string;
|
|
weatherIcon?: string;
|
|
shelter?: 'outdoor' | 'partial' | 'covered' | 'indoor';
|
|
}) => {
|
|
const { weatherColor, weatherIcon, shelter } = opts ?? {};
|
|
|
|
const fill = weatherColor
|
|
? `background: ${weatherColor};`
|
|
: 'background: linear-gradient(135deg, #ff0040, #ff6600);';
|
|
const glowColor = weatherColor || '#ff0040';
|
|
const glyph = SPOT_GLYPH;
|
|
const isSheltered = shelter === 'covered' || shelter === 'indoor';
|
|
|
|
// Roof cap for covered/indoor spots — a small cyan "dry" marker that sits
|
|
// above the pin so it reads even when zoomed out.
|
|
const roofCap = isSheltered
|
|
? `
|
|
<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>
|
|
);
|
|
}
|