rohitsar567's picture
#33: add "?" help affordance to every policy-calculation control
deefe3a
Raw
History Blame Contribute Delete
8.53 kB
"use client";
/**
* HelpTip β€” a shared "?" affordance placed next to every policy-calculation
* control. Hover (desktop) or tap (touch; this app is mobile-first) reveals a
* concise, plain-language explanation of what the control does and how it
* moves the premium. ONE component + ONE copy map is reused across all three
* surfaces (PolicyPremiumWidget, the ProfileBuilder cover/cost sliders, and
* the PerPolicyPremiumEstimator) so the wording is identical everywhere.
*
* Body-root portal rule: all three surfaces sit inside `overflow` scroll
* containers (compare modal, profile drawer, scorecard panel) that clip an
* absolutely-positioned popover. This is the repo's FIRST portal tooltip:
* the popover is rendered via ReactDOM.createPortal into document.body and
* positioned with `position:fixed` from the badge's getBoundingClientRect(),
* which escapes every clipping ancestor. z-index sits above modals (modals
* use z-[60]; we use 1000).
*
* Copy is strictly qualitative β€” no digits, %, or β‚Ή anywhere β€” per the
* no-invented-numbers rule. It explains direction (raises / lowers premium),
* never magnitude.
*
* SSR-safe: the portal only renders after a mounted state flips true (the
* same pattern useIsTouch uses), so the static export emits nothing.
*/
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useIsTouch } from "@/lib/useIsTouch";
export type CalcHelpId =
| "sum_insured"
| "tenure"
| "deductible"
| "copay"
| "existing_cover"
| "budget";
export const CALC_HELP_COPY: Record<CalcHelpId, { title: string; body: string }> = {
sum_insured: {
title: "Sum insured",
body:
"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.",
},
tenure: {
title: "Policy tenure",
body:
"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.",
},
deductible: {
title: "Voluntary deductible",
body:
"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.",
},
copay: {
title: "Co-pay (your share)",
body:
"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.",
},
existing_cover: {
title: "Existing cover you hold",
body:
"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.",
},
budget: {
title: "Annual premium budget",
body:
"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.",
},
};
// Keep the popover inside the viewport with a small breathing margin.
const VIEWPORT_MARGIN = 8;
// Matches the `.helptip-pop` max width clamp in globals.css.
const POP_MAX_WIDTH = 320;
export default function HelpTip({ id }: { id: CalcHelpId }) {
const entry = CALC_HELP_COPY[id];
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number; placeAbove: boolean }>({
top: 0,
left: 0,
placeAbove: true,
});
const isTouch = useIsTouch();
const badgeRef = useRef<HTMLButtonElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(null);
const rid = useId();
const popId = `helptip-pop-${rid}`;
// SSR-safe: mirror useIsTouch's mount pattern so the static export emits
// nothing and the portal only attaches on the client. The setState is
// routed through a closure (same shape as useIsTouch's `update`) so it
// synchronises React with an external fact (we are now on the client)
// rather than being a direct in-effect state write.
useEffect(() => {
const markMounted = () => setMounted(true);
markMounted();
}, []);
// Position the fixed popover from the badge's viewport rect. Prefer above;
// flip below if there's no room; clamp horizontally to the viewport.
const reposition = () => {
const badge = badgeRef.current;
if (typeof window === "undefined" || !badge) return;
const r = badge.getBoundingClientRect();
const popH = popRef.current?.offsetHeight ?? 96;
const popW = Math.min(
popRef.current?.offsetWidth ?? POP_MAX_WIDTH,
POP_MAX_WIDTH,
);
const spaceAbove = r.top;
const placeAbove = spaceAbove >= popH + 12;
const top = placeAbove ? r.top - popH - 8 : r.bottom + 8;
const badgeCenter = r.left + r.width / 2;
let left = badgeCenter - popW / 2;
const maxLeft = window.innerWidth - popW - VIEWPORT_MARGIN;
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
if (left > maxLeft) left = Math.max(VIEWPORT_MARGIN, maxLeft);
setCoords({ top, left, placeAbove });
};
// Reposition synchronously once the popover is in the DOM (before paint) so
// it never flashes at the wrong spot, and keep it pinned on resize.
useLayoutEffect(() => {
if (!open) return;
reposition();
const onResize = () => reposition();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [open]);
// Touch/keyboard dismissal: outside pointerdown, Esc, and scroll all close.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const t = e.target as Node | null;
if (
t &&
(badgeRef.current?.contains(t) || popRef.current?.contains(t))
) {
return;
}
setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
badgeRef.current?.focus();
}
};
const onScroll = () => setOpen(false);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("scroll", onScroll, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("scroll", onScroll, true);
};
}, [open]);
if (!entry) return null;
const { title, body } = entry;
// Desktop: hover/focus opens, leaving/blur closes. Touch: tap toggles
// (dismissal handled by the document listeners above).
const hoverHandlers = isTouch
? {}
: {
onMouseEnter: () => setOpen(true),
onMouseLeave: () => setOpen(false),
onFocus: () => setOpen(true),
onBlur: () => setOpen(false),
};
return (
<span style={{ display: "inline-flex", verticalAlign: "middle" }}>
<button
ref={badgeRef}
type="button"
className="helptip-badge"
aria-label={"Explain: " + title}
aria-describedby={open ? popId : undefined}
aria-expanded={open}
{...hoverHandlers}
onClick={(e) => {
// Don't let the badge toggle the surrounding <label> control.
e.preventDefault();
e.stopPropagation();
if (isTouch) setOpen((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === "Escape") {
setOpen(false);
}
}}
>
?
</button>
{mounted && open
? ReactDOM.createPortal(
<div
ref={popRef}
role="tooltip"
id={popId}
className={
"helptip-pop" + (coords.placeAbove ? " helptip-pop--above" : " helptip-pop--below")
}
style={{
position: "fixed",
top: coords.top,
left: coords.left,
zIndex: 1000,
}}
>
<span className="helptip-title">{title}</span>
<span className="helptip-body">{body}</span>
</div>,
document.body,
)
: null}
</span>
);
}