feat(hail-recon): Hail Recon integration — storm intel panel, hail history, auto storm mode, address monitoring

This commit is contained in:
Satyam-Rastogi
2026-05-18 15:50:59 +05:30
parent 912967fa3a
commit 9e2173fd43
13 changed files with 893 additions and 3 deletions
+22
View File
@@ -5,6 +5,7 @@ import { useTheme } from '../context/ThemeContext';
import { useAuth } from '../context/AuthContext';
import { useMockStore } from '../data/mockStore';
import { toast } from 'sonner';
import { useRegisterAddress } from '../hooks/useHailRecon';
import {
User, MapPin, Briefcase, Shield, Users,
ChevronLeft, Zap, FileText, Save, AlertCircle,
@@ -109,6 +110,7 @@ export default function CreateLeadPage() {
const { theme } = useTheme();
const { user } = useAuth();
const { addLead } = useMockStore();
const registerAddress = useRegisterAddress();
const isDark = theme === 'dark';
const navigate = useNavigate();
const [validationError, setValidationError] = useState('');
@@ -218,6 +220,26 @@ export default function CreateLeadPage() {
isQuickCapture,
});
// Silently register the address with Hail Recon for future monitoring
registerAddress({
id: savedLead?.id,
urgency: formData.urgency,
leadType: formData.jobType,
property: {
address: formData.address,
city: formData.city || 'Plano',
state: formData.state || 'TX',
zip: formData.zip || '',
lat: formData.lat || null,
lng: formData.lng || null,
},
customer: {
name: `${formData.firstName || ''} ${formData.lastName || ''}`.trim(),
phone: formData.phone || '',
email: formData.email || '',
},
});
toast.success(`Lead saved — ${formData.firstName} ${formData.lastName}`, {
description: formData.address || 'No address provided',
});
+97 -1
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { useAuth } from '../context/AuthContext';
import { useMockStore } from '../data/mockStore';
import { useHailHistory, hailSeverityColor, daysAgo } from '../hooks/useHailRecon';
import { SpotlightCard } from '../components/SpotlightCard';
import { AnimatedCounter } from '../components/AnimatedCounter';
import { toast } from 'sonner';
@@ -15,7 +16,7 @@ import {
MessageSquare, CheckCircle, ChevronDown, Activity, AlertTriangle,
TrendingUp, Calendar, DollarSign, GitPullRequest, AlertCircle,
Milestone, FolderOpen, Upload, Trash2, Eye, Pencil, X as XIcon,
File, StickyNote, Download, Image as ImageIcon
File, StickyNote, Download, Image as ImageIcon, CloudLightning
} from 'lucide-react';
// ── Neomorphic constants (mirrors OwnerProjectDetail) ─────────────────────────
@@ -209,6 +210,89 @@ const TABS_SIMPLE = [
];
// ── Main component ────────────────────────────────────────────────────────────
// ── Hail History Section ──────────────────────────────────────────────────────
function HailHistorySection({ leadId, lat, lng }) {
const { history, loading, hitCount, lastHit, maxSize } = useHailHistory(leadId, lat, lng, 12);
const summaryColor = maxSize >= 2.0 ? 'text-red-600 dark:text-red-400' :
maxSize >= 1.5 ? 'text-orange-600 dark:text-orange-400' :
maxSize >= 1.0 ? 'text-yellow-600 dark:text-yellow-400' :
'text-zinc-400';
return (
<div className="rounded-2xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-zinc-100 dark:border-white/[0.04]">
<div className="p-2 rounded-xl bg-amber-500/10 border border-amber-500/20">
<CloudLightning size={16} className="text-amber-500" />
</div>
<div className="flex-1">
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white">Hail Impact History</h3>
<p className="text-[10px] text-zinc-500 mt-0.5">Last 12 months · Powered by Hail Recon</p>
</div>
{!loading && hitCount > 0 && (
<div className="text-right">
<p className={`text-lg font-black font-mono ${summaryColor}`}>{hitCount}x</p>
<p className="text-[10px] text-zinc-400">
{lastHit ? `Last: ${daysAgo(lastHit.date)}d ago` : ''}
</p>
</div>
)}
</div>
{/* Body */}
<div className="px-5 py-4">
{loading ? (
<div className="flex items-center gap-2 py-4">
<div className="w-4 h-4 rounded-full border-2 border-amber-500 border-t-transparent animate-spin" />
<p className="text-xs text-zinc-400">Checking hail history...</p>
</div>
) : hitCount === 0 ? (
<div className="flex items-center gap-2 py-2">
<Shield size={14} className="text-emerald-500 shrink-0" />
<p className="text-sm text-zinc-500">No hail impacts recorded in the last 12 months.</p>
</div>
) : (
<div className="space-y-2">
{history.map((event, i) => {
const textCls = hailSeverityColor(event.hailSize, 'text');
const bgCls = hailSeverityColor(event.hailSize, 'bg');
const brdCls = hailSeverityColor(event.hailSize, 'border');
const days = daysAgo(event.date);
const dateObj = new Date(event.date);
const dateLabel = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return (
<div key={i} className="flex items-center gap-3 py-2.5 border-b border-zinc-100 dark:border-white/[0.04] last:border-0">
{/* Date */}
<div className="w-28 shrink-0">
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200">{dateLabel}</p>
<p className="text-[10px] text-zinc-400">{days === 0 ? 'Today' : `${days}d ago`}</p>
</div>
{/* Size badge */}
<span className={`inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-black border ${bgCls} ${brdCls} ${textCls}`}>
{event.hailSize?.toFixed(2)}"
</span>
{/* Severity label */}
<span className={`text-[11px] font-semibold ${textCls}`}>
{event.hailSize >= 2.0 ? 'Severe' :
event.hailSize >= 1.5 ? 'Significant' :
event.hailSize >= 1.0 ? 'Moderate' : 'Trace'}
</span>
{i === 0 && (
<span className="ml-auto text-[10px] font-bold px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20">
Most Recent
</span>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
export default function LeadProjectPage() {
const { leadId } = useParams();
const navigate = useNavigate();
@@ -468,11 +552,17 @@ export default function LeadProjectPage() {
)}
</NeoCard>
</div>
<HailHistorySection
leadId={leadId}
lat={lead.lat ?? 33.055}
lng={lead.lng ?? -96.752}
/>
</div>
)}
{/* Overview for simple (no project data) */}
{activeTab === 'overview' && !proj && (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<NeoCard spotlightColor="rgba(59,130,246,0.08)">
<div className="flex items-center gap-3 mb-5">
@@ -528,6 +618,12 @@ export default function LeadProjectPage() {
</div>
</NeoCard>
</div>
<HailHistorySection
leadId={leadId}
lat={lead.lat ?? 33.055}
lng={lead.lng ?? -96.752}
/>
</div>
)}
{/* ── CONTACT (simple only) ── */}
+52
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
@@ -8,6 +9,9 @@ import DispatchMapPanel from '../components/dispatch/DispatchMapPanel';
import LeadQuickViewModal from '../components/dispatch/LeadQuickViewModal';
import DispatchLogDrawer from '../components/dispatch/DispatchLogDrawer';
import DispatchChartModal from '../components/dispatch/DispatchChartModal';
import StormIntelPanel from '../components/dispatch/StormIntelPanel';
import { useHailSummaries } from '../hooks/useHailRecon';
import { useStormStatus } from '../hooks/useStormStatus';
// ---------------------------------------------------------------------------
// Panel — themed container
@@ -145,6 +149,28 @@ const LynkDispatchPage = () => {
const [stormMode, setStormMode] = useState(false);
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
const [chartModalOpen, setChartModalOpen] = useState(false);
const [filterHailOnly, setFilterHailOnly] = useState(false);
// Hail Recon — load summaries for all leads in queue
const { summaries: hailSummaries, loading: hailLoading } = useHailSummaries(dispatchLeads);
// Auto Storm Mode from webhook polling
const { stormEvent } = useStormStatus(useCallback((event) => {
setStormMode(true);
toast.custom(toastId => (
<div className="flex items-start gap-3 px-4 py-3 rounded-2xl bg-amber-50 border border-amber-300 shadow-lg shadow-amber-100 dark:bg-zinc-900 dark:border-amber-500/40 dark:shadow-amber-500/10">
<CloudLightning size={18} className="text-amber-500 animate-pulse mt-0.5 shrink-0" />
<div>
<p className="text-sm font-black text-amber-700 dark:text-amber-400 uppercase tracking-wide">Hail Alert Storm Mode Activated</p>
<p className="text-xs text-amber-600 dark:text-amber-500 mt-0.5">
{event?.address ?? 'A monitored property'} was just hit
{event?.hailSize ? ` with ${event.hailSize}" hail` : ''}.
</p>
</div>
<button onClick={() => toast.dismiss(toastId)} className="ml-auto text-amber-400 hover:text-amber-600 text-xs font-bold"></button>
</div>
), { duration: 8000 });
}, []));
// Rep status live cycling — overrides static DISPATCH_REPS.status
const [repStatuses, setRepStatuses] = useState(
@@ -485,6 +511,28 @@ const LynkDispatchPage = () => {
)
}
/>
{/* Storm Intel Panel — Storm Mode only */}
<AnimatePresence>
{stormMode && !hailLoading && (
<motion.div
key="storm-intel"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25 }}
className="shrink-0 px-3 pb-2 overflow-hidden"
>
<StormIntelPanel
leads={dispatchLeads}
summaries={hailSummaries}
stormEvent={stormEvent}
filterHailOnly={filterHailOnly}
onFilterChange={setFilterHailOnly}
/>
</motion.div>
)}
</AnimatePresence>
{isDesktop ? (
<DispatchLeadQueue
selectedLead={selectedLead}
@@ -493,6 +541,8 @@ const LynkDispatchPage = () => {
onQuickAssign={handleAssign}
stormMode={stormMode}
accent={accent}
hailSummaries={hailSummaries}
filterHailOnly={filterHailOnly}
/>
) : (
<CollapsePanel open={!collapsed.queue}>
@@ -503,6 +553,8 @@ const LynkDispatchPage = () => {
onQuickAssign={handleAssign}
stormMode={stormMode}
accent={accent}
hailSummaries={hailSummaries}
filterHailOnly={filterHailOnly}
/>
</CollapsePanel>
)}