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:
parent
88d610c947
commit
88a36e52ae
2 changed files with 130 additions and 13 deletions
|
|
@ -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: `
|
||||
<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)
|
||||
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 = `
|
||||
<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
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</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
|
||||
variant={showBtcMap ? 'default' : 'outline'}
|
||||
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>
|
||||
<div className="space-y-0.5">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
|
@ -1416,10 +1434,12 @@ export default function MapPage() {
|
|||
className="h-full"
|
||||
weatherEnabled={weatherEnabled}
|
||||
weatherData={weatherData}
|
||||
events={upcomingEvents || []}
|
||||
showSpots={showSpots}
|
||||
showShops={showShops}
|
||||
showGear={showGear}
|
||||
showTours={showTours}
|
||||
showEvents={showEvents}
|
||||
showBtcMap={showBtcMap}
|
||||
flyTo={flyTo}
|
||||
/>
|
||||
|
|
@ -1538,7 +1558,7 @@ export default function MapPage() {
|
|||
aria-label="Map layers"
|
||||
>
|
||||
<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">
|
||||
{mobileLayerCount}
|
||||
</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>
|
||||
<div className="space-y-0.5">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
|
|
|||
Loading…
Reference in a new issue