// PolicyScorecardWidget — profile-aware A/B+/etc grade card for a single // policy. Mounted inside PolicyCompareModal once per policy. // // Why a per-policy widget (rather than a single multi-policy table): // - PolicyCompareModal renders 2-4 of these side-by-side in a flex row. // - Each card is self-contained: insurer logo strip, header grade + // overall score, sub-score bars with their *drivers* (the concrete // policy fields that moved each bar), a prominent "Why this fits you" // personalisation panel, and a data-completeness warning. // - The fetch is one bulk POST per widget mount (single-policy), but the // SAME endpoint can be batched by a parent that wants to issue one call // for all N policies — we expose `precomputed` for that case. // // Ranking is UNIQUE TO THE USER's PROFILE. The backend's // _profile_tuned_weights() re-balances the 6 sub-score weights based on: // - diabetes / BP / hyper -> heavier waiting-period + claim-experience // - age >= 50 -> heavier renewal-protection // - dependents = spouse -> heavier coverage-breadth + cost-predictability // - existing_cover_inr=0 -> heavier cost-predictability (first-time buyer) // So the same policy will literally score differently for different users. // // ── Visual system ────────────────────────────────────────────────────── // Re-grounded on the premium editorial-fintech landing (see app/globals.css): // Fraunces display serif for the grade + score numerals, Plus Jakarta for // UI chrome, the teal --primary token, color-mix soft depth, the kicker // pill pattern for the "Personalised" tag, and tabular-nums everywhere a // number must align. All chrome reads from CSS variables so the card shifts // with the page's light/dark scheme. Reduced-motion is honoured. "use client"; import { useEffect, useMemo, useState } from "react"; import { postScorecardBulk, type BulkScorecardEntry, type BulkScorecardProfile, } from "@/lib/api"; export type PolicyScorecardWidgetProps = { policyId: string; policyName: string; profile?: BulkScorecardProfile; // When the parent has already fetched a bulk response, pass the entry // directly — avoids a second network call per widget. precomputed?: BulkScorecardEntry; // Optional callback so the parent can collect entries for analytics / // a ranking row above the cards. onLoaded?: (entry: BulkScorecardEntry) => void; className?: string; }; // Sub-score keys we know about — controls render order. Anything else the // backend returns gets appended after these in arrival order. const SUBSCORE_ORDER: { key: string; label: string }[] = [ { key: "coverage_breadth", label: "Coverage breadth" }, { key: "cost_predictability", label: "Cost predictability" }, { key: "waiting_period_friction", label: "Waiting periods" }, { key: "claim_experience", label: "Claim experience" }, { key: "renewal_protection", label: "Renewal protection" }, { key: "bonus_and_loyalty", label: "Bonuses" }, ]; // Insurer brand assets — forked from PolicyCompareModal so the scorecard // block can carry the same logo top-left without importing from a sibling // (keeps each widget self-contained; both maps stay in lock-step). const INSURER_COLOR: Record = { "aditya-birla": "#ea580c", "bajaj-allianz": "#1d4ed8", "care-health": "#047857", "hdfc-ergo": "#be123c", "icici-lombard": "#f97316", "manipalcigna": "#a21caf", "new-india": "#4338ca", "niva-bupa": "#0e7490", "star-health": "#d97706", "tata-aig": "#334155", }; const INSURER_LOGO_URL: Record = { "aditya-birla": "https://www.adityabirlacapital.com/healthinsurance/static/assets/images/abhi-logo.svg", "bajaj-allianz": "https://www.bajajallianz.com/content/dam/bagic/header/logo.png", "care-health": "https://www.careinsurance.com/upload_master/images/logo.png", "hdfc-ergo": "https://www.hdfcergo.com/etc.clientlibs/hdfcergo/clientlibs/clientlib-site/resources/images/HDFC-ERGO-Logo.png", "icici-lombard": "https://www.icicilombard.com/content/dam/ilom-website/icon/icici-lombard-logo-new.svg", "manipalcigna": "https://www.manipalcigna.com/o/manipal-cigna-theme/images/manipal-cigna-logo.svg", "new-india": "https://www.newindia.co.in/portal/readWriteData/NIAImages/NewLogo.png", "niva-bupa": "https://transactions.nivabupa.com/_next/static/media/niva-bupa-logo.7b6e7f4e.svg", "star-health": "https://www.starhealth.in/sites/default/files/star-logo-revised.png", "tata-aig": "https://www.tataaig.com/etc/designs/tataaig/clientlibs/responsive/images/tataaig-logo.svg", }; function insurerInitials(name: string): string { return name .split(/[\s-]+/) .map((w) => w[0]) .filter(Boolean) .join("") .slice(0, 2) .toUpperCase(); } // Small brand chip — image with a typeset-initials fallback. Used top-left // of the scorecard so the card is identifiable at a glance inside a row of // 2-4 side-by-side scorecards. function InsurerLogo({ slug, name, size = 36, }: { slug: string; name: string; size?: number; }) { const [failed, setFailed] = useState(false); const url = INSURER_LOGO_URL[slug]; const color = INSURER_COLOR[slug] || "#64748b"; if (!url || failed) { return (
{insurerInitials(name)}
); } return (
{/* eslint-disable-next-line @next/next/no-img-element */} {name} setFailed(true)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
); } // Humanise an insurer slug for the fallback initials / label. function humaniseSlug(slug: string): string { return slug .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } // Pretty label for a sub-score key when the backend hands us one we don't // have a curated label for. function prettyKey(k: string): string { return k.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase()); } // Serif display face + 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)"; // A→F grade ramp. Kept inside one tonal family per letter so a B+ reads as // a stronger sibling of a B-. The "A" tier is the brand teal; the rest walk // a calm green→amber→red gradient that still feels editorial, not alarmist. function gradeColor(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) 13%, var(--card))", ring: "color-mix(in srgb, var(--primary) 60%, 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)", }; } } // Sub-score bar fill — same calm ramp as the grade so the card never has // two competing color stories. function barColor(score: number): string { if (score >= 80) return "var(--primary)"; if (score >= 65) return "#3d9c98"; if (score >= 50) return "#cf9b3f"; if (score >= 35) return "#d4793b"; return "#cf4b4b"; } function rationaleTone(bullet: string): "pos" | "neg" | "neutral" { const lower = bullet.toLowerCase(); if (lower.startsWith("strong fit") || lower.startsWith("strongest")) return "pos"; if (lower.startsWith("weak fit") || lower.startsWith("watch out")) return "neg"; return "neutral"; } // Shared shell so loading / error / loaded states share identical framing // (no layout jump between states inside the compare grid). const shellStyle: React.CSSProperties = { borderRadius: 18, border: "1px solid var(--border)", background: "var(--card)", padding: 18, 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)", }; export default function PolicyScorecardWidget({ policyId, policyName, profile, precomputed, onLoaded, className, }: PolicyScorecardWidgetProps) { const [entry, setEntry] = useState(precomputed ?? null); const [loading, setLoading] = useState(!precomputed); const [error, setError] = useState(null); // Stabilise profile dependency: callers usually rebuild the object each // render but the values rarely change. Stringify-key the effect so we don't // re-fetch on identity churn. const profileKey = useMemo( () => (profile ? JSON.stringify(profile) : ""), [profile], ); useEffect(() => { if (precomputed) { setEntry(precomputed); setLoading(false); return; } let cancelled = false; setLoading(true); setError(null); postScorecardBulk({ policy_ids: [policyId], profile: profile ?? undefined, }) .then((resp) => { if (cancelled) return; const e = resp.per_policy?.[policyId]; if (!e) { setError("No scorecard returned for this policy."); setEntry(null); } else { setEntry(e); onLoaded?.(e); } }) .catch((err: unknown) => { if (cancelled) return; setError(err instanceof Error ? err.message : "Failed to load scorecard."); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; // policyId + profileKey + precomputed identity are the real deps; onLoaded // is intentionally excluded to avoid re-fetch loops if the parent passes // an inline arrow. // eslint-disable-next-line react-hooks/exhaustive-deps }, [policyId, profileKey, precomputed]); if (loading) { return (
Scoring {policyName}…
); } if (error || !entry) { return (
Couldn’t score this policy.{" "} {error ?? "Unknown error"}
); } const isNA = entry.overall_grade === "N/A"; const colors = gradeColor(entry.overall_grade); const completeness = entry.data_completeness_pct; const showLimitedWarning = completeness < 50 && !isNA; // Render sub-scores in the canonical order first, then any extras. const knownKeys = new Set(SUBSCORE_ORDER.map((s) => s.key)); const extras = Object.keys(entry.sub_scores).filter((k) => !knownKeys.has(k)); const renderable = [ ...SUBSCORE_ORDER.filter((s) => entry.sub_scores[s.key] !== undefined), ...extras.map((k) => ({ key: k, label: k.replace(/_/g, " ") })), ]; return (
{/* Insurer identity strip — logo top-left so a row of side-by-side scorecards is scannable without reading the policy name. */}
{entry.insurer_slug ? humaniseSlug(entry.insurer_slug) : "Fit scorecard"}
{/* Header: grade medallion + overall score */}
1 ? 26 : 32, fontWeight: 600, letterSpacing: "-0.02em", flexShrink: 0, boxShadow: "inset 0 1px 0 color-mix(in srgb, #fff 50%, transparent), 0 2px 6px color-mix(in srgb, var(--foreground) 8%, transparent)", }} aria-label={`Grade ${entry.overall_grade}`} > {entry.overall_grade}
{entry.policy_name || policyName}
{isNA ? "—" : `${entry.overall_score}`} {!isNA && ( / 100 )}
{/* Task #31 — the deterministic profile_summary replaces the generic one_liner here. The first strength reads as the headline takeaway; the full list + caveat live in the "Why this fits you" panel below. Fall back to one_liner only when the structured summary is empty / insufficient. */} {entry.profile_summary && entry.profile_summary.strengths.length > 0 ? (
{entry.profile_summary.strengths[0]}
) : ( entry.one_liner && (
{entry.one_liner}
) )}
{/* "Personalised" tag — kicker-pill pattern from the landing. Full width so it never collides with a long policy name. */} {profile && !isNA && (
Personalised for you
)} {/* Sub-scores — labelled rows with tabular numerals + a calm fill */} {renderable.length > 0 && (
{renderable.map(({ key, label }) => { const v = entry.sub_scores[key] ?? 0; const pct = Math.max(0, Math.min(100, v)); // What drove THIS bar — the backend returns a per-sub-score // signal list (the concrete policy fields / rules that moved // the number). Surfacing it makes each bar auditable instead // of a mystery fill. const drivers = (entry.signals?.[key] ?? []) .map((s) => s.trim()) .filter(Boolean) .slice(0, 3); return (
{label} {v}
{drivers.length > 0 && (
    {drivers.map((d, di) => (
  • {d}
  • ))}
)}
); })}
Bars show what each criterion contributed — drivers under a bar are the exact policy fields that moved it.
)} {/* "Why this fits you" — the headline personalisation payoff. Given its own emphatic teal-tinted panel so it reads as the answer to the user's real question, not a footnote under the bars. Task #31 — prefer the deterministic profile_summary: strengths render in a positive tone (teal tick), the single caveat in a negative tone (red tick). When the structured summary is empty / insufficient, fall back to the legacy profile_rationale list (tone inferred via the existing rationaleTone()). */} {(() => { const ps = entry.profile_summary; const bullets: { text: string; tone: "pos" | "neg" | "neutral" }[] = ps && ps.strengths.length > 0 ? [ ...ps.strengths.map( (s) => ({ text: s, tone: "pos" as const }), ), ...(ps.caveat ? [{ text: ps.caveat, tone: "neg" as const }] : []), ] : entry.profile_rationale.map((b) => ({ text: b, tone: rationaleTone(b), })); if (bullets.length === 0) return null; return (
Why this fits you
    {bullets.map((b, i) => { const tickColor = b.tone === "pos" ? "var(--primary)" : b.tone === "neg" ? "#cf4b4b" : "var(--muted-foreground)"; return (
  • {b.text}
  • ); })}
); })()} {/* Limited-data warning — warm amber, single tidy row. Copy is branched per source: catalogued insurer cards reflect gaps in the insurer's own filings, but USER-UPLOADED PDFs are missing fields because OUR extractor couldn't pull them from the PDF text. Saying "the insurer hasn't published…" on an uploaded doc is just wrong — flip to honest copy there. */} {showLimitedWarning && (
Partial information · {policyId.startsWith("user-upload__") ? ( <> Some fields couldn't be pulled from this PDF automatically — open the document for the full wording before you decide. (Re-grading in the background as more fields are extracted.) ) : ( <> The insurer hasn't published every term for this policy yet, so this grade is an early read — open the policy PDF for the full wording before you decide. )}
)}
); }