InsuranceBot / frontend /src /components /PolicyCompareModal.tsx
rohitsar567's picture
fix(batch): voice interim, TTS truncation, RULE2.5 re-ask, faithfulness regression, markdown, compare-reviews, policy-name
8828a2e
Raw
History Blame Contribute Delete
59.3 kB
"use client";
// PolicyCompareModal β€” side-by-side comparison modal opened from the chat
// reply. EVERY column renders the SAME ordered sections so the columns are
// row-aligned and equal-height (a missing field renders a graceful "β€”",
// never a collapsed/absent block):
// 1. Header β€” insurer logo + policy name + a top-right "Policy PDF"
// link (mirrors page.tsx's PdfIcon "Open policy PDF").
// 2. Highlights β€” full marketplace-parity coverage grid (sum insured,
// PED wait, network, cashless, room rent, co-pay, NCB,
// restoration/AYUSH, pre/post-hosp, waiting periods).
// 3. Premium β€” pluggable widget (B2) via props.renderPremiumFor.
// 4. Scorecard β€” pluggable widget (B3) via props.renderScorecardFor.
// 5. Policy details β€” expandable section with the source URL.
//
// On < 640px the columns become a single horizontal CSS scroll-snap
// carousel (one card per viewport, swipeable, no page-level horizontal
// overflow) with full-size tappable targets; β‰₯ 640px they are an
// equal-fraction grid.
//
// ── Visual system ─────────────────────────────────────────────────────
// Re-grounded on the premium editorial-fintech landing (app/globals.css):
// Fraunces display serif via `.font-display` for the modal title, the teal
// --primary token, color-mix soft depth shadows, the kicker-pill pattern
// for section eyebrows, and tight one-line fact bullets. Everything reads
// from CSS variables so the modal tracks the page's light/dark scheme. The
// grade chip uses the same calm A→F ramp as PolicyScorecardWidget.
import { useEffect, useState, type ReactNode, type CSSProperties } from "react";
import {
Citation,
MarketplacePolicy,
ScorecardResponse,
getScorecard,
BACKEND_URL,
} from "@/lib/api";
// ----- local visual helpers (forked from page.tsx so this file is
// self-contained; the originals stay in page.tsx untouched). -----
const INSURER_COLOR: Record<string, string> = {
"aditya-birla": "bg-orange-600",
"bajaj-allianz": "bg-blue-700",
"care-health": "bg-emerald-700",
"hdfc-ergo": "bg-rose-700",
"icici-lombard": "bg-orange-500",
"manipalcigna": "bg-fuchsia-700",
"new-india": "bg-indigo-700",
"niva-bupa": "bg-cyan-700",
"star-health": "bg-amber-600",
"tata-aig": "bg-slate-700",
};
// #102 β€” official insurer domains for the DDG icon fallback (the old
// hotlink map was dead). Kept in sync with page.tsx INSURER_DOMAIN.
const INSURER_DOMAIN: Record<string, string> = {
"acko": "acko.com",
"aditya-birla": "adityabirlacapital.com",
"bajaj-allianz": "bajajallianz.com",
"care-health": "careinsurance.com",
"cholamandalam": "cholainsurance.com",
"go-digit": "godigit.com",
"hdfc-ergo": "hdfcergo.com",
"icici-lombard": "icicilombard.com",
"iffco-tokio": "iffcotokio.co.in",
"indusind-general": "indusindinsurance.com",
"manipalcigna": "manipalcigna.com",
"national-insurance": "nationalinsurance.nic.co.in",
"new-india": "newindia.co.in",
"niva-bupa": "nivabupa.com",
"oriental-insurance": "orientalinsurance.org.in",
"reliance-general": "reliancegeneral.co.in",
"royal-sundaram": "royalsundaram.in",
"sbi-general": "sbigeneral.in",
"star-health": "starhealth.in",
"tata-aig": "tataaig.com",
};
function insurerInitials(name: string): string {
return name.split(/[\s-]+/).map((w) => w[0]).filter(Boolean).join("").slice(0, 2).toUpperCase();
}
// #102/#103 β€” same reliable, unboxed logo as page.tsx: locally-hosted real
// PNG β†’ DuckDuckGo icon β†’ colored letter avatar. No white box; the mark
// sits directly on the surface.
function InsurerLogo({ slug, name, size = 40 }: { slug: string; name: string; size?: number }) {
const [stage, setStage] = useState(0);
const color = INSURER_COLOR[slug] || "bg-slate-500";
const domain = INSURER_DOMAIN[slug];
const sources = [
`/insurer-logos/${slug}.png`,
domain ? `https://icons.duckduckgo.com/ip3/${domain}.ico` : "",
].filter(Boolean);
if (stage >= sources.length) {
return (
<div
className={`rounded-xl ${color} text-white flex items-center justify-center font-bold shrink-0`}
style={{ width: size, height: size, fontSize: size * 0.32 }}
>
{insurerInitials(name)}
</div>
);
}
return (
<div
className="flex items-center justify-center shrink-0"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={sources[stage]}
alt={name}
onError={() => setStage((s) => s + 1)}
className="w-full h-full object-contain"
/>
</div>
);
}
// Inline PDF glyph β€” forked verbatim from page.tsx's PdfIcon so the
// compare-column source link reads identically to the one on the chat
// citation chips ("Open policy PDF").
function PdfIcon({ size = 13 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text
x="7"
y="18"
fontSize="6"
fill="currentColor"
stroke="none"
fontWeight="bold"
>
PDF
</text>
</svg>
);
}
// Resolve the best policy-PDF URL for a column. Prefers the marketplace
// row's curated `source_pdf_url`, then the citation's `source_url`, then a
// scoped Google fallback (same precedence/UX as the marketplace detail
// modal in page.tsx). Always returns *something* so every column gets a
// uniform top-right link β€” `isReal` flags whether it's a direct PDF.
function resolvePdfHref(
citation: Citation,
marketplacePolicy?: MarketplacePolicy,
): { href: string; isReal: boolean } {
// #87 β€” accept both a public origin URL and the backend-served local
// corpus path (/api/policy-pdf/...), resolving the latter to an absolute
// BACKEND_URL so every column links the real document.
const curated = marketplacePolicy?.source_pdf_url;
if (curated && (curated.startsWith("http") || curated.startsWith("/api/"))) {
return {
href: curated.startsWith("/api/") ? `${BACKEND_URL}${curated}` : curated,
isReal: true,
};
}
if (citation.source_url && citation.source_url.startsWith("http")) {
return { href: citation.source_url, isReal: true };
}
let host = "www.google.com";
try {
if (marketplacePolicy?.insurer_home_url) {
host = new URL(marketplacePolicy.insurer_home_url).hostname;
}
} catch {
/* keep google host */
}
const q = encodeURIComponent(
`${citation.policy_name} policy wording PDF`,
);
return {
href: `https://www.google.com/search?q=site:${host}+${q}`,
isReal: false,
};
}
// Calm A→F ramp — the same tonal family as PolicyScorecardWidget so the
// chip in the column header matches the full scorecard below it. "A" is the
// brand teal; the rest walk a quiet green→amber→red gradient.
function gradeChip(grade: string): { fg: string; bg: string; ring: string } {
const head = (grade || "").charAt(0).toUpperCase();
switch (head) {
case "A":
return {
fg: "color-mix(in srgb, var(--primary) 82%, #042f2a)",
bg: "color-mix(in srgb, var(--primary) 14%, var(--card))",
ring: "color-mix(in srgb, var(--primary) 55%, var(--border))",
};
case "B":
return { fg: "#155e63", bg: "#e3f4f3", ring: "#3d9c98" };
case "C":
return { fg: "#855316", bg: "#fbeed2", ring: "#cf9b3f" };
case "D":
return { fg: "#8a3c12", bg: "#fae0cd", ring: "#d4793b" };
case "F":
return { fg: "#8a2020", bg: "#f8d9d9", ring: "#cf4b4b" };
default:
return {
fg: "var(--muted-foreground)",
bg: "var(--muted)",
ring: "var(--border)",
};
}
}
// Dedupe citations by policy_id, preserving order.
function uniquePolicies(citations: Citation[]): Citation[] {
const seen = new Set<string>();
const out: Citation[] = [];
for (const c of citations) {
if (seen.has(c.policy_id)) continue;
seen.add(c.policy_id);
out.push(c);
}
return out;
}
export type PolicyCompareModalProps = {
policies: Citation[];
onClose: () => void;
// B2 + B3 plug points. Both are optional; safe fallbacks render below.
renderPremiumFor?: (policyId: string, policyName: string) => ReactNode;
renderScorecardFor?: (policyId: string, policyName: string) => ReactNode;
// FIX #21 β€” insurer reviews / claim-settlement plug point, keyed by the
// citation's insurer_slug (reviews are per-insurer, not per-policy). The
// inline cited cards + the full detail modal both show a reviews section;
// this compare modal previously did NOT (user-flagged: "no Reviews /
// claim-settlement section here"). Optional β€” when unwired, a graceful
// "reputation data unavailable" line renders so the section never
// silently vanishes.
renderReviewsFor?: (insurerSlug: string) => ReactNode;
// Profile hint for downstream personalized widgets (unused by the shell
// itself; pass-through so widgets opened via renderXxxFor can read it).
// Typed loosely (any) by contract; harness should narrow when wiring.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
profile?: any;
// Optional resolver returning the marketplace MarketplacePolicy row for a
// citation. Powers the new "POLICY HIGHLIGHTS" section (4-stat grid +
// bullets). When undefined, the highlights section is skipped.
// Resolve by cited policy_id; policyName is a canonical fallback so a
// doctype/variant/alias id (e.g. recommended `hdfc-ergo__optima-restore`
// vs marketplace card `..__brochure`) still resolves to its card β€”
// otherwise the card silently degrades (no Hospitals link, SI falls back
// to "As per policy schedule", fewer fields β†’ asymmetric). #57/#58/#59.
policyDataFor?: (
policyId: string,
policyName?: string,
) => MarketplacePolicy | undefined;
// Hook for "Open in full marketplace" β€” defaults to no-op + closes modal.
onOpenMarketplace?: () => void;
};
export default function PolicyCompareModal({
policies,
onClose,
renderPremiumFor,
renderScorecardFor,
renderReviewsFor,
profile: _profile,
policyDataFor,
onOpenMarketplace,
}: PolicyCompareModalProps) {
const uniq = uniquePolicies(policies).slice(0, 3);
const n = uniq.length;
// Close on Escape β€” keyboard parity with the click-outside backdrop.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 z-[70] flex items-stretch sm:items-center justify-center p-0 sm:p-4 animate-fade-up"
style={{
background:
"color-mix(in srgb, var(--foreground) 48%, transparent)",
backdropFilter: "blur(3px)",
WebkitBackdropFilter: "blur(3px)",
}}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={`Compare ${n} polic${n === 1 ? "y" : "ies"}`}
>
<div
className="bg-[var(--card)] sm:rounded-3xl w-full sm:max-w-7xl sm:w-[92vw] max-h-screen sm:max-h-[92vh] overflow-y-auto scrollbar-thin"
style={{
border: "1px solid var(--border)",
boxShadow:
"0 1px 2px color-mix(in srgb, var(--foreground) 6%, transparent), 0 40px 90px -40px color-mix(in srgb, var(--foreground) 55%, transparent)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header β€” Fraunces display title + brand-tinted eyebrow */}
<div
className="sticky top-0 z-10 px-5 sm:px-6 py-4 flex items-start justify-between gap-4"
style={{
background:
"linear-gradient(180deg, color-mix(in srgb, var(--primary) 6%, var(--card)) 0%, var(--card) 100%)",
borderBottom: "1px solid var(--border)",
}}
>
<div className="min-w-0">
<div
className="inline-flex items-center gap-2 mb-2"
style={{
padding: "4px 10px 4px 9px",
borderRadius: 999,
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "var(--primary)",
background:
"color-mix(in srgb, var(--primary) 9%, var(--card))",
border:
"1px solid color-mix(in srgb, var(--primary) 22%, var(--border))",
}}
>
<span
aria-hidden
style={{
width: 5,
height: 5,
borderRadius: 999,
background: "var(--primary)",
}}
/>
Side-by-side
</div>
<h3
className="font-display"
style={{
fontSize: 22,
fontWeight: 600,
color: "var(--foreground)",
lineHeight: 1.15,
letterSpacing: "-0.012em",
}}
>
Compare {n} polic{n === 1 ? "y" : "ies"}
</h3>
<p
style={{
fontSize: 12,
color: "var(--muted-foreground)",
marginTop: 4,
lineHeight: 1.4,
}}
>
Premiums, fit scores and policy details β€” aligned side-by-side.
</p>
</div>
<button
onClick={onClose}
className="shrink-0"
style={{
width: 34,
height: 34,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 20,
lineHeight: 1,
color: "var(--muted-foreground)",
background: "var(--card)",
border: "1px solid var(--border)",
cursor: "pointer",
transition: "color .15s ease, border-color .15s ease",
}}
aria-label="Close comparison"
>
Γ—
</button>
</div>
{/* Body.
β€’ < 640px: a horizontal scroll-snap carousel. Each card is
~86vw so a sliver of the next card peeks (affordance that it
swipes), `overflow-x-auto` is scoped to THIS strip so the page
never gets a horizontal scrollbar, and `touch-pan-x` keeps
vertical scroll of the modal working.
β€’ β‰₯ 640px: an equal-fraction grid. `align-items: stretch` (grid
default) + each column being a flex-col makes every column the
SAME height regardless of content. */}
<div className="p-4 sm:p-6">
{n > 1 && (
<div
className="sm:hidden mb-3 flex items-center gap-1.5"
style={{
fontSize: 11,
color: "var(--muted-foreground)",
}}
aria-hidden
>
<span>Swipe to compare all {n}</span>
<span style={{ color: "var(--primary)" }}>β†’</span>
</div>
)}
<div
className={
n > 1
? "flex sm:grid gap-4 sm:gap-5 overflow-x-auto sm:overflow-visible snap-x snap-mandatory sm:snap-none -mx-4 px-4 sm:mx-0 sm:px-0 scrollbar-thin touch-pan-x"
: "grid grid-cols-1"
}
style={
n > 1
? {
gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))`,
WebkitOverflowScrolling: "touch",
}
: undefined
}
>
{uniq.map((c) => (
<div
key={c.policy_id}
className={
n > 1
? "snap-center shrink-0 sm:shrink basis-[86%] sm:basis-auto min-w-0"
: "min-w-0"
}
>
<CompareColumn
citation={c}
premiumSlot={renderPremiumFor?.(c.policy_id, c.policy_name)}
scorecardSlot={renderScorecardFor?.(
c.policy_id,
c.policy_name,
)}
reviewsSlot={renderReviewsFor?.(c.insurer_slug)}
marketplacePolicy={policyDataFor?.(c.policy_id, c.policy_name)}
profile={_profile as SnapProfile}
/>
</div>
))}
</div>
</div>
{/* Footer */}
<div
className="sticky bottom-0 px-5 sm:px-6 py-3.5 flex flex-wrap items-center justify-between gap-2"
style={{
background:
"linear-gradient(0deg, color-mix(in srgb, var(--primary) 5%, var(--card)) 0%, var(--card) 100%)",
borderTop: "1px solid var(--border)",
}}
>
<span
style={{
fontSize: 11.5,
color: "var(--muted-foreground)",
lineHeight: 1.4,
}}
>
Comparing the policies cited in this reply. Open the full
marketplace for filters and 30+ more options.
</span>
<button
onClick={() => {
onOpenMarketplace?.();
onClose();
}}
className="hover:underline"
style={{
fontSize: 12.5,
fontWeight: 600,
color: "var(--primary)",
cursor: "pointer",
}}
>
Open in full marketplace β†’
</button>
</div>
</div>
</div>
);
}
// One vertical card per cited policy.
function CompareColumn({
citation,
premiumSlot,
scorecardSlot,
reviewsSlot,
marketplacePolicy,
profile,
}: {
citation: Citation;
premiumSlot?: ReactNode;
scorecardSlot?: ReactNode;
reviewsSlot?: ReactNode;
marketplacePolicy?: MarketplacePolicy;
profile?: SnapProfile;
}) {
// Prefer the canonical insurer name from the marketplace row when we
// have it (e.g. "HDFC ERGO General Insurance" vs the slug "hdfc-ergo"
// un-prettified). Falls back to the slug humanised.
const insurerName =
marketplacePolicy?.insurer_name ?? citation.insurer_slug.replace(/-/g, " ");
const pdf = resolvePdfHref(citation, marketplacePolicy);
// #4 β€” fetch the SAME scorecard the widget uses and recover structured
// facts from its signals. PolicyHighlights then shows real coverage even
// when the flat marketplace row is missing values for this policy.
const [scFacts, setScFacts] = useState<ScorecardFacts>({});
useEffect(() => {
let alive = true;
getScorecard(citation.policy_id)
.then((sc) => {
if (alive) setScFacts(parseScorecardFacts(sc));
})
.catch(() => {
/* no scorecard β†’ highlights fall back to marketplace fields only */
});
return () => {
alive = false;
};
}, [citation.policy_id]);
return (
<div
// h-full + flex-col makes every column the SAME height in the desktop
// grid (grid stretches each cell; the card fills it). Sections inside
// are uniform across columns so rows line up.
className="flex flex-col gap-4 min-w-0 h-full"
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 18,
padding: 18,
boxShadow:
"0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent), 0 18px 44px -34px color-mix(in srgb, var(--foreground) 30%, transparent)",
}}
>
{/* Header β€” logo + insurer + policy name, with a top-right PDF link */}
<div
className="flex items-start gap-3 pb-4"
style={{ borderBottom: "1px solid var(--border)" }}
>
<InsurerLogo slug={citation.insurer_slug} name={insurerName} size={44} />
<div className="flex-1 min-w-0">
<div
className="truncate"
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.1em",
color: "var(--muted-foreground)",
fontWeight: 600,
}}
>
{insurerName}
</div>
<div
className="break-words"
style={{
fontFamily: "var(--font-serif)",
fontOpticalSizing: "auto",
fontSize: 16,
fontWeight: 600,
color: "var(--foreground)",
lineHeight: 1.25,
letterSpacing: "-0.01em",
marginTop: 3,
}}
>
{citation.policy_name}
</div>
{marketplacePolicy?.aliases &&
marketplacePolicy.aliases.length > 0 && (
<div
className="break-words"
style={{
fontSize: 11,
color: "var(--muted-foreground)",
fontStyle: "italic",
marginTop: 4,
lineHeight: 1.4,
}}
>
Also marketed as: {marketplacePolicy.aliases.join(", ")}
</div>
)}
</div>
{/* Top-right policy-PDF link β€” uniform on every column. ~36px tall
so it's a comfortable tap target on mobile. */}
<a
href={pdf.href}
target="_blank"
rel="noopener"
className="shrink-0 hover:opacity-90 active:opacity-80"
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
fontSize: 11,
fontWeight: 600,
lineHeight: 1,
color: pdf.isReal ? "#fff" : "var(--primary)",
background: pdf.isReal
? "var(--primary)"
: "color-mix(in srgb, var(--primary) 9%, var(--card))",
border: pdf.isReal
? "1px solid var(--primary)"
: "1px solid color-mix(in srgb, var(--primary) 30%, var(--border))",
borderRadius: 999,
padding: "8px 12px",
textDecoration: "none",
whiteSpace: "nowrap",
}}
title={
pdf.isReal
? "Open the source policy PDF"
: "Search the insurer's site for the policy PDF (no direct link indexed yet)"
}
aria-label={
pdf.isReal ? "Open policy PDF" : "Find policy PDF"
}
>
<PdfIcon size={13} />
{pdf.isReal ? "Policy PDF" : "Find PDF"}
</a>
{/* #86 β€” insurer's official network hospital list, beside the PDF */}
{marketplacePolicy?.network_list_url && (
<a
href={marketplacePolicy.network_list_url}
target="_blank"
rel="noopener"
className="shrink-0 hover:opacity-90 active:opacity-80"
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
fontSize: 11,
fontWeight: 600,
lineHeight: 1,
color: "var(--primary)",
background: "color-mix(in srgb, var(--primary) 9%, var(--card))",
border: "1px solid color-mix(in srgb, var(--primary) 30%, var(--border))",
borderRadius: 999,
padding: "8px 12px",
textDecoration: "none",
whiteSpace: "nowrap",
}}
title={`Open ${marketplacePolicy.insurer_name}'s official ${marketplacePolicy.network_list_is_pdf ? "network hospital list (PDF)" : "cashless hospital locator"}`}
>
Hospitals list β†—
</a>
)}
</div>
{/* POLICY SNAPSHOT β€” the shared decision-ordered lens (#75 + #64),
identical to the marketplace detail modal. Always rendered so
every column carries the SAME section in the SAME position. */}
<PolicyHighlights
policy={marketplacePolicy}
facts={scFacts}
profile={profile}
/>
{/* FIT SCORECARD slot (B3). Order is Details β†’ Score β†’ Pricing to match
the marketplace PolicyDetailModal exactly (one consistent reading
order everywhere a policy is shown). */}
<Section title="Your fit scorecard">
{scorecardSlot ?? <ScorecardFallback policyId={citation.policy_id} />}
</Section>
{/* PREMIUM ESTIMATE slot (B2). Render the slot directly β€” the parent's
PolicyPremiumWidget already owns its own bordered card chrome, so
double-wrapping it produces nested boxes. Only fall back to a
placeholder when the parent didn't wire renderPremiumFor at all. */}
<Section title="Premium estimate">
{premiumSlot ?? <PlaceholderWidget label="Premium calculator coming soon" />}
</Section>
{/* FIX #21 β€” REVIEWS & CLAIM SETTLEMENT. The inline cited cards + the
full marketplace detail modal both surface insurer reputation /
claim-settlement; this compare modal omitted it (user-flagged).
The slot reuses the SAME getInsurerReviews fetch + fields as the
inline "Reviews:" line. Always render the Section so every column
carries it in the SAME position; the slot itself owns its
loading / missing state ("never silently vanish"). */}
<Section title="Reviews & claim settlement">
{reviewsSlot ?? (
<PlaceholderWidget label="Insurer reputation data unavailable" />
)}
</Section>
{/* POLICY DETAILS expandable */}
<PolicyDetails citation={citation} policy={marketplacePolicy} />
</div>
);
}
// ────────────────────────────────────────────────────────────────────────
// Scorecard-derived facts. The scorecard the widget renders is computed from
// the SAME canonical coverage data the highlights grid wants β€” it just
// exposes it as human-readable "signals" (e.g. "βˆ’ 20% copayment", "540
// day-care procedures", "14,000+ network hospitals", "βˆ’ 36mo PED waiting").
// When the flat marketplace fields are missing for a policy but the
// scorecard clearly HAS the detail, we recover the structured values from
// those signals so the highlights / "what this covers" grids show the real
// numbers instead of a wall of "β€”". This is the single shared data source
// the task requires: the grids read what the scorecard read.
// ────────────────────────────────────────────────────────────────────────
export type ScorecardFacts = {
copaymentPct?: number;
pedWaitingMonths?: number;
networkHospitals?: number;
cashless?: boolean;
ayush?: boolean;
dayCareCount?: number;
maternity?: boolean;
noRoomRentCap?: boolean;
roomRentCapText?: string;
csrPct?: number;
maxEntryAge?: number;
};
export function parseScorecardFacts(
sc?: ScorecardResponse | null,
): ScorecardFacts {
const f: ScorecardFacts = {};
if (!sc || !sc.sub_scores) return f;
const all: string[] = [];
for (const s of sc.sub_scores) for (const sig of s.signals || []) all.push(sig);
for (const raw of all) {
const sig = raw.trim();
const low = sig.toLowerCase();
// Copayment β€” "βˆ’ 20% copayment" / "0% copayment"
let m = sig.match(/(\d+(?:\.\d+)?)%\s*copay/i);
if (m) f.copaymentPct = parseFloat(m[1]);
else if (/0% copayment/i.test(sig)) f.copaymentPct = 0;
// PED waiting β€” "βˆ’ 36mo PED waiting" / "12mo PED waiting (short)"
m = sig.match(/(\d+)\s*mo\s*PED\s*waiting/i);
if (m) f.pedWaitingMonths = parseInt(m[1], 10);
// Network hospitals β€” "14,000+ network hospitals" / "only 1500 network hospitals"
m = sig.match(/([\d,]+)\+?\s*network hospitals/i);
if (m) f.networkHospitals = parseInt(m[1].replace(/,/g, ""), 10);
// Cashless
if (/cashless supported/i.test(low)) f.cashless = true;
else if (/no cashless/i.test(low)) f.cashless = false;
// AYUSH
if (/ayush covered/i.test(low)) f.ayush = true;
else if (/no ayush/i.test(low)) f.ayush = false;
// Day-care β€” "540 day-care procedures" / "only 80 day-care procedures"
m = sig.match(/(\d+)\s*day-care procedures/i);
if (m) f.dayCareCount = parseInt(m[1], 10);
// Maternity
if (/maternity covered/i.test(low)) f.maternity = true;
// Room rent
if (/no room rent cap/i.test(low)) f.noRoomRentCap = true;
else {
const rr = sig.match(/room rent capped:\s*(.+)$/i);
if (rr) f.roomRentCapText = rr[1].trim();
}
// CSR β€” "82.3% CSR" / "95.0% CSR (IRDAI 2023-24)"
m = sig.match(/(\d+(?:\.\d+)?)%\s*CSR/i);
if (m) f.csrPct = parseFloat(m[1]);
// Entry age β€” "entry up to 65"
m = sig.match(/entry up to\s*(\d+)/i);
if (m) f.maxEntryAge = parseInt(m[1], 10);
}
return f;
}
// Coalesce: prefer the explicit marketplace field, fall back to the
// scorecard-recovered value. Returns undefined only when BOTH are absent
// (so the caller can omit the row gracefully instead of showing "β€”").
function coalesceNum(
primary: number | null | undefined,
fallback: number | undefined,
): number | undefined {
if (primary != null) return primary;
if (fallback != null) return fallback;
return undefined;
}
function coalesceBool(
primary: boolean | null | undefined,
fallback: boolean | undefined,
): boolean | undefined {
if (primary != null) return primary;
if (fallback != null) return fallback;
return undefined;
}
// SI RATIONALISATION (D1) β€” Sum Insured shows as a continuous range
// "β‚ΉX – β‚ΉY" ONLY when the policy genuinely offers a continuous band
// (sum_insured_is_band, decided server-side from the field's own
// source_quote in backend/sum_insured.py). Otherwise list the discrete,
// source-corroborated plan tiers ("β‚Ή25 L / β‚Ή50 L / β‚Ή1 Cr"; >4 tiers β†’
// "β‚Ή{min} … β‚Ή{max} Β· N plans"). "As per policy schedule" only when there
// is no corroborated SI at all β€” never a synthesized floor/ceiling.
export function fmtSumInsured(p?: MarketplacePolicy): string {
const f = (v: number) =>
v >= 10_000_000 ? `${+(v / 10_000_000).toFixed(1)} Cr` : `${+(v / 100_000).toFixed(1)} L`;
const tiers = (p?.sum_insured_tiers && p.sum_insured_tiers.length
? p.sum_insured_tiers
: p?.sum_insured_options) || [];
const mn = p?.sum_insured_min ?? (tiers.length ? Math.min(...tiers) : null);
const mx = p?.sum_insured_max ?? (tiers.length ? Math.max(...tiers) : null);
if (!tiers.length || mn == null || mx == null) return "As per policy schedule";
// Genuine continuous band β†’ single range.
if (p?.sum_insured_is_band && mn !== mx) return `β‚Ή${f(mn)} – β‚Ή${f(mx)}`;
// Discrete tiers.
const uniq = Array.from(new Set(tiers)).sort((a, b) => a - b);
if (uniq.length === 1) return `β‚Ή${f(uniq[0])}`;
if (uniq.length > 4) return `β‚Ή${f(uniq[0])} … β‚Ή${f(uniq[uniq.length - 1])} Β· ${uniq.length} plans`;
return uniq.map((v) => `β‚Ή${f(v)}`).join(" / ");
}
// ────────────────────────────────────────────────────────────────────────
// POLICY SNAPSHOT (#75 + #64) β€” the single shared decision lens used by
// BOTH the marketplace PolicyDetailModal (page.tsx) and the in-chat compare
// card (PolicyHighlights). It answers, in plain words and decision order:
//
// 1. WHAT YOU GET β€” cover, no-claim bonus, cashless reach
// 2. WHO QUALIFIES & WHEN β€” entry/renewal, the waits before you claim,
// COVER STARTS and how reliably the insurer actually pays
// 3. YOUR SHARE & THE LIMITS β€” your out-of-pocket and the caps
//
// CONDITIONAL facts (maternity / AYUSH / day-care) are never headline β€”
// they live in a "Situational coverage" disclosure and only auto-surface
// (with a "for you" tag + the panel pre-opened) when the user's profile
// makes them relevant (e.g. maternity for a couple/family profile).
//
// Sum Insured is rendered via the existing fmtSumInsured (a RANGE / honest
// "As per policy schedule", never a synthesized floor/ceiling). Its deeper
// data semantics are tracked in the separate data exercise β€” NOT here.
//
// Rows whose value is genuinely absent are OMITTED (no "β€”" wall); the
// three group shells always render so the structure is identical across
// every policy and every surface.
// ────────────────────────────────────────────────────────────────────────
export type SnapRow = { label: string; value: string; relevant?: boolean; term?: GlossaryKey };
// #64/#65/#98 β€” ONE canonical plain-language glossary, used identically by
// the snapshot rows AND the marketplace card tiles via <GlossaryTip>. Copy
// is deliberately layperson-simple ("explain it to someone who knows
// nothing about insurance"). Hover/focus only β€” never click.
export type GlossaryKey =
| "cover" | "ncb" | "cashless" | "entry" | "initwait" | "ped"
| "csr" | "copay" | "room" | "maternity" | "ayush" | "daycare";
export const GLOSSARY_TIPS: Record<GlossaryKey, { title: string; body: string }> = {
cover: { title: "Cover amount (sum insured)", body: "The most this policy will pay for your hospital bills in one year. β‚Ή10 L means up to β‚Ή10 lakh of covered treatment per year." },
ncb: { title: "No-claim bonus", body: "If you make no claim in a year, your cover increases for free the next year β€” your premium does not go up for it." },
cashless: { title: "Cashless treatment", body: "At a network hospital you don't pay and wait for a refund β€” the insurer settles the bill directly. Outside the network you pay first, then claim it back." },
entry: { title: "Who can buy + renew", body: "The age at which you can first take this policy, and whether you can keep renewing it for the rest of your life." },
initwait: { title: "Wait before any claim", body: "A short period right after you buy when only accident claims are paid. Claims for normal illness start once this is over." },
ped: { title: "Wait for a pre-existing condition", body: "If you already have an illness (diabetes, BP, thyroid, anything ongoing) when you buy, claims for THAT illness are only paid after this waiting period." },
csr: { title: "Claims actually paid", body: "Out of every 100 claims people made to this insurer, how many they actually paid (official IRDAI data). Higher is better." },
copay: { title: "Mandatory co-pay", body: "A fixed share of every hospital bill you must pay yourself, always. 'None' means the insurer cannot force you to share any bill." },
room: { title: "Hospital room category", body: "The room type the policy will pay for. If it's capped and you take a costlier room, you pay the difference β€” and sometimes a bigger share of the whole bill." },
maternity: { title: "Maternity & newborn", body: "Whether childbirth and newborn-baby expenses are covered, and how long you must wait before you can claim maternity." },
ayush: { title: "AYUSH treatment", body: "Treatment under Ayurveda, Yoga, Unani, Siddha or Homeopathy at a recognised hospital β€” covered or not." },
daycare: { title: "Day-care procedures", body: "Treatments that need hospital admission but finish in under 24 hours (e.g. cataract, dialysis, chemotherapy) β€” how many this policy covers." },
};
// The ONE explainer affordance used everywhere (snapshot rows + card
// tiles). Hover or keyboard-focus shows it; moving away hides it. No
// click, no close button. Width-constrained + above the badge so it never
// spills across cards. Styling via .gtip-* in globals.css to match the
// site's editorial system.
export function GlossaryTip({ term }: { term?: GlossaryKey }) {
if (!term || !GLOSSARY_TIPS[term]) return null;
const { title, body } = GLOSSARY_TIPS[term];
return (
<span className="gtip">
<span tabIndex={0} role="img" aria-label={`Explain: ${title}`} className="gtip-badge">?</span>
<span role="tooltip" className="gtip-pop">
<span className="gtip-title">{title}</span>
<span className="gtip-body">{body}</span>
</span>
</span>
);
}
export type SnapGroup = {
key: "get" | "eligible" | "limits";
title: string;
sub: string;
rows: SnapRow[];
};
export type SnapProfile = {
dependents?: string | null;
primary_goal?: string | null;
age?: number | null;
health_conditions?: string[] | null;
// #76 β€” the customer's stated/selected cover. When a plan doesn't
// publish fixed SI tiers we price at THIS and must show it, not the
// dismissive "As per policy schedule" (which looks like their input
// was ignored).
desired_sum_insured_inr?: number | null;
} | null;
export function buildSnapshot(
policy: MarketplacePolicy | undefined,
facts: ScorecardFacts,
profile?: SnapProfile,
): { groups: SnapGroup[]; situational: SnapRow[]; anyRelevant: boolean } {
const cPed = coalesceNum(
policy?.pre_existing_disease_waiting_months,
facts.pedWaitingMonths,
);
const cCopay = coalesceNum(policy?.copayment_pct, facts.copaymentPct);
// #86 β€” prefer the sourced insurer-level official count over the
// web-backfilled per-policy figure. #88 β€” ONLY the insurer's officially
// published total is ever shown as a number; when the insurer publishes
// none we say "cashless network β€” see official list" (the link sits in
// the panel header) rather than assert the unsourced backfilled figure.
const cNetwork = policy?.network_count_official ?? null;
const cCashless = coalesceBool(
policy?.cashless_treatment_supported,
facts.cashless,
);
const cAyush = coalesceBool(policy?.ayush_coverage, facts.ayush);
const cMaternity = coalesceBool(policy?.maternity_coverage, facts.maternity);
const initWait = policy?.initial_waiting_period_days ?? null;
const ncb = policy?.no_claim_bonus_pct ?? null;
const csr = facts.csrPct ?? null;
const dayCare = facts.dayCareCount ?? null;
const minEntry = policy?.min_entry_age ?? null;
const maxEntry = policy?.max_entry_age ?? facts.maxEntryAge ?? null;
const roomRent =
(policy?.room_rent_capping && policy.room_rent_capping.trim()) ||
(facts.noRoomRentCap
? "No room rent cap"
: facts.roomRentCapText || "");
const fmtNet = (nh: number) =>
nh >= 1000 ? `${Math.round(nh / 1000)}K+` : `${nh}`;
const push = (
arr: SnapRow[],
label: string,
value: string | null,
term?: GlossaryKey,
) => {
if (value != null && value !== "") arr.push({ label, value, term });
};
// 1 β€” WHAT YOU GET
const get: SnapRow[] = [];
// #76 β€” when the plan publishes no fixed SI ("As per policy schedule")
// BUT the customer stated/selected a cover, the premium IS priced at
// their cover β€” so show it, with a clear label, instead of the
// dismissive placeholder that reads as if their input was discarded.
let _coverVal = fmtSumInsured(policy);
const _custSI = profile?.desired_sum_insured_inr;
if (_coverVal === "As per policy schedule" && _custSI && _custSI > 0) {
const _c =
_custSI >= 10_000_000
? `${+(_custSI / 10_000_000).toFixed(1)} Cr`
: `${+(_custSI / 100_000).toFixed(1)} L`;
_coverVal = `β‚Ή${_c} (your chosen cover)`;
}
get.push({ label: "Cover amount", value: _coverVal, term: "cover" });
push(
get,
"No-claim bonus",
ncb == null
? null
: ncb === 0
? "None"
: `+${ncb}% cover for each claim-free year`,
"ncb",
);
push(
get,
"Cashless treatment",
cCashless === true
? cNetwork && cNetwork > 0
? `Yes Β· ${fmtNet(cNetwork)}+ network hospitals`
: "Yes Β· cashless network β€” see official list"
: cCashless === false
? "Not available"
: null,
"cashless",
);
// 2 β€” WHO QUALIFIES & WHEN COVER STARTS
const eligible: SnapRow[] = [];
const minStr =
minEntry != null
? minEntry >= 30 && minEntry <= 365
? `${minEntry} days`
: `${minEntry} yrs`
: null;
const maxStr = maxEntry != null ? `${maxEntry} yrs` : null;
const ageRange =
minStr && maxStr ? `${minStr} – ${maxStr}` : minStr || maxStr || "";
push(
eligible,
"Who can buy + renew",
ageRange ? `${ageRange} Β· lifelong renewal` : null,
"entry",
);
push(
eligible,
"Wait before any claim",
initWait == null
? null
: initWait === 0
? "None"
: `${initWait} days from start`,
"initwait",
);
push(
eligible,
"Wait if you already had a condition",
cPed == null ? null : cPed === 0 ? "None" : `${cPed} months`,
"ped",
);
push(
eligible,
"Claims actually paid",
csr == null ? null : `${csr}% of claims settled`,
"csr",
);
// 3 β€” YOUR SHARE & THE LIMITS
const limits: SnapRow[] = [];
// #84 β€” the only decision-critical co-pay question is whether the policy
// FORCES a share on every claim. The exact % the user opts into is set
// later on the pricing slider; what matters here is mandatory-or-not (a
// hard minimum is a real consideration). So: binary first, figure second.
// #30 β€” both rows render on EVERY card with an explicit "Not specified"
// fallback (never omitted), so this section is consistent across
// policies instead of showing whichever single field happened to be
// non-null (which read as random to the user).
push(
limits,
"Mandatory co-pay",
cCopay == null
? "Not specified"
: cCopay === 0
? "None β€” no forced co-pay"
: `Yes Β· ${cCopay}% minimum on every claim`,
"copay",
);
push(
limits,
"Hospital room category",
roomRent ? roomRent : "Not specified",
"room",
);
// CONDITIONAL β€” profile-aware, never headline
const ctx = `${(profile?.dependents || "").toLowerCase()} ${(
profile?.primary_goal || ""
).toLowerCase()}`;
const familyCtx =
/spouse|wife|husband|partner|couple|family|kid|child|son|daughter|matern|newborn|pregnan/.test(
ctx,
);
const situational: SnapRow[] = [];
if (cMaternity != null)
situational.push({
label: "Maternity & newborn",
value: cMaternity
? policy?.maternity_waiting_months
? `Covered after ${policy.maternity_waiting_months}-month wait`
: "Covered"
: "Not covered",
relevant: familyCtx,
term: "maternity",
});
if (cAyush != null)
situational.push({
label: "AYUSH (Ayurveda, Yoga, Unani, Siddha, Homeopathy)",
value: cAyush ? "Covered" : "Not covered",
term: "ayush",
});
if (dayCare != null)
situational.push({
label: "Day-care procedures",
value: `${dayCare} covered`,
term: "daycare",
});
return {
groups: [
{
key: "get",
title: "What you get",
sub: "The cover, bonus and cashless reach this policy gives you.",
rows: get,
},
{
key: "eligible",
title: "Who qualifies & when cover starts",
sub: "Entry age, renewal, and the waits before you can actually claim.",
rows: eligible,
},
{
key: "limits",
title: "Your share & the limits",
sub: "What you pay out of pocket and the caps that apply.",
rows: limits,
},
],
situational,
anyRelevant: situational.some((s) => s.relevant),
};
}
// Shared presentational layer for the snapshot. Pure CSS classes (.snap-*
// in globals.css) so the SAME elevated editorial chrome renders in the
// detail modal and the compare card β€” single source of truth for both the
// grouping logic AND the look.
const SNAP_ACCENT: Record<SnapGroup["key"], string> = {
get: "var(--primary)",
eligible: "#5b6bb5",
limits: "#c98a2b",
};
export function SnapshotView({
policy,
facts,
profile,
}: {
policy?: MarketplacePolicy;
facts: ScorecardFacts;
profile?: SnapProfile;
}) {
const { groups, situational, anyRelevant } = buildSnapshot(
policy,
facts,
profile,
);
// Auto-open the situational disclosure once the profile resolves and a
// conditional fact turns relevant. useState's initializer alone is stale
// here: on first render `completeness` is still loading β†’ anyRelevant is
// false β†’ the panel would stay collapsed even after the profile arrives
// and the "Relevant to you" pill appears. The effect re-opens it when
// relevance flips true; the user can still collapse it manually after.
const [openSit, setOpenSit] = useState(anyRelevant);
useEffect(() => {
if (anyRelevant) setOpenSit(true);
}, [anyRelevant]);
return (
<div className="snap-stack">
{groups.map((g) =>
g.rows.length === 0 ? null : (
<div
key={g.key}
className="snap-group"
style={
{ ["--snap-accent" as string]: SNAP_ACCENT[g.key] } as CSSProperties
}
>
<div className="snap-head">
<span className="snap-dot" />
<span className="snap-title">{g.title}</span>
</div>
<p className="snap-sub">{g.sub}</p>
<dl className="snap-rows">
{g.rows.map((r, i) => (
<div key={i} className="snap-row">
<dt>
{r.label}
<GlossaryTip term={r.term} />
</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
</div>
),
)}
{situational.length > 0 && (
<div className="snap-group snap-group--sit">
<button
type="button"
onClick={() => setOpenSit((o) => !o)}
className="snap-sit-toggle"
aria-expanded={openSit}
>
<span className="snap-head" style={{ marginBottom: 0 }}>
<span className="snap-dot" />
<span className="snap-title">Situational coverage</span>
</span>
{anyRelevant && (
<span className="snap-rel-pill">Relevant to you</span>
)}
<span className="snap-chevron" data-open={openSit} aria-hidden>
βŒ„
</span>
</button>
{openSit && (
<dl className="snap-rows" style={{ marginTop: 10 }}>
{situational.map((r, i) => (
<div
key={i}
className="snap-row"
data-rel={r.relevant ? "true" : "false"}
>
<dt>
{r.label}
<GlossaryTip term={r.term} />
{r.relevant && <span className="snap-rel-tag">for you</span>}
</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
)}
</div>
)}
</div>
);
}
// Full marketplace-parity coverage block. ALWAYS rendered (the section
// shell, the 4 headline stats AND the detailed spec list) so every column
// has the identical structure β€” missing values degrade to "β€”". When no
// marketplace row is wired at all, a thin context line explains it but the
// structure (and the citation-derived facts) still render.
// #75 + #64 β€” the compare card's snapshot is now the SAME decision-ordered
// editorial lens as the marketplace detail modal (one shared SnapshotView).
// Profile-aware: situational facts (maternity/AYUSH/day-care) only
// auto-surface when the user's profile makes them relevant.
function PolicyHighlights({
policy,
facts,
profile,
}: {
policy?: MarketplacePolicy;
// Scorecard-recovered facts β€” the SAME data the scorecard read, so a
// policy whose scorecard has full detail never renders an empty snapshot.
facts: ScorecardFacts;
profile?: SnapProfile;
}) {
return (
<Section title="Policy snapshot">
<SnapshotView policy={policy} facts={facts} profile={profile} />
</Section>
);
}
// Section header β€” a brand-tinted eyebrow with a hairline rule that grows
// from the label, echoing the landing's "titled chapter" section pattern.
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
marginBottom: 10,
}}
>
<span
aria-hidden
style={{
width: 5,
height: 5,
borderRadius: 999,
background: "var(--primary)",
flexShrink: 0,
}}
/>
<span
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.12em",
color:
"color-mix(in srgb, var(--primary) 78%, var(--muted-foreground))",
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{title}
</span>
<span
aria-hidden
style={{
flex: 1,
height: 1,
background:
"linear-gradient(90deg, color-mix(in srgb, var(--primary) 28%, var(--border)) 0%, var(--border) 35%, transparent 100%)",
}}
/>
</div>
{children}
</div>
);
}
function PlaceholderWidget({ label }: { label: string }) {
return (
<div
style={{
background: "var(--muted)",
border: "1px dashed var(--border)",
borderRadius: 12,
padding: "16px 12px",
textAlign: "center",
fontSize: 11.5,
color: "var(--muted-foreground)",
}}
>
{label}
</div>
);
}
// Default scorecard preview β€” fetches `/api/policies/:id/scorecard` and
// renders a compact grade + one-liner card. Used when the parent doesn't
// pass a renderScorecardFor() prop.
function ScorecardFallback({ policyId }: { policyId: string }) {
const [sc, setSc] = useState<ScorecardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(false);
getScorecard(policyId)
.then((r) => {
if (!cancelled) setSc(r);
})
.catch(() => {
if (!cancelled) setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [policyId]);
if (loading) {
return <PlaceholderWidget label="Loading scorecard…" />;
}
if (error || !sc) {
return <PlaceholderWidget label="Scorecard unavailable" />;
}
const chip = gradeChip(sc.grade);
return (
<div
style={{
background: "color-mix(in srgb, var(--primary) 3%, var(--muted))",
border: "1px solid var(--border)",
borderRadius: 14,
padding: 14,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 42,
height: 42,
borderRadius: 12,
fontFamily: "var(--font-serif)",
fontOpticalSizing: "auto",
fontSize: sc.grade.length > 1 ? 17 : 21,
fontWeight: 600,
letterSpacing: "-0.02em",
color: chip.fg,
background: chip.bg,
border: `1px solid ${chip.ring}`,
flexShrink: 0,
}}
>
{sc.grade}
</span>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--foreground)",
lineHeight: 1.2,
fontVariantNumeric: "tabular-nums",
}}
>
{sc.overall_score}
<span
style={{
color: "var(--muted-foreground)",
fontSize: 10,
fontWeight: 500,
}}
>
{" "}
/ 100
</span>
</div>
<div
className="truncate"
style={{
fontSize: 10.5,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
{sc.one_liner}
</div>
</div>
</div>
</div>
);
}
function PolicyDetails({
citation,
policy,
}: {
citation: Citation;
policy?: MarketplacePolicy;
}) {
const [open, setOpen] = useState(false);
const hasSource = !!citation.source_url && citation.source_url.startsWith("http");
const pageRange =
citation.page_start && citation.page_end
? citation.page_start === citation.page_end
? `p. ${citation.page_start}`
: `pp. ${citation.page_start}–${citation.page_end}`
: null;
return (
<div
style={{
border: "1px solid var(--border)",
borderRadius: 14,
background: "var(--card)",
overflow: "hidden",
}}
>
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left"
style={{
padding: "11px 13px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
background: open
? "color-mix(in srgb, var(--primary) 5%, var(--card))"
: "var(--card)",
cursor: "pointer",
transition: "background .15s ease",
}}
aria-expanded={open}
>
<span
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.12em",
color:
"color-mix(in srgb, var(--primary) 70%, var(--muted-foreground))",
fontWeight: 700,
}}
>
Policy details
</span>
<span
aria-hidden
style={{
fontSize: 16,
lineHeight: 1,
color: "var(--primary)",
fontWeight: 500,
}}
>
{open ? "βˆ’" : "+"}
</span>
</button>
{open && (
<div
style={{
padding: "12px 13px 13px",
borderTop: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
gap: 9,
}}
>
<DetailRow label="Policy" value={citation.policy_name} />
<DetailRow
label="Insurer"
value={citation.insurer_slug.replace(/-/g, " ")}
/>
{pageRange && <DetailRow label="Cited" value={pageRange} />}
{/* #66 β€” enrich the sparse expander with the key decision facts
already on the resolved marketplace row (was just name +
insurer + PDF, which the user flagged as pointless). */}
{policy &&
(() => {
const rows: [string, string][] = [];
const cov = fmtSumInsured(policy);
if (cov) rows.push(["Cover", cov]);
if (policy.grade)
rows.push([
"Grade",
`${policy.grade} Β· ${policy.overall_score}/100`,
]);
if (policy.no_claim_bonus_pct != null)
rows.push([
"No-claim bonus",
`+${policy.no_claim_bonus_pct}% per claim-free year`,
]);
if (policy.pre_existing_disease_waiting_months != null)
rows.push([
"Pre-existing wait",
`${policy.pre_existing_disease_waiting_months} months`,
]);
if (policy.initial_waiting_period_days != null)
rows.push([
"Initial wait",
`${policy.initial_waiting_period_days} days`,
]);
if (policy.room_rent_capping)
rows.push(["Room rent", policy.room_rent_capping]);
if (policy.copayment_pct != null)
rows.push([
"Co-pay",
policy.copayment_pct ? `${policy.copayment_pct}%` : "None",
]);
if (policy.network_count_official != null)
rows.push([
"Network",
`${policy.network_count_official.toLocaleString(
"en-IN",
)}+ cashless hospitals`,
]);
if (policy.max_entry_age != null)
rows.push(["Max entry age", `${policy.max_entry_age} yrs`]);
return rows.map(([l, v]) => (
<DetailRow key={l} label={l} value={v} />
));
})()}
{hasSource ? (
<a
href={citation.source_url}
target="_blank"
rel="noopener"
className="hover:underline"
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
fontSize: 11.5,
fontWeight: 600,
color: "var(--primary)",
marginTop: 2,
}}
>
Open policy PDF β†’
</a>
) : (
<span
style={{
fontSize: 11,
color: "var(--muted-foreground)",
fontStyle: "italic",
}}
>
No source PDF link available
</span>
)}
</div>
)}
</div>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: "flex", gap: 10 }}>
<span
style={{
fontSize: 9.5,
textTransform: "uppercase",
letterSpacing: "0.07em",
color: "var(--muted-foreground)",
fontWeight: 600,
width: 60,
flexShrink: 0,
paddingTop: 1,
}}
>
{label}
</span>
<span
className="break-words"
style={{
fontSize: 12,
color: "var(--foreground)",
}}
>
{value}
</span>
</div>
);
}