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 && ( +{lead.customer?.name}
+
+
+ Hail Alert Received +
++ {stormEvent.address}{stormEvent.city ? `, ${stormEvent.city}` : ''} —{' '} + {stormEvent.hailSize ? `${stormEvent.hailSize}" hail` : 'Hail reported'} +
++ No hail history found for current leads +
+ ) : ( + hailedLeads.map(lead => ( +Last 12 months · Powered by Hail Recon
+{hitCount}x
++ {lastHit ? `Last: ${daysAgo(lastHit.date)}d ago` : ''} +
+Checking hail history...
+No hail impacts recorded in the last 12 months.
+{dateLabel}
+{days === 0 ? 'Today' : `${days}d ago`}
+Hail Alert — Storm Mode Activated
++ {event?.address ?? 'A monitored property'} was just hit + {event?.hailSize ? ` with ${event.hailSize}" hail` : ''}. +
+