locate adress on map and find hail risk

This commit is contained in:
Mayur Shinde
2026-06-09 16:15:21 +05:30
parent 716d096fc8
commit 110790d5ae
8 changed files with 522 additions and 21 deletions
@@ -0,0 +1,83 @@
/**
* HailRiskSnapshot — presentational hail-risk card (spec §6).
* Strictly historical framing + mandatory confidence/source/disclaimer.
* Never says "your home will get hit" — only area-level historical exposure.
*/
import React from 'react';
import { CloudHail, TrendingUp, ShieldQuestion, Clock, Info } from 'lucide-react';
const tierStyle = {
high: { 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', label: 'Higher historical exposure' },
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', label: 'Moderate historical exposure' },
low: { text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', label: 'Lower historical exposure' },
};
const Metric = ({ icon: Icon, label, value, sub }) => (
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-3.5">
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-zinc-400">
{Icon && <Icon size={13} />} {label}
</div>
<div className="text-lg font-bold text-zinc-900 dark:text-white mt-1">{value}</div>
{sub && <div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{sub}</div>}
</div>
);
const HailRiskSnapshot = ({ snapshot }) => {
if (!snapshot) return null;
const tier = tierStyle[snapshot.exposureTier] || tierStyle.low;
const p = snapshot.probability10yr;
return (
<div className="space-y-4">
{/* Exposure banner */}
<div className={`rounded-2xl border ${tier.border} ${tier.bg} p-4 flex items-start gap-3`}>
<CloudHail size={24} className={tier.text} />
<div>
<div className={`font-bold ${tier.text}`}>{tier.label}</div>
<p className="text-sm text-zinc-600 dark:text-zinc-300 mt-0.5">{snapshot.wording.probability}</p>
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Metric
icon={TrendingUp}
label="10-yr probability"
value={snapshot.eventCount === 0 ? '—' : `${p.low}${p.high}%`}
sub={`Base ${p.base}%`}
/>
<Metric
icon={CloudHail}
label="Annualized rate"
value={`${snapshot.annualRate}/yr`}
sub={`${snapshot.eventCount} events / ${snapshot.windowYears} yrs`}
/>
<Metric
icon={ShieldQuestion}
label="Confidence"
value={snapshot.confidence}
sub={`~${snapshot.radiusMiles} mi radius`}
/>
</div>
{/* Source + freshness */}
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 space-y-1.5 text-xs text-zinc-500 dark:text-zinc-400">
<div className="flex items-center gap-1.5">
<Info size={13} /> Source: {snapshot.source}
</div>
<div className="flex items-center gap-1.5">
<Clock size={13} /> {snapshot.wording.lastUpdated}
</div>
<div>{snapshot.wording.confidence}</div>
</div>
{/* Trust rule (spec §6): historical, not a forecast */}
<p className="text-xs text-zinc-400 leading-relaxed">
This is a historical hazard estimate for your <span className="font-medium">area</span> not a forecast or
guarantee for your specific home. Final assessment requires an on-site inspection.
</p>
</div>
);
};
export default HailRiskSnapshot;
@@ -0,0 +1,48 @@
/**
* PropertyMap — small Leaflet preview of the geocoded property + risk radius.
* Uses the same default-marker-icon fix the rest of the app uses (Maps.jsx).
*/
import React from 'react';
import { MapContainer, TileLayer, Marker, Circle } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
import icon from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
const DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
const PropertyMap = ({ lat, lon, radiusMiles = 20, height = 220 }) => {
if (lat == null || lon == null) return null;
const center = [lat, lon];
return (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-white/10" style={{ height }}>
<MapContainer
key={`${lat},${lon}`}
center={center}
zoom={11}
scrollWheelZoom={false}
dragging={false}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}
style={{ height: '100%', width: '100%' }}
>
<TileLayer url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png" />
<Circle
center={center}
radius={radiusMiles * 1609.34}
pathOptions={{ color: '#fda913', fillColor: '#fda913', fillOpacity: 0.08, weight: 1.5 }}
/>
<Marker position={center} />
</MapContainer>
</div>
);
};
export default PropertyMap;
@@ -15,6 +15,7 @@ import ChoiceCards from './ChoiceCards';
import StartStep from './steps/StartStep';
import AddressStep from './steps/AddressStep';
import PremiumStep from './steps/PremiumStep';
import HailRiskStep from './steps/HailRiskStep';
import PlaceholderStep from './steps/PlaceholderStep';
// Step registry — later phases swap PlaceholderStep entries for real components.
@@ -22,7 +23,7 @@ const STEP_COMPONENTS = {
start: StartStep,
address: AddressStep,
premium: PremiumStep,
hailRisk: (props) => <PlaceholderStep stepId="hailRisk" {...props} />,
hailRisk: HailRiskStep,
options: (props) => <PlaceholderStep stepId="options" {...props} />,
addons: (props) => <PlaceholderStep stepId="addons" {...props} />,
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
@@ -1,15 +1,30 @@
/**
* AddressStep — Screen 2 "Property validation" (spec §4).
* Phase 1: confirm/edit the address captured at Start.
* Phase 2 will add geocoding (lat/lon, county, timezone) + a Leaflet map preview
* and populate property_profiles via /api/properties/validate-address.
* Geocodes the address (module-local Nominatim geocoder) and shows a Leaflet map
* preview, populating property_profiles (spec §12). Hail history is pulled in the
* next step from the existing /api/storm-history endpoint.
*/
import React from 'react';
import { MapPin, Map as MapIcon } from 'lucide-react';
import React, { useState } from 'react';
import { MapPin, Loader2, CheckCircle2, Search } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import { geocodeAddress } from '../../api/geocode';
import PropertyMap from '../PropertyMap';
const AddressStep = () => {
const { inputs, setInput } = useEstimateWizard();
const { inputs, setInput, session, patchSession, logEvent } = useEstimateWizard();
const [locating, setLocating] = useState(false);
const property = session.property;
const handleLocate = async () => {
setLocating(true);
try {
const prop = await geocodeAddress(inputs.address);
patchSession({ property: prop, hailSnapshot: null }); // re-locating invalidates a stale snapshot
logEvent('address_geocoded', { confidence: prop.confidence, fallback: prop.fallback });
} finally {
setLocating(false);
}
};
return (
<div className="space-y-4">
@@ -17,22 +32,46 @@ const AddressStep = () => {
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
<MapPin size={15} className="text-amber-500" /> Confirm your property address
</span>
<input
className="w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="Street, city, state, ZIP"
value={inputs.address}
onChange={(e) => setInput('address', e.target.value)}
autoComplete="street-address"
/>
<div className="flex gap-2">
<input
className="flex-1 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="Street, city, state, ZIP"
value={inputs.address}
onChange={(e) => { setInput('address', e.target.value); if (property) patchSession({ property: null, hailSnapshot: null }); }}
autoComplete="street-address"
/>
<button
type="button"
onClick={handleLocate}
disabled={locating || !inputs.address?.trim()}
className="rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 px-4 py-2.5 text-sm font-semibold flex items-center gap-1.5 disabled:opacity-40"
>
{locating ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
Locate
</button>
</div>
</label>
{/* Map preview placeholder — Leaflet + geocode wired in Phase 2 */}
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-6 flex flex-col items-center justify-center text-center">
<MapIcon size={28} className="text-zinc-400 mb-2" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Well confirm the location on a map and pull your areas historical storm data in the next step.
</p>
</div>
{property ? (
<>
<PropertyMap lat={property.lat} lon={property.lon} radiusMiles={20} />
<div className="flex items-start gap-2 text-sm">
<CheckCircle2 size={16} className="text-emerald-500 mt-0.5 shrink-0" />
<span className="text-zinc-600 dark:text-zinc-300">
{property.fallback
? 'We placed an approximate pin for your area. Your rep will confirm the exact location.'
: <>Located: <span className="font-medium">{property.displayName}</span></>}
</span>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-6 text-center">
<MapPin size={26} className="text-zinc-400 mx-auto mb-2" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Tap <span className="font-medium">Locate</span> to confirm your home on the map and pull local storm history.
</p>
</div>
)}
<p className="text-xs text-zinc-400">
Final roof measurements are validated during your inspection nothing here is binding.
@@ -0,0 +1,83 @@
/**
* HailRiskStep — Screen 6 "Hail risk" (spec §6).
* Runs geocode → /api/storm-history → Poisson model, freezes the snapshot into
* the session (spec §12), and renders the historical-only risk card + map.
*/
import React, { useEffect } from 'react';
import { Loader2, AlertCircle } from 'lucide-react';
import { useEstimateWizard } from '../../EstimateWizardContext';
import { useWizardHailRisk } from '../../hooks/useWizardHailRisk';
import PropertyMap from '../PropertyMap';
import HailRiskSnapshot from '../HailRiskSnapshot';
const HailRiskStep = () => {
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
// Reuse a previously frozen snapshot if the user navigates back/forward.
const alreadyHave = !!session.hailSnapshot;
const { loading, error, property, snapshot } = useWizardHailRisk({
address: inputs.address,
property: session.property,
enabled: !alreadyHave,
stormSettings: tenantConfig.stormSettings,
});
// Freeze property + snapshot into the session once resolved.
useEffect(() => {
if (alreadyHave) return;
if (snapshot && property) {
patchSession({ property, hailSnapshot: snapshot });
logEvent('hail_snapshot_generated', {
exposureTier: snapshot.exposureTier,
p10Base: snapshot.probability10yr.base,
confidence: snapshot.confidence,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [snapshot, property, alreadyHave]);
const shownProperty = session.property || property;
const shownSnapshot = session.hailSnapshot || snapshot;
if (loading && !shownSnapshot) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 size={28} className="text-amber-500 animate-spin mb-3" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Checking historical storm reports for your area
</p>
</div>
);
}
if (error && !shownSnapshot) {
return (
<div className="rounded-2xl border border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/10 p-4 flex items-start gap-3">
<AlertCircle size={20} className="text-red-500 shrink-0" />
<div>
<p className="font-semibold text-red-700 dark:text-red-300">Storm history unavailable</p>
<p className="text-sm text-red-600 dark:text-red-400 mt-0.5">
We couldnt load storm data right now your rep will review local hail history during your visit.
You can still continue.
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{shownProperty && (
<PropertyMap
lat={shownProperty.lat}
lon={shownProperty.lon}
radiusMiles={shownSnapshot?.radiusMiles ?? 20}
/>
)}
<HailRiskSnapshot snapshot={shownSnapshot} />
</div>
);
};
export default HailRiskStep;