Add Events as an opt-in map layer

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 10:48:24 +07:00
parent 88d610c947
commit 88a36e52ae
2 changed files with 130 additions and 13 deletions

View file

@ -6,7 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { MapPin } from 'lucide-react'; 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 { SPOT_TYPE_LABELS, formatPrice } from '@/lib/types';
import type { SpotWeatherData } from '@/hooks/useSpotWeather'; import type { SpotWeatherData } from '@/hooks/useSpotWeather';
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces'; import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
@ -209,6 +209,32 @@ const createTourIcon = () => L.divIcon({
popupAnchor: [0, -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 BTC Map marker icon (for Bitcoin-accepting places) // Custom BTC Map marker icon (for Bitcoin-accepting places)
const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({ const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({
className: 'btcmap-marker', className: 'btcmap-marker',
@ -305,6 +331,7 @@ interface SpotMapProps {
spots: SkateSpot[]; spots: SkateSpot[];
marketplaceItems?: MarketplaceItem[]; marketplaceItems?: MarketplaceItem[];
tours?: SkateTour[]; tours?: SkateTour[];
events?: SkateEvent[];
btcMapPlaces?: BtcMapPlace[]; btcMapPlaces?: BtcMapPlace[];
center?: [number, number]; center?: [number, number];
zoom?: number; zoom?: number;
@ -320,6 +347,7 @@ interface SpotMapProps {
showShops?: boolean; showShops?: boolean;
showGear?: boolean; showGear?: boolean;
showTours?: boolean; showTours?: boolean;
showEvents?: boolean;
showBtcMap?: boolean; showBtcMap?: boolean;
flyTo?: { lat: number; lng: number; zoom?: number } | null; flyTo?: { lat: number; lng: number; zoom?: number } | null;
} }
@ -547,6 +575,7 @@ export function SpotMap({
spots, spots,
marketplaceItems = [], marketplaceItems = [],
tours = [], tours = [],
events = [],
btcMapPlaces = [], btcMapPlaces = [],
center = [40.7128, -74.006], // Default to NYC center = [40.7128, -74.006], // Default to NYC
zoom = 3, zoom = 3,
@ -562,6 +591,7 @@ export function SpotMap({
showShops = true, showShops = true,
showGear = true, showGear = true,
showTours = true, showTours = true,
showEvents = false,
showBtcMap = false, showBtcMap = false,
flyTo = null, flyTo = null,
}: SpotMapProps) { }: SpotMapProps) {
@ -1029,7 +1059,73 @@ export function SpotMap({
markersRef.current.push(tourMarker); 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 = `
<div class="w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
<span class="text-lg">📅</span>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-base text-white leading-tight">${evt.title}</h3>
<p class="text-xs text-[#a3cc00] font-medium">UPCOMING EVENT</p>
</div>
`;
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 += `<div class="flex items-center gap-2">🗓️ ${dateStr} · ${timeStr}</div>`;
if (evt.location) {
details.innerHTML += `<div class="flex items-center gap-2">📍 ${evt.location}</div>`;
}
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 // Update BTC Map markers
useEffect(() => { useEffect(() => {

View file

@ -25,6 +25,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useShops } from '@/hooks/useShops'; import { useShops } from '@/hooks/useShops';
import { useMarketplaceItems } from '@/hooks/useMarketplace'; import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useTours } from '@/hooks/useTours'; import { useTours } from '@/hooks/useTours';
import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents';
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces'; import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowing } from '@/hooks/useFollowing'; import { useFollowing } from '@/hooks/useFollowing';
@ -544,6 +545,8 @@ export default function MapPage() {
const { data: shops, isLoading: shopsLoading } = useShops(200); const { data: shops, isLoading: shopsLoading } = useShops(200);
const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200); const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200);
const { data: tours, isLoading: toursLoading } = useTours(100); 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 // Check if coming with ?view=gear to focus on marketplace
const viewParam = searchParams.get('view'); const viewParam = searchParams.get('view');
@ -569,6 +572,8 @@ export default function MapPage() {
const [showShops, setShowShops] = useState(!focusGear); const [showShops, setShowShops] = useState(!focusGear);
const [showGear, setShowGear] = useState(true); const [showGear, setShowGear] = useState(true);
const [showTours, setShowTours] = useState(!focusGear); 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); const [showBtcMap, setShowBtcMap] = useState(false);
// Mobile filters sheet // Mobile filters sheet
@ -898,8 +903,9 @@ export default function MapPage() {
// Count active layer filters for mobile badge // Count active layer filters for mobile badge
const activeLayerCount = [showSpots, showShops, showGear, showTours, showBtcMap].filter(Boolean).length; 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 // Count of the primary layers active — shown as a badge on the Layers button
const mobileLayerCount = [showSpots, showShops, showGear, showTours].filter(Boolean).length; const mobileLayerCount = [showSpots, showShops, showGear, showTours, showEvents].filter(Boolean).length;
const totalPrimaryLayers = 5;
const activeCategoryCount = selectedCategories.length; const activeCategoryCount = selectedCategories.length;
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0); const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
@ -1038,6 +1044,17 @@ export default function MapPage() {
> >
🗺️ Tours 🗺️ Tours
</Button> </Button>
<Button
variant={showEvents ? 'default' : 'outline'}
size="sm"
className={cn(
'justify-center gap-1 h-9 text-xs px-2',
showEvents ? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90' : ''
)}
onClick={() => setShowEvents(!showEvents)}
>
📅 Events
</Button>
<Button <Button
variant={showBtcMap ? 'default' : 'outline'} variant={showBtcMap ? 'default' : 'outline'}
size="sm" size="sm"
@ -1187,10 +1204,11 @@ export default function MapPage() {
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p> <p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
{[ {[
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(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: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(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: '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 }) => ( ].map(({ key, label, dot, active, toggle }) => (
<button <button
key={key} key={key}
@ -1416,10 +1434,12 @@ export default function MapPage() {
className="h-full" className="h-full"
weatherEnabled={weatherEnabled} weatherEnabled={weatherEnabled}
weatherData={weatherData} weatherData={weatherData}
events={upcomingEvents || []}
showSpots={showSpots} showSpots={showSpots}
showShops={showShops} showShops={showShops}
showGear={showGear} showGear={showGear}
showTours={showTours} showTours={showTours}
showEvents={showEvents}
showBtcMap={showBtcMap} showBtcMap={showBtcMap}
flyTo={flyTo} flyTo={flyTo}
/> />
@ -1538,7 +1558,7 @@ export default function MapPage() {
aria-label="Map layers" aria-label="Map layers"
> >
<Layers className={cn('h-4 w-4', layersOpen ? 'text-[#ff0040]' : 'text-gray-200')} /> <Layers className={cn('h-4 w-4', layersOpen ? 'text-[#ff0040]' : 'text-gray-200')} />
{mobileLayerCount < 4 && ( {mobileLayerCount < totalPrimaryLayers && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[9px] font-black bg-[#ccff00] text-black rounded-full flex items-center justify-center leading-none border-2 border-black"> <span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[9px] font-black bg-[#ccff00] text-black rounded-full flex items-center justify-center leading-none border-2 border-black">
{mobileLayerCount} {mobileLayerCount}
</span> </span>
@ -1553,10 +1573,11 @@ export default function MapPage() {
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p> <p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
{[ {[
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(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: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(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: '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 }) => ( ].map(({ key, label, dot, active, toggle }) => (
<button <button
key={key} key={key}