From 88a36e52ae3d99e6e228d24b11aa82401f6fa755 Mon Sep 17 00:00:00 2001
From: "shakespeare.diy"
Date: Fri, 24 Jul 2026 10:48:24 +0700
Subject: [PATCH] Add Events as an opt-in map layer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Events can now be discovered directly on the map:
- New "Events" layer (lime #ccff00, calendar pin marker) plotting upcoming,
geo-located events. Off by default β users opt in via the Layers control.
- Event markers show a popup with title, date/time, location, and a
"View Event" button linking to the event detail page.
- Only future/ongoing events with a valid geohash are plotted (past events
are filtered out to keep the map clean).
- Added to the mobile Layers popover, desktop Layers popover, and the mobile
filter drawer's "Show on Map" grid. Layer count badge now tracks 5 layers.
- SpotMap gains `events` + `showEvents` props and a createEventIcon marker.
Co-authored-by: shakespeare.diy
---
src/components/SpotMap.tsx | 100 ++++++++++++++++++++++++++++++++++++-
src/pages/MapPage.tsx | 43 ++++++++++++----
2 files changed, 130 insertions(+), 13 deletions(-)
diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx
index de68094..3bf49b3 100644
--- a/src/components/SpotMap.tsx
+++ b/src/components/SpotMap.tsx
@@ -6,7 +6,7 @@ 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 } from '@/lib/types';
+import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent } from '@/lib/types';
import { SPOT_TYPE_LABELS, formatPrice } from '@/lib/types';
import type { SpotWeatherData } from '@/hooks/useSpotWeather';
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
@@ -209,6 +209,32 @@ const createTourIcon = () => L.divIcon({
popupAnchor: [0, -40],
});
+// Custom event marker icon (lime β matches the Events section + Add Event action)
+const createEventIcon = () => L.divIcon({
+ className: 'event-marker',
+ html: `
+
+ `,
+ iconSize: [38, 38],
+ iconAnchor: [19, 38],
+ popupAnchor: [0, -38],
+});
+
// Custom BTC Map marker icon (for Bitcoin-accepting places)
const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({
className: 'btcmap-marker',
@@ -305,6 +331,7 @@ interface SpotMapProps {
spots: SkateSpot[];
marketplaceItems?: MarketplaceItem[];
tours?: SkateTour[];
+ events?: SkateEvent[];
btcMapPlaces?: BtcMapPlace[];
center?: [number, number];
zoom?: number;
@@ -320,6 +347,7 @@ interface SpotMapProps {
showShops?: boolean;
showGear?: boolean;
showTours?: boolean;
+ showEvents?: boolean;
showBtcMap?: boolean;
flyTo?: { lat: number; lng: number; zoom?: number } | null;
}
@@ -547,6 +575,7 @@ export function SpotMap({
spots,
marketplaceItems = [],
tours = [],
+ events = [],
btcMapPlaces = [],
center = [40.7128, -74.006], // Default to NYC
zoom = 3,
@@ -562,6 +591,7 @@ export function SpotMap({
showShops = true,
showGear = true,
showTours = true,
+ showEvents = false,
showBtcMap = false,
flyTo = null,
}: SpotMapProps) {
@@ -1029,7 +1059,73 @@ export function SpotMap({
markersRef.current.push(tourMarker);
});
}
- }, [locationGroups, tours, showTours, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
+
+ // 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
+ const header = document.createElement('div');
+ header.className = 'flex items-start gap-2 mb-3';
+ header.innerHTML = `
+
+ π
+
+
+
${evt.title}
+
UPCOMING EVENT
+
+ `;
+ container.appendChild(header);
+
+ // Details
+ 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.innerHTML += `ποΈ ${dateStr} Β· ${timeStr}
`;
+ if (evt.location) {
+ details.innerHTML += `π ${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);
+ });
+ }
+ }, [locationGroups, tours, showTours, events, showEvents, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
// Update BTC Map markers
useEffect(() => {
diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx
index 53fb517..175e178 100644
--- a/src/pages/MapPage.tsx
+++ b/src/pages/MapPage.tsx
@@ -25,6 +25,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useShops } from '@/hooks/useShops';
import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useTours } from '@/hooks/useTours';
+import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents';
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowing } from '@/hooks/useFollowing';
@@ -544,6 +545,8 @@ export default function MapPage() {
const { data: shops, isLoading: shopsLoading } = useShops(200);
const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200);
const { data: tours, isLoading: toursLoading } = useTours(100);
+ // Upcoming events for the (opt-in) Events map layer
+ const { data: upcomingEvents } = useUpcomingSkateEvents(100);
// Check if coming with ?view=gear to focus on marketplace
const viewParam = searchParams.get('view');
@@ -569,6 +572,8 @@ export default function MapPage() {
const [showShops, setShowShops] = useState(!focusGear);
const [showGear, setShowGear] = useState(true);
const [showTours, setShowTours] = useState(!focusGear);
+ // Events layer β off by default; users opt in via the Layers control
+ const [showEvents, setShowEvents] = useState(false);
const [showBtcMap, setShowBtcMap] = useState(false);
// Mobile filters sheet
@@ -898,8 +903,9 @@ export default function MapPage() {
// Count active layer filters for mobile badge
const activeLayerCount = [showSpots, showShops, showGear, showTours, showBtcMap].filter(Boolean).length;
- // Count of the 4 primary layers active β shown as a badge on the mobile Layers button
- const mobileLayerCount = [showSpots, showShops, showGear, showTours].filter(Boolean).length;
+ // Count of the primary layers active β shown as a badge on the Layers button
+ const mobileLayerCount = [showSpots, showShops, showGear, showTours, showEvents].filter(Boolean).length;
+ const totalPrimaryLayers = 5;
const activeCategoryCount = selectedCategories.length;
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
@@ -1038,6 +1044,17 @@ export default function MapPage() {
>
πΊοΈ Tours
+
{[
- { key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
- { key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
- { key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
- { key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
+ { key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
+ { key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
+ { key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
+ { key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
+ { key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
].map(({ key, label, dot, active, toggle }) => (
@@ -1538,7 +1558,7 @@ export default function MapPage() {
aria-label="Map layers"
>
- {mobileLayerCount < 4 && (
+ {mobileLayerCount < totalPrimaryLayers && (
{mobileLayerCount}
@@ -1553,10 +1573,11 @@ export default function MapPage() {
Show on Map
{[
- { key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
- { key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
- { key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
- { key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
+ { key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
+ { key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
+ { key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
+ { key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
+ { key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
].map(({ key, label, dot, active, toggle }) => (