feat(hail-recon): Hail Recon integration — storm intel panel, hail history, auto storm mode, address monitoring
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Proxies Hail Recon API calls from the browser.
|
||||||
|
* Solves CORS and keeps credentials server-side.
|
||||||
|
*
|
||||||
|
* Usage: GET /api/hail-proxy?endpoint=ImpactDatesForLatLong&Lat=33.055&Long=-96.752&Months=12
|
||||||
|
* POST /api/hail-proxy?endpoint=AddressMonitoringImport2g (body = JSON payload)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = 'https://maps.interactivehailmaps.com/ExternalApi';
|
||||||
|
|
||||||
|
const ALLOWED_ENDPOINTS = new Set([
|
||||||
|
'ImpactDatesForLatLong',
|
||||||
|
'ImpactDatesForAddressMarker',
|
||||||
|
'AddressMonitoringImport2g',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.status(200).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { endpoint, ...params } = req.query;
|
||||||
|
|
||||||
|
if (!endpoint || !ALLOWED_ENDPOINTS.has(endpoint)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or missing endpoint' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessKey = process.env.HAIL_RECON_ACCESS_KEY;
|
||||||
|
const accessSecret = process.env.HAIL_RECON_ACCESS_SECRET;
|
||||||
|
|
||||||
|
if (!accessKey || !accessSecret) {
|
||||||
|
return res.status(500).json({ error: 'Hail Recon credentials not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = Buffer.from(`${accessKey}:${accessSecret}`).toString('base64');
|
||||||
|
const authHeader = `Basic ${credentials}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
response = await fetch(`${BASE_URL}/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req.body),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
const url = `${BASE_URL}/${endpoint}${qs ? `?${qs}` : ''}`;
|
||||||
|
response = await fetch(url, {
|
||||||
|
headers: { 'Authorization': authHeader },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
const data = contentType.includes('application/json')
|
||||||
|
? await response.json()
|
||||||
|
: await response.text();
|
||||||
|
|
||||||
|
return res.status(response.status).json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(502).json({ error: 'Failed to reach Hail Recon API', detail: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Returns current storm status — polled by the frontend every 60 seconds.
|
||||||
|
* Storm is considered "active" if an event was received within the last 24 hours.
|
||||||
|
*
|
||||||
|
* Response: { stormActive: boolean, event: StormEvent | null, checkedAt: number }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { kv } from '@vercel/kv';
|
||||||
|
|
||||||
|
const STORM_ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await kv.get('lastStormEvent');
|
||||||
|
|
||||||
|
const stormActive = !!event &&
|
||||||
|
typeof event.receivedAt === 'number' &&
|
||||||
|
(Date.now() - event.receivedAt) < STORM_ACTIVE_WINDOW_MS;
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
stormActive,
|
||||||
|
event: stormActive ? event : null,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// KV not configured (local dev without env vars) — return inactive
|
||||||
|
console.warn('[hail-status] KV unavailable:', err.message);
|
||||||
|
return res.status(200).json({
|
||||||
|
stormActive: false,
|
||||||
|
event: null,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Receives POST from Hail Recon when a monitored address is hailed.
|
||||||
|
* Stores the storm event in Vercel KV so /api/hail-status can serve it.
|
||||||
|
*
|
||||||
|
* Configure in Hail Recon dashboard:
|
||||||
|
* Webhook URL: https://yourapp.vercel.app/api/hail-webhook?secret=YOUR_WEBHOOK_SECRET
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { kv } from '@vercel/kv';
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = process.env.HAIL_RECON_WEBHOOK_SECRET;
|
||||||
|
if (secret && req.query.secret !== secret) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = req.body || {};
|
||||||
|
|
||||||
|
const stormEvent = {
|
||||||
|
address: payload.address || payload.street || 'Unknown address',
|
||||||
|
city: payload.city || '',
|
||||||
|
state: payload.state || 'TX',
|
||||||
|
zip: payload.zip || '',
|
||||||
|
hailSize: payload.hailSize || payload.hail_size || payload.size || null,
|
||||||
|
stormDate: payload.stormDate || payload.storm_date || payload.date || new Date().toISOString(),
|
||||||
|
markerId: payload.markerId || payload.AddressMarker_id || null,
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await kv.set('lastStormEvent', stormEvent);
|
||||||
|
|
||||||
|
console.log('[hail-webhook] Storm event stored:', stormEvent);
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true, received: stormEvent });
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Globe, MapPin, Users, Phone, Clock, CloudLightning, FileText, Search, X, Zap, CheckCircle } from 'lucide-react';
|
import { Globe, MapPin, Users, Phone, Clock, CloudLightning, FileText, Search, X, Zap, CheckCircle } from 'lucide-react';
|
||||||
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
||||||
|
import HailBadge from './HailBadge';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config maps
|
// Config maps
|
||||||
@@ -87,7 +88,7 @@ const DROP_POOL = [
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LeadCard
|
// LeadCard
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const LeadCard = ({ lead, isSelected, isNew, onSelect, onViewDetails, onQuickAssign, stormMode, accent, index }) => {
|
const LeadCard = ({ lead, isSelected, isNew, onSelect, onViewDetails, onQuickAssign, stormMode, accent, index, hailSummary }) => {
|
||||||
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
||||||
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
|
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
|
||||||
const srcCfg = SOURCE_CONFIG[lead.source] || SOURCE_CONFIG.call_center;
|
const srcCfg = SOURCE_CONFIG[lead.source] || SOURCE_CONFIG.call_center;
|
||||||
@@ -188,6 +189,13 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, onViewDetails, onQuickAss
|
|||||||
{lead.property.address}, {lead.property.city}
|
{lead.property.address}, {lead.property.city}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Hail badge — Storm Mode only */}
|
||||||
|
{stormMode && hailSummary && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<HailBadge summary={hailSummary} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom row: status + time */}
|
{/* Bottom row: status + time */}
|
||||||
<div className="flex items-center justify-between mt-1.5 gap-2">
|
<div className="flex items-center justify-between mt-1.5 gap-2">
|
||||||
{assignedRep ? (
|
{assignedRep ? (
|
||||||
@@ -275,7 +283,7 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, onViewDetails, onQuickAss
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DispatchLeadQueue
|
// DispatchLeadQueue
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickAssign, stormMode, accent }) => {
|
const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickAssign, stormMode, accent, hailSummaries = {}, filterHailOnly = false }) => {
|
||||||
const { dispatchLeads } = useMockStore();
|
const { dispatchLeads } = useMockStore();
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
const [droppedLeads, setDropped] = useState([]);
|
const [droppedLeads, setDropped] = useState([]);
|
||||||
@@ -346,6 +354,11 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickA
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hail filter — only show leads with at least one hail hit
|
||||||
|
if (stormMode && filterHailOnly) {
|
||||||
|
filteredLeads = filteredLeads.filter(l => (hailSummaries[l.id]?.hitCount ?? 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
||||||
@@ -427,6 +440,7 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickA
|
|||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
index={index}
|
index={index}
|
||||||
|
hailSummary={hailSummaries[lead.id] ?? null}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CloudLightning } from 'lucide-react';
|
||||||
|
import { hailSeverity, hailSeverityColor, daysAgo } from '../../hooks/useHailRecon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact hail hit badge for dispatch queue cards.
|
||||||
|
* Shows hit count + last-hit recency. Only rendered in Storm Mode.
|
||||||
|
*/
|
||||||
|
export default function HailBadge({ summary, className = '' }) {
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
const { hitCount, lastHit, maxSize } = summary;
|
||||||
|
|
||||||
|
if (hitCount === 0) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold border
|
||||||
|
bg-zinc-100 dark:bg-zinc-800 border-zinc-200 dark:border-white/10 text-zinc-400 ${className}`}>
|
||||||
|
<CloudLightning size={9} />
|
||||||
|
No hail history
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = lastHit ? daysAgo(lastHit.date) : null;
|
||||||
|
const textCls = hailSeverityColor(maxSize, 'text');
|
||||||
|
const bgCls = hailSeverityColor(maxSize, 'bg');
|
||||||
|
const brdCls = hailSeverityColor(maxSize, 'border');
|
||||||
|
|
||||||
|
const recencyLabel = days === null ? '' :
|
||||||
|
days === 0 ? 'Today' :
|
||||||
|
days === 1 ? 'Yesterday' :
|
||||||
|
days < 30 ? `${days}d ago` :
|
||||||
|
days < 365 ? `${Math.floor(days / 30)}mo ago` :
|
||||||
|
`${Math.floor(days / 365)}yr ago`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold border
|
||||||
|
${bgCls} ${brdCls} ${textCls} ${className}`}>
|
||||||
|
<CloudLightning size={9} />
|
||||||
|
Hit {hitCount}x
|
||||||
|
{recencyLabel && <span className="opacity-70">· {recencyLabel}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { CloudLightning, ChevronDown, AlertTriangle, MapPin, Calendar, Shield } from 'lucide-react';
|
||||||
|
import { hailSeverity, hailSeverityColor, daysAgo } from '../../hooks/useHailRecon';
|
||||||
|
|
||||||
|
// ── Severity badge ────────────────────────────────────────────────────────────
|
||||||
|
function SizeBadge({ size }) {
|
||||||
|
if (!size) return null;
|
||||||
|
const textCls = hailSeverityColor(size, 'text');
|
||||||
|
const bgCls = hailSeverityColor(size, 'bg');
|
||||||
|
const brdCls = hailSeverityColor(size, 'border');
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded-md text-[10px] font-black border ${bgCls} ${brdCls} ${textCls}`}>
|
||||||
|
{size.toFixed(2)}"
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single lead row ───────────────────────────────────────────────────────────
|
||||||
|
function IntelRow({ lead, summary }) {
|
||||||
|
const { hitCount, lastHit, maxSize } = summary;
|
||||||
|
const days = lastHit ? daysAgo(lastHit.date) : null;
|
||||||
|
const severity = hailSeverity(maxSize);
|
||||||
|
const isHot = severity === 'severe' || severity === 'significant';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-3 px-4 py-3 border-b border-amber-100 dark:border-amber-500/10 last:border-0
|
||||||
|
${isHot ? 'bg-amber-50/60 dark:bg-amber-500/5' : ''}`}>
|
||||||
|
{/* Urgency dot */}
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||||||
|
lead.urgency === 'emergency' ? 'bg-red-500' :
|
||||||
|
lead.urgency === 'high' ? 'bg-amber-500' : 'bg-zinc-400'
|
||||||
|
}`} />
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-bold text-zinc-900 dark:text-white truncate">{lead.customer?.name}</p>
|
||||||
|
<p className="text-[10px] text-zinc-500 truncate flex items-center gap-1">
|
||||||
|
<MapPin size={9} className="shrink-0" />
|
||||||
|
{lead.property?.address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hit stats */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{lastHit && (
|
||||||
|
<span className="text-[10px] text-zinc-500 flex items-center gap-0.5">
|
||||||
|
<Calendar size={9} />
|
||||||
|
{days === 0 ? 'Today' : days === 1 ? 'Yesterday' : `${days}d ago`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<SizeBadge size={maxSize} />
|
||||||
|
<span className="text-[10px] font-bold text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-500/10
|
||||||
|
border border-amber-200 dark:border-amber-500/20 rounded-full px-1.5 py-0.5">
|
||||||
|
{hitCount}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main panel ────────────────────────────────────────────────────────────────
|
||||||
|
export default function StormIntelPanel({ leads, summaries, stormEvent, onFilterChange, filterHailOnly }) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
const hailedLeads = useMemo(() => {
|
||||||
|
return leads
|
||||||
|
.filter(l => (summaries[l.id]?.hitCount ?? 0) > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aLast = summaries[a.id]?.lastHit?.date ?? '';
|
||||||
|
const bLast = summaries[b.id]?.lastHit?.date ?? '';
|
||||||
|
return bLast.localeCompare(aLast);
|
||||||
|
});
|
||||||
|
}, [leads, summaries]);
|
||||||
|
|
||||||
|
const recentCount = useMemo(() =>
|
||||||
|
hailedLeads.filter(l => {
|
||||||
|
const d = summaries[l.id]?.lastHit?.date;
|
||||||
|
return d && daysAgo(d) <= 60;
|
||||||
|
}).length,
|
||||||
|
[hailedLeads, summaries]);
|
||||||
|
|
||||||
|
const totalHailed = hailedLeads.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-amber-200 dark:border-amber-500/20 overflow-hidden
|
||||||
|
bg-amber-50/80 dark:bg-amber-500/5 shadow-sm">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-amber-100/50 dark:hover:bg-amber-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="p-1.5 rounded-lg bg-amber-500/20 border border-amber-400/30">
|
||||||
|
<CloudLightning size={14} className="text-amber-600 dark:text-amber-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-xs font-black uppercase tracking-widest text-amber-700 dark:text-amber-400">
|
||||||
|
Storm Intel
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-amber-600/70 dark:text-amber-500/70 mt-0.5">
|
||||||
|
{totalHailed} of {leads.length} leads in hail-affected zones
|
||||||
|
{recentCount > 0 && ` · ${recentCount} hit within 60 days`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Webhook event pill */}
|
||||||
|
{stormEvent && (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-red-500/10 border border-red-400/30 shrink-0">
|
||||||
|
<AlertTriangle size={10} className="text-red-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] font-bold text-red-600 dark:text-red-400 uppercase tracking-wider">
|
||||||
|
Live Alert
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className="text-amber-500 shrink-0 transition-transform duration-200"
|
||||||
|
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Live webhook event banner */}
|
||||||
|
{stormEvent && (
|
||||||
|
<div className="mx-3 mb-2 mt-1 px-3 py-2 rounded-xl bg-red-50 dark:bg-red-500/10
|
||||||
|
border border-red-200 dark:border-red-500/20 flex items-start gap-2">
|
||||||
|
<AlertTriangle size={13} className="text-red-500 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-wider text-red-600 dark:text-red-400">
|
||||||
|
Hail Alert Received
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-red-700 dark:text-red-300 mt-0.5">
|
||||||
|
{stormEvent.address}{stormEvent.city ? `, ${stormEvent.city}` : ''} —{' '}
|
||||||
|
{stormEvent.hailSize ? `${stormEvent.hailSize}" hail` : 'Hail reported'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter toggle */}
|
||||||
|
<div className="px-4 pb-2 pt-1 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Shield size={11} className="text-amber-500" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-500">
|
||||||
|
Hail-Affected Leads
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onFilterChange?.(!filterHailOnly)}
|
||||||
|
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wider
|
||||||
|
border transition-all ${filterHailOnly
|
||||||
|
? 'bg-amber-500 border-amber-500 text-white'
|
||||||
|
: 'bg-white dark:bg-black/20 border-amber-300 dark:border-amber-500/30 text-amber-600 dark:text-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filterHailOnly ? 'Showing hail-affected' : 'Filter queue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lead list */}
|
||||||
|
<div className="border-t border-amber-100 dark:border-amber-500/10 max-h-64 overflow-y-auto">
|
||||||
|
{hailedLeads.length === 0 ? (
|
||||||
|
<p className="text-center text-xs text-zinc-400 py-6">
|
||||||
|
No hail history found for current leads
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
hailedLeads.map(lead => (
|
||||||
|
<IntelRow
|
||||||
|
key={lead.id}
|
||||||
|
lead={lead}
|
||||||
|
summary={summaries[lead.id]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3025,6 +3025,59 @@ const DISPATCH_LEADS_INITIAL = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Hail Recon mock data ────────────────────────────────────────────────────
|
||||||
|
// Simulates ImpactDatesForLatLong responses, keyed by leadId.
|
||||||
|
// hailSize in inches. Each event = one hail storm hit on that property.
|
||||||
|
export const MOCK_HAIL_HISTORY = {
|
||||||
|
'LD-1001': [
|
||||||
|
{ date: '2026-04-28', hailSize: 2.00, stormId: 'TX-2026-0428' },
|
||||||
|
{ date: '2026-01-14', hailSize: 1.50, stormId: 'TX-2026-0114' },
|
||||||
|
{ date: '2025-09-03', hailSize: 1.00, stormId: 'TX-2025-0903' },
|
||||||
|
],
|
||||||
|
'LD-1002': [
|
||||||
|
{ date: '2026-04-28', hailSize: 1.75, stormId: 'TX-2026-0428' },
|
||||||
|
{ date: '2025-11-22', hailSize: 1.25, stormId: 'TX-2025-1122' },
|
||||||
|
],
|
||||||
|
'LD-1003': [
|
||||||
|
{ date: '2026-03-18', hailSize: 2.25, stormId: 'TX-2026-0318' },
|
||||||
|
{ date: '2025-07-09', hailSize: 1.50, stormId: 'TX-2025-0709' },
|
||||||
|
{ date: '2025-02-14', hailSize: 0.75, stormId: 'TX-2025-0214' },
|
||||||
|
],
|
||||||
|
'LD-1004': [
|
||||||
|
{ date: '2026-03-18', hailSize: 1.50, stormId: 'TX-2026-0318' },
|
||||||
|
],
|
||||||
|
'LD-1005': [
|
||||||
|
{ date: '2026-04-28', hailSize: 1.75, stormId: 'TX-2026-0428' },
|
||||||
|
{ date: '2026-01-14', hailSize: 1.25, stormId: 'TX-2026-0114' },
|
||||||
|
],
|
||||||
|
'LD-1006': [
|
||||||
|
{ date: '2026-04-28', hailSize: 2.00, stormId: 'TX-2026-0428' },
|
||||||
|
{ date: '2026-03-18', hailSize: 1.75, stormId: 'TX-2026-0318' },
|
||||||
|
{ date: '2025-10-05', hailSize: 1.25, stormId: 'TX-2025-1005' },
|
||||||
|
{ date: '2025-06-17', hailSize: 1.00, stormId: 'TX-2025-0617' },
|
||||||
|
],
|
||||||
|
'LD-1007': [
|
||||||
|
{ date: '2025-08-21', hailSize: 1.00, stormId: 'TX-2025-0821' },
|
||||||
|
],
|
||||||
|
'LD-1008': [],
|
||||||
|
'LD-1009': [
|
||||||
|
{ date: '2026-04-28', hailSize: 2.50, stormId: 'TX-2026-0428' },
|
||||||
|
{ date: '2026-01-14', hailSize: 1.75, stormId: 'TX-2026-0114' },
|
||||||
|
{ date: '2025-09-03', hailSize: 1.50, stormId: 'TX-2025-0903' },
|
||||||
|
{ date: '2025-04-11', hailSize: 1.25, stormId: 'TX-2025-0411' },
|
||||||
|
],
|
||||||
|
'LD-1010': [
|
||||||
|
{ date: '2026-04-28', hailSize: 1.50, stormId: 'TX-2026-0428' },
|
||||||
|
{ date: '2025-12-30', hailSize: 1.00, stormId: 'TX-2025-1230' },
|
||||||
|
],
|
||||||
|
'LD-1011': [
|
||||||
|
{ date: '2026-04-28', hailSize: 2.25, stormId: 'TX-2026-0428' },
|
||||||
|
{ date: '2026-03-18', hailSize: 2.00, stormId: 'TX-2026-0318' },
|
||||||
|
{ date: '2025-11-22', hailSize: 1.50, stormId: 'TX-2025-1122' },
|
||||||
|
],
|
||||||
|
'LD-1012': [],
|
||||||
|
};
|
||||||
|
|
||||||
// Pre-baked AI recommendations keyed by lead ID — no algorithm needed, just convincing data.
|
// Pre-baked AI recommendations keyed by lead ID — no algorithm needed, just convincing data.
|
||||||
// Each rep entry: repId, score, slotStart/End, factors (0-100 each), reasons[], stormReasons[], routePath[[lat,lng]]
|
// Each rep entry: repId, score, slotStart/End, factors (0-100 each), reasons[], stormReasons[], routePath[[lat,lng]]
|
||||||
export const DISPATCH_RECOMMENDATIONS = {
|
export const DISPATCH_RECOMMENDATIONS = {
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Hail Recon integration hook.
|
||||||
|
*
|
||||||
|
* USE_MOCK = true → returns mock data from mockStore (local dev, no API key needed)
|
||||||
|
* USE_MOCK = false → calls /api/hail-proxy which forwards to Hail Recon with Basic Auth
|
||||||
|
*
|
||||||
|
* Flip USE_MOCK to false once:
|
||||||
|
* 1. HAIL_RECON_ACCESS_KEY + HAIL_RECON_ACCESS_SECRET are set in Vercel env vars
|
||||||
|
* 2. Vercel KV is connected to the project
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { MOCK_HAIL_HISTORY } from '../data/mockStore';
|
||||||
|
|
||||||
|
const USE_MOCK = true;
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function hailSeverity(sizeInches) {
|
||||||
|
if (!sizeInches || sizeInches < 0.75) return 'none';
|
||||||
|
if (sizeInches < 1.00) return 'trace';
|
||||||
|
if (sizeInches < 1.50) return 'moderate';
|
||||||
|
if (sizeInches < 2.00) return 'significant';
|
||||||
|
return 'severe';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hailSeverityColor(sizeInches, mode = 'text') {
|
||||||
|
const s = hailSeverity(sizeInches);
|
||||||
|
const map = {
|
||||||
|
none: { text: 'text-zinc-400', bg: 'bg-zinc-100 dark:bg-zinc-800', border: 'border-zinc-200 dark:border-white/10' },
|
||||||
|
trace: { 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' },
|
||||||
|
moderate: { text: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20' },
|
||||||
|
significant: { 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' },
|
||||||
|
severe: { text: 'text-red-700 dark:text-red-300', bg: 'bg-red-100 dark:bg-red-500/20', border: 'border-red-300 dark:border-red-500/30' },
|
||||||
|
};
|
||||||
|
return map[s]?.[mode] ?? map.none[mode];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysAgo(dateStr) {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API calls (real path) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function proxyGet(endpoint, params = {}) {
|
||||||
|
const qs = new URLSearchParams({ endpoint, ...params }).toString();
|
||||||
|
const res = await fetch(`/api/hail-proxy?${qs}`);
|
||||||
|
if (!res.ok) throw new Error(`Hail proxy error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyPost(endpoint, body) {
|
||||||
|
const res = await fetch(`/api/hail-proxy?endpoint=${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Hail proxy error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main hook: hail history for a single lead ────────────────────────────────
|
||||||
|
|
||||||
|
export function useHailHistory(leadId, lat, lng, months = 12) {
|
||||||
|
const [history, setHistory] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!leadId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const fetch_ = async () => {
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
data = MOCK_HAIL_HISTORY[leadId] ?? [];
|
||||||
|
} else {
|
||||||
|
data = await proxyGet('ImpactDatesForLatLong', {
|
||||||
|
Lat: lat, Long: lng, Months: months,
|
||||||
|
});
|
||||||
|
if (!Array.isArray(data)) data = [];
|
||||||
|
}
|
||||||
|
if (!cancelled) setHistory(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e.message);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch_();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [leadId, lat, lng, months]);
|
||||||
|
|
||||||
|
const hitCount = history?.length ?? 0;
|
||||||
|
const lastHit = history?.[0] ?? null;
|
||||||
|
const maxSize = history?.reduce((m, e) => Math.max(m, e.hailSize ?? 0), 0) ?? 0;
|
||||||
|
|
||||||
|
return { history, loading, error, hitCount, lastHit, maxSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Register an address for monitoring ──────────────────────────────────────
|
||||||
|
|
||||||
|
export function useRegisterAddress() {
|
||||||
|
return useCallback(async (lead) => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
console.log('[HailRecon mock] registerAddress:', lead?.property?.address);
|
||||||
|
return { success: true, AddressMarker_id: Math.floor(Math.random() * 9000000) + 1000000 };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const p = lead.property ?? {};
|
||||||
|
const c = lead.customer ?? {};
|
||||||
|
const urgencyToSize = { emergency: 3, high: 2, standard: 1 };
|
||||||
|
return await proxyPost('AddressMonitoringImport2g', {
|
||||||
|
street: p.address ?? '',
|
||||||
|
city: p.city ?? 'Plano',
|
||||||
|
state: p.state ?? 'TX',
|
||||||
|
zip: p.zip ?? '',
|
||||||
|
customer_name: c.name ?? '',
|
||||||
|
customer_phone: c.phone ?? '',
|
||||||
|
customer_email: c.email ?? '',
|
||||||
|
comment1: '#planoRealtyCRM',
|
||||||
|
comment2: lead.leadType ?? '',
|
||||||
|
address_monitoring_size: urgencyToSize[lead.urgency] ?? 1,
|
||||||
|
status: 'Monitoring',
|
||||||
|
integration_partner: 2,
|
||||||
|
latitude: p.lat ?? null,
|
||||||
|
longitude: p.lng ?? null,
|
||||||
|
external_key: lead.id ?? null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[HailRecon] registerAddress failed (non-fatal):', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk hail summaries for a list of leads (dispatch queue) ─────────────────
|
||||||
|
|
||||||
|
export function useHailSummaries(leads) {
|
||||||
|
const [summaries, setSummaries] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!leads?.length) { setLoading(false); return; }
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const results = {};
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await new Promise(r => setTimeout(r, 600));
|
||||||
|
leads.forEach(lead => {
|
||||||
|
const events = MOCK_HAIL_HISTORY[lead.id] ?? [];
|
||||||
|
const lastHit = events[0] ?? null;
|
||||||
|
const maxSize = events.reduce((m, e) => Math.max(m, e.hailSize ?? 0), 0);
|
||||||
|
results[lead.id] = { events, hitCount: events.length, lastHit, maxSize };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await Promise.all(leads.map(async (lead) => {
|
||||||
|
try {
|
||||||
|
const p = lead.property ?? {};
|
||||||
|
const events = await proxyGet('ImpactDatesForLatLong', {
|
||||||
|
Lat: p.lat, Long: p.lng, Months: 12,
|
||||||
|
});
|
||||||
|
const arr = Array.isArray(events) ? events : [];
|
||||||
|
const lastHit = arr[0] ?? null;
|
||||||
|
const maxSize = arr.reduce((m, e) => Math.max(m, e.hailSize ?? 0), 0);
|
||||||
|
results[lead.id] = { events: arr, hitCount: arr.length, lastHit, maxSize };
|
||||||
|
} catch {
|
||||||
|
results[lead.id] = { events: [], hitCount: 0, lastHit: null, maxSize: 0 };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!cancelled) setSummaries(results);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAll();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [leads]);
|
||||||
|
|
||||||
|
return { summaries, loading };
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Polls /api/hail-status every 60 seconds.
|
||||||
|
* Auto-triggers stormMode when a real hail event is detected.
|
||||||
|
*
|
||||||
|
* In mock mode: simulates a storm event arriving after MOCK_TRIGGER_DELAY ms
|
||||||
|
* so the full auto-trigger flow can be tested locally without a real webhook.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { stormActive, stormEvent, lastChecked } = useStormStatus(setStormMode);
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
const MOCK_TRIGGER_DELAY = 0; // set > 0 (e.g. 30000) to simulate auto-trigger in dev
|
||||||
|
|
||||||
|
export function useStormStatus(onStormDetected) {
|
||||||
|
const [stormActive, setStormActive] = useState(false);
|
||||||
|
const [stormEvent, setStormEvent] = useState(null);
|
||||||
|
const [lastChecked, setLastChecked] = useState(null);
|
||||||
|
const prevActive = useRef(false);
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/hail-status');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
setStormActive(data.stormActive ?? false);
|
||||||
|
setStormEvent(data.event ?? null);
|
||||||
|
setLastChecked(Date.now());
|
||||||
|
|
||||||
|
if (data.stormActive && !prevActive.current) {
|
||||||
|
onStormDetected?.(data.event);
|
||||||
|
}
|
||||||
|
prevActive.current = data.stormActive ?? false;
|
||||||
|
} catch {
|
||||||
|
// Network error or KV unavailable — silently ignore, don't disrupt UI
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Mock trigger for local testing
|
||||||
|
if (MOCK_TRIGGER_DELAY > 0) {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
const mockEvent = {
|
||||||
|
address: '4817 Shady Brook Ln',
|
||||||
|
city: 'Plano',
|
||||||
|
state: 'TX',
|
||||||
|
zip: '75093',
|
||||||
|
hailSize: 1.75,
|
||||||
|
stormDate: new Date().toISOString(),
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
};
|
||||||
|
setStormActive(true);
|
||||||
|
setStormEvent(mockEvent);
|
||||||
|
setLastChecked(Date.now());
|
||||||
|
if (!prevActive.current) {
|
||||||
|
onStormDetected?.(mockEvent);
|
||||||
|
prevActive.current = true;
|
||||||
|
}
|
||||||
|
}, MOCK_TRIGGER_DELAY);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real polling
|
||||||
|
checkStatus();
|
||||||
|
const interval = setInterval(checkStatus, POLL_INTERVAL_MS);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { stormActive, stormEvent, lastChecked };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useMockStore } from '../data/mockStore';
|
import { useMockStore } from '../data/mockStore';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useRegisterAddress } from '../hooks/useHailRecon';
|
||||||
import {
|
import {
|
||||||
User, MapPin, Briefcase, Shield, Users,
|
User, MapPin, Briefcase, Shield, Users,
|
||||||
ChevronLeft, Zap, FileText, Save, AlertCircle,
|
ChevronLeft, Zap, FileText, Save, AlertCircle,
|
||||||
@@ -109,6 +110,7 @@ export default function CreateLeadPage() {
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { addLead } = useMockStore();
|
const { addLead } = useMockStore();
|
||||||
|
const registerAddress = useRegisterAddress();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [validationError, setValidationError] = useState('');
|
const [validationError, setValidationError] = useState('');
|
||||||
@@ -218,6 +220,26 @@ export default function CreateLeadPage() {
|
|||||||
isQuickCapture,
|
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}`, {
|
toast.success(`Lead saved — ${formData.firstName} ${formData.lastName}`, {
|
||||||
description: formData.address || 'No address provided',
|
description: formData.address || 'No address provided',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useMockStore } from '../data/mockStore';
|
import { useMockStore } from '../data/mockStore';
|
||||||
|
import { useHailHistory, hailSeverityColor, daysAgo } from '../hooks/useHailRecon';
|
||||||
import { SpotlightCard } from '../components/SpotlightCard';
|
import { SpotlightCard } from '../components/SpotlightCard';
|
||||||
import { AnimatedCounter } from '../components/AnimatedCounter';
|
import { AnimatedCounter } from '../components/AnimatedCounter';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
MessageSquare, CheckCircle, ChevronDown, Activity, AlertTriangle,
|
MessageSquare, CheckCircle, ChevronDown, Activity, AlertTriangle,
|
||||||
TrendingUp, Calendar, DollarSign, GitPullRequest, AlertCircle,
|
TrendingUp, Calendar, DollarSign, GitPullRequest, AlertCircle,
|
||||||
Milestone, FolderOpen, Upload, Trash2, Eye, Pencil, X as XIcon,
|
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';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// ── Neomorphic constants (mirrors OwnerProjectDetail) ─────────────────────────
|
// ── Neomorphic constants (mirrors OwnerProjectDetail) ─────────────────────────
|
||||||
@@ -209,6 +210,89 @@ const TABS_SIMPLE = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── 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() {
|
export default function LeadProjectPage() {
|
||||||
const { leadId } = useParams();
|
const { leadId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -468,11 +552,17 @@ export default function LeadProjectPage() {
|
|||||||
)}
|
)}
|
||||||
</NeoCard>
|
</NeoCard>
|
||||||
</div>
|
</div>
|
||||||
|
<HailHistorySection
|
||||||
|
leadId={leadId}
|
||||||
|
lat={lead.lat ?? 33.055}
|
||||||
|
lng={lead.lng ?? -96.752}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overview for simple (no project data) */}
|
{/* Overview for simple (no project data) */}
|
||||||
{activeTab === 'overview' && !proj && (
|
{activeTab === 'overview' && !proj && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<NeoCard spotlightColor="rgba(59,130,246,0.08)">
|
<NeoCard spotlightColor="rgba(59,130,246,0.08)">
|
||||||
<div className="flex items-center gap-3 mb-5">
|
<div className="flex items-center gap-3 mb-5">
|
||||||
@@ -528,6 +618,12 @@ export default function LeadProjectPage() {
|
|||||||
</div>
|
</div>
|
||||||
</NeoCard>
|
</NeoCard>
|
||||||
</div>
|
</div>
|
||||||
|
<HailHistorySection
|
||||||
|
leadId={leadId}
|
||||||
|
lat={lead.lat ?? 33.055}
|
||||||
|
lng={lead.lng ?? -96.752}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── CONTACT (simple only) ── */}
|
{/* ── CONTACT (simple only) ── */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react';
|
import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
||||||
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
||||||
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
|
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
|
||||||
@@ -8,6 +9,9 @@ import DispatchMapPanel from '../components/dispatch/DispatchMapPanel';
|
|||||||
import LeadQuickViewModal from '../components/dispatch/LeadQuickViewModal';
|
import LeadQuickViewModal from '../components/dispatch/LeadQuickViewModal';
|
||||||
import DispatchLogDrawer from '../components/dispatch/DispatchLogDrawer';
|
import DispatchLogDrawer from '../components/dispatch/DispatchLogDrawer';
|
||||||
import DispatchChartModal from '../components/dispatch/DispatchChartModal';
|
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
|
// Panel — themed container
|
||||||
@@ -145,6 +149,28 @@ const LynkDispatchPage = () => {
|
|||||||
const [stormMode, setStormMode] = useState(false);
|
const [stormMode, setStormMode] = useState(false);
|
||||||
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
|
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
|
||||||
const [chartModalOpen, setChartModalOpen] = 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
|
// Rep status live cycling — overrides static DISPATCH_REPS.status
|
||||||
const [repStatuses, setRepStatuses] = useState(
|
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 ? (
|
{isDesktop ? (
|
||||||
<DispatchLeadQueue
|
<DispatchLeadQueue
|
||||||
selectedLead={selectedLead}
|
selectedLead={selectedLead}
|
||||||
@@ -493,6 +541,8 @@ const LynkDispatchPage = () => {
|
|||||||
onQuickAssign={handleAssign}
|
onQuickAssign={handleAssign}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
|
hailSummaries={hailSummaries}
|
||||||
|
filterHailOnly={filterHailOnly}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CollapsePanel open={!collapsed.queue}>
|
<CollapsePanel open={!collapsed.queue}>
|
||||||
@@ -503,6 +553,8 @@ const LynkDispatchPage = () => {
|
|||||||
onQuickAssign={handleAssign}
|
onQuickAssign={handleAssign}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
|
hailSummaries={hailSummaries}
|
||||||
|
filterHailOnly={filterHailOnly}
|
||||||
/>
|
/>
|
||||||
</CollapsePanel>
|
</CollapsePanel>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
|
{ "source": "/api/(.*)", "destination": "/api/$1" },
|
||||||
{ "source": "/(.*)", "destination": "/index.html" }
|
{ "source": "/(.*)", "destination": "/index.html" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user