locate adress on map and find hail risk
This commit is contained in:
@@ -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">
|
||||
We’ll confirm the location on a map and pull your area’s 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 couldn’t 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;
|
||||
Reference in New Issue
Block a user