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 { 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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue