feat(storm-intel): Storm Intelligence Hub — Phase 1

Adds a new Storm Intel section to the sidebar and routes for ADMIN and OWNER roles.

- StormIntelPage: full-height layout with Leaflet map + storm polygon overlays,
  severity-coded color system, horizontal filter bar (date range / severity / sort),
  and a scrollable storm event list
- Storm detail drawer slides in from right with hail size, est. homes, days ago,
  conversion window indicator (Hot Zone / Warm / Cold / Too Fresh), intel text,
  and quick-action buttons (Create Lead, Open in Dispatch, Assign Canvassers stub)
- Clicking a storm card or map polygon zooms the map to that polygon and opens the drawer
- useStormEvents hook with USE_MOCK flag, date-range cutoff filter, type/severity filter,
  sort by date or severity, and conversionWindow() helper
- api/storm-events.js: NWS api.weather.gov proxy (free, no auth needed), parses
  GeoJSON polygons and hail size from alert descriptions
- MOCK_STORM_EVENTS: 7 realistic Plano TX storm events with accurate polygon coords
- Sidebar: CloudLightning "Storm Intel" entry added for OWNER and ADMIN nav
This commit is contained in:
Satyam-Rastogi
2026-05-18 16:49:37 +05:30
parent 4c1c454681
commit 2e797c89f6
6 changed files with 979 additions and 1 deletions
+660
View File
@@ -0,0 +1,660 @@
import React, { useState, useCallback, useRef, useEffect, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { MapContainer, TileLayer, Polygon, Tooltip, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import {
CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight,
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
} from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
// ─── Constants ───────────────────────────────────────────────────────────────
const PLANO_CENTER = [33.055, -96.752];
const DEFAULT_ZOOM = 11;
const SEVERITY_STYLES = {
severe: { fill: '#DC2626', stroke: '#991B1B', fillOpacity: 0.28, weight: 2.5, text: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', label: 'Severe', dot: '#DC2626' },
significant: { fill: '#EF4444', stroke: '#DC2626', fillOpacity: 0.22, weight: 2, text: 'text-red-500 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', label: 'Significant', dot: '#EF4444' },
moderate: { fill: '#F97316', stroke: '#EA580C', fillOpacity: 0.20, weight: 2, text: 'text-orange-500 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20', label: 'Moderate', dot: '#F97316' },
trace: { fill: '#EAB308', stroke: '#CA8A04', fillOpacity: 0.15, weight: 1.5, text: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-500/10', border: 'border-yellow-200 dark:border-yellow-500/20', label: 'Trace', dot: '#EAB308' },
};
const TYPE_ICONS = {
hail: CloudLightning,
wind: Wind,
tornado: Zap,
flood: Droplets,
};
const MAP_STYLES = `
.storm-intel-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; }
.storm-tooltip { background: white !important; border: 1px solid rgba(0,0,0,0.08) !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important; padding: 8px 10px !important; pointer-events: none !important; }
.storm-tooltip::before { display: none !important; }
@keyframes stormPulse { 0%,100% { opacity: 0.7; } 50% { opacity: 1; } }
`;
// ─── Map sub-components ───────────────────────────────────────────────────────
const MapFlyTo = ({ storm, flyCount }) => {
const map = useMap();
const prevCount = useRef(0);
useEffect(() => {
const invalidate = () => map.invalidateSize({ animate: false });
const t1 = setTimeout(invalidate, 60);
const t2 = setTimeout(invalidate, 340);
window.addEventListener('resize', invalidate);
return () => { clearTimeout(t1); clearTimeout(t2); window.removeEventListener('resize', invalidate); };
}, [map]);
useEffect(() => {
if (!storm?.polygon?.length || flyCount === prevCount.current) return;
prevCount.current = flyCount;
try {
const bounds = L.latLngBounds(storm.polygon);
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 14, animate: true, duration: 0.7 });
} catch { /* malformed polygon */ }
}, [storm, flyCount, map]);
return null;
};
const StormMap = memo(({ events, selectedId, onSelect, theme, flyCount, selectedStorm }) => {
return (
<div className="relative w-full h-full storm-intel-map">
<style>{MAP_STYLES}</style>
<MapContainer
center={PLANO_CENTER}
zoom={DEFAULT_ZOOM}
scrollWheelZoom
zoomControl={false}
style={{ width: '100%', height: '100%' }}
>
<TileLayer
key={theme}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className={theme === 'dark' ? 'map-tiles filter invert grayscale contrast-100' : 'map-tiles'}
/>
<MapFlyTo storm={selectedStorm} flyCount={flyCount} />
{events.map(storm => {
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const isSelected = storm.id === selectedId;
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
const days = Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000);
return (
<Polygon
key={storm.id}
positions={storm.polygon}
pathOptions={{
color: isSelected ? sty.stroke : sty.fill,
fillColor: sty.fill,
fillOpacity: isSelected ? Math.min(sty.fillOpacity + 0.12, 0.42) : sty.fillOpacity,
weight: isSelected ? sty.weight + 1.5 : sty.weight,
opacity: isSelected ? 1 : 0.8,
dashArray: isSelected ? undefined : '6 4',
}}
eventHandlers={{
click: () => onSelect(storm),
mouseover: (e) => { e.target.setStyle({ fillOpacity: Math.min(sty.fillOpacity + 0.1, 0.4), weight: sty.weight + 1 }); },
mouseout: (e) => {
if (storm.id === selectedId) return;
e.target.setStyle({ fillOpacity: sty.fillOpacity, weight: sty.weight });
},
}}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip" sticky>
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 160 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 4 }}>
<span style={{ fontSize: 10, fontWeight: 800, color: sty.dot, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{sty.label} Hail
</span>
{storm.maxHailSize && (
<span style={{ fontSize: 10, fontWeight: 700, color: '#6B7280' }}>
{storm.maxHailSize}"
</span>
)}
</div>
<p style={{ fontSize: 12, fontWeight: 700, color: '#18181b', marginBottom: 2 }}>
{storm.areaName}
</p>
<p style={{ fontSize: 10, color: '#71717a' }}>
{days === 0 ? 'Today' : `${days}d ago`} · ~{(storm.estimatedHomes ?? '?').toLocaleString()} homes
</p>
</div>
</Tooltip>
</Polygon>
);
})}
</MapContainer>
{/* Legend */}
<div className="absolute bottom-3 left-3 z-[1000] pointer-events-none">
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Severity</p>
<div className="space-y-1">
{Object.entries(SEVERITY_STYLES).map(([key, sty]) => (
<div key={key} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: sty.fill, opacity: 0.85 }} />
<span className="text-[10px] font-medium text-zinc-600 dark:text-zinc-300">{sty.label}</span>
</div>
))}
</div>
</div>
</div>
{/* Storm count badge */}
<div className="absolute top-3 left-3 z-[1000] pointer-events-none">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-zinc-900/80 dark:bg-white/10 backdrop-blur-sm shadow-lg border border-white/10">
<CloudLightning size={11} className="text-amber-400" />
<span className="text-[11px] font-bold text-white">{events.length} storm{events.length !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
);
});
StormMap.displayName = 'StormMap';
// ─── Storm card ───────────────────────────────────────────────────────────────
const StormCard = ({ storm, isSelected, onSelect }) => {
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const conv = conversionWindow(storm.date);
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
const days = Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000);
const dateLabel = new Date(storm.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return (
<button
onClick={() => onSelect(storm)}
className={`w-full text-left rounded-xl border transition-all duration-150 overflow-hidden ${
isSelected
? 'border-amber-400 dark:border-amber-500 bg-amber-50/60 dark:bg-amber-500/5 shadow-md'
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 hover:border-zinc-300 dark:hover:border-white/15 hover:shadow-sm'
}`}
>
{/* Severity bar */}
<div className="h-1 w-full" style={{ backgroundColor: sty.dot }} />
<div className="p-3">
<div className="flex items-start gap-2.5">
{/* Icon */}
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 mt-0.5" style={{ backgroundColor: sty.dot + '18' }}>
<TypeIcon size={15} style={{ color: sty.dot }} />
</div>
<div className="flex-1 min-w-0">
{/* Area + date */}
<div className="flex items-start justify-between gap-1 mb-1">
<p className="text-[13px] font-bold text-zinc-900 dark:text-white leading-snug truncate">
{storm.areaName}
</p>
<span className="text-[10px] text-zinc-400 dark:text-zinc-500 shrink-0 mt-0.5">
{days === 0 ? 'Today' : `${days}d ago`}
</span>
</div>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{dateLabel}</p>
{/* Badges row */}
<div className="flex items-center gap-1.5 flex-wrap">
{/* Severity */}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${sty.text} ${sty.bg} ${sty.border}`}>
{sty.label}
</span>
{/* Hail size */}
{storm.maxHailSize && (
<span className="text-[10px] font-bold text-zinc-600 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md border border-zinc-200 dark:border-white/10">
{storm.maxHailSize}"
</span>
)}
{/* Conversion window */}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${conv.color} ${conv.bg} ${conv.border}`}>
{conv.label}
</span>
</div>
{/* Homes estimate */}
{storm.estimatedHomes && (
<div className="flex items-center gap-1 mt-1.5">
<Home size={10} className="text-zinc-400" />
<span className="text-[10px] text-zinc-400">
~{storm.estimatedHomes.toLocaleString()} homes affected
</span>
</div>
)}
</div>
</div>
</div>
</button>
);
};
// ─── Detail drawer ────────────────────────────────────────────────────────────
const StormDetailDrawer = ({ storm, open, onClose, onFlyTo }) => {
const navigate = useNavigate();
const { theme } = useTheme();
if (!storm) return null;
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const conv = conversionWindow(storm.date);
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
const days = Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000);
const dateLabel = new Date(storm.date).toLocaleDateString('en-US', { weekday: 'short', month: 'long', day: 'numeric', year: 'numeric' });
const handleCreateLead = () => {
navigate('/emp/fa/leads/new');
};
const handleViewInDispatch = () => {
navigate('/admin/dispatch');
};
return (
<>
{/* Backdrop on mobile */}
<AnimatePresence>
{open && (
<motion.div
key="backdrop"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/30 backdrop-blur-[2px] z-40 lg:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
{/* Drawer panel */}
<AnimatePresence>
{open && (
<motion.div
key="drawer"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 34 }}
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-50 flex flex-col bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-white/[0.07] shadow-2xl"
>
{/* Severity bar */}
<div className="h-1 w-full shrink-0" style={{ backgroundColor: sty.dot }} />
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: sty.dot + '18' }}>
<TypeIcon size={16} style={{ color: sty.dot }} />
</div>
<div>
<p className="text-[11px] font-bold uppercase tracking-wider" style={{ color: sty.dot }}>
{sty.label} {storm.type === 'hail' ? 'Hail' : storm.type}
</p>
<p className="text-[10px] text-zinc-400">{dateLabel}</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors"
>
<X size={18} />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{/* Area name */}
<div>
<h2 className="text-lg font-black text-zinc-900 dark:text-white leading-tight">
{storm.areaName}
</h2>
<p className="text-[12px] text-zinc-500 dark:text-zinc-400 mt-0.5">{storm.county} County, TX</p>
</div>
{/* Conversion window */}
<div className={`flex items-center gap-2 px-3 py-2 rounded-xl border ${conv.bg} ${conv.border}`}>
{conv.label === 'Hot Zone' && <Flame size={14} className={conv.color} />}
{conv.label === 'Warm' && <TrendingUp size={14} className={conv.color} />}
{conv.label === 'Cold' && <Snowflake size={14} className={conv.color} />}
{conv.label === 'Too Fresh' && <Clock size={14} className={conv.color} />}
<div>
<p className={`text-[12px] font-bold ${conv.color}`}>{conv.label}</p>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400">
{conv.label === 'Hot Zone' && 'Peak canvassing window — highest conversion likelihood'}
{conv.label === 'Warm' && 'Still worth canvassing — moderate conversion rate'}
{conv.label === 'Cold' && 'Low conversion — storm damage assessment mostly done'}
{conv.label === 'Too Fresh' && 'Too recent — wait for claims process to begin'}
</p>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-2">
{storm.maxHailSize && (
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">Max Hail</p>
<p className="text-xl font-black" style={{ color: sty.dot }}>{storm.maxHailSize}"</p>
<p className="text-[10px] text-zinc-400">{sty.label} category</p>
</div>
)}
{storm.estimatedHomes && (
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">Est. Homes</p>
<p className="text-xl font-black text-zinc-900 dark:text-white">{storm.estimatedHomes.toLocaleString()}</p>
<p className="text-[10px] text-zinc-400">in impact zone</p>
</div>
)}
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">Days Ago</p>
<p className="text-xl font-black text-zinc-900 dark:text-white">{days}</p>
<p className="text-[10px] text-zinc-400">since impact</p>
</div>
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">Source</p>
<p className="text-base font-black text-zinc-900 dark:text-white">{storm.source}</p>
<p className="text-[10px] text-zinc-400">data origin</p>
</div>
</div>
{/* Description */}
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5">Intel</p>
<p className="text-[13px] text-zinc-600 dark:text-zinc-300 leading-relaxed">
{storm.description}
</p>
</div>
{/* Map action */}
<button
onClick={onFlyTo}
className="w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl border border-zinc-200 dark:border-white/[0.07] hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group"
>
<div className="flex items-center gap-2">
<Eye size={14} className="text-zinc-400 group-hover:text-zinc-600 dark:group-hover:text-zinc-200" />
<span className="text-[13px] font-semibold text-zinc-600 dark:text-zinc-300">Zoom to zone on map</span>
</div>
<ChevronRight size={14} className="text-zinc-300 group-hover:text-zinc-500 dark:group-hover:text-zinc-300" />
</button>
</div>
{/* Action buttons */}
<div className="px-4 py-3 border-t border-zinc-100 dark:border-white/[0.06] space-y-2 shrink-0">
<button
onClick={handleCreateLead}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ background: `linear-gradient(135deg, ${sty.dot}, ${sty.stroke})` }}
>
<UserPlus size={15} />
Create Lead from Zone
</button>
<button
onClick={handleViewInDispatch}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-zinc-700 dark:text-zinc-200 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors active:scale-[0.98]"
>
<Zap size={15} />
Open in LynkDispatch
</button>
<button
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-zinc-400 dark:text-zinc-500 border border-dashed border-zinc-200 dark:border-white/10 cursor-default"
disabled
>
<Users size={15} />
Assign Canvassers — Phase 2
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
// ─── Filter bar ───────────────────────────────────────────────────────────────
const FilterChip = ({ active, onClick, children }) => (
<button
onClick={onClick}
className={`px-3 py-1.5 rounded-lg text-[12px] font-bold whitespace-nowrap transition-all border ${
active
? 'bg-amber-500 text-white border-amber-600 shadow-sm'
: 'bg-white dark:bg-zinc-900 text-zinc-600 dark:text-zinc-300 border-zinc-200 dark:border-white/[0.07] hover:border-zinc-300 dark:hover:border-white/15'
}`}
>
{children}
</button>
);
// ─── Main page ────────────────────────────────────────────────────────────────
const StormIntelPage = () => {
const { theme } = useTheme();
const [dateRange, setDateRange] = useState('6m');
const [typeFilter, setTypeFilter] = useState('all');
const [severityFilter, setSeverityFilter] = useState('all');
const [sortBy, setSortBy] = useState('date');
const [selectedStorm, setSelectedStorm] = useState(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [flyCount, setFlyCount] = useState(0);
const [showFilters, setShowFilters] = useState(false);
const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy });
const handleSelect = useCallback((storm) => {
setSelectedStorm(storm);
setDrawerOpen(true);
setFlyCount(c => c + 1);
}, []);
const handleFlyTo = useCallback(() => {
setFlyCount(c => c + 1);
}, []);
const handleClose = useCallback(() => {
setDrawerOpen(false);
}, []);
// Responsive map height
const [mapHeight, setMapHeight] = useState('320px');
useEffect(() => {
const update = () => {
const w = window.innerWidth;
setMapHeight(w < 640 ? '280px' : w < 768 ? '340px' : w < 1024 ? '400px' : '100%');
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
const isDark = theme === 'dark';
return (
<div className="flex flex-col h-full bg-zinc-50 dark:bg-zinc-950">
{/* ── Header ── */}
<div className="shrink-0 px-4 sm:px-6 py-4 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/80 backdrop-blur-md">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 dark:bg-amber-500/15 flex items-center justify-center border border-amber-200 dark:border-amber-500/20">
<CloudLightning size={20} className="text-amber-500" />
</div>
<div>
<h1 className="text-xl font-black text-zinc-900 dark:text-white tracking-tight">Storm Intel</h1>
<p className="text-[12px] text-zinc-400 dark:text-zinc-500">
Plano TX · Collin County
</p>
</div>
</div>
{/* Summary stats */}
<div className="hidden sm:flex items-center gap-4">
<div className="text-right">
<p className="text-xl font-black text-zinc-900 dark:text-white">{events.length}</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Storms</p>
</div>
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
<div className="text-right">
<p className="text-xl font-black text-zinc-900 dark:text-white">{(totalHomes / 1000).toFixed(1)}k</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Homes</p>
</div>
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
<div className="text-right">
<p className="text-xl font-black text-amber-500">
{events.filter(e => conversionWindow(e.date).label === 'Hot Zone').length}
</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Hot Zones</p>
</div>
</div>
{/* Mobile filter toggle */}
<button
onClick={() => setShowFilters(f => !f)}
className={`lg:hidden p-2 rounded-lg border transition-colors ${
showFilters
? 'bg-amber-500 border-amber-600 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-500 hover:bg-zinc-50 dark:hover:bg-white/5'
}`}
>
<Filter size={16} />
</button>
</div>
</div>
{/* ── Filter bar ── */}
<AnimatePresence>
{(showFilters || true) && (
<motion.div
initial={false}
className={`shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60 ${showFilters || window.innerWidth >= 1024 ? '' : 'hidden lg:flex'}`}
>
<div className="px-4 sm:px-6 py-3 flex flex-wrap gap-x-6 gap-y-2 items-center">
{/* Date range */}
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Range</span>
{[['1m', '1 Month'], ['3m', '3 Months'], ['6m', '6 Months'], ['1y', '1 Year']].map(([val, label]) => (
<FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip>
))}
</div>
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
{/* Severity */}
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span>
{[['all', 'All'], ['severe', 'Severe'], ['significant', 'Significant'], ['moderate', 'Moderate'], ['trace', 'Trace']].map(([val, label]) => (
<FilterChip key={val} active={severityFilter === val} onClick={() => setSeverityFilter(val)}>{label}</FilterChip>
))}
</div>
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
{/* Sort */}
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Sort</span>
<FilterChip active={sortBy === 'date'} onClick={() => setSortBy('date')}>Newest</FilterChip>
<FilterChip active={sortBy === 'severity'} onClick={() => setSortBy('severity')}>Severity</FilterChip>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── Body ── */}
<div className="flex-1 flex flex-col lg:flex-row min-h-0">
{/* Map */}
<div
className="relative shrink-0 lg:shrink lg:flex-1 lg:min-h-0"
style={{ height: mapHeight }}
>
{loading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-100 dark:bg-zinc-900 gap-3">
<Loader2 size={24} className="text-amber-500 animate-spin" />
<p className="text-[13px] text-zinc-400 font-medium">Loading storm data...</p>
</div>
) : error ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-100 dark:bg-zinc-900 gap-2 px-6 text-center">
<AlertTriangle size={24} className="text-red-400" />
<p className="text-[13px] text-zinc-500 font-medium">Failed to load storm data</p>
<p className="text-[11px] text-zinc-400">{error}</p>
</div>
) : events.length === 0 ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-100 dark:bg-zinc-900 gap-2">
<CloudLightning size={28} className="text-zinc-300" />
<p className="text-[13px] text-zinc-400 font-medium">No storms match your filters</p>
</div>
) : (
<StormMap
events={events}
selectedId={selectedStorm?.id}
selectedStorm={selectedStorm}
onSelect={handleSelect}
theme={theme}
flyCount={flyCount}
/>
)}
</div>
{/* Storm list */}
<div className="lg:w-[23rem] flex flex-col border-t lg:border-t-0 lg:border-l border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 min-h-0">
{/* List header */}
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 flex items-center justify-between">
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{events.length} Event{events.length !== 1 ? 's' : ''}
</p>
{selectedStorm && (
<button
onClick={() => { setSelectedStorm(null); setDrawerOpen(false); }}
className="text-[11px] text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 flex items-center gap-1 transition-colors"
>
<X size={12} /> Clear
</button>
)}
</div>
{/* Cards */}
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-xl bg-zinc-100 dark:bg-zinc-800/40 animate-pulse" />
))
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<CloudLightning size={22} className="text-zinc-300 mb-2" />
<p className="text-[12px] text-zinc-400">No storms in this range</p>
</div>
) : (
events.map(storm => (
<StormCard
key={storm.id}
storm={storm}
isSelected={selectedStorm?.id === storm.id}
onSelect={handleSelect}
/>
))
)}
</div>
</div>
</div>
{/* Detail drawer */}
<StormDetailDrawer
storm={selectedStorm}
open={drawerOpen}
onClose={handleClose}
onFlyTo={handleFlyTo}
/>
</div>
);
};
export default StormIntelPage;