diff --git a/api/hail-proxy.js b/api/hail-proxy.js new file mode 100644 index 0000000..dfeb2e0 --- /dev/null +++ b/api/hail-proxy.js @@ -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 }); + } +} diff --git a/api/hail-status.js b/api/hail-status.js new file mode 100644 index 0000000..4688055 --- /dev/null +++ b/api/hail-status.js @@ -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(), + }); + } +} diff --git a/api/hail-webhook.js b/api/hail-webhook.js new file mode 100644 index 0000000..c0182b2 --- /dev/null +++ b/api/hail-webhook.js @@ -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 }); +} diff --git a/src/components/dispatch/DispatchLeadQueue.jsx b/src/components/dispatch/DispatchLeadQueue.jsx index 10eee9e..c96d7b7 100644 --- a/src/components/dispatch/DispatchLeadQueue.jsx +++ b/src/components/dispatch/DispatchLeadQueue.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; 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 HailBadge from './HailBadge'; // --------------------------------------------------------------------------- // Config maps @@ -87,7 +88,7 @@ const DROP_POOL = [ // --------------------------------------------------------------------------- // 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 statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned; 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}

+ {/* Hail badge — Storm Mode only */} + {stormMode && hailSummary && ( +
+ +
+ )} + {/* Bottom row: status + time */}
{assignedRep ? ( @@ -275,7 +283,7 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, onViewDetails, onQuickAss // --------------------------------------------------------------------------- // DispatchLeadQueue // --------------------------------------------------------------------------- -const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickAssign, stormMode, accent }) => { +const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickAssign, stormMode, accent, hailSummaries = {}, filterHailOnly = false }) => { const { dispatchLeads } = useMockStore(); const [activeTab, setActiveTab] = useState('all'); 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 (
@@ -427,6 +440,7 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickA stormMode={stormMode} accent={accent} index={index} + hailSummary={hailSummaries[lead.id] ?? null} /> )) )} diff --git a/src/components/dispatch/HailBadge.jsx b/src/components/dispatch/HailBadge.jsx new file mode 100644 index 0000000..25d866c --- /dev/null +++ b/src/components/dispatch/HailBadge.jsx @@ -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 ( + + + No hail history + + ); + } + + 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 ( + + + Hit {hitCount}x + {recencyLabel && · {recencyLabel}} + + ); +} diff --git a/src/components/dispatch/StormIntelPanel.jsx b/src/components/dispatch/StormIntelPanel.jsx new file mode 100644 index 0000000..79b2574 --- /dev/null +++ b/src/components/dispatch/StormIntelPanel.jsx @@ -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 ( + + {size.toFixed(2)}" + + ); +} + +// ── 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 ( +
+ {/* Urgency dot */} +
+ + {/* Address */} +
+

{lead.customer?.name}

+

+ + {lead.property?.address} +

+
+ + {/* Hit stats */} +
+ {lastHit && ( + + + {days === 0 ? 'Today' : days === 1 ? 'Yesterday' : `${days}d ago`} + + )} + + + {hitCount}x + +
+
+ ); +} + +// ── 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 ( +
+ + {/* Header */} + + + + {open && ( + + {/* Live webhook event banner */} + {stormEvent && ( +
+ +
+

+ Hail Alert Received +

+

+ {stormEvent.address}{stormEvent.city ? `, ${stormEvent.city}` : ''} —{' '} + {stormEvent.hailSize ? `${stormEvent.hailSize}" hail` : 'Hail reported'} +

+
+
+ )} + + {/* Filter toggle */} +
+
+ + + Hail-Affected Leads + +
+ +
+ + {/* Lead list */} +
+ {hailedLeads.length === 0 ? ( +

+ No hail history found for current leads +

+ ) : ( + hailedLeads.map(lead => ( + + )) + )} +
+
+ )} +
+
+ ); +} diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 6c79776..d2d7ca2 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -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. // Each rep entry: repId, score, slotStart/End, factors (0-100 each), reasons[], stormReasons[], routePath[[lat,lng]] export const DISPATCH_RECOMMENDATIONS = { diff --git a/src/hooks/useHailRecon.js b/src/hooks/useHailRecon.js new file mode 100644 index 0000000..d5c6111 --- /dev/null +++ b/src/hooks/useHailRecon.js @@ -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 }; +} diff --git a/src/hooks/useStormStatus.js b/src/hooks/useStormStatus.js new file mode 100644 index 0000000..d0cef8c --- /dev/null +++ b/src/hooks/useStormStatus.js @@ -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 }; +} diff --git a/src/pages/CreateLeadPage.jsx b/src/pages/CreateLeadPage.jsx index 8237725..0834fd9 100644 --- a/src/pages/CreateLeadPage.jsx +++ b/src/pages/CreateLeadPage.jsx @@ -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', }); diff --git a/src/pages/LeadProjectPage.jsx b/src/pages/LeadProjectPage.jsx index 96d3ccd..7fb21d7 100644 --- a/src/pages/LeadProjectPage.jsx +++ b/src/pages/LeadProjectPage.jsx @@ -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 ( +
+ {/* Header */} +
+
+ +
+
+

Hail Impact History

+

Last 12 months · Powered by Hail Recon

+
+ {!loading && hitCount > 0 && ( +
+

{hitCount}x

+

+ {lastHit ? `Last: ${daysAgo(lastHit.date)}d ago` : ''} +

+
+ )} +
+ + {/* Body */} +
+ {loading ? ( +
+
+

Checking hail history...

+
+ ) : hitCount === 0 ? ( +
+ +

No hail impacts recorded in the last 12 months.

+
+ ) : ( +
+ {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 ( +
+ {/* Date */} +
+

{dateLabel}

+

{days === 0 ? 'Today' : `${days}d ago`}

+
+ {/* Size badge */} + + {event.hailSize?.toFixed(2)}" + + {/* Severity label */} + + {event.hailSize >= 2.0 ? 'Severe' : + event.hailSize >= 1.5 ? 'Significant' : + event.hailSize >= 1.0 ? 'Moderate' : 'Trace'} + + {i === 0 && ( + + Most Recent + + )} +
+ ); + })} +
+ )} +
+
+ ); +} + export default function LeadProjectPage() { const { leadId } = useParams(); const navigate = useNavigate(); @@ -468,11 +552,17 @@ export default function LeadProjectPage() { )}
+
)} {/* Overview for simple (no project data) */} {activeTab === 'overview' && !proj && ( +
@@ -528,6 +618,12 @@ export default function LeadProjectPage() {
+ +
)} {/* ── CONTACT (simple only) ── */} diff --git a/src/pages/LynkDispatchPage.jsx b/src/pages/LynkDispatchPage.jsx index bb85a80..95fbe14 100644 --- a/src/pages/LynkDispatchPage.jsx +++ b/src/pages/LynkDispatchPage.jsx @@ -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 => ( +
+ +
+

Hail Alert — Storm Mode Activated

+

+ {event?.address ?? 'A monitored property'} was just hit + {event?.hailSize ? ` with ${event.hailSize}" hail` : ''}. +

+
+ +
+ ), { 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 */} + + {stormMode && !hailLoading && ( + + + + )} + + {isDesktop ? ( { onQuickAssign={handleAssign} stormMode={stormMode} accent={accent} + hailSummaries={hailSummaries} + filterHailOnly={filterHailOnly} /> ) : ( @@ -503,6 +553,8 @@ const LynkDispatchPage = () => { onQuickAssign={handleAssign} stormMode={stormMode} accent={accent} + hailSummaries={hailSummaries} + filterHailOnly={filterHailOnly} /> )} diff --git a/vercel.json b/vercel.json index 0d199b2..591e056 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,6 @@ { "rewrites": [ + { "source": "/api/(.*)", "destination": "/api/$1" }, { "source": "/(.*)", "destination": "/index.html" } ] }