"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 = { "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 = { "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 (
{insurerInitials(name)}
); } return (
{/* eslint-disable-next-line @next/next/no-img-element */} {name} setStage((s) => s + 1)} className="w-full h-full object-contain" />
); } // 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 ( PDF ); } // 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(); 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 (
e.stopPropagation()} > {/* Header — Fraunces display title + brand-tinted eyebrow */}
Side-by-side

Compare {n} polic{n === 1 ? "y" : "ies"}

Premiums, fit scores and policy details — aligned side-by-side.

{/* 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. */}
{n > 1 && (
Swipe to compare all {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) => (
1 ? "snap-center shrink-0 sm:shrink basis-[86%] sm:basis-auto min-w-0" : "min-w-0" } >
))}
{/* Footer */}
Comparing the policies cited in this reply. Open the full marketplace for filters and 30+ more options.
); } // 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({}); 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 (
{/* Header — logo + insurer + policy name, with a top-right PDF link */}
{insurerName}
{citation.policy_name}
{marketplacePolicy?.aliases && marketplacePolicy.aliases.length > 0 && (
Also marketed as: {marketplacePolicy.aliases.join(", ")}
)}
{/* Top-right policy-PDF link — uniform on every column. ~36px tall so it's a comfortable tap target on mobile. */} {pdf.isReal ? "Policy PDF" : "Find PDF"} {/* #86 — insurer's official network hospital list, beside the PDF */} {marketplacePolicy?.network_list_url && ( Hospitals list ↗ )}
{/* 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. */} {/* FIT SCORECARD slot (B3). Order is Details → Score → Pricing to match the marketplace PolicyDetailModal exactly (one consistent reading order everywhere a policy is shown). */}
{scorecardSlot ?? }
{/* 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. */}
{premiumSlot ?? }
{/* 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"). */}
{reviewsSlot ?? ( )}
{/* POLICY DETAILS expandable */}
); } // ──────────────────────────────────────────────────────────────────────── // 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 . 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 = { 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 ( ? {title} {body} ); } 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 = { 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 (
{groups.map((g) => g.rows.length === 0 ? null : (
{g.title}

{g.sub}

{g.rows.map((r, i) => (
{r.label}
{r.value}
))}
), )} {situational.length > 0 && (
{openSit && (
{situational.map((r, i) => (
{r.label} {r.relevant && for you}
{r.value}
))}
)}
)}
); } // 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 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 (
{title}
{children}
); } function PlaceholderWidget({ label }: { label: string }) { return (
{label}
); } // 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(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 ; } if (error || !sc) { return ; } const chip = gradeChip(sc.grade); return (
1 ? 17 : 21, fontWeight: 600, letterSpacing: "-0.02em", color: chip.fg, background: chip.bg, border: `1px solid ${chip.ring}`, flexShrink: 0, }} > {sc.grade}
{sc.overall_score} {" "} / 100
{sc.one_liner}
); } 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 (
{open && (
{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]) => ( )); })()} {hasSource ? ( Open policy PDF → ) : ( No source PDF link available )}
)}
); } function DetailRow({ label, value }: { label: string; value: string }) { return (
{label} {value}
); }