feat(hail-recon): Hail Recon integration — storm intel panel, hail history, auto storm mode, address monitoring
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user