023c76471b
Search: text filter across areaName/type/severity in sidebar Pin drop (up to 5 pins): - Crosshair button activates drop mode; next map click places a pin - 5-mile radius circle drawn on map for each pin - Sidebar shows only events within 5mi of any pin, sorted by distance - Distance label (e.g. "1.2 mi") shown on each card - Individual pin removal + clear all; + Add pin shortcut in pin strip - Map shows blank + drop-prompt until at least one pin is placed More event types: - Hook now fetches WS/BZ/WW/IS (winter storm, blizzard, ice) from IEM - SV without hailtag maps to wind; SV with hailtag maps to hail - New icons: CloudHail, CloudSnow, CloudRain per type - Type filter row added to filter bar: All/Hail/Tornado/Flood/Wind/Snow/Rain
1572 lines
89 KiB
React
1572 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 PIN_RADIUS_M = 8047; // 5 miles in metres
|
|
|
|
const StormMap = memo(({
|
|
events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
|
|
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
|
|
pinDropMode, pins, onDropPin,
|
|
}) => {
|
|
return (
|
|
<div className="relative w-full h-full storm-intel-map">
|
|
<style>{MAP_STYLES}</style>
|
|
<MapContainer
|
|
center={PLANO_CENTER}
|
|
zoom={DEFAULT_ZOOM}
|
|
scrollWheelZoom
|
|
zoomControl={false}
|
|
style={{ width: '100%', height: '100%' }}
|
|
>
|
|
<TileLayer
|
|
key={theme}
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
className={theme === 'dark' ? 'map-tiles filter invert grayscale contrast-100' : 'map-tiles'}
|
|
/>
|
|
|
|
<MapFlyTo storm={selectedStorm} flyCount={flyCount} />
|
|
|
|
{/* 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={PIN_RADIUS_M}
|
|
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} · 5 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('12m');
|
|
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 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 <= 5 && !seen.has(event.id)) {
|
|
seen.add(event.id);
|
|
results.push({ ...event, _distanceMiles: minDist });
|
|
}
|
|
}
|
|
return results.sort((a, b) => a._distanceMiles - b._distanceMiles);
|
|
}, [displayEvents, pins]);
|
|
|
|
// 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;
|
|
});
|
|
}, []);
|
|
|
|
// Map shows only selected + pinned events; if nothing active, show all
|
|
const mapEvents = useMemo(() => {
|
|
if (!selectedStorm && pinnedIds.size === 0) return events;
|
|
return events.filter(e => pinnedIds.has(e.id) || e.id === selectedStorm?.id);
|
|
}, [events, selectedStorm, pinnedIds]);
|
|
|
|
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>
|
|
) : pins.length === 0 ? (
|
|
/* Show base map with pin prompt overlay */
|
|
<StormMap
|
|
events={[]}
|
|
selectedId={null}
|
|
pinnedIds={pinnedIds}
|
|
selectedStorm={null}
|
|
onSelect={handleSelect}
|
|
theme={theme}
|
|
flyCount={flyCount}
|
|
stormZones={stormZones}
|
|
isDrawing={false}
|
|
draftPolygon={[]}
|
|
onAddPoint={handleAddPoint}
|
|
onFinishPolygon={handleFinishPolygon}
|
|
pinDropMode={pinDropMode}
|
|
pins={pins}
|
|
onDropPin={handleDropPin}
|
|
/>
|
|
) : mapEvents.length === 0 ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-100 dark:bg-zinc-900 gap-2">
|
|
<CloudLightning size={28} className="text-zinc-300" />
|
|
<p className="text-[13px] text-zinc-400 font-medium">No storms within 5 miles of your pins</p>
|
|
</div>
|
|
) : (
|
|
<StormMap
|
|
events={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}
|
|
/>
|
|
)}
|
|
</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} Nearby{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 (5 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 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>
|
|
)}
|
|
|
|
{/* 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 5 miles.</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 5 miles 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;
|