Files
LynkedUpPro_CRM/src/pages/StormIntelPage.jsx
T
Satyam-Rastogi bdb7a4e465 fix(storm-intel): show CloudHail icon for ice-pellet/hail forecast codes
Tomorrow.io 7xxx codes are ice pellets, not snow — remapped from Snowflake
to CloudHail. Added hailRisk override so any day flagged as hail risk by
the API always renders the hail icon regardless of weatherCode. Also added
missing 6xxx (freezing rain) codes to the icon table.

Mock Sunday entry corrected to weatherCode 8000 (thunderstorm) — 7102 at
76°F made no meteorological sense.
2026-05-19 02:40:21 +05:30

1976 lines
117 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useStormReports } from '../hooks/useStormReports';
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,
// 5xxx = actual snow
5000: Snowflake, 5001: Snowflake, 5100: Snowflake, 5101: Snowflake,
// 6xxx = freezing rain
6000: CloudDrizzle, 6001: CloudRain, 6200: CloudDrizzle, 6201: CloudRain,
// 7xxx = ice pellets / hail — NOT snow
7000: CloudHail, 7101: CloudHail, 7102: CloudHail,
};
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; }
`;
const LSR_COLORS = {
H: '#F97316', '4': '#F97316', // hail - orange
G: '#06B6D4', W: '#06B6D4', // wind - cyan
T: '#DC2626', // tornado - red
F: '#3B82F6', // flood - blue
D: '#8B5CF6', // damage - purple
};
// ─── 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 FitToPins = ({ pins, pinRadius }) => {
const map = useMap();
const prevLen = useRef(0);
useEffect(() => {
if (pins.length === 0) {
if (prevLen.current > 0) map.flyTo(PLANO_CENTER, DEFAULT_ZOOM, { animate: true, duration: 0.6 });
prevLen.current = 0;
return;
}
// Only zoom when a pin is added (length increased)
if (pins.length <= prevLen.current) { prevLen.current = pins.length; return; }
prevLen.current = pins.length;
try {
const radiusM = pinRadius * 1609.34;
const latOffset = radiusM / 111319;
let bounds = null;
pins.forEach(([lat, lng]) => {
const lngOffset = latOffset / Math.cos(lat * Math.PI / 180);
const pb = L.latLngBounds([lat - latOffset, lng - lngOffset], [lat + latOffset, lng + lngOffset]);
bounds = bounds ? bounds.extend(pb) : pb;
});
if (bounds) map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12, animate: true, duration: 0.6 });
} catch { /* ignore */ }
}, [pins, pinRadius, map]);
return null;
};
const SpotterReportsLayer = ({ reports }) => {
if (!reports?.length) return null;
return reports.map(r => {
const color = LSR_COLORS[r.type] ?? '#9CA3AF';
const unitLabel = (r.type === 'H' || r.type === '4') ? '"' : ' mph';
const tooltipRemark = r.remark ? r.remark.slice(0, 80) + (r.remark.length > 80 ? '…' : '') : '';
return (
<CircleMarker
key={r.id}
center={[r.lat, r.lon]}
radius={4}
pathOptions={{ color, fillColor: color, fillOpacity: 0.85, weight: 1.5 }}
>
<Tooltip direction="top" opacity={1} className="storm-tooltip" sticky={false}>
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 140, maxWidth: 200 }}>
<p style={{ fontSize: 11, fontWeight: 800, color, marginBottom: 2 }}>
{r.typetext}
{r.magnitude != null ? ` · ${r.magnitude}${unitLabel}` : ''}
</p>
{r.city && <p style={{ fontSize: 10, color: '#71717a', marginBottom: 1 }}>{r.city}</p>}
{tooltipRemark && <p style={{ fontSize: 10, color: '#a1a1aa', lineHeight: 1.4 }}>{tooltipRemark}</p>}
{r.source && <p style={{ fontSize: 9, color: '#d4d4d8', marginTop: 2 }}>{r.source}</p>}
</div>
</Tooltip>
</CircleMarker>
);
});
};
const StormMap = memo(({
events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
pinDropMode, pins, onDropPin, pinRadius = 5,
reports, showSpotterReports,
}) => {
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} />
<FitToPins pins={pins} pinRadius={pinRadius} />
{/* Storm polygons — only selected + pinned events */}
{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: 190, maxWidth: 230 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 10, fontWeight: 800, color: sty.dot, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{sty.label} · {storm.phenomenaLabel ?? storm.type}
</span>
{storm.maxHailSize && (
<span style={{ fontSize: 10, fontWeight: 700, color: '#6B7280', background: '#F4F4F5', padding: '1px 5px', borderRadius: 4 }}>
{storm.maxHailSize}"
</span>
)}
</div>
<p style={{ fontSize: 12, fontWeight: 700, color: '#18181b', marginBottom: 2 }}>
{storm.areaName}
</p>
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 6 }}>
{Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000)}d ago
{storm.windSpeed ? ` · ${storm.windSpeed} mph` : ''}
{storm.areaSqMi ? ` · ${storm.areaSqMi} sq mi` : ''}
</p>
<div style={{ borderTop: '1px solid #E4E4E7', paddingTop: 5 }}>
<p style={{ fontSize: 9, fontWeight: 700, color: '#9CA3AF', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 2 }}>
NWS Warning Boundary
</p>
<p style={{ fontSize: 9, color: '#A1A1AA', lineHeight: 1.4 }}>
Official zone drawn by NWS meteorologist — covers the projected storm track, not the exact cloud outline.
</p>
</div>
</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>
))}
{/* Spotter reports layer */}
{showSpotterReports && <SpotterReportsLayer reports={reports} />}
{/* 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 = day.hailRisk ? CloudHail : (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, eventReports = [] }) => {
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-5">
{/* Title */}
<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>
{/* Canvassing window */}
<div className={`flex items-start gap-2.5 px-3 py-2.5 rounded-xl border ${conv.bg} ${conv.border}`}>
<div className="mt-0.5 shrink-0">
{conv.label === 'Hot Zone' && <Flame size={15} className={conv.color} />}
{conv.label === 'Warm' && <TrendingUp size={15} className={conv.color} />}
{conv.label === 'Cold' && <Snowflake size={15} className={conv.color} />}
{conv.label === 'Too Fresh' && <Clock size={15} className={conv.color} />}
</div>
<div>
<p className={`text-[12px] font-bold ${conv.color}`}>{conv.label}</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-relaxed mt-0.5">
{conv.label === 'Hot Zone' && 'Peak canvassing window. Insurance claims actively being filed — homeowners are receptive. Lead with free inspection.'}
{conv.label === 'Warm' && 'Claims in process. Many have had adjuster visits. Lead with supplement expertise and contractor credentials.'}
{conv.label === 'Cold' && 'Most claims settled. Final window before statute expires. Lead with "late claim" and supplemental services.'}
{conv.label === 'Too Fresh' && 'Adjuster process hasn\'t started. Build rapport, collect contacts, follow up in 23 weeks when claims open.'}
</p>
</div>
</div>
{/* Warning Classification (Tier 1) */}
{(storm.damageTag || storm.tornadoTag || storm.isEmergency || storm.significance === 'A' || storm.significance === 'Y') && (
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-2">Warning Classification</p>
<div className="flex flex-wrap gap-1.5">
{storm.damageTag && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-red-700 dark:text-red-400">
{storm.damageTag}
</span>
)}
{storm.tornadoTag && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 text-purple-700 dark:text-purple-300">
{storm.tornadoTag}
</span>
)}
{storm.isEmergency && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-red-600 border border-red-700 text-white">
EMERGENCY
</span>
)}
{(storm.significance === 'A' || storm.significance === 'Y') && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300">
Advisory only
</span>
)}
</div>
</div>
)}
{/* Weather metrics */}
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-2">Weather Metrics</p>
<div className="grid grid-cols-2 gap-2">
<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">Hail Size</p>
{storm.maxHailSize ? (
<>
<p className="text-xl font-black" style={{ color: sty.dot }}>{storm.maxHailSize}"</p>
<p className="text-[10px] text-zinc-400">
{storm.maxHailSize >= 2.0 ? 'Golf ball — major roof damage' :
storm.maxHailSize >= 1.5 ? 'Ping pong — significant bruising' :
storm.maxHailSize >= 1.0 ? 'Quarter — moderate impact' :
'Dime — cosmetic impact'}
</p>
</>
) : (
<>
<p className="text-xl font-black text-zinc-400">—</p>
<p className="text-[10px] text-zinc-400">not reported</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">Wind Speed</p>
{storm.windSpeed ? (
<>
<p className="text-xl font-black text-zinc-900 dark:text-white">{storm.windSpeed}</p>
<p className="text-[10px] text-zinc-400">
mph — {storm.windSpeed >= 65 ? 'destructive' : storm.windSpeed >= 58 ? 'significant damage' : 'moderate damage'}
</p>
</>
) : (
<>
<p className="text-xl font-black text-zinc-400">—</p>
<p className="text-[10px] text-zinc-400">not reported</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">Severity</p>
<p className="text-xl font-black" style={{ color: sty.dot }}>{sty.label}</p>
<p className="text-[10px] text-zinc-400">{storm.phenomenaCode} warning</p>
</div>
</div>
</div>
{/* Impact zone */}
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-2">Impact Zone</p>
<div className="grid grid-cols-2 gap-2">
{storm.areaSqMi > 0 && (
<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">Warning Area</p>
<p className="text-xl font-black text-zinc-900 dark:text-white">{storm.areaSqMi.toLocaleString()}</p>
<p className="text-[10px] text-zinc-400">sq miles covered</p>
</div>
)}
{storm.estimatedHomes > 0 && (
<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 Affected</p>
<p className="text-xl font-black text-zinc-900 dark:text-white">{storm.estimatedHomes.toLocaleString()}</p>
<p className="text-[10px] text-zinc-400">
~{storm.damagePct}% damage probability
</p>
</div>
)}
</div>
</div>
{/* Warning timeline */}
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-2">Warning Timeline</p>
<div className="rounded-xl bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.06] divide-y divide-zinc-100 dark:divide-white/[0.05]">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Issued</span>
<span className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200">
{storm.date ? new Date(storm.date).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
</span>
</div>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Expired</span>
<span className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200">
{storm.expireTime ? new Date(storm.expireTime).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
</span>
</div>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Duration</span>
<span className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200">
{storm.warningDurationMins ? `${storm.warningDurationMins} min` : '—'}
</span>
</div>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Issuing Office</span>
<span className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200">NWS {storm.wfo ?? 'FWD'} — Fort Worth</span>
</div>
</div>
</div>
{/* NWS boundary note */}
<div className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-zinc-50 dark:bg-zinc-800/40 px-3 py-2.5">
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-1">NWS Warning Boundary</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-relaxed">
The polygon on the map is the official NWS warning zone — drawn by a meteorologist to cover the storm's projected track and affected area, not the exact cloud outline. Shape is typical for the storm's direction of travel.
</p>
</div>
{/* Ground Reports — Tier 2 */}
{eventReports.length > 0 && (() => {
const hailReports = eventReports.filter(r => r.type === 'H' || r.type === '4');
const windReports = eventReports.filter(r => r.type === 'G' || r.type === 'W');
const maxHail = hailReports.length ? Math.max(...hailReports.map(r => r.magnitude ?? 0)) : null;
const maxWind = windReports.length ? Math.max(...windReports.map(r => r.magnitude ?? 0)) : null;
const summaryParts = [];
if (maxHail && maxHail > 0) summaryParts.push(`max hail ${maxHail}"`);
if (maxWind && maxWind > 0) summaryParts.push(`max wind ${maxWind} mph`);
return (
<div>
<p className="text-[10px] font-black uppercase tracking-wider text-zinc-400 mb-2">Ground Reports (Spotter Verified)</p>
<div className="rounded-xl border border-zinc-200 dark:border-white/[0.07] bg-zinc-50 dark:bg-zinc-800/40 overflow-hidden">
<div className="px-3 py-2 border-b border-zinc-100 dark:border-white/[0.05]">
<p className="text-[11px] font-semibold text-zinc-600 dark:text-zinc-300">
{eventReports.length} report{eventReports.length !== 1 ? 's' : ''}
{summaryParts.length > 0 ? ` · ${summaryParts.join(' · ')}` : ''}
</p>
</div>
<div className="divide-y divide-zinc-100 dark:divide-white/[0.05]">
{eventReports.slice(0, 4).map(r => {
const color = LSR_COLORS[r.type] ?? '#9CA3AF';
const unit = (r.type === 'H' || r.type === '4') ? '"' : ' mph';
const remark = r.remark ? r.remark.slice(0, 60) + (r.remark.length > 60 ? '…' : '') : '';
return (
<div key={r.id} className="flex items-start gap-2 px-3 py-2">
<div className="w-2 h-2 rounded-full shrink-0 mt-1" style={{ backgroundColor: color }} />
<div className="min-w-0">
<p className="text-[11px] font-bold text-zinc-700 dark:text-zinc-200">
{r.typetext}
{r.magnitude != null ? ` ${r.magnitude}${unit}` : ''}
{r.city ? ` · ${r.city}` : ''}
</p>
{remark && <p className="text-[10px] text-zinc-400 leading-snug">{remark}</p>}
</div>
</div>
);
})}
</div>
</div>
</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>
);
// ─── Hail Recon response parser ──────────────────────────────────────────────
function parseHailReconData(data) {
if (data == null) return null;
let events = null;
if (Array.isArray(data)) {
events = data;
} else if (Array.isArray(data?.ImpactDates)) {
events = data.ImpactDates;
} else if (Array.isArray(data?.Events)) {
events = data.Events;
}
if (!events) return { count: null, maxSize: null, lastDate: null, unknown: true };
if (events.length === 0) return { count: 0, maxSize: null, lastDate: null };
const maxSize = events.length
? Math.max(...events.map(d => parseFloat(d.MaxSize ?? d.size ?? d.magnitude ?? 0) || 0))
: 0;
const dates = events.map(d => d.Date || d.date || d.ImpactDate || d.impact_date || null).filter(Boolean);
const lastDate = dates.length
? new Date(Math.max(...dates.map(s => new Date(s).getTime())))
: null;
return { count: events.length, maxSize: maxSize > 0 ? maxSize : null, lastDate };
}
// ─── 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); // applied radius (miles)
const [radiusInput, setRadiusInput] = useState('5'); // draft value in input
const [showAllOnMap, setShowAllOnMap] = useState(false);
const MAX_PINS = 5;
const applyRadius = useCallback(() => {
const v = parseInt(radiusInput, 10);
if (!isNaN(v) && v >= 1) setPinRadius(Math.min(v, 500));
}, [radiusInput]);
// Zone drawing state
const [isDrawing, setIsDrawing] = useState(false);
const [draftPolygon, setDraftPolygon] = useState([]);
const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null);
const [showZoneModal, setShowZoneModal] = useState(false);
const [showSpotterReports, setShowSpotterReports] = useState(false);
const [pinHailData, setPinHailData] = useState({}); // { idx: { loading, data, error } }
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();
const { reports } = useStormReports();
// Homes affected by pinned events (sum; note polygons may overlap)
const pinnedHomes = useMemo(() => {
if (pinnedIds.size === 0) return null;
return [...pinnedIds]
.map(id => events.find(e => e.id === id))
.filter(Boolean)
.reduce((sum, e) => sum + (e.estimatedHomes ?? 0), 0);
}, [pinnedIds, events]);
// 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: if showAllOnMap → all sidebar events; else → only selected/event-pinned.
// Location pins filter the sidebar list but don't auto-render all nearby polygons —
// NWS warning polygons can be 60-80 mi across and would visually flood the map.
const mapPool = pins.length > 0 ? nearbyEvents : displayEvents;
const mapEvents = useMemo(() => {
if (showAllOnMap) return mapPool;
if (!selectedStorm && pinnedIds.size === 0) return [];
return mapPool.filter(e => pinnedIds.has(e.id) || e.id === selectedStorm?.id);
}, [mapPool, showAllOnMap, selectedStorm, pinnedIds]);
// LSR reports near the selected storm (±6 hours + 50 miles of centroid)
const eventReports = useMemo(() => {
if (!selectedStorm || !reports.length) return [];
const centroid = selectedStorm.polygon?.length
? polygonCentroid(selectedStorm.polygon)
: null;
if (!centroid) return [];
const stormTime = new Date(selectedStorm.date).getTime();
const SIX_HOURS = 6 * 3600 * 1000;
return reports.filter(r => {
if (!r.valid) return false;
const rTime = new Date(r.valid).getTime();
if (Math.abs(rTime - stormTime) > SIX_HOURS) return false;
const dist = haversine(centroid[0], centroid[1], r.lat, r.lon);
return dist <= 50;
});
}, [reports, selectedStorm]);
// Tier 3: fetch Hail Recon data for each dropped pin
useEffect(() => {
if (!pins.length) { setPinHailData({}); return; }
pins.forEach((pin, idx) => {
if (pinHailData[idx] !== undefined) return;
const [lat, lng] = pin;
setPinHailData(prev => ({ ...prev, [idx]: { loading: true, data: null, error: null } }));
fetch(`/api/hail-proxy?endpoint=ImpactDatesForLatLong&Lat=${lat}&Long=${lng}&Months=60`)
.then(async r => {
const ct = r.headers.get('content-type') || '';
if (!ct.includes('json')) throw new Error('proxy_unavailable');
const body = await r.json();
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
return body;
})
.then(data => setPinHailData(prev => ({ ...prev, [idx]: { loading: false, data, error: null } })))
.catch(err => setPinHailData(prev => ({ ...prev, [idx]: { loading: false, data: null, error: err.message } })));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pins]);
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" title={pinnedHomes !== null ? 'Sum of est. damaged homes across pinned events — zones may overlap' : 'Pin events to see estimated homes affected'}>
<p className={`text-xl font-black ${pinnedHomes !== null ? 'text-amber-500' : 'text-zinc-400'}`}>
{pinnedHomes !== null
? (pinnedHomes >= 1000 ? `${(pinnedHomes / 1000).toFixed(1)}k` : pinnedHomes.toLocaleString())
: '—'
}
</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">
{pinnedHomes !== null ? `Est. Homes · ${pinnedIds.size} pinned` : 'Est. 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}
reports={reports}
showSpotterReports={showSpotterReports}
/>
)}
</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">
{/* Spotter reports toggle */}
<button
onClick={() => setShowSpotterReports(v => !v)}
title={showSpotterReports ? 'Hide spotter reports' : 'Show spotter reports'}
className={`p-1.5 rounded-lg border transition-colors ${
showSpotterReports
? 'bg-teal-600 border-teal-700 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-400 hover:text-teal-500 hover:border-teal-300 dark:hover:border-teal-500/40'
}`}
>
<Navigation size={14} />
</button>
{/* 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 + Show all toggle */}
<div className="px-3 pt-2 pb-1.5 border-b border-zinc-100 dark:border-white/[0.06] shrink-0 space-y-1.5">
<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>
{/* Show all on map toggle */}
<button
onClick={() => setShowAllOnMap(v => !v)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-bold transition-colors w-full justify-center border ${
showAllOnMap
? 'bg-amber-500 border-amber-600 text-white'
: 'border-zinc-200 dark:border-white/[0.07] text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5'
}`}
>
<Map size={11} />
{showAllOnMap ? 'Showing all on map' : 'Show all on map'}
</button>
</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="500"
value={radiusInput}
onChange={e => setRadiusInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && applyRadius()}
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>
<button
onClick={applyRadius}
className="px-2 py-0.5 rounded-md bg-violet-600 hover:bg-violet-700 text-white text-[10px] font-bold transition-colors"
>
Go
</button>
{radiusInput !== String(pinRadius) && (
<span className="text-[9px] text-violet-400 italic">press Go</span>
)}
</div>
{/* Per-pin hail history — Tier 3 */}
{pins.length > 0 && (
<div className="pt-0.5 space-y-0.5">
<p className="text-[9px] font-black uppercase tracking-wider text-violet-400 mb-0.5">Hail History (5yr)</p>
{pins.map((_, idx) => {
const entry = pinHailData[idx];
let line;
if (!entry || entry.loading) {
line = (
<span className="flex items-center gap-1 text-[10px] text-violet-400">
<Loader2 size={10} className="animate-spin" />
Loading...
</span>
);
} else if (entry.error) {
line = entry.error === 'proxy_unavailable'
? <span className="text-[10px] text-violet-300/70 italic">Not available in dev mode</span>
: <span className="text-[10px] text-red-400">{entry.error}</span>;
} else {
const parsed = parseHailReconData(entry.data);
if (!parsed) {
line = <span className="text-[10px] text-violet-300">No data</span>;
} else if (parsed.unknown) {
line = <span className="text-[10px] text-violet-300">Unknown response format</span>;
} else if (parsed.count === 0) {
line = <span className="text-[10px] text-violet-300">No hail events found</span>;
} else {
const lastStr = parsed.lastDate
? parsed.lastDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
line = (
<span className="text-[10px] text-violet-200 font-semibold">
{parsed.count} event{parsed.count !== 1 ? 's' : ''}
{parsed.maxSize ? ` · max ${parsed.maxSize}"` : ''}
{lastStr ? ` · last ${lastStr}` : ''}
</span>
);
}
}
return (
<div key={idx} className="flex items-center gap-1.5">
<span className="text-[10px] font-bold text-violet-400 shrink-0">Pin {idx + 1}:</span>
{line}
</div>
);
})}
</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}
eventReports={eventReports}
/>
{/* Zone assignment modal */}
<ZoneAssignmentModal
open={showZoneModal}
onClose={() => { setShowZoneModal(false); setDraftPolygon([]); setDrawingLinkedStormId(null); }}
onSave={handleSaveZone}
polygon={draftPolygon}
linkedStormId={drawingLinkedStormId}
events={allEvents}
/>
</div>
);
};
export default StormIntelPage;