rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
fc6581d
·
1 Parent(s): a16d228

fix(pricing): KI-277 — F6 Option B+ curated vs non-curated premium UI

Browse files

User clarified: per-policy pricing is only real for the 26 curated policies
in illustrative_premiums.json. For the other ~124 policies in the corpus
we have NO quote data — the flat heuristic just reuses the same buyer math
across them, giving false per-policy precision.

Implementation of Option B+:
- Curated policy (base_sample_used=true): existing slider widget renders
unchanged — real quote-anchored estimate with SI/tenure/deductible
sliders + ±15% range + methodology footnote. (Removed the now-redundant
"Estimate" badge since this branch is curated-only.)
- Non-curated policy (base_sample_used=false): NEW NonCuratedPricingNotice
sub-component renders instead. Blue-accented info card. Reads:
"We don't have a verified quote for [Policy Name] yet, so we can't
show a plan-specific estimate. Based on your profile, plans in this
category typically cost: ₹X-Y/year."
+ median sub-line + sample size + footnote pointing to PolicyBazaar /
InsuranceDekho for an exact quote.

Wiring:
- PolicyPremiumWidget accepts aggregateBand prop (typed PredictedPremiumBandResponse).
- PolicyCompareModal accepts aggregateBand and forwards via the existing
renderPremiumFor closure (parent closes over the band; modal just
declares the contract).
- page.tsx Message + CitedPolicyCards thread premiumBand state down to
the modal instance + the inline renderPremiumFor closure.

So now the user sees ONE consistent story:
- For 26 curated policies: real quote estimate
- For the other ~124: "we don't have a quote — here's your aggregate band"
- Header chip: aggregate band (always there, scoped clearly via F4 labels)

Verification:
- npx tsc --noEmit clean
- npx next build succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

frontend/src/components/PolicyCompareModal.tsx CHANGED
@@ -120,6 +120,21 @@ export type PolicyCompareModalProps = {
120
  policyDataFor?: (policyId: string) => MarketplacePolicy | undefined;
121
  // Hook for "Open in full marketplace" — defaults to no-op + closes modal.
122
  onOpenMarketplace?: () => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  };
124
 
125
  export default function PolicyCompareModal({
@@ -130,6 +145,8 @@ export default function PolicyCompareModal({
130
  profile: _profile,
131
  policyDataFor,
132
  onOpenMarketplace,
 
 
133
  }: PolicyCompareModalProps) {
134
  const uniq = uniquePolicies(policies).slice(0, 4);
135
  const n = uniq.length;
 
120
  policyDataFor?: (policyId: string) => MarketplacePolicy | undefined;
121
  // Hook for "Open in full marketplace" — defaults to no-op + closes modal.
122
  onOpenMarketplace?: () => void;
123
+ // User's profile-level predicted premium band (same number rendered in
124
+ // the chat header chip). Threaded down to PolicyPremiumWidget so that
125
+ // non-curated policies (base_sample_used: false) can surface the band
126
+ // as their indicative reference instead of a heuristic slider estimate.
127
+ // Optional: when omitted, non-curated widgets fall back to a "band not
128
+ // available" hint. The parent (page.tsx) typically closes over the same
129
+ // value inside renderPremiumFor too — this prop is the declarative
130
+ // contract for future callers.
131
+ aggregateBand?: {
132
+ min_inr: number;
133
+ max_inr: number;
134
+ median_inr: number;
135
+ sample_size?: number;
136
+ assumed?: boolean;
137
+ } | null;
138
  };
139
 
140
  export default function PolicyCompareModal({
 
145
  profile: _profile,
146
  policyDataFor,
147
  onOpenMarketplace,
148
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
149
+ aggregateBand: _aggregateBand,
150
  }: PolicyCompareModalProps) {
151
  const uniq = uniquePolicies(policies).slice(0, 4);
152
  const n = uniq.length;
frontend/src/components/PolicyPremiumWidget.tsx CHANGED
@@ -38,6 +38,18 @@ export type PolicyPremiumWidgetProps = {
38
  initialTenureYears?: 1 | 2 | 3;
39
  initialDeductibleInr?: 0 | 25000 | 50000 | 100000;
40
  onCalculated?: (premium: number) => void;
 
 
 
 
 
 
 
 
 
 
 
 
41
  };
42
 
43
  const SUM_INSURED_MIN = 500_000;
@@ -114,6 +126,7 @@ export default function PolicyPremiumWidget({
114
  initialTenureYears = 1,
115
  initialDeductibleInr = 0,
116
  onCalculated,
 
117
  }: PolicyPremiumWidgetProps) {
118
  const [sumInsured, setSumInsured] = useState<number>(initialSumInsured);
119
  const [tenureYears, setTenureYears] = useState<1 | 2 | 3>(initialTenureYears);
@@ -181,10 +194,21 @@ export default function PolicyPremiumWidget({
181
  }, [fetchPremium]);
182
 
183
  const profileSummary = summariseProfile(profile);
184
- // `base_sample_used: false` means the curated illustrative_premiums.json
185
- // had no anchor sample for this policy estimate() fell back to the generic
186
- // FALLBACK_BASE_INR path. Same semantics as the old bulk row.assumed flag.
187
- const isHeuristic = resp ? resp.base_sample_used === false : false;
 
 
 
 
 
 
 
 
 
 
 
188
 
189
  // Compact, profile-only breakdown — the estimate endpoint doesn't expose
190
  // a multiplicative chain so we surface the active overrides + methodology
@@ -208,11 +232,10 @@ export default function PolicyPremiumWidget({
208
  <div className="policy-premium-widget" style={widgetStyle}>
209
  <header style={headerStyle}>
210
  <div style={{ fontWeight: 600, fontSize: 14 }}>{policyName}</div>
211
- {isHeuristic && (
212
- <span style={badgeStyle} title="Heuristic no exact actuarial data for this policy.">
213
- Estimate
214
- </span>
215
- )}
216
  </header>
217
 
218
  {profileSummary && (
@@ -316,6 +339,81 @@ export default function PolicyPremiumWidget({
316
  );
317
  }
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  /* ------------------------------------------------------------------ */
320
  /* Inline styles — kept local so the widget drops into any modal */
321
  /* without a CSS-module dependency. */
@@ -339,16 +437,6 @@ const headerStyle: React.CSSProperties = {
339
  gap: 8,
340
  };
341
 
342
- const badgeStyle: React.CSSProperties = {
343
- fontSize: 11,
344
- fontWeight: 600,
345
- padding: "2px 8px",
346
- borderRadius: 999,
347
- background: "#fff8e1",
348
- color: "#8a6d00",
349
- border: "1px solid #f1d680",
350
- };
351
-
352
  const profileLineStyle: React.CSSProperties = {
353
  fontSize: 12,
354
  color: "#666",
@@ -436,3 +524,92 @@ const noteStyle: React.CSSProperties = {
436
  color: "#6b7280",
437
  fontStyle: "italic",
438
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  initialTenureYears?: 1 | 2 | 3;
39
  initialDeductibleInr?: 0 | 25000 | 50000 | 100000;
40
  onCalculated?: (premium: number) => void;
41
+ // User's profile-level predicted premium band (the same number rendered in
42
+ // the header chip / `getPredictedPremiumBand`). Surfaced as the indicative
43
+ // reference when the backend reports `base_sample_used: false` (i.e. no
44
+ // curated quote sample for this specific policy). Threaded down from
45
+ // PolicyCompareModal so we don't refetch per-column.
46
+ aggregateBand?: {
47
+ min_inr: number;
48
+ max_inr: number;
49
+ median_inr: number;
50
+ sample_size?: number;
51
+ assumed?: boolean;
52
+ } | null;
53
  };
54
 
55
  const SUM_INSURED_MIN = 500_000;
 
126
  initialTenureYears = 1,
127
  initialDeductibleInr = 0,
128
  onCalculated,
129
+ aggregateBand,
130
  }: PolicyPremiumWidgetProps) {
131
  const [sumInsured, setSumInsured] = useState<number>(initialSumInsured);
132
  const [tenureYears, setTenureYears] = useState<1 | 2 | 3>(initialTenureYears);
 
194
  }, [fetchPremium]);
195
 
196
  const profileSummary = summariseProfile(profile);
197
+
198
+ // Option B+: when the backend has no curated quote sample for this policy,
199
+ // hide the slider widget entirely and surface the user's aggregate band
200
+ // (same number rendered in the header chip) as the indicative reference.
201
+ // We wait for the first response before deciding so we don't flash the
202
+ // notice while loading. Loading + error states fall through to the slider
203
+ // widget below (which renders its own loading/error UI).
204
+ if (resp && resp.base_sample_used === false) {
205
+ return (
206
+ <NonCuratedPricingNotice
207
+ policyName={policyName}
208
+ aggregateBand={aggregateBand ?? null}
209
+ />
210
+ );
211
+ }
212
 
213
  // Compact, profile-only breakdown — the estimate endpoint doesn't expose
214
  // a multiplicative chain so we surface the active overrides + methodology
 
232
  <div className="policy-premium-widget" style={widgetStyle}>
233
  <header style={headerStyle}>
234
  <div style={{ fontWeight: 600, fontSize: 14 }}>{policyName}</div>
235
+ {/* Curated-only branch: by the time we render this widget,
236
+ base_sample_used is guaranteed not false (the !== false branch
237
+ short-circuits to NonCuratedPricingNotice above). No "Estimate"
238
+ badge needed here — the number is anchored to a real quote sample. */}
 
239
  </header>
240
 
241
  {profileSummary && (
 
339
  );
340
  }
341
 
342
+ /* ------------------------------------------------------------------ */
343
+ /* NonCuratedPricingNotice — rendered in place of the slider widget */
344
+ /* when /api/premium/estimate reports base_sample_used: false. Shows */
345
+ /* the user's aggregate predicted-premium band (threaded down from */
346
+ /* PolicyCompareModal) as the indicative reference + a clear pricing- */
347
+ /* note explaining we don't have a policy-specific quote. */
348
+ /* ------------------------------------------------------------------ */
349
+
350
+ function NonCuratedPricingNotice({
351
+ policyName,
352
+ aggregateBand,
353
+ }: {
354
+ policyName: string;
355
+ aggregateBand: {
356
+ min_inr: number;
357
+ max_inr: number;
358
+ median_inr: number;
359
+ sample_size?: number;
360
+ assumed?: boolean;
361
+ } | null;
362
+ }) {
363
+ const hasBand = !!aggregateBand;
364
+ return (
365
+ <div style={noticeWidgetStyle} role="note" aria-label="Pricing note">
366
+ <header style={noticeHeaderStyle}>
367
+ <span style={noticeIconStyle} aria-hidden="true">
368
+ {/* info icon — pure SVG so we don't pull a new dependency */}
369
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
370
+ <circle cx="12" cy="12" r="10" />
371
+ <line x1="12" y1="8" x2="12" y2="8" />
372
+ <line x1="12" y1="12" x2="12" y2="16" />
373
+ </svg>
374
+ </span>
375
+ <span style={noticeBadgeStyle}>Pricing note</span>
376
+ </header>
377
+
378
+ <p style={noticeBodyStyle}>
379
+ We don&apos;t have a verified quote for <strong>{policyName}</strong> yet,
380
+ so we can&apos;t show a plan-specific estimate. Based on your profile,
381
+ plans in this category typically cost:
382
+ </p>
383
+
384
+ <div style={noticeBandBoxStyle}>
385
+ {hasBand ? (
386
+ <>
387
+ <div style={noticeBandHeadlineStyle}>
388
+ ₹{formatInr(aggregateBand!.min_inr)}–₹{formatInr(aggregateBand!.max_inr)}
389
+ <span style={noticeBandSuffixStyle}>&nbsp;/ year</span>
390
+ </div>
391
+ <div style={noticeBandMedianStyle}>
392
+ Median ₹{formatInr(aggregateBand!.median_inr)}/year
393
+ {typeof aggregateBand!.sample_size === "number" &&
394
+ aggregateBand!.sample_size > 0 && (
395
+ <> · across {aggregateBand!.sample_size} similar profiles</>
396
+ )}
397
+ </div>
398
+ </>
399
+ ) : (
400
+ <div style={noticeBandFallbackStyle}>
401
+ Profile-level band not available yet — complete your profile to see
402
+ an indicative range.
403
+ </div>
404
+ )}
405
+ </div>
406
+
407
+ <p style={noticeFootnoteStyle}>
408
+ This is an indicative range — the actual premium depends on the
409
+ insurer&apos;s underwriting + your final disclosures. To get an exact
410
+ quote, request one from the insurer directly or via
411
+ PolicyBazaar / InsuranceDekho.
412
+ </p>
413
+ </div>
414
+ );
415
+ }
416
+
417
  /* ------------------------------------------------------------------ */
418
  /* Inline styles — kept local so the widget drops into any modal */
419
  /* without a CSS-module dependency. */
 
437
  gap: 8,
438
  };
439
 
 
 
 
 
 
 
 
 
 
 
440
  const profileLineStyle: React.CSSProperties = {
441
  fontSize: 12,
442
  color: "#666",
 
524
  color: "#6b7280",
525
  fontStyle: "italic",
526
  };
527
+
528
+ /* ---------------- NonCuratedPricingNotice styles ------------------- */
529
+ /* Same outer card framing as the slider widget (border + radius + bg) */
530
+ /* but with an amber/blue informational accent so users immediately */
531
+ /* distinguish "indicative range" from "calculated estimate". */
532
+
533
+ const noticeWidgetStyle: React.CSSProperties = {
534
+ border: "1px solid #bfdbfe", // soft blue
535
+ borderLeft: "4px solid #2563eb", // strong blue accent strip
536
+ borderRadius: 12,
537
+ padding: 16,
538
+ background: "#f8fbff",
539
+ display: "flex",
540
+ flexDirection: "column",
541
+ gap: 10,
542
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
543
+ };
544
+
545
+ const noticeHeaderStyle: React.CSSProperties = {
546
+ display: "flex",
547
+ alignItems: "center",
548
+ gap: 6,
549
+ };
550
+
551
+ const noticeIconStyle: React.CSSProperties = {
552
+ display: "inline-flex",
553
+ alignItems: "center",
554
+ justifyContent: "center",
555
+ width: 20,
556
+ height: 20,
557
+ borderRadius: 999,
558
+ color: "#2563eb",
559
+ };
560
+
561
+ const noticeBadgeStyle: React.CSSProperties = {
562
+ fontSize: 10,
563
+ fontWeight: 700,
564
+ letterSpacing: "0.06em",
565
+ textTransform: "uppercase",
566
+ color: "#1d4ed8",
567
+ };
568
+
569
+ const noticeBodyStyle: React.CSSProperties = {
570
+ margin: 0,
571
+ fontSize: 12.5,
572
+ lineHeight: 1.55,
573
+ color: "#374151",
574
+ };
575
+
576
+ const noticeBandBoxStyle: React.CSSProperties = {
577
+ background: "#fff",
578
+ border: "1px solid #dbeafe",
579
+ borderRadius: 10,
580
+ padding: "10px 12px",
581
+ display: "flex",
582
+ flexDirection: "column",
583
+ gap: 2,
584
+ };
585
+
586
+ const noticeBandHeadlineStyle: React.CSSProperties = {
587
+ fontSize: 16,
588
+ fontWeight: 700,
589
+ color: "#0f172a",
590
+ };
591
+
592
+ const noticeBandSuffixStyle: React.CSSProperties = {
593
+ fontSize: 12,
594
+ fontWeight: 400,
595
+ color: "#6b7280",
596
+ };
597
+
598
+ const noticeBandMedianStyle: React.CSSProperties = {
599
+ fontSize: 11.5,
600
+ color: "#475569",
601
+ };
602
+
603
+ const noticeBandFallbackStyle: React.CSSProperties = {
604
+ fontSize: 12,
605
+ color: "#92400e",
606
+ fontStyle: "italic",
607
+ };
608
+
609
+ const noticeFootnoteStyle: React.CSSProperties = {
610
+ margin: 0,
611
+ fontSize: 11,
612
+ lineHeight: 1.5,
613
+ color: "#6b7280",
614
+ fontStyle: "italic",
615
+ };