Files
LynkedUpPro_CRM/src/pages/StormIntelPage.jsx
T
Satyam-Rastogi a6d3997d07 feat(storm-intel): configurable pin radius, fix blank map, 6mo default, remove mock polygons
- Map always renders (removed blank overlay when no nearby events)
- Pin radius is now user-configurable (default 5 mi, input shown in pin strip)
- Default date range changed to 6 months for faster initial load
- IEM fetch window narrowed to 6 months to match default
- MOCK_STORM_EVENTS cleared (IEM provides real NWS polygon data)
2026-05-19 00:47:43 +05:30

1561 lines
89 KiB
React

import React, { useState, useCallback, useRef, useEffect, memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
MapContainer, TileLayer, Polygon, Polyline, CircleMarker,
Circle, Tooltip, useMap, useMapEvents,
} from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import {
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
ResponsiveContainer, CartesianGrid, Cell,
} from 'recharts';
import {
CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight,
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud,
CloudRain, CloudDrizzle, CheckCircle, XCircle, CalendarRange,
Search, Crosshair, CloudSnow, CloudHail, Navigation,
} from 'lucide-react';
import { toast } from 'sonner';
import { useTheme } from '../context/ThemeContext';
import { useMockStore } from '../data/mockStore';
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
import { useStormAttribution } from '../hooks/useStormAttribution';
import { useStormForecast } from '../hooks/useStormForecast';
import ZoneAssignmentModal from '../components/storm/ZoneAssignmentModal';
import { useAuth } from '../context/AuthContext';
// ─── 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: CloudHail,
wind: Wind,
tornado: Zap,
flood: Droplets,
rain: CloudRain,
snow: CloudSnow,
ice: CloudSnow,
thunderstorm: CloudLightning,
};
const TYPE_LABELS = {
hail: 'Hail', wind: 'Wind', tornado: 'Tornado', flood: 'Flood',
rain: 'Rain', snow: 'Snow', ice: 'Ice Storm', thunderstorm: 'Thunderstorm',
};
const FORECAST_ICONS = {
1000: Sun, 1100: Sun,
1001: Cloud, 1101: Cloud, 1102: Cloud,
4000: CloudDrizzle, 4200: CloudDrizzle,
4001: CloudRain, 4201: CloudRain,
8000: CloudLightning,
7000: Snowflake, 7101: Snowflake, 7102: Snowflake,
5000: Snowflake, 5100: Snowflake, 5101: Snowflake,
};
function scoreColor(score) {
if (score >= 65) return '#DC2626';
if (score >= 40) return '#F97316';
if (score >= 20) return '#EAB308';
return '#10B981';
}
function haversine(lat1, lon1, lat2, lon2) {
const R = 3958.8;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function polygonCentroid(polygon) {
const n = polygon.length;
return [
polygon.reduce((s, p) => s + p[0], 0) / n,
polygon.reduce((s, p) => s + p[1], 0) / n,
];
}
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; }
.draw-cursor .leaflet-container { cursor: crosshair !important; }
`;
// ─── 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 ZoneLayer = ({ zones }) => {
const map = useMap();
if (!zones?.length) return null;
const flyToZone = (polygon) => {
try {
const bounds = L.latLngBounds(polygon);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15, animate: true, duration: 0.7 });
} catch { /* malformed polygon */ }
};
return zones.map(zone => {
if (!zone.polygon?.length) return null;
const centroid = zone.polygon.reduce(
(acc, pt) => [acc[0] + pt[0] / zone.polygon.length, acc[1] + pt[1] / zone.polygon.length],
[0, 0]
);
return (
<React.Fragment key={zone.id}>
<Polygon
positions={zone.polygon}
pathOptions={{
color: zone.color,
fillColor: zone.color,
fillOpacity: zone.status === 'completed' ? 0.06 : 0.14,
weight: 2,
opacity: zone.status === 'completed' ? 0.45 : 0.85,
dashArray: zone.status === 'completed' ? '5 4' : undefined,
}}
eventHandlers={{ click: () => flyToZone(zone.polygon) }}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip" sticky={false}>
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 140 }}>
<p style={{ fontSize: 12, fontWeight: 700, color: '#18181b', marginBottom: 2 }}>{zone.name}</p>
<p style={{ fontSize: 10, color: '#71717a' }}>
{zone.assignedAgentName} · {zone.status === 'completed' ? 'Completed' : 'Active'}
</p>
</div>
</Tooltip>
</Polygon>
{/* Agent initials circle at centroid — also clickable */}
<CircleMarker
center={centroid}
radius={11}
pathOptions={{ color: zone.color, fillColor: zone.color, fillOpacity: 1, weight: 1.5 }}
eventHandlers={{ click: () => flyToZone(zone.polygon) }}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip">
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b' }}>{zone.name}</p>
</Tooltip>
</CircleMarker>
</React.Fragment>
);
});
};
const ZoneDrawingLayer = ({ draftPolygon, onAddPoint, onFinish }) => {
const map = useMap();
useEffect(() => {
map.doubleClickZoom.disable();
map.getContainer().classList.add('draw-cursor');
return () => {
map.doubleClickZoom.enable();
map.getContainer().classList.remove('draw-cursor');
};
}, [map]);
useMapEvents({
click(e) {
onAddPoint([e.latlng.lat, e.latlng.lng]);
},
dblclick() {
if (draftPolygon.length >= 3) onFinish();
},
});
if (draftPolygon.length === 0) return null;
const closedPath = draftPolygon.length > 1
? [...draftPolygon, draftPolygon[0]]
: draftPolygon;
return (
<>
<Polyline
positions={closedPath}
pathOptions={{ color: '#F59E0B', weight: 2.5, dashArray: '6 4', opacity: 0.9 }}
/>
{/* Fill preview */}
{draftPolygon.length >= 3 && (
<Polygon
positions={draftPolygon}
pathOptions={{ color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.12, weight: 0 }}
/>
)}
{/* Vertex dots */}
{draftPolygon.map((pt, i) => (
<CircleMarker
key={i}
center={pt}
radius={i === 0 ? 6 : 4}
pathOptions={{
color: '#F59E0B',
fillColor: i === 0 ? '#fff' : '#F59E0B',
fillOpacity: 1,
weight: 2,
}}
/>
))}
</>
);
};
const PinDropLayer = ({ onDrop }) => {
useMapEvents({ click: e => onDrop([e.latlng.lat, e.latlng.lng]) });
return null;
};
const StormMap = memo(({
events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
pinDropMode, pins, onDropPin, pinRadius = 5,
}) => {
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} />
{/* Storm polygons — only selected + pinned (or all if none active) */}
{events.map(storm => {
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const isSelected = storm.id === selectedId;
const isPinned = pinnedIds?.has(storm.id);
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.14, 0.44)
: isPinned
? Math.min(sty.fillOpacity + 0.06, 0.36)
: sty.fillOpacity,
weight: isSelected ? sty.weight + 1.5 : isPinned ? sty.weight + 0.5 : sty.weight,
opacity: isSelected ? 1 : isPinned ? 0.95 : 0.75,
dashArray: isSelected ? undefined : isPinned ? '3 3' : '6 4',
}}
eventHandlers={{
click: () => !isDrawing && !pinDropMode && 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: isPinned ? Math.min(sty.fillOpacity + 0.06, 0.36) : sty.fillOpacity,
weight: isPinned ? sty.weight + 0.5 : 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' }}>
{Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000)}d ago · ~{(storm.estimatedHomes ?? '?').toLocaleString()} homes
</p>
</div>
</Tooltip>
</Polygon>
);
})}
{/* Canvasser zones layer */}
<ZoneLayer zones={stormZones} />
{/* Pin drop circles */}
{pins?.map((pin, i) => (
<React.Fragment key={i}>
<Circle
center={pin}
radius={pinRadius * 1609.34}
pathOptions={{ color: '#8B5CF6', fillColor: '#8B5CF6', fillOpacity: 0.06, weight: 1.5, dashArray: '5 5' }}
/>
<CircleMarker
center={pin}
radius={7}
pathOptions={{ color: '#fff', fillColor: '#8B5CF6', fillOpacity: 1, weight: 2 }}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip" permanent={false}>
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b' }}>Pin {i + 1} · {pinRadius} mi radius</p>
</Tooltip>
</CircleMarker>
</React.Fragment>
))}
{/* Pin drop capture layer */}
{pinDropMode && <PinDropLayer onDrop={onDropPin} />}
{/* Drawing layer (active only when isDrawing) */}
{isDrawing && (
<ZoneDrawingLayer
draftPolygon={draftPolygon}
onAddPoint={onAddPoint}
onFinish={onFinishPolygon}
/>
)}
</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>
{/* Drawing instructions */}
{isDrawing && (
<div className="absolute top-3 right-3 z-[1000]">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-500 text-white shadow-lg text-[11px] font-bold">
<Pencil size={12} />
{draftPolygon.length < 3
? `Click to place vertices (${draftPolygon.length}/3 min)`
: `Double-click to close polygon (${draftPolygon.length} pts)`
}
</div>
</div>
)}
{/* Pin drop instruction */}
{pinDropMode && (
<div className="absolute top-3 right-3 z-[1000]">
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-violet-600 text-white shadow-lg text-[11px] font-bold">
<Crosshair size={12} />
Click map to drop a pin (5 mi radius)
</div>
</div>
)}
</div>
);
});
StormMap.displayName = 'StormMap';
// ─── Forecast strip ───────────────────────────────────────────────────────────
const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => {
if (loading) {
return (
<div className="shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60 px-4 sm:px-6 py-2">
<div className="flex items-center gap-2">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-14 w-[72px] shrink-0 rounded-lg bg-zinc-100 dark:bg-zinc-800/40 animate-pulse" />
))}
</div>
</div>
);
}
if (!forecast.length) return null;
return (
<div className="shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60">
{/* Storm alert banner */}
{hasStormAlert && alertDay && (
<div className="mx-4 sm:mx-6 mt-2.5 mb-0 px-3 py-2 rounded-xl border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/8 flex items-center gap-2.5">
<AlertTriangle size={14} className="text-red-500 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[12px] font-black text-red-700 dark:text-red-400">High-Risk Storm Day</span>
<span className="text-[12px] text-red-600 dark:text-red-400 ml-1.5">
{new Date(alertDay.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
{' — '}{alertDay.weatherLabel} · {alertDay.windGust} mph gusts · {alertDay.precipProb}% rain
</span>
</div>
<span className="text-[10px] font-black text-red-600 dark:text-red-400 shrink-0">Score {alertDay.stormScore}</span>
</div>
)}
{/* 7-day forecast row */}
<div className="flex items-stretch gap-1.5 px-4 sm:px-6 py-2.5 overflow-x-auto scrollbar-none">
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 self-center mr-1 shrink-0 hidden sm:block">7-Day</span>
{forecast.map((day, i) => {
const FIcon = FORECAST_ICONS[day.weatherCode] ?? Cloud;
const isAlert = day.stormScore >= 65;
const isWarn = day.stormScore >= 40 && !isAlert;
const dayLabel = i === 0
? 'Today'
: new Date(day.date).toLocaleDateString('en-US', { weekday: 'short' });
return (
<div
key={day.date}
className={`flex-none w-[70px] rounded-xl border px-2 py-1.5 flex flex-col items-center gap-0.5 ${
isAlert
? 'border-red-300 dark:border-red-500/30 bg-red-100 dark:bg-red-500/15'
: isWarn
? 'border-orange-200 dark:border-orange-500/20 bg-orange-50/60 dark:bg-orange-500/10'
: 'border-zinc-100 dark:border-white/[0.06] bg-zinc-50/60 dark:bg-zinc-800/30'
}`}
>
{/* Day label — high contrast on alert cards */}
<span className={`text-[10px] font-black uppercase tracking-wide ${
i === 0
? 'text-amber-600 dark:text-amber-400'
: isAlert
? 'text-red-700 dark:text-red-300'
: isWarn
? 'text-orange-700 dark:text-orange-300'
: 'text-zinc-500 dark:text-zinc-400'
}`}>
{dayLabel}
</span>
<FIcon
size={16}
style={{ color: isAlert ? '#DC2626' : isWarn ? '#F97316' : '#71717a' }}
/>
{/* Temp — white in dark mode on alert so it pops against the colored bg */}
<span className={`text-[11px] font-black ${
isAlert
? 'text-red-700 dark:text-white'
: isWarn
? 'text-orange-700 dark:text-white'
: 'text-zinc-700 dark:text-zinc-200'
}`}>
{day.tempF}°
</span>
{/* Storm score bar */}
<div className="w-full h-1 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden mt-0.5">
<div
className="h-full rounded-full"
style={{ width: `${day.stormScore}%`, backgroundColor: scoreColor(day.stormScore) }}
/>
</div>
<span className={`text-[9px] font-bold ${
isAlert ? 'text-red-700 dark:text-red-300' : isWarn ? 'text-orange-600 dark:text-orange-300' : 'text-zinc-400'
}`}>
{day.precipProb}% rain
</span>
</div>
);
})}
</div>
</div>
);
};
// ─── Storm card ───────────────────────────────────────────────────────────────
const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin, distanceMiles }) => {
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'
: isPinned
? 'border-amber-300/60 dark:border-amber-500/30 bg-amber-50/30 dark:bg-amber-500/[0.03]'
: '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'
}`}
>
<div className="h-1 w-full" style={{ backgroundColor: sty.dot }} />
<div className="p-3">
<div className="flex items-start gap-2.5">
<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">
<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>
<div className="flex items-center gap-1 shrink-0">
{distanceMiles != null && (
<span className="text-[10px] font-bold text-violet-500 dark:text-violet-400">
{distanceMiles.toFixed(1)} mi
</span>
)}
<span className="text-[10px] text-zinc-400 dark:text-zinc-500">
{days === 0 ? 'Today' : `${days}d ago`}
</span>
{/* Pin button */}
<button
onClick={e => { e.stopPropagation(); onTogglePin(storm.id); }}
title={isPinned ? 'Unpin from map' : 'Pin to map'}
className={`ml-0.5 p-1 rounded-md transition-colors ${
isPinned
? 'text-amber-500 bg-amber-50 dark:bg-amber-500/10'
: 'text-zinc-300 dark:text-zinc-600 hover:text-amber-400 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10'
}`}
>
<MapPin size={11} fill={isPinned ? 'currentColor' : 'none'} />
</button>
</div>
</div>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{dateLabel}</p>
<div className="flex items-center gap-1.5 flex-wrap">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border ${sty.text} ${sty.bg} ${sty.border}`}>
{sty.label}
</span>
{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>
)}
<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>
{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, onAssignZone, canManageZones }) => {
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', {
state: {
stormSource: {
id: storm.id,
date: storm.date,
areaName: storm.areaName,
maxHailSize: storm.maxHailSize,
severity: storm.severity,
},
},
});
};
return (
<>
<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>
<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"
>
<div className="h-1 w-full shrink-0" style={{ backgroundColor: sty.dot }} />
<div className="flex items-center justify-between pl-5 pr-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>
<div className="flex-1 overflow-y-auto pl-5 pr-4 py-4 space-y-4">
<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>
<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>
<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>
<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>
<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>
<div className="pl-5 pr-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>
{canManageZones ? (
<button
onClick={() => { onClose(); onAssignZone(storm.id); }}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors active:scale-[0.98]"
>
<Pencil size={15} />
Draw Canvasser Zone
</button>
) : (
<button
onClick={() => navigate('/admin/dispatch')}
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>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
// ─── Attribution view ────────────────────────────────────────────────────────
const SEVERITY_DOT = { severe: '#DC2626', significant: '#EF4444', moderate: '#F97316', trace: '#EAB308' };
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl shadow-xl px-3 py-2.5 text-left">
<p className="text-[11px] font-bold text-zinc-500 dark:text-zinc-400 mb-1.5">{label}</p>
{payload.map(p => (
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
<div className="w-2 h-2 rounded-sm shrink-0" style={{ backgroundColor: p.fill }} />
<span className="text-[11px] text-zinc-700 dark:text-zinc-200 font-medium">{p.name}: <strong>{p.value}</strong></span>
</div>
))}
</div>
);
};
const AttributionView = ({ events, sessionLeads }) => {
const { theme } = useTheme();
const { summary, perStorm } = useStormAttribution(events, sessionLeads);
const isDark = theme === 'dark';
const maxRevenue = Math.max(...perStorm.map(p => p.revenue), 1);
const chartData = perStorm
.filter(p => p.leadsCount > 0)
.map(p => ({
name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().replace('Plano', '').trim() || p.storm.areaName,
Leads: p.leadsCount,
Closed: p.closedCount,
stormId: p.storm.id,
severity: p.storm.severity,
}));
const statCards = [
{ label: 'Storm Leads', value: summary.totalLeads, icon: Users, color: '#3B82F6' },
{ label: 'Closed', value: summary.closedLeads, icon: Target, color: '#10B981' },
{ label: 'Conv. Rate', value: `${summary.conversionRate}%`, icon: TrendingUp, color: '#F59E0B' },
{ label: 'Revenue', value: `$${(summary.totalRevenue / 1000).toFixed(0)}k`, icon: DollarSign, color: '#8B5CF6' },
{ label: 'Pipeline', value: `$${(summary.pipelineValue / 1000).toFixed(0)}k`, icon: BarChart2, color: '#F97316' },
{ label: 'Avg Deal', value: `$${(summary.avgDealSize / 1000).toFixed(1)}k`, icon: CloudLightning, color: '#EC4899' },
];
return (
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{statCards.map(({ label, value, icon: Icon, color }) => (
<div key={label} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 px-4 py-3">
<div className="flex items-center gap-1.5 mb-2">
<Icon size={12} style={{ color }} />
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">{label}</p>
</div>
<p className="text-2xl font-black text-zinc-900 dark:text-white">{value}</p>
</div>
))}
</div>
<div className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 p-4">
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-4">Leads Generated Per Storm</p>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData} barGap={4} barCategoryGap="30%">
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'} vertical={false} />
<XAxis dataKey="name" tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa', fontWeight: 600 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: isDark ? '#71717a' : '#a1a1aa' }} axisLine={false} tickLine={false} width={24} />
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)' }} />
<Bar dataKey="Leads" name="Generated" fill="#F59E0B" radius={[4, 4, 0, 0]}>
{chartData.map((entry, i) => (
<Cell key={i} fill={SEVERITY_DOT[entry.severity] ?? '#F59E0B'} fillOpacity={0.5} />
))}
</Bar>
<Bar dataKey="Closed" name="Closed" fill="#10B981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-3">
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-sm bg-amber-500/50" /><span className="text-[10px] text-zinc-400 font-medium">Generated (by severity)</span></div>
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-sm bg-emerald-500" /><span className="text-[10px] text-zinc-400 font-medium">Closed</span></div>
</div>
</div>
<div>
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-400 mb-3">Storm Revenue Breakdown</p>
<div className="space-y-3">
{perStorm.map((item, idx) => {
const sty = SEVERITY_STYLES[item.storm.severity] ?? SEVERITY_STYLES.trace;
const revenuePercent = item.revenue > 0 ? Math.round((item.revenue / maxRevenue) * 100) : 0;
const stormDate = item.storm.date
? new Date(item.storm.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<div key={item.storm.id ?? idx} className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 overflow-hidden">
<div className="h-0.5 w-full" style={{ backgroundColor: sty.dot }} />
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-6 h-6 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center shrink-0">
<span className="text-[10px] font-black text-zinc-500">#{idx + 1}</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[14px] font-bold text-zinc-900 dark:text-white truncate">
{item.storm.areaName ?? 'Unknown Zone'}
</p>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md border shrink-0 ${sty.text} ${sty.bg} ${sty.border}`}>
{sty.label}
</span>
</div>
<p className="text-[11px] text-zinc-400 mt-0.5">{stormDate}{item.storm.maxHailSize ? ` · ${item.storm.maxHailSize}"` : ''}</p>
</div>
</div>
<div className="text-right shrink-0">
<p className="text-lg font-black text-zinc-900 dark:text-white">
{item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'}
</p>
<p className="text-[10px] text-zinc-400">revenue</p>
</div>
</div>
<div className="flex items-center gap-4 mb-3 flex-wrap">
{[
{ label: 'Leads', value: item.leadsCount, color: null },
{ label: 'Closed', value: item.closedCount, color: '#10B981' },
{ label: 'Appointed', value: item.appointedCount, color: '#F59E0B' },
{ label: 'Conv %', value: `${item.conversionRate}%`, color: null },
{ label: 'Pipeline', value: item.pipeline > 0 ? `$${(item.pipeline / 1000).toFixed(0)}k` : '—', color: '#F97316' },
].map(({ label, value, color }) => (
<div key={label} className="text-center">
<p
className="text-[13px] font-black text-zinc-900 dark:text-white"
style={color ? { color } : undefined}
>
{value}
</p>
<p className="text-[9px] font-bold uppercase tracking-wider text-zinc-400">{label}</p>
</div>
))}
</div>
{item.revenue > 0 && (
<div className="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: sty.dot }}
initial={{ width: 0 }}
animate={{ width: `${revenuePercent}%` }}
transition={{ duration: 0.7, ease: 'easeOut', delay: idx * 0.05 }}
/>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{perStorm.length > 0 && perStorm[0].revenue > 0 && (
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50 dark:bg-emerald-500/5 px-4 py-3 flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center shrink-0">
<TrendingUp size={16} className="text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-[12px] font-black uppercase tracking-wider text-emerald-700 dark:text-emerald-400 mb-0.5">Top Performing Zone</p>
<p className="text-[13px] text-zinc-700 dark:text-zinc-300 font-semibold">
{perStorm[0].storm.areaName} generated ${perStorm[0].revenue.toLocaleString()} from {perStorm[0].closedCount} closed deal{perStorm[0].closedCount !== 1 ? 's' : ''} ({perStorm[0].conversionRate}% conversion).
{perStorm[0].pipeline > 0 && ` $${(perStorm[0].pipeline / 1000).toFixed(0)}k more in pipeline.`}
</p>
</div>
</div>
)}
</div>
);
};
// ─── Filter chip ──────────────────────────────────────────────────────────────
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 { user } = useAuth();
const { leads: storeLeads = [], stormZones, addStormZone } = useMockStore();
const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role);
const [activeTab, setActiveTab] = useState('map');
const [dateRange, setDateRange] = useState('6m');
const [customStart, setCustomStart] = useState('');
const [customEnd, setCustomEnd] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [severityFilter, setSeverityFilter] = useState('all');
const [sortBy, setSortBy] = useState('date');
const [searchQuery, setSearchQuery] = useState('');
const [selectedStorm, setSelectedStorm] = useState(null);
const [pinnedIds, setPinnedIds] = useState(new Set());
const [drawerOpen, setDrawerOpen] = useState(false);
const [flyCount, setFlyCount] = useState(0);
const [showFilters, setShowFilters] = useState(false);
// Pin drop — up to 5 pins
const [pinDropMode, setPinDropMode] = useState(false);
const [pins, setPins] = useState([]); // array of [lat,lng]
const [pinRadius, setPinRadius] = useState(5); // miles
const MAX_PINS = 5;
// Zone drawing state
const [isDrawing, setIsDrawing] = useState(false);
const [draftPolygon, setDraftPolygon] = useState([]);
const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null);
const [showZoneModal, setShowZoneModal] = useState(false);
const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy, customStart, customEnd });
const { events: allEvents } = useStormEvents({ dateRange: '36m' });
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
// Text search filter
const displayEvents = useMemo(() => {
if (!searchQuery.trim()) return events;
const q = searchQuery.toLowerCase();
return events.filter(e =>
e.areaName?.toLowerCase().includes(q) ||
e.type?.toLowerCase().includes(q) ||
e.severity?.toLowerCase().includes(q) ||
e.description?.toLowerCase().includes(q)
);
}, [events, searchQuery]);
// Events within 5 miles of ANY dropped pin
const nearbyEvents = useMemo(() => {
if (!pins.length) return [];
const seen = new Set();
const results = [];
for (const event of displayEvents) {
if (!event.polygon?.length) continue;
const [cLat, cLon] = polygonCentroid(event.polygon);
const minDist = Math.min(...pins.map(p => haversine(p[0], p[1], cLat, cLon)));
if (minDist <= pinRadius && !seen.has(event.id)) {
seen.add(event.id);
results.push({ ...event, _distanceMiles: minDist });
}
}
return results.sort((a, b) => a._distanceMiles - b._distanceMiles);
}, [displayEvents, pins, pinRadius]);
// Sidebar shows nearby events when pins exist, otherwise full filtered list
const sidebarEvents = pins.length > 0 ? nearbyEvents : displayEvents;
// Map shows: when pins exist → all nearby; else → selected+pinned only (or none until pins placed)
const mapEvents = useMemo(() => {
if (pins.length > 0) return nearbyEvents;
if (!selectedStorm && pinnedIds.size === 0) return []; // nothing until pin placed
return displayEvents.filter(e => pinnedIds.has(e.id) || e.id === selectedStorm?.id);
}, [displayEvents, nearbyEvents, pins, selectedStorm, pinnedIds]);
const handleDropPin = useCallback((latlng) => {
setPins(prev => {
if (prev.length >= MAX_PINS) return prev; // cap at 5
return [...prev, latlng];
});
setPinDropMode(false);
}, []);
const handleRemovePin = useCallback((idx) => {
setPins(prev => prev.filter((_, i) => i !== idx));
}, []);
const handleClearPins = useCallback(() => {
setPins([]);
setPinDropMode(false);
}, []);
const handleTogglePin = useCallback((id) => {
setPinnedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const handleSelect = useCallback((storm) => {
setSelectedStorm(storm);
setDrawerOpen(true);
setFlyCount(c => c + 1);
}, []);
const handleFlyTo = useCallback(() => {
setFlyCount(c => c + 1);
setDrawerOpen(false);
}, []);
const handleClose = useCallback(() => {
setDrawerOpen(false);
}, []);
const handleStartDrawing = useCallback((linkedStormId = null) => {
setIsDrawing(true);
setDraftPolygon([]);
setDrawingLinkedStormId(linkedStormId);
setDrawerOpen(false);
}, []);
const handleAddPoint = useCallback((pt) => {
setDraftPolygon(prev => [...prev, pt]);
}, []);
const handleFinishPolygon = useCallback(() => {
if (draftPolygon.length < 3) {
toast.error('Need at least 3 points to create a zone');
return;
}
setIsDrawing(false);
setShowZoneModal(true);
}, [draftPolygon]);
const handleCancelDrawing = useCallback(() => {
setIsDrawing(false);
setDraftPolygon([]);
setDrawingLinkedStormId(null);
}, []);
const handleSaveZone = useCallback((zoneData) => {
addStormZone({ ...zoneData, polygon: draftPolygon });
setShowZoneModal(false);
setDraftPolygon([]);
setDrawingLinkedStormId(null);
toast.success(`Zone "${zoneData.name}" saved and assigned to ${zoneData.assignedAgentName}`);
}, [addStormZone, draftPolygon]);
// 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);
}, []);
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>
{stormZones.length > 0 && (
<>
<div className="w-px h-8 bg-zinc-200 dark:bg-white/10" />
<div className="text-right">
<p className="text-xl font-black text-emerald-500">{stormZones.filter(z => z.status === 'active').length}</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Active Zones</p>
</div>
</>
)}
</div>
<div className="flex items-center gap-2">
{/* Draw Zone button — Admin/Owner only */}
{canManageZones && activeTab === 'map' && !isDrawing && (
<button
onClick={() => handleStartDrawing(null)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 text-[12px] font-bold hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors"
>
<Pencil size={13} />
<span className="hidden sm:inline">Draw Zone</span>
</button>
)}
{/* Cancel drawing */}
{isDrawing && (
<button
onClick={handleCancelDrawing}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 text-[12px] font-bold hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors"
>
<XCircle size={13} />
Cancel
</button>
)}
{/* Tab switcher */}
<div className="flex items-center p-1 rounded-xl gap-0.5 bg-zinc-100 dark:bg-zinc-800">
<button
onClick={() => setActiveTab('map')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
activeTab === 'map'
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
<Map size={12} />
Map
</button>
<button
onClick={() => setActiveTab('attribution')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
activeTab === 'attribution'
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
<BarChart2 size={12} />
Attribution
</button>
</div>
{activeTab === 'map' && (
<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>
</div>
{/* ── Filter bar — map tab only ── */}
<AnimatePresence>
{activeTab === 'map' && (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">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Range</span>
{[['1m', '1 Mo'], ['3m', '3 Mo'], ['6m', '6 Mo'], ['12m', '12 Mo'], ['18m', '18 Mo'], ['24m', '24 Mo'], ['36m', '36 Mo']].map(([val, label]) => (
<FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip>
))}
<FilterChip active={dateRange === 'custom'} onClick={() => setDateRange('custom')}>
<span className="flex items-center gap-1"><CalendarRange size={11} />Custom</span>
</FilterChip>
</div>
{/* Custom date inputs */}
{dateRange === 'custom' && (
<div className="flex items-center gap-2 flex-wrap">
<input
type="date"
value={customStart}
onChange={e => setCustomStart(e.target.value)}
className="px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-white/[0.08] bg-white dark:bg-zinc-800 text-[12px] text-zinc-800 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-400 transition-all"
/>
<span className="text-[11px] text-zinc-400 font-medium">to</span>
<input
type="date"
value={customEnd}
onChange={e => setCustomEnd(e.target.value)}
className="px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-white/[0.08] bg-white dark:bg-zinc-800 text-[12px] text-zinc-800 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-400 transition-all"
/>
</div>
)}
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Type</span>
{[['all','All'],['hail','Hail'],['tornado','Tornado'],['flood','Flood'],['wind','Wind'],['snow','Snow'],['rain','Rain']].map(([val, label]) => (
<FilterChip key={val} active={typeFilter === val} onClick={() => setTypeFilter(val)}>{label}</FilterChip>
))}
</div>
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
<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" />
<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>
{/* ── Forecast strip — map tab only ── */}
{activeTab === 'map' && (
<ForecastStrip
forecast={forecast}
loading={forecastLoading}
alertDay={alertDay}
hasStormAlert={hasStormAlert}
/>
)}
{/* ── Attribution tab ── */}
{activeTab === 'attribution' && (
<AttributionView events={allEvents} sessionLeads={storeLeads} />
)}
{/* ── Map tab body ── */}
<div className={`flex-1 flex flex-col lg:flex-row min-h-0 ${activeTab !== 'map' ? 'hidden' : ''}`}>
{/* 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>
) : (
<StormMap
events={pins.length === 0 ? [] : mapEvents}
selectedId={selectedStorm?.id}
pinnedIds={pinnedIds}
selectedStorm={selectedStorm}
onSelect={handleSelect}
theme={theme}
flyCount={flyCount}
stormZones={stormZones}
isDrawing={isDrawing}
draftPolygon={draftPolygon}
onAddPoint={handleAddPoint}
onFinishPolygon={handleFinishPolygon}
pinDropMode={pinDropMode}
pins={pins}
onDropPin={handleDropPin}
pinRadius={pinRadius}
/>
)}
</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">
{/* Sidebar header */}
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 flex items-center justify-between gap-2">
<p className="text-[12px] font-black uppercase tracking-wider text-zinc-500 dark:text-zinc-400 min-w-0 truncate">
{pins.length > 0
? <>{nearbyEvents.length} within {pinRadius} mi{pinnedIds.size > 0 && <span className="ml-1 text-amber-500">· {pinnedIds.size} pinned</span>}</>
: <>{displayEvents.length} Event{displayEvents.length !== 1 ? 's' : ''}{pinnedIds.size > 0 && <span className="ml-1 text-amber-500">· {pinnedIds.size} pinned</span>}</>
}
</p>
<div className="flex items-center gap-1.5 shrink-0">
{/* Pin drop toggle */}
<button
onClick={() => setPinDropMode(m => !m)}
disabled={pins.length >= MAX_PINS}
title={pins.length >= MAX_PINS ? 'Max 5 pins' : pinDropMode ? 'Cancel pin drop' : `Drop a pin (${pinRadius} mi radius)`}
className={`p-1.5 rounded-lg border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
pinDropMode
? 'bg-violet-600 border-violet-700 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-400 hover:text-violet-500 hover:border-violet-300 dark:hover:border-violet-500/40'
}`}
>
<Crosshair size={14} />
</button>
{pins.length > 0 && (
<button
onClick={handleClearPins}
className="text-[11px] text-violet-500 hover:text-violet-600 flex items-center gap-0.5 font-bold transition-colors"
>
<X size={11} /> Pins
</button>
)}
{pinnedIds.size > 0 && (
<button onClick={() => setPinnedIds(new Set())} className="text-[11px] text-amber-500 hover:text-amber-600 flex items-center gap-0.5 font-bold transition-colors">
<MapPin size={11} /> Clear
</button>
)}
{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-0.5 transition-colors">
<X size={12} />
</button>
)}
</div>
</div>
{/* Search */}
<div className="px-3 pt-2 pb-1.5 border-b border-zinc-100 dark:border-white/[0.06] shrink-0">
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search events..."
className="w-full pl-7 pr-7 py-1.5 text-[12px] rounded-lg border border-zinc-200 dark:border-white/[0.07] bg-zinc-50 dark:bg-zinc-800/60 text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 outline-none focus:ring-2 focus:ring-amber-500/30 focus:border-amber-400 transition-all"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600">
<X size={12} />
</button>
)}
</div>
</div>
{/* Active pins strip */}
{pins.length > 0 && (
<div className="px-3 py-2 border-b border-violet-100 dark:border-violet-500/15 bg-violet-50/60 dark:bg-violet-500/5 shrink-0 space-y-1.5">
<div className="flex flex-wrap gap-1.5">
{pins.map((pin, i) => (
<div key={i} className="flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full bg-violet-100 dark:bg-violet-500/15 border border-violet-200 dark:border-violet-500/25">
<div className="w-2 h-2 rounded-full bg-violet-500 shrink-0" />
<span className="text-[10px] font-bold text-violet-700 dark:text-violet-300">Pin {i+1}</span>
<button onClick={() => handleRemovePin(i)} className="text-violet-400 hover:text-violet-600 ml-0.5">
<X size={10} />
</button>
</div>
))}
{pins.length < MAX_PINS && (
<button
onClick={() => setPinDropMode(true)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-violet-300 dark:border-violet-500/40 text-[10px] font-bold text-violet-500 hover:bg-violet-100 dark:hover:bg-violet-500/15 transition-colors"
>
+ Add pin
</button>
)}
</div>
{/* Radius control */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-bold text-violet-500 dark:text-violet-400 uppercase tracking-wider">Radius</span>
<input
type="number"
min="1"
max="100"
value={pinRadius}
onChange={e => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 1) setPinRadius(Math.min(v, 100));
}}
className="w-14 text-center text-[11px] font-bold rounded-md border border-violet-200 dark:border-violet-500/30 bg-white dark:bg-violet-500/10 text-violet-700 dark:text-violet-300 outline-none focus:ring-1 focus:ring-violet-400 px-1 py-0.5"
/>
<span className="text-[10px] text-violet-400">mi</span>
</div>
</div>
)}
{/* Zone summary strip */}
{stormZones.length > 0 && (
<div className="px-4 py-2 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 bg-zinc-50/50 dark:bg-white/[0.02]">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400">Canvasser Zones</p>
{canManageZones && (
<button
onClick={() => handleStartDrawing(null)}
className="text-[10px] font-bold text-amber-600 dark:text-amber-400 hover:underline flex items-center gap-0.5"
>
<Pencil size={10} /> New
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{stormZones.slice(0, 4).map(zone => (
<div key={zone.id} className="flex items-center gap-1.5 px-2 py-1 rounded-lg border border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-800/40">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: zone.color }} />
<span className="text-[10px] font-semibold text-zinc-600 dark:text-zinc-300 max-w-[90px] truncate">{zone.name}</span>
{zone.status === 'completed' && <CheckCircle size={9} className="text-emerald-500 shrink-0" />}
</div>
))}
{stormZones.length > 4 && (
<div className="px-2 py-1 rounded-lg bg-zinc-100 dark:bg-zinc-800/40">
<span className="text-[10px] font-semibold text-zinc-400">+{stormZones.length - 4}</span>
</div>
)}
</div>
</div>
)}
<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" />
))
) : pins.length === 0 ? (
/* No pins yet — prompt user to drop one */
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<div className="w-12 h-12 rounded-2xl bg-violet-50 dark:bg-violet-500/10 border border-violet-200 dark:border-violet-500/20 flex items-center justify-center mb-3">
<Crosshair size={22} className="text-violet-500" />
</div>
<p className="text-[13px] font-bold text-zinc-700 dark:text-zinc-200 mb-1">Drop a pin to explore</p>
<p className="text-[11px] text-zinc-400 mb-3 leading-relaxed">Click the crosshair button and place a pin on the map to find storm events within your chosen radius.</p>
<button
onClick={() => setPinDropMode(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 text-white text-[12px] font-bold hover:bg-violet-700 transition-colors"
>
<Crosshair size={12} /> Drop a Pin
</button>
</div>
) : sidebarEvents.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 events within {pinRadius} mi of your pins</p>
</div>
) : (
sidebarEvents.map(storm => (
<StormCard
key={storm.id}
storm={storm}
isSelected={selectedStorm?.id === storm.id}
isPinned={pinnedIds.has(storm.id)}
onSelect={handleSelect}
onTogglePin={handleTogglePin}
distanceMiles={storm._distanceMiles}
/>
))
)}
</div>
</div>
</div>
{/* Detail drawer */}
<StormDetailDrawer
storm={selectedStorm}
open={drawerOpen}
onClose={handleClose}
onFlyTo={handleFlyTo}
onAssignZone={handleStartDrawing}
canManageZones={canManageZones}
/>
{/* Zone assignment modal */}
<ZoneAssignmentModal
open={showZoneModal}
onClose={() => { setShowZoneModal(false); setDraftPolygon([]); setDrawingLinkedStormId(null); }}
onSave={handleSaveZone}
polygon={draftPolygon}
linkedStormId={drawingLinkedStormId}
events={allEvents}
/>
</div>
);
};
export default StormIntelPage;