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
+95
View File
@@ -0,0 +1,95 @@
/**
* Proxies active NWS severe weather alerts for Texas to the frontend.
* No auth required — api.weather.gov is a free public endpoint.
*
* For historical data beyond 7 days, integrate NOAA Storm Events CSV API
* or SWDI (https://www.ncei.noaa.gov/pub/data/swdi/) in a future iteration.
*
* Response: array of normalised storm event objects (same shape as MOCK_STORM_EVENTS)
*/
const NWS_HEADERS = {
'User-Agent': 'PlanoRealtyCRM/1.0 (admin@plano-realty.com)',
Accept: 'application/geo+json',
};
function severityFromSize(inches) {
if (!inches || inches < 0.75) return 'none';
if (inches < 1.00) return 'trace';
if (inches < 1.50) return 'moderate';
if (inches < 2.00) return 'significant';
return 'severe';
}
function parseHailSize(text = '') {
const m = text.match(/(\d+\.?\d*)\s*inch/i) || text.match(/(\d+\.?\d*)"/) ;
return m ? parseFloat(m[1]) : null;
}
export default async function handler(req, res) {
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// NWS active severe weather alerts for TX — hail + thunderstorm
const url = 'https://api.weather.gov/alerts/active?area=TX&event=Hail,Severe+Thunderstorm,Tornado';
const nwsRes = await fetch(url, { headers: NWS_HEADERS });
if (!nwsRes.ok) {
console.warn('[storm-events] NWS returned', nwsRes.status);
return res.status(200).json([]);
}
const geojson = await nwsRes.json();
const features = (geojson.features || []);
const events = features
.filter(f => {
const evt = (f.properties?.event || '').toLowerCase();
return evt.includes('hail') || evt.includes('thunderstorm') || evt.includes('tornado');
})
.map((f, i) => {
const p = f.properties || {};
const desc = p.description || p.headline || '';
const hailSize = parseHailSize(desc) ?? parseHailSize(p.parameters?.hailSize?.[0] ?? '');
// NWS GeoJSON polygon coordinates are [lng, lat] — swap for Leaflet
let polygon = [];
const geom = f.geometry;
if (geom?.type === 'Polygon' && geom.coordinates?.[0]) {
polygon = geom.coordinates[0].map(([lng, lat]) => [lat, lng]);
} else if (geom?.type === 'MultiPolygon' && geom.coordinates?.[0]?.[0]) {
polygon = geom.coordinates[0][0].map(([lng, lat]) => [lat, lng]);
}
const typeStr = (p.event || '').toLowerCase();
const type = typeStr.includes('tornado') ? 'tornado'
: typeStr.includes('hail') ? 'hail'
: 'wind';
return {
id: `NWS-${p.id || i}`,
date: p.onset || p.sent || new Date().toISOString(),
type,
severity: severityFromSize(hailSize),
maxHailSize: hailSize,
areaName: p.areaDesc || 'Unknown Area',
county: 'Collin',
estimatedHomes: null,
description: p.headline || desc.slice(0, 300),
source: 'NWS',
polygon,
};
})
.filter(e => e.polygon.length >= 3); // require a valid polygon
return res.status(200).json(events);
} catch (err) {
console.error('[storm-events] error:', err.message);
return res.status(200).json([]);
}
}
+17
View File
@@ -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={
+3 -1
View File
@@ -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",
+119
View File
@@ -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 (11.5"), significant (1.52"), severe (2"+)
// conversionWindow: days 760 = hot, 61120 = 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 ballsized 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 1520 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],
],
},
];
+85
View File
@@ -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' };
}
+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;