Spaces:
Sleeping
Sleeping
Commit ·
deefe3a
1
Parent(s): d486a56
#33: add "?" help affordance to every policy-calculation control
Browse filesShared HelpTip component (createPortal body-root popover, desktop
hover/focus + touch tap-toggle, Esc/outside/scroll dismiss, a11y
labelled) driven by one CALC_HELP_COPY map. Wired next to every
calc control across all 3 surfaces:
- ProfileBuilder sliders (sum insured / existing cover / co-pay / budget)
- PerPolicyPremiumEstimator (sum insured / co-pay)
- PolicyPremiumWidget (sum insured / tenure / deductible)
Deductible tip auto-absent where unsupported (sits inside the #29
supportsDeductible gate). Copy is purely qualitative — zero
digits/%/₹ (no-invented-numbers).
Verified: tsc 0, eslint 0-new vs baseline, static build OK,
copy-map digit scan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
frontend/src/app/globals.css
CHANGED
|
@@ -1105,3 +1105,78 @@ html, body { max-width: 100%; overflow-x: hidden; }
|
|
| 1105 |
font-weight: 400;
|
| 1106 |
letter-spacing: normal;
|
| 1107 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1105 |
font-weight: 400;
|
| 1106 |
letter-spacing: normal;
|
| 1107 |
}
|
| 1108 |
+
|
| 1109 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 1110 |
+
* HelpTip — the shared "?" affordance for policy-calculation controls.
|
| 1111 |
+
*
|
| 1112 |
+
* Visual chrome mirrors `.gtip-*` above (14px badge, var(--card) bg,
|
| 1113 |
+
* var(--border) hairline, Fraunces title, soft shadow, small arrow). The
|
| 1114 |
+
* difference is structural: the popover is portalled to <body> and given
|
| 1115 |
+
* `position:fixed` coords by HelpTip.tsx (the surfaces sit in overflow
|
| 1116 |
+
* scroll containers that clip an absolute popover), so this block carries
|
| 1117 |
+
* NO positioning — only appearance and the up/down arrow direction.
|
| 1118 |
+
* ───────────────────────────────────────────────────────────────────── */
|
| 1119 |
+
.helptip-badge {
|
| 1120 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 1121 |
+
width: 14px; height: 14px; border-radius: 999px;
|
| 1122 |
+
border: 1px solid color-mix(in srgb, var(--muted-foreground) 55%, transparent);
|
| 1123 |
+
background: transparent;
|
| 1124 |
+
color: var(--muted-foreground);
|
| 1125 |
+
font-size: 9px; font-weight: 700; line-height: 1;
|
| 1126 |
+
cursor: help; user-select: none; outline: none; padding: 0;
|
| 1127 |
+
margin-left: 5px;
|
| 1128 |
+
transition: color .15s ease, border-color .15s ease;
|
| 1129 |
+
}
|
| 1130 |
+
.helptip-badge:hover,
|
| 1131 |
+
.helptip-badge:focus-visible,
|
| 1132 |
+
.helptip-badge[aria-expanded="true"] {
|
| 1133 |
+
color: var(--primary);
|
| 1134 |
+
border-color: color-mix(in srgb, var(--primary) 60%, transparent);
|
| 1135 |
+
}
|
| 1136 |
+
.helptip-badge:focus-visible {
|
| 1137 |
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 35%, transparent);
|
| 1138 |
+
}
|
| 1139 |
+
.helptip-pop {
|
| 1140 |
+
width: min(20rem, 74vw);
|
| 1141 |
+
background: var(--card);
|
| 1142 |
+
border: 1px solid var(--border);
|
| 1143 |
+
border-radius: 12px;
|
| 1144 |
+
box-shadow:
|
| 1145 |
+
0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent),
|
| 1146 |
+
0 18px 44px -22px color-mix(in srgb, var(--foreground) 32%, transparent);
|
| 1147 |
+
padding: 11px 13px;
|
| 1148 |
+
text-align: left;
|
| 1149 |
+
}
|
| 1150 |
+
.helptip-pop::after {
|
| 1151 |
+
content: "";
|
| 1152 |
+
position: absolute;
|
| 1153 |
+
left: 50%;
|
| 1154 |
+
transform: translateX(-50%);
|
| 1155 |
+
border: 6px solid transparent;
|
| 1156 |
+
}
|
| 1157 |
+
.helptip-pop--above::after {
|
| 1158 |
+
top: 100%;
|
| 1159 |
+
border-top-color: var(--card);
|
| 1160 |
+
}
|
| 1161 |
+
.helptip-pop--below::after {
|
| 1162 |
+
bottom: 100%;
|
| 1163 |
+
border-bottom-color: var(--card);
|
| 1164 |
+
}
|
| 1165 |
+
.helptip-title {
|
| 1166 |
+
display: block;
|
| 1167 |
+
font-family: var(--font-serif);
|
| 1168 |
+
font-weight: 600;
|
| 1169 |
+
font-size: 12.5px;
|
| 1170 |
+
letter-spacing: -.01em;
|
| 1171 |
+
color: var(--foreground);
|
| 1172 |
+
margin-bottom: 4px;
|
| 1173 |
+
}
|
| 1174 |
+
.helptip-body {
|
| 1175 |
+
display: block;
|
| 1176 |
+
font-size: 11.5px;
|
| 1177 |
+
line-height: 1.5;
|
| 1178 |
+
color: var(--muted-foreground);
|
| 1179 |
+
text-transform: none;
|
| 1180 |
+
font-weight: 400;
|
| 1181 |
+
letter-spacing: normal;
|
| 1182 |
+
}
|
frontend/src/app/page.tsx
CHANGED
|
@@ -41,6 +41,7 @@ import type { PremiumBulkProfile, BulkScorecardProfile } from "@/lib/api";
|
|
| 41 |
// remains on disk as a graveyard reference until KI-168 is field-verified.
|
| 42 |
import { useStreamingVoice } from "@/lib/useStreamingVoice";
|
| 43 |
import { useIsTouch } from "@/lib/useIsTouch";
|
|
|
|
| 44 |
|
| 45 |
type DisplayMessage = ChatMessage & {
|
| 46 |
id: string;
|
|
@@ -2708,7 +2709,12 @@ function ProfileBuilderPanel({
|
|
| 2708 |
hint={hindi ? "ये premium अनुमान और cover-band की खोज तय करते हैं।" : "These drive the premium estimate and the cover band we search."}
|
| 2709 |
>
|
| 2710 |
<RupeeSlider
|
| 2711 |
-
label={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2712 |
value={desiredSI}
|
| 2713 |
min={300000}
|
| 2714 |
max={20000000}
|
|
@@ -2719,7 +2725,12 @@ function ProfileBuilderPanel({
|
|
| 2719 |
/>
|
| 2720 |
|
| 2721 |
<RupeeSlider
|
| 2722 |
-
label={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2723 |
value={existingCover}
|
| 2724 |
min={0}
|
| 2725 |
max={10000000}
|
|
@@ -2731,7 +2742,12 @@ function ProfileBuilderPanel({
|
|
| 2731 |
/>
|
| 2732 |
|
| 2733 |
<RupeeSlider
|
| 2734 |
-
label={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2735 |
value={copay}
|
| 2736 |
min={0}
|
| 2737 |
max={40}
|
|
@@ -2749,7 +2765,12 @@ function ProfileBuilderPanel({
|
|
| 2749 |
/>
|
| 2750 |
|
| 2751 |
<RupeeSlider
|
| 2752 |
-
label={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2753 |
value={budgetInr}
|
| 2754 |
min={5000}
|
| 2755 |
max={150000}
|
|
@@ -5127,7 +5148,7 @@ function PerPolicyPremiumEstimator({ policy, desiredSI }: { policy: MarketplaceP
|
|
| 5127 |
</div>
|
| 5128 |
<div>
|
| 5129 |
<div className="flex items-center justify-between text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">
|
| 5130 |
-
<span>Sum insured</span><span className="font-mono">{siDisp}</span>
|
| 5131 |
</div>
|
| 5132 |
<input type="range" min={300000} max={10000000} step={100000} value={si} onChange={(e) => setSI(parseInt(e.target.value))} className="w-full accent-[var(--primary)]" />
|
| 5133 |
</div>
|
|
@@ -5152,7 +5173,7 @@ function PerPolicyPremiumEstimator({ policy, desiredSI }: { policy: MarketplaceP
|
|
| 5152 |
</div>
|
| 5153 |
<div>
|
| 5154 |
<div className="flex items-center justify-between text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">
|
| 5155 |
-
<span>Your share per claim</span>
|
| 5156 |
<span className="font-mono">
|
| 5157 |
{copay === 0 ? (
|
| 5158 |
<span className="text-emerald-600">Insurer pays all</span>
|
|
|
|
| 41 |
// remains on disk as a graveyard reference until KI-168 is field-verified.
|
| 42 |
import { useStreamingVoice } from "@/lib/useStreamingVoice";
|
| 43 |
import { useIsTouch } from "@/lib/useIsTouch";
|
| 44 |
+
import HelpTip from "@/components/HelpTip";
|
| 45 |
|
| 46 |
type DisplayMessage = ChatMessage & {
|
| 47 |
id: string;
|
|
|
|
| 2709 |
hint={hindi ? "ये premium अनुमान और cover-band की खोज तय करते हैं।" : "These drive the premium estimate and the cover band we search."}
|
| 2710 |
>
|
| 2711 |
<RupeeSlider
|
| 2712 |
+
label={
|
| 2713 |
+
<>
|
| 2714 |
+
{hindi ? "आप कितना cover चाहते हैं" : "Sum insured you want"}
|
| 2715 |
+
<HelpTip id="sum_insured" />
|
| 2716 |
+
</>
|
| 2717 |
+
}
|
| 2718 |
value={desiredSI}
|
| 2719 |
min={300000}
|
| 2720 |
max={20000000}
|
|
|
|
| 2725 |
/>
|
| 2726 |
|
| 2727 |
<RupeeSlider
|
| 2728 |
+
label={
|
| 2729 |
+
<>
|
| 2730 |
+
{hindi ? "पहले से कोई health insurance?" : "Existing health cover you hold"}
|
| 2731 |
+
<HelpTip id="existing_cover" />
|
| 2732 |
+
</>
|
| 2733 |
+
}
|
| 2734 |
value={existingCover}
|
| 2735 |
min={0}
|
| 2736 |
max={10000000}
|
|
|
|
| 2742 |
/>
|
| 2743 |
|
| 2744 |
<RupeeSlider
|
| 2745 |
+
label={
|
| 2746 |
+
<>
|
| 2747 |
+
{hindi ? "हर claim में आपका हिस्सा (co-pay)" : "Your share per claim (co-pay)"}
|
| 2748 |
+
<HelpTip id="copay" />
|
| 2749 |
+
</>
|
| 2750 |
+
}
|
| 2751 |
value={copay}
|
| 2752 |
min={0}
|
| 2753 |
max={40}
|
|
|
|
| 2765 |
/>
|
| 2766 |
|
| 2767 |
<RupeeSlider
|
| 2768 |
+
label={
|
| 2769 |
+
<>
|
| 2770 |
+
{hindi ? "सालाना premium budget" : "Annual premium budget"}
|
| 2771 |
+
<HelpTip id="budget" />
|
| 2772 |
+
</>
|
| 2773 |
+
}
|
| 2774 |
value={budgetInr}
|
| 2775 |
min={5000}
|
| 2776 |
max={150000}
|
|
|
|
| 5148 |
</div>
|
| 5149 |
<div>
|
| 5150 |
<div className="flex items-center justify-between text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">
|
| 5151 |
+
<span className="inline-flex items-center gap-1">Sum insured<HelpTip id="sum_insured" /></span><span className="font-mono">{siDisp}</span>
|
| 5152 |
</div>
|
| 5153 |
<input type="range" min={300000} max={10000000} step={100000} value={si} onChange={(e) => setSI(parseInt(e.target.value))} className="w-full accent-[var(--primary)]" />
|
| 5154 |
</div>
|
|
|
|
| 5173 |
</div>
|
| 5174 |
<div>
|
| 5175 |
<div className="flex items-center justify-between text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">
|
| 5176 |
+
<span className="inline-flex items-center gap-1">Your share per claim<HelpTip id="copay" /></span>
|
| 5177 |
<span className="font-mono">
|
| 5178 |
{copay === 0 ? (
|
| 5179 |
<span className="text-emerald-600">Insurer pays all</span>
|
frontend/src/components/HelpTip.tsx
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* HelpTip — a shared "?" affordance placed next to every policy-calculation
|
| 5 |
+
* control. Hover (desktop) or tap (touch; this app is mobile-first) reveals a
|
| 6 |
+
* concise, plain-language explanation of what the control does and how it
|
| 7 |
+
* moves the premium. ONE component + ONE copy map is reused across all three
|
| 8 |
+
* surfaces (PolicyPremiumWidget, the ProfileBuilder cover/cost sliders, and
|
| 9 |
+
* the PerPolicyPremiumEstimator) so the wording is identical everywhere.
|
| 10 |
+
*
|
| 11 |
+
* Body-root portal rule: all three surfaces sit inside `overflow` scroll
|
| 12 |
+
* containers (compare modal, profile drawer, scorecard panel) that clip an
|
| 13 |
+
* absolutely-positioned popover. This is the repo's FIRST portal tooltip:
|
| 14 |
+
* the popover is rendered via ReactDOM.createPortal into document.body and
|
| 15 |
+
* positioned with `position:fixed` from the badge's getBoundingClientRect(),
|
| 16 |
+
* which escapes every clipping ancestor. z-index sits above modals (modals
|
| 17 |
+
* use z-[60]; we use 1000).
|
| 18 |
+
*
|
| 19 |
+
* Copy is strictly qualitative — no digits, %, or ₹ anywhere — per the
|
| 20 |
+
* no-invented-numbers rule. It explains direction (raises / lowers premium),
|
| 21 |
+
* never magnitude.
|
| 22 |
+
*
|
| 23 |
+
* SSR-safe: the portal only renders after a mounted state flips true (the
|
| 24 |
+
* same pattern useIsTouch uses), so the static export emits nothing.
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
| 28 |
+
import ReactDOM from "react-dom";
|
| 29 |
+
import { useIsTouch } from "@/lib/useIsTouch";
|
| 30 |
+
|
| 31 |
+
export type CalcHelpId =
|
| 32 |
+
| "sum_insured"
|
| 33 |
+
| "tenure"
|
| 34 |
+
| "deductible"
|
| 35 |
+
| "copay"
|
| 36 |
+
| "existing_cover"
|
| 37 |
+
| "budget";
|
| 38 |
+
|
| 39 |
+
export const CALC_HELP_COPY: Record<CalcHelpId, { title: string; body: string }> = {
|
| 40 |
+
sum_insured: {
|
| 41 |
+
title: "Sum insured",
|
| 42 |
+
body:
|
| 43 |
+
"The maximum the insurer will pay for your covered hospital bills in one policy year. A higher sum insured raises the premium; a lower one reduces it.",
|
| 44 |
+
},
|
| 45 |
+
tenure: {
|
| 46 |
+
title: "Policy tenure",
|
| 47 |
+
body:
|
| 48 |
+
"How many years of cover you buy and pay for upfront. Choosing a multi-year tenure usually lowers the per-year premium versus renewing yearly.",
|
| 49 |
+
},
|
| 50 |
+
deductible: {
|
| 51 |
+
title: "Voluntary deductible",
|
| 52 |
+
body:
|
| 53 |
+
"An amount you agree to pay yourself on a claim before the insurer pays the rest. Choosing a deductible lowers your premium; zero means no deductible. Only offered on plans that support it — it won't appear for plans that don't.",
|
| 54 |
+
},
|
| 55 |
+
copay: {
|
| 56 |
+
title: "Co-pay (your share)",
|
| 57 |
+
body:
|
| 58 |
+
"The share of every approved hospital bill you pay yourself, with the insurer paying the rest. A higher co-pay lowers your premium but increases what you pay at claim time.",
|
| 59 |
+
},
|
| 60 |
+
existing_cover: {
|
| 61 |
+
title: "Existing cover you hold",
|
| 62 |
+
body:
|
| 63 |
+
"Health insurance you already have. We use it to suggest a top-up instead of a fresh base policy; it doesn't change this premium directly but shapes which plans are recommended.",
|
| 64 |
+
},
|
| 65 |
+
budget: {
|
| 66 |
+
title: "Annual premium budget",
|
| 67 |
+
body:
|
| 68 |
+
"What you're willing to pay per year. This doesn't change any premium — it's compared against the estimate to flag plans that fit or exceed your budget.",
|
| 69 |
+
},
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
// Keep the popover inside the viewport with a small breathing margin.
|
| 73 |
+
const VIEWPORT_MARGIN = 8;
|
| 74 |
+
// Matches the `.helptip-pop` max width clamp in globals.css.
|
| 75 |
+
const POP_MAX_WIDTH = 320;
|
| 76 |
+
|
| 77 |
+
export default function HelpTip({ id }: { id: CalcHelpId }) {
|
| 78 |
+
const entry = CALC_HELP_COPY[id];
|
| 79 |
+
|
| 80 |
+
const [mounted, setMounted] = useState(false);
|
| 81 |
+
const [open, setOpen] = useState(false);
|
| 82 |
+
const [coords, setCoords] = useState<{ top: number; left: number; placeAbove: boolean }>({
|
| 83 |
+
top: 0,
|
| 84 |
+
left: 0,
|
| 85 |
+
placeAbove: true,
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
const isTouch = useIsTouch();
|
| 89 |
+
const badgeRef = useRef<HTMLButtonElement | null>(null);
|
| 90 |
+
const popRef = useRef<HTMLDivElement | null>(null);
|
| 91 |
+
const rid = useId();
|
| 92 |
+
const popId = `helptip-pop-${rid}`;
|
| 93 |
+
|
| 94 |
+
// SSR-safe: mirror useIsTouch's mount pattern so the static export emits
|
| 95 |
+
// nothing and the portal only attaches on the client. The setState is
|
| 96 |
+
// routed through a closure (same shape as useIsTouch's `update`) so it
|
| 97 |
+
// synchronises React with an external fact (we are now on the client)
|
| 98 |
+
// rather than being a direct in-effect state write.
|
| 99 |
+
useEffect(() => {
|
| 100 |
+
const markMounted = () => setMounted(true);
|
| 101 |
+
markMounted();
|
| 102 |
+
}, []);
|
| 103 |
+
|
| 104 |
+
// Position the fixed popover from the badge's viewport rect. Prefer above;
|
| 105 |
+
// flip below if there's no room; clamp horizontally to the viewport.
|
| 106 |
+
const reposition = () => {
|
| 107 |
+
const badge = badgeRef.current;
|
| 108 |
+
if (typeof window === "undefined" || !badge) return;
|
| 109 |
+
const r = badge.getBoundingClientRect();
|
| 110 |
+
const popH = popRef.current?.offsetHeight ?? 96;
|
| 111 |
+
const popW = Math.min(
|
| 112 |
+
popRef.current?.offsetWidth ?? POP_MAX_WIDTH,
|
| 113 |
+
POP_MAX_WIDTH,
|
| 114 |
+
);
|
| 115 |
+
|
| 116 |
+
const spaceAbove = r.top;
|
| 117 |
+
const placeAbove = spaceAbove >= popH + 12;
|
| 118 |
+
|
| 119 |
+
const top = placeAbove ? r.top - popH - 8 : r.bottom + 8;
|
| 120 |
+
|
| 121 |
+
const badgeCenter = r.left + r.width / 2;
|
| 122 |
+
let left = badgeCenter - popW / 2;
|
| 123 |
+
const maxLeft = window.innerWidth - popW - VIEWPORT_MARGIN;
|
| 124 |
+
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
|
| 125 |
+
if (left > maxLeft) left = Math.max(VIEWPORT_MARGIN, maxLeft);
|
| 126 |
+
|
| 127 |
+
setCoords({ top, left, placeAbove });
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
// Reposition synchronously once the popover is in the DOM (before paint) so
|
| 131 |
+
// it never flashes at the wrong spot, and keep it pinned on resize.
|
| 132 |
+
useLayoutEffect(() => {
|
| 133 |
+
if (!open) return;
|
| 134 |
+
reposition();
|
| 135 |
+
const onResize = () => reposition();
|
| 136 |
+
window.addEventListener("resize", onResize);
|
| 137 |
+
return () => window.removeEventListener("resize", onResize);
|
| 138 |
+
}, [open]);
|
| 139 |
+
|
| 140 |
+
// Touch/keyboard dismissal: outside pointerdown, Esc, and scroll all close.
|
| 141 |
+
useEffect(() => {
|
| 142 |
+
if (!open) return;
|
| 143 |
+
const onPointerDown = (e: PointerEvent) => {
|
| 144 |
+
const t = e.target as Node | null;
|
| 145 |
+
if (
|
| 146 |
+
t &&
|
| 147 |
+
(badgeRef.current?.contains(t) || popRef.current?.contains(t))
|
| 148 |
+
) {
|
| 149 |
+
return;
|
| 150 |
+
}
|
| 151 |
+
setOpen(false);
|
| 152 |
+
};
|
| 153 |
+
const onKeyDown = (e: KeyboardEvent) => {
|
| 154 |
+
if (e.key === "Escape") {
|
| 155 |
+
setOpen(false);
|
| 156 |
+
badgeRef.current?.focus();
|
| 157 |
+
}
|
| 158 |
+
};
|
| 159 |
+
const onScroll = () => setOpen(false);
|
| 160 |
+
document.addEventListener("pointerdown", onPointerDown, true);
|
| 161 |
+
document.addEventListener("keydown", onKeyDown);
|
| 162 |
+
window.addEventListener("scroll", onScroll, true);
|
| 163 |
+
return () => {
|
| 164 |
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
| 165 |
+
document.removeEventListener("keydown", onKeyDown);
|
| 166 |
+
window.removeEventListener("scroll", onScroll, true);
|
| 167 |
+
};
|
| 168 |
+
}, [open]);
|
| 169 |
+
|
| 170 |
+
if (!entry) return null;
|
| 171 |
+
|
| 172 |
+
const { title, body } = entry;
|
| 173 |
+
|
| 174 |
+
// Desktop: hover/focus opens, leaving/blur closes. Touch: tap toggles
|
| 175 |
+
// (dismissal handled by the document listeners above).
|
| 176 |
+
const hoverHandlers = isTouch
|
| 177 |
+
? {}
|
| 178 |
+
: {
|
| 179 |
+
onMouseEnter: () => setOpen(true),
|
| 180 |
+
onMouseLeave: () => setOpen(false),
|
| 181 |
+
onFocus: () => setOpen(true),
|
| 182 |
+
onBlur: () => setOpen(false),
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
return (
|
| 186 |
+
<span style={{ display: "inline-flex", verticalAlign: "middle" }}>
|
| 187 |
+
<button
|
| 188 |
+
ref={badgeRef}
|
| 189 |
+
type="button"
|
| 190 |
+
className="helptip-badge"
|
| 191 |
+
aria-label={"Explain: " + title}
|
| 192 |
+
aria-describedby={open ? popId : undefined}
|
| 193 |
+
aria-expanded={open}
|
| 194 |
+
{...hoverHandlers}
|
| 195 |
+
onClick={(e) => {
|
| 196 |
+
// Don't let the badge toggle the surrounding <label> control.
|
| 197 |
+
e.preventDefault();
|
| 198 |
+
e.stopPropagation();
|
| 199 |
+
if (isTouch) setOpen((v) => !v);
|
| 200 |
+
}}
|
| 201 |
+
onKeyDown={(e) => {
|
| 202 |
+
if (e.key === "Enter" || e.key === " ") {
|
| 203 |
+
e.preventDefault();
|
| 204 |
+
setOpen((v) => !v);
|
| 205 |
+
} else if (e.key === "Escape") {
|
| 206 |
+
setOpen(false);
|
| 207 |
+
}
|
| 208 |
+
}}
|
| 209 |
+
>
|
| 210 |
+
?
|
| 211 |
+
</button>
|
| 212 |
+
|
| 213 |
+
{mounted && open
|
| 214 |
+
? ReactDOM.createPortal(
|
| 215 |
+
<div
|
| 216 |
+
ref={popRef}
|
| 217 |
+
role="tooltip"
|
| 218 |
+
id={popId}
|
| 219 |
+
className={
|
| 220 |
+
"helptip-pop" + (coords.placeAbove ? " helptip-pop--above" : " helptip-pop--below")
|
| 221 |
+
}
|
| 222 |
+
style={{
|
| 223 |
+
position: "fixed",
|
| 224 |
+
top: coords.top,
|
| 225 |
+
left: coords.left,
|
| 226 |
+
zIndex: 1000,
|
| 227 |
+
}}
|
| 228 |
+
>
|
| 229 |
+
<span className="helptip-title">{title}</span>
|
| 230 |
+
<span className="helptip-body">{body}</span>
|
| 231 |
+
</div>,
|
| 232 |
+
document.body,
|
| 233 |
+
)
|
| 234 |
+
: null}
|
| 235 |
+
</span>
|
| 236 |
+
);
|
| 237 |
+
}
|
frontend/src/components/PolicyPremiumWidget.tsx
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
| 47 |
type PreExistingCondition,
|
| 48 |
type PremiumEstimateResponse,
|
| 49 |
} from "@/lib/api";
|
|
|
|
| 50 |
|
| 51 |
export type PolicyPremiumWidgetProps = {
|
| 52 |
policyId: string;
|
|
@@ -351,7 +352,10 @@ export default function PolicyPremiumWidget({
|
|
| 351 |
<div style={sliderGroupStyle}>
|
| 352 |
<label style={labelStyle}>
|
| 353 |
<span style={labelHeadStyle}>
|
| 354 |
-
<span style={
|
|
|
|
|
|
|
|
|
|
| 355 |
<strong style={labelValueStyle}>₹{formatSiLabel(sumInsured)}</strong>
|
| 356 |
</span>
|
| 357 |
<input
|
|
@@ -372,7 +376,10 @@ export default function PolicyPremiumWidget({
|
|
| 372 |
|
| 373 |
<label style={labelStyle}>
|
| 374 |
<span style={labelHeadStyle}>
|
| 375 |
-
<span style={
|
|
|
|
|
|
|
|
|
|
| 376 |
<strong style={labelValueStyle}>
|
| 377 |
{tenureYears} {tenureYears === 1 ? "year" : "years"}
|
| 378 |
</strong>
|
|
@@ -396,7 +403,10 @@ export default function PolicyPremiumWidget({
|
|
| 396 |
{supportsDeductible && (
|
| 397 |
<label style={labelStyle}>
|
| 398 |
<span style={labelHeadStyle}>
|
| 399 |
-
<span style={
|
|
|
|
|
|
|
|
|
|
| 400 |
<strong style={labelValueStyle}>
|
| 401 |
{formatDeductibleLabel(deductibleInr)}
|
| 402 |
</strong>
|
|
|
|
| 47 |
type PreExistingCondition,
|
| 48 |
type PremiumEstimateResponse,
|
| 49 |
} from "@/lib/api";
|
| 50 |
+
import HelpTip from "@/components/HelpTip";
|
| 51 |
|
| 52 |
export type PolicyPremiumWidgetProps = {
|
| 53 |
policyId: string;
|
|
|
|
| 352 |
<div style={sliderGroupStyle}>
|
| 353 |
<label style={labelStyle}>
|
| 354 |
<span style={labelHeadStyle}>
|
| 355 |
+
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
| 356 |
+
<span style={labelTextStyle}>Sum insured</span>
|
| 357 |
+
<HelpTip id="sum_insured" />
|
| 358 |
+
</span>
|
| 359 |
<strong style={labelValueStyle}>₹{formatSiLabel(sumInsured)}</strong>
|
| 360 |
</span>
|
| 361 |
<input
|
|
|
|
| 376 |
|
| 377 |
<label style={labelStyle}>
|
| 378 |
<span style={labelHeadStyle}>
|
| 379 |
+
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
| 380 |
+
<span style={labelTextStyle}>Tenure</span>
|
| 381 |
+
<HelpTip id="tenure" />
|
| 382 |
+
</span>
|
| 383 |
<strong style={labelValueStyle}>
|
| 384 |
{tenureYears} {tenureYears === 1 ? "year" : "years"}
|
| 385 |
</strong>
|
|
|
|
| 403 |
{supportsDeductible && (
|
| 404 |
<label style={labelStyle}>
|
| 405 |
<span style={labelHeadStyle}>
|
| 406 |
+
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
| 407 |
+
<span style={labelTextStyle}>Deductible</span>
|
| 408 |
+
<HelpTip id="deductible" />
|
| 409 |
+
</span>
|
| 410 |
<strong style={labelValueStyle}>
|
| 411 |
{formatDeductibleLabel(deductibleInr)}
|
| 412 |
</strong>
|