InsuranceBot / frontend /src /components /PolicyPremiumWidget.tsx
rohitsar567's picture
#33: add "?" help affordance to every policy-calculation control
deefe3a
Raw
History Blame Contribute Delete
26.5 kB
"use client";
/**
* PolicyPremiumWidget β€” per-policy slider-driven premium calculator.
*
* Embedded inside PolicyCompareModal (B1). Fetches an initial estimate from
* /api/premium/estimate using the user's profile defaults, then re-fetches
* (debounced 300ms) whenever the user moves the SI / tenure / deductible
* sliders. When the backend reports `base_sample_used: false` (no curated
* actuarial sample for this policy) the widget shows an "Estimate" badge so
* the user understands the number is heuristic, not a quote.
*
* KI-bugfix (2026-05-15): switched from /api/premium/bulk β†’ /api/premium/estimate
* so per-policy pricing shares the curated-anchored math used by the standalone
* PremiumCalculatorPanel. The bulk endpoint's flat β‚Ή500/lakh fallback was
* producing wildly low numbers (~β‚Ή6K) versus the panel's curated number
* (~β‚Ή18-25K) for the same profile. Tenure + deductible are still honoured β€”
* the estimate endpoint now applies the bulk multipliers post-anchor.
*
* KI-278 (2026-05-16) — header≠panel reconciliation. The widget now seeds
* its SI slider via resolveProfileSumInsured(profile) β€” the byte-identical
* client mirror of backend/premium_calculator.py::resolve_profile_sum_insured,
* the SAME precedence the header "Premium range" chip
* (estimate_premium_band) prices its basket at: desired_sum_insured_inr β†’
* existing_cover_inr β†’ β‚Ή10L default. Because the chip band aggregates this
* exact policy (one basket member) at the SAME profile-resolved SI, this
* widget's number is guaranteed to fall inside the header band the user saw
* β€” they can no longer contradict each other. `smoker` is forwarded to
* /api/premium/estimate (`profile?.smoker === true`) and applied by the
* backend estimate() loading chain; family-history is applied on the header
* band path (it consumes the full SLOT_UNION profile).
*
* ── Visual system ─────────────────────────────────────────────────────
* Re-grounded on the premium editorial-fintech landing (app/globals.css):
* Fraunces display serif for the headline premium numeral, Plus Jakarta for
* UI chrome, the teal --primary token for the active pills + slider track,
* color-mix soft depth, and tight one-line fact bullets. All chrome reads
* from CSS variables so the widget tracks the page's light/dark scheme.
* Reduced-motion is honoured via a local media query.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
postPremiumEstimate,
type PremiumBulkProfile,
type PreExistingCondition,
type PremiumEstimateResponse,
} from "@/lib/api";
import HelpTip from "@/components/HelpTip";
export type PolicyPremiumWidgetProps = {
policyId: string;
policyName: string;
// Kept the bulk-profile shape because PolicyCompareModal still hands us this
// exact object β€” it doubles as the predicted-premium-band profile. We map
// its fields onto the estimate-endpoint contract internally.
profile?: PremiumBulkProfile;
// When omitted, the widget resolves its starting SI from the profile with
// the SAME precedence the header-chip backend uses
// (resolveProfileSumInsured ↔ premium_calculator.resolve_profile_sum_insured)
// so the per-policy number reconciles with the header band. Pass an explicit
// value only to force a specific starting SI (overrides profile resolution).
initialSumInsured?: number;
initialTenureYears?: 1 | 2 | 3;
initialDeductibleInr?: 0 | 25000 | 50000 | 100000;
onCalculated?: (premium: number) => void;
};
const SUM_INSURED_MIN = 500_000;
const SUM_INSURED_MAX = 10_000_000;
const SUM_INSURED_STEP = 500_000;
const TENURE_CHOICES = [1, 2, 3] as const;
const DEDUCTIBLE_CHOICES = [0, 25_000, 50_000, 100_000] as const;
/**
* Client mirror of backend/premium_calculator.py::resolve_profile_sum_insured.
*
* KI-278 (2026-05-16) — header≠panel reconciliation. The header "Premium
* range" chip is produced by estimate_premium_band(), which now prices the
* basket at the profile-resolved SI (desired_sum_insured_inr β†’
* existing_cover_inr β†’ β‚Ή10L default). The per-policy widget MUST seed its SI
* slider with the SAME precedence, or it re-introduces the original bug
* (chip priced at one SI, widget at another β†’ contradictory numbers for one
* profile). Precedence + clamp + β‚Ή50k snap are byte-identical to the Python
* resolver so the two surfaces agree by construction. The user can still
* drag the slider to explore other SIs afterward.
*/
function resolveProfileSumInsured(
profile: PremiumBulkProfile | undefined,
fallbackDefault = 1_000_000,
): number {
const coerce = (v: unknown): number | null => {
if (v === null || v === undefined || v === "") return null;
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return null;
return Math.trunc(n);
};
let si =
coerce(profile?.desired_sum_insured_inr) ??
coerce(profile?.existing_cover_inr) ??
fallbackDefault;
// Clamp to the slider domain, then snap to the nearest β‚Ή50k slider stop.
si = Math.max(SUM_INSURED_MIN, Math.min(SUM_INSURED_MAX, si));
return Math.round(si / 50_000) * 50_000;
}
// Display serif + sans UI face, pulled from the landing's CSS vars so the
// widget shares the exact type system as the rest of the app.
const SERIF = "var(--font-serif)";
const SANS = "var(--font-sans)";
function formatInr(value: number): string {
// Indian-style 1,23,456 grouping.
return value.toLocaleString("en-IN");
}
function formatSiLabel(inr: number): string {
if (inr >= 10_000_000) return `${(inr / 10_000_000).toFixed(inr % 10_000_000 === 0 ? 0 : 1)}Cr`;
return `${Math.round(inr / 100_000)}L`;
}
function formatDeductibleLabel(inr: number): string {
if (inr === 0) return "β‚Ή0";
if (inr >= 100_000) return `β‚Ή${(inr / 100_000).toFixed(0)}L`;
return `β‚Ή${Math.round(inr / 1_000)}K`;
}
function summariseProfile(profile?: PremiumBulkProfile): string | null {
if (!profile) return null;
const parts: string[] = [];
if (typeof profile.age === "number") parts.push(`age ${profile.age}`);
if (typeof profile.family_size === "number" && profile.family_size > 1) {
parts.push(`family of ${profile.family_size}`);
} else if (profile.dependents) {
parts.push(profile.dependents);
}
if (profile.location_tier) parts.push(String(profile.location_tier).toLowerCase());
return parts.length ? parts.join(", ") : null;
}
/**
* Map the free-text `location_tier` we ship in PremiumBulkProfile onto the
* strict {metro|tier1|tier2} enum the /api/premium/estimate endpoint expects.
* Same normalization rule used inside premium_calculator.bulk_estimate.
*/
function normaliseCityTier(tier: string | null | undefined): "metro" | "tier1" | "tier2" {
if (!tier) return "metro";
const t = String(tier).toLowerCase().replace(/[-_\s]/g, "");
if (t === "metro") return "metro";
if (t.includes("1")) return "tier1";
if (t.includes("2")) return "tier2";
// tier3 / unknown β€” fall back to tier2 (closest cheaper bucket the estimate
// endpoint accepts; matches the standalone panel's default).
return "tier2";
}
/**
* Coerce the typed `pre_existing_conditions` profile slot onto the
* estimate-endpoint's PreExistingCondition union, defaulting to "none".
*/
function normalisePed(ped: string | null | undefined): PreExistingCondition {
if (!ped) return "none";
const allowed: PreExistingCondition[] = [
"none",
"diabetes_or_hypertension",
"heart_disease",
"multiple",
];
return (allowed as string[]).includes(ped) ? (ped as PreExistingCondition) : "none";
}
export default function PolicyPremiumWidget({
policyId,
policyName,
profile,
initialSumInsured,
initialTenureYears = 1,
initialDeductibleInr = 0,
onCalculated,
}: PolicyPremiumWidgetProps) {
// KI-278 β€” seed the SI from the profile (same precedence as the header
// chip) unless the caller forced an explicit initialSumInsured. This is
// what makes the per-policy number land inside the header band.
const resolvedInitialSI = useMemo(
() =>
typeof initialSumInsured === "number"
? initialSumInsured
: resolveProfileSumInsured(profile),
[initialSumInsured, profile],
);
const [sumInsured, setSumInsured] = useState<number>(resolvedInitialSI);
const [tenureYears, setTenureYears] = useState<1 | 2 | 3>(initialTenureYears);
const [deductibleInr, setDeductibleInr] = useState<0 | 25000 | 50000 | 100000>(
initialDeductibleInr,
);
const [resp, setResp] = useState<PremiumEstimateResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// BUG #29 β€” only the ~2 of 148 policies that genuinely offer a
// user-selectable voluntary deductible expose the selector. The backend
// is authoritative; default to "unsupported" until the estimate arrives.
// Declared here (before fetchPremium / the JSX) so both the request
// builder and the render read the same value without a TDZ hazard.
// Stale-selection reset is handled in the fetch callback (an async event
// handler β€” the React-recommended place to react to a response), not an
// effect, so we don't trigger cascading-render lint.
const supportsDeductible = resp?.supports_voluntary_deductible === true;
const deductibleChoices: readonly number[] =
resp?.allowed_deductibles && resp.allowed_deductibles.length
? resp.allowed_deductibles
: DEDUCTIBLE_CHOICES;
// Stable string key so we can put `profile` in the effect dep list without
// triggering refetches on every parent re-render (object identity changes).
const profileKey = useMemo(() => JSON.stringify(profile ?? {}), [profile]);
// KI-278 β€” async-profile re-sync. The profile often arrives AFTER the
// widget mounts (the compare modal opens the instant data finishes
// fetching). Without this, the SI slider stays on the β‚Ή10L fallback while
// the header chip already re-priced at the user's stated SI β†’ the exact
// header≠panel contradiction we're fixing. We re-seed the SI from the
// resolved profile value ONLY while the user hasn't touched the slider
// (slider still equals the prior resolved value). Once the user drags it,
// their choice is sticky and profile updates no longer override it. Mirror
// of PremiumCalculatorPanel's snapshot-guard in page.tsx.
const lastResolvedSIRef = useRef<number>(resolvedInitialSI);
useEffect(() => {
if (sumInsured === lastResolvedSIRef.current && resolvedInitialSI !== sumInsured) {
setSumInsured(resolvedInitialSI);
}
lastResolvedSIRef.current = resolvedInitialSI;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedInitialSI]);
const onCalculatedRef = useRef(onCalculated);
useEffect(() => {
onCalculatedRef.current = onCalculated;
}, [onCalculated]);
const fetchPremium = useCallback(
async (signal: AbortSignal) => {
setLoading(true);
setError(null);
try {
// Defaults match the standalone PremiumCalculatorPanel so the two
// surfaces converge on the same number for the same profile.
const age = typeof profile?.age === "number" ? profile.age : 35;
const familySize =
typeof profile?.family_size === "number" ? profile.family_size : 1;
const r = await postPremiumEstimate({
age,
sum_insured_inr: sumInsured,
city_tier: normaliseCityTier(profile?.location_tier),
smoker: profile?.smoker === true,
family_size: familySize,
policy_id: policyId,
pre_existing_conditions: normalisePed(profile?.pre_existing_conditions),
copayment_pct: 0,
tenure_years: tenureYears,
deductible_inr: supportsDeductible ? deductibleInr : 0,
});
if (signal.aborted) return;
setResp(r);
// BUG #29 β€” if this policy does NOT support a voluntary deductible
// but a stale non-zero selection carried over from a previously
// compared policy, clear it so it can't persist or be re-sent.
if (r.supports_voluntary_deductible === false && deductibleInr !== 0) {
setDeductibleInr(0);
}
onCalculatedRef.current?.(r.point_estimate_inr);
} catch (e) {
if (signal.aborted) return;
console.error("Premium estimate failed:", e);
setError(e instanceof Error ? e.message : String(e));
} finally {
if (!signal.aborted) setLoading(false);
}
},
[policyId, profileKey, sumInsured, tenureYears, deductibleInr], // eslint-disable-line react-hooks/exhaustive-deps
);
// 300ms debounce on slider drags; immediate fetch on mount / policy switch.
useEffect(() => {
const ctrl = new AbortController();
const handle = window.setTimeout(() => {
void fetchPremium(ctrl.signal);
}, 300);
return () => {
window.clearTimeout(handle);
ctrl.abort();
};
}, [fetchPremium]);
const profileSummary = summariseProfile(profile);
// Every recommended policy renders the SAME per-policy estimate block
// below (point + Β±15% band + methodology). When the backend has no
// curated quote sample, the `methodology` string itself states it is a
// rules-based estimate β€” we no longer substitute the wide profile-level
// basket band for some policies (that produced an identical, 4-5x-wide
// "no verified quote" panel on every non-curated card).
// Compact, profile-only breakdown β€” the estimate endpoint doesn't expose
// a multiplicative chain so we surface the active overrides + methodology
// instead. This keeps the widget transparent without faking precision.
const bullets: string[] = [];
if (resp) {
bullets.push(
`Range β‚Ή${formatInr(resp.low_inr)} – β‚Ή${formatInr(resp.high_inr)}/year (Β±15% band)`,
);
if (resp.tenure_years && resp.tenure_years !== 1) {
bullets.push(`Multi-year discount applied (${resp.tenure_years}-year policy)`);
}
if (resp.deductible_inr && resp.deductible_inr > 0) {
bullets.push(
`Voluntary deductible discount applied (${formatDeductibleLabel(resp.deductible_inr)})`,
);
}
}
return (
<div className="policy-premium-widget" style={widgetStyle}>
{/* Local accent-color + reduced-motion scoping for the native range
input. Scoped to .policy-premium-widget so it never leaks. */}
<style>{PREMIUM_WIDGET_CSS}</style>
<header style={headerStyle}>
<div
style={{
fontWeight: 600,
fontSize: 13.5,
color: "var(--foreground)",
lineHeight: 1.35,
letterSpacing: "-0.005em",
}}
>
{policyName}
</div>
{/* The methodology line under the estimate states whether the
number is anchored to a curated quote sample or a rules-based
formula β€” so no separate badge is needed here. */}
</header>
{profileSummary && (
<div style={profileLineStyle}>
Your profile defaults Β· {profileSummary}
</div>
)}
<div style={sliderGroupStyle}>
<label style={labelStyle}>
<span style={labelHeadStyle}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<span style={labelTextStyle}>Sum insured</span>
<HelpTip id="sum_insured" />
</span>
<strong style={labelValueStyle}>β‚Ή{formatSiLabel(sumInsured)}</strong>
</span>
<input
type="range"
min={SUM_INSURED_MIN}
max={SUM_INSURED_MAX}
step={SUM_INSURED_STEP}
value={sumInsured}
onChange={(e) => setSumInsured(Number(e.target.value))}
aria-label="Sum insured"
style={{ width: "100%" }}
/>
<div style={tickRowStyle}>
<span>β‚Ή5L</span>
<span>β‚Ή1Cr</span>
</div>
</label>
<label style={labelStyle}>
<span style={labelHeadStyle}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<span style={labelTextStyle}>Tenure</span>
<HelpTip id="tenure" />
</span>
<strong style={labelValueStyle}>
{tenureYears} {tenureYears === 1 ? "year" : "years"}
</strong>
</span>
<div role="radiogroup" aria-label="Tenure" style={pillRowStyle}>
{TENURE_CHOICES.map((y) => (
<button
key={y}
type="button"
role="radio"
aria-checked={tenureYears === y}
onClick={() => setTenureYears(y)}
style={pillStyle(tenureYears === y)}
>
{y}y
</button>
))}
</div>
</label>
{supportsDeductible && (
<label style={labelStyle}>
<span style={labelHeadStyle}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<span style={labelTextStyle}>Deductible</span>
<HelpTip id="deductible" />
</span>
<strong style={labelValueStyle}>
{formatDeductibleLabel(deductibleInr)}
</strong>
</span>
<div role="radiogroup" aria-label="Deductible" style={pillRowStyle}>
{deductibleChoices.map((d) => (
<button
key={d}
type="button"
role="radio"
aria-checked={deductibleInr === d}
onClick={() =>
setDeductibleInr(d as 0 | 25000 | 50000 | 100000)
}
style={pillStyle(deductibleInr === d)}
>
{formatDeductibleLabel(d)}
</button>
))}
</div>
</label>
)}
</div>
<div style={resultBoxStyle} aria-live="polite">
{error ? (
<div style={errorTextStyle}>Couldn&apos;t calculate this estimate. Try again in a moment.</div>
) : loading && !resp ? (
<div style={calculatingStyle}>
<span aria-hidden style={dotPulseStyle} />
Calculating estimate…
</div>
) : resp ? (
<>
<div style={resultHeadlineRowStyle}>
<span style={resultLabelStyle}>Estimated premium</span>
<span style={resultValueWrapStyle}>
<strong style={resultValueStyle}>
β‚Ή{formatInr(resp.point_estimate_inr)}
</strong>
<span style={resultSuffixStyle}>/year</span>
{loading && (
<span style={spinnerHintStyle}>updating…</span>
)}
</span>
</div>
{bullets.length > 0 && (
<ul style={breakdownListStyle}>
{bullets.map((b) => (
<li key={b} style={breakdownItemStyle}>
<span aria-hidden style={breakdownTickStyle} />
<span>{b}</span>
</li>
))}
</ul>
)}
{resp.methodology && (
<div style={noteStyle}>{resp.methodology}</div>
)}
</>
) : null}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Inline styles β€” kept local so the widget drops into any modal */
/* without a CSS-module dependency. All colors read from the landing's */
/* CSS variables (--primary teal, --card, --border, --muted, etc.) so */
/* the widget belongs to the premium editorial-fintech design system */
/* and tracks the page's light/dark scheme automatically. */
/* ------------------------------------------------------------------ */
// Scoped native-range-input theming + reduced-motion guard. Kept as a tiny
// string so the file stays CSS-module-free and self-contained.
const PREMIUM_WIDGET_CSS = `
.policy-premium-widget input[type="range"]{
-webkit-appearance:none;appearance:none;height:6px;border-radius:999px;
background:linear-gradient(90deg,
color-mix(in srgb, var(--primary) 55%, var(--border)) 0%,
var(--muted) 100%);
outline:none;cursor:pointer;margin:2px 0;
}
.policy-premium-widget input[type="range"]:focus-visible{
outline:2px solid var(--primary);outline-offset:3px;
}
.policy-premium-widget input[type="range"]::-webkit-slider-thumb{
-webkit-appearance:none;appearance:none;width:17px;height:17px;border-radius:999px;
background:var(--card);border:2px solid var(--primary);cursor:pointer;
box-shadow:0 1px 3px color-mix(in srgb, var(--foreground) 22%, transparent);
transition:transform .15s ease;
}
.policy-premium-widget input[type="range"]::-webkit-slider-thumb:hover{transform:scale(1.12);}
.policy-premium-widget input[type="range"]::-moz-range-thumb{
width:17px;height:17px;border-radius:999px;background:var(--card);
border:2px solid var(--primary);cursor:pointer;
box-shadow:0 1px 3px color-mix(in srgb, var(--foreground) 22%, transparent);
}
@keyframes ppw-dot{0%,100%{opacity:.35}50%{opacity:1}}
@media (prefers-reduced-motion: reduce){
.policy-premium-widget input[type="range"]::-webkit-slider-thumb{transition:none!important}
.policy-premium-widget *{animation:none!important}
}
`;
const widgetStyle: React.CSSProperties = {
border: "1px solid var(--border)",
borderRadius: 18,
padding: 18,
background: "var(--card)",
display: "flex",
flexDirection: "column",
gap: 14,
fontFamily: SANS,
boxShadow:
"0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent), 0 16px 40px -32px color-mix(in srgb, var(--foreground) 28%, transparent)",
};
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
};
const profileLineStyle: React.CSSProperties = {
fontSize: 11.5,
color: "var(--muted-foreground)",
letterSpacing: "0.005em",
};
const sliderGroupStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 16,
};
const labelStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 8,
fontSize: 12,
color: "var(--foreground)",
};
const labelHeadStyle: React.CSSProperties = {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
};
const labelTextStyle: React.CSSProperties = {
fontWeight: 500,
color: "var(--muted-foreground)",
textTransform: "uppercase",
letterSpacing: "0.07em",
fontSize: 10.5,
};
const labelValueStyle: React.CSSProperties = {
fontWeight: 700,
color: "var(--foreground)",
fontSize: 13,
fontVariantNumeric: "tabular-nums",
};
const tickRowStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
fontSize: 10.5,
color: "var(--muted-foreground)",
fontVariantNumeric: "tabular-nums",
};
const pillRowStyle: React.CSSProperties = {
display: "flex",
gap: 7,
flexWrap: "wrap",
};
const pillStyle = (active: boolean): React.CSSProperties => ({
border: active
? "1px solid var(--primary)"
: "1px solid var(--border)",
background: active
? "var(--primary)"
: "var(--card)",
color: active ? "var(--primary-foreground)" : "var(--muted-foreground)",
padding: "5px 13px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
fontVariantNumeric: "tabular-nums",
transition: "background .16s ease, color .16s ease, border-color .16s ease",
boxShadow: active
? "0 2px 8px -2px color-mix(in srgb, var(--primary) 45%, transparent)"
: "none",
});
const resultBoxStyle: React.CSSProperties = {
borderTop: "1px solid var(--border)",
paddingTop: 14,
display: "flex",
flexDirection: "column",
gap: 10,
};
const resultHeadlineRowStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 4,
};
const resultLabelStyle: React.CSSProperties = {
fontSize: 10.5,
textTransform: "uppercase",
letterSpacing: "0.1em",
fontWeight: 700,
color: "color-mix(in srgb, var(--primary) 70%, var(--muted-foreground))",
};
const resultValueWrapStyle: React.CSSProperties = {
display: "flex",
alignItems: "baseline",
gap: 6,
flexWrap: "wrap",
};
const resultValueStyle: React.CSSProperties = {
fontFamily: SERIF,
fontOpticalSizing: "auto",
fontSize: 28,
fontWeight: 600,
color: "var(--foreground)",
letterSpacing: "-0.02em",
fontVariantNumeric: "tabular-nums",
};
const resultSuffixStyle: React.CSSProperties = {
color: "var(--muted-foreground)",
fontWeight: 500,
fontSize: 12.5,
};
const spinnerHintStyle: React.CSSProperties = {
marginLeft: 4,
fontSize: 11,
color: "var(--muted-foreground)",
fontStyle: "italic",
};
const errorTextStyle: React.CSSProperties = {
color: "color-mix(in srgb, var(--error) 78%, var(--foreground))",
fontSize: 12.5,
fontWeight: 500,
};
const calculatingStyle: React.CSSProperties = {
color: "var(--muted-foreground)",
fontSize: 12.5,
display: "flex",
alignItems: "center",
gap: 8,
};
const dotPulseStyle: React.CSSProperties = {
width: 7,
height: 7,
borderRadius: 999,
background: "var(--primary)",
animation: "ppw-dot 1.2s ease-in-out infinite",
};
const breakdownListStyle: React.CSSProperties = {
margin: 0,
padding: 0,
listStyle: "none",
display: "flex",
flexDirection: "column",
gap: 6,
};
const breakdownItemStyle: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
gap: 8,
color: "var(--muted-foreground)",
fontSize: 12,
lineHeight: 1.45,
};
const breakdownTickStyle: React.CSSProperties = {
flex: "none",
marginTop: 6,
width: 5,
height: 5,
borderRadius: 999,
background: "color-mix(in srgb, var(--primary) 70%, transparent)",
};
const noteStyle: React.CSSProperties = {
fontSize: 11,
color: "var(--muted-foreground)",
fontStyle: "italic",
lineHeight: 1.5,
paddingTop: 2,
};
/* (NonCuratedPricingNotice + its styles removed β€” every policy now
renders the unified per-policy estimate block; the methodology line
states when the number is rules-based vs curated-sample anchored.) */