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:
+17
@@ -36,6 +36,7 @@ import LynkDispatchPage from './pages/LynkDispatchPage';
|
||||
import EstimatesPage from './pages/EstimatesPage';
|
||||
import KanbanPage from './pages/KanbanPage';
|
||||
import LeadProjectPage from './pages/LeadProjectPage';
|
||||
import StormIntelPage from './pages/StormIntelPage';
|
||||
|
||||
// ... (existing imports)
|
||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||
@@ -262,6 +263,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/storm-intel"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||
<StormIntelPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/owner/storm-intel"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||
<StormIntelPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/maps"
|
||||
element={
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
||||
import {
|
||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning
|
||||
} from 'lucide-react';
|
||||
|
||||
import PageTransition from './PageTransition';
|
||||
@@ -140,6 +140,7 @@ const Layout = () => {
|
||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||
{ to: "/owner/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
||||
{ to: "/owner/storm-intel", icon: CloudLightning, label: "Storm Intel" },
|
||||
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
|
||||
{
|
||||
to: "/owner/pro-canvas",
|
||||
@@ -160,6 +161,7 @@ const Layout = () => {
|
||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||
{ to: "/admin/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
||||
{ to: "/admin/storm-intel", icon: CloudLightning, label: "Storm Intel" },
|
||||
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
|
||||
{
|
||||
to: "/admin/pro-canvas",
|
||||
|
||||
@@ -5582,3 +5582,122 @@ export const LEAD_FORM_OPTIONS = {
|
||||
'SD','TN','TX','UT','VT','VA','WA','WV','WI','WY',
|
||||
],
|
||||
};
|
||||
|
||||
// ── Storm Events — mock history of severe weather events in Plano TX area ──────
|
||||
// Each polygon is an array of [lat, lng] pairs forming the storm impact boundary.
|
||||
// severity scale: trace (<1"), moderate (1–1.5"), significant (1.5–2"), severe (2"+)
|
||||
// conversionWindow: days 7–60 = hot, 61–120 = warm, >120 = cold
|
||||
export const MOCK_STORM_EVENTS = [
|
||||
{
|
||||
id: 'SE-001',
|
||||
date: '2026-04-28',
|
||||
type: 'hail',
|
||||
severity: 'severe',
|
||||
maxHailSize: 2.5,
|
||||
areaName: 'NE Plano / Spring Creek Pkwy',
|
||||
county: 'Collin',
|
||||
estimatedHomes: 1240,
|
||||
description: 'Golf ball–sized hail (2.5") swept through northeast Plano, generating dozens of immediate roof damage reports. Highest claim density in the Spring Creek / Custer Rd corridor.',
|
||||
source: 'NOAA',
|
||||
polygon: [
|
||||
[33.110, -96.715], [33.101, -96.695], [33.083, -96.695],
|
||||
[33.074, -96.715], [33.083, -96.735], [33.101, -96.735],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SE-002',
|
||||
date: '2026-03-15',
|
||||
type: 'hail',
|
||||
severity: 'moderate',
|
||||
maxHailSize: 1.2,
|
||||
areaName: 'Central Plano / W 15th St',
|
||||
county: 'Collin',
|
||||
estimatedHomes: 870,
|
||||
description: 'Quarter-sized hail moved through central Plano neighborhoods. Minor granule loss on older roofs; several shingle bruising reports filed.',
|
||||
source: 'NWS',
|
||||
polygon: [
|
||||
[33.072, -96.748], [33.065, -96.730], [33.051, -96.730],
|
||||
[33.044, -96.748], [33.051, -96.766], [33.065, -96.766],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SE-003',
|
||||
date: '2026-02-03',
|
||||
type: 'hail',
|
||||
severity: 'significant',
|
||||
maxHailSize: 1.75,
|
||||
areaName: 'SE Plano / Parker Rd Corridor',
|
||||
county: 'Collin',
|
||||
estimatedHomes: 960,
|
||||
description: 'Half-dollar sized hail impacted the Parker Rd corridor. High claim conversion probability — many homes in this zone have 15–20 year old roofs.',
|
||||
source: 'NOAA',
|
||||
polygon: [
|
||||
[33.036, -96.718], [33.029, -96.700], [33.015, -96.700],
|
||||
[33.008, -96.718], [33.015, -96.736], [33.029, -96.736],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SE-004',
|
||||
date: '2026-01-20',
|
||||
type: 'hail',
|
||||
severity: 'moderate',
|
||||
maxHailSize: 1.0,
|
||||
areaName: 'W Plano / Legacy West',
|
||||
county: 'Collin',
|
||||
estimatedHomes: 680,
|
||||
description: 'Quarter-sized hail with sustained winds up to 45 mph. Mixed residential and commercial impact around the Legacy West district.',
|
||||
source: 'NWS',
|
||||
polygon: [
|
||||
[33.086, -96.822], [33.079, -96.804], [33.065, -96.804],
|
||||
[33.058, -96.822], [33.065, -96.840], [33.079, -96.840],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SE-005',
|
||||
date: '2025-12-10',
|
||||
type: 'hail',
|
||||
severity: 'trace',
|
||||
maxHailSize: 0.8,
|
||||
areaName: 'Allen / N Plano Border',
|
||||
county: 'Collin',
|
||||
estimatedHomes: 430,
|
||||
description: 'Marble-sized hail, minimal visible damage. Worth a light canvassing pass on homes with 10+ year roofs in this zone.',
|
||||
source: 'NWS',
|
||||
polygon: [
|
||||
[33.120, -96.658], [33.114, -96.643], [33.102, -96.643],
|
||||
[33.096, -96.658], [33.102, -96.673], [33.114, -96.673],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SE-006',
|
||||
date: '2025-11-05',
|
||||
type: 'hail',
|
||||
severity: 'significant',
|
||||
maxHailSize: 1.6,
|
||||
areaName: 'S Plano / Haggard Park Area',
|
||||
county: 'Collin',
|
||||
estimatedHomes: 810,
|
||||
description: 'Half-dollar hail combined with strong gusts. Shingle bruising confirmed across multiple streets. This zone responded well to canvassing in Q4.',
|
||||
source: 'NOAA',
|
||||
polygon: [
|
||||
[33.047, -96.773], [33.040, -96.755], [33.026, -96.755],
|
||||
[33.019, -96.773], [33.026, -96.791], [33.040, -96.791],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'SE-007',
|
||||
date: '2025-09-18',
|
||||
type: 'hail',
|
||||
severity: 'moderate',
|
||||
maxHailSize: 1.1,
|
||||
areaName: 'Frisco / N Plano',
|
||||
county: 'Collin',
|
||||
estimatedHomes: 530,
|
||||
description: 'Storm cell crossed from Frisco into north Plano. Roofs 10 years or older in this zone are likely showing impact marks.',
|
||||
source: 'NOAA',
|
||||
polygon: [
|
||||
[33.138, -96.782], [33.130, -96.762], [33.116, -96.762],
|
||||
[33.108, -96.782], [33.116, -96.802], [33.130, -96.802],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Storm events hook.
|
||||
*
|
||||
* USE_MOCK = true → returns MOCK_STORM_EVENTS from mockStore (local dev)
|
||||
* USE_MOCK = false → calls /api/storm-events which proxies NWS/NOAA
|
||||
*
|
||||
* Flip to false once the Vercel deployment is live (no extra env vars needed —
|
||||
* NWS api.weather.gov is a free, unauthenticated public endpoint).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { MOCK_STORM_EVENTS } from '../data/mockStore';
|
||||
|
||||
const USE_MOCK = true;
|
||||
|
||||
const DATE_RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365 };
|
||||
|
||||
const SEVERITY_ORDER = { severe: 4, significant: 3, moderate: 2, trace: 1 };
|
||||
|
||||
export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityFilter = 'all', sortBy = 'date' } = {}) {
|
||||
const [allEvents, setAllEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
let data;
|
||||
if (USE_MOCK) {
|
||||
await new Promise(r => setTimeout(r, 450));
|
||||
data = MOCK_STORM_EVENTS;
|
||||
} else {
|
||||
const days = DATE_RANGE_DAYS[dateRange] || 180;
|
||||
const res = await fetch(`/api/storm-events?days=${days}&county=Collin`);
|
||||
if (!res.ok) throw new Error(`Storm events error: ${res.status}`);
|
||||
data = await res.json();
|
||||
}
|
||||
if (!cancelled) setAllEvents(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [dateRange]);
|
||||
|
||||
const events = useMemo(() => {
|
||||
const cutoffMs = Date.now() - (DATE_RANGE_DAYS[dateRange] || 180) * 86400000;
|
||||
|
||||
let filtered = allEvents.filter(e => {
|
||||
if (new Date(e.date).getTime() < cutoffMs) return false;
|
||||
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
|
||||
if (severityFilter !== 'all' && e.severity !== severityFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (sortBy === 'severity') {
|
||||
filtered = filtered.sort((a, b) => (SEVERITY_ORDER[b.severity] ?? 0) - (SEVERITY_ORDER[a.severity] ?? 0));
|
||||
} else {
|
||||
filtered = filtered.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [allEvents, dateRange, typeFilter, severityFilter, sortBy]);
|
||||
|
||||
const totalHomes = useMemo(() => events.reduce((s, e) => s + (e.estimatedHomes ?? 0), 0), [events]);
|
||||
|
||||
return { events, loading, error, totalHomes };
|
||||
}
|
||||
|
||||
// Returns a label + color class for how "hot" a storm zone is (conversion likelihood)
|
||||
export function conversionWindow(dateStr) {
|
||||
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
|
||||
if (days < 7) return { label: 'Too Fresh', color: 'text-sky-500', bg: 'bg-sky-50 dark:bg-sky-500/10', border: 'border-sky-200 dark:border-sky-500/20' };
|
||||
if (days <= 60) return { label: 'Hot Zone', color: 'text-red-500', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20' };
|
||||
if (days <= 120) return { label: 'Warm', color: 'text-orange-500', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20' };
|
||||
return { label: 'Cold', color: 'text-zinc-400', bg: 'bg-zinc-100 dark:bg-zinc-800', border: 'border-zinc-200 dark:border-white/10' };
|
||||
}
|
||||
@@ -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='© <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;
|
||||
Reference in New Issue
Block a user