Spaces:
Sleeping
fix(voice): KI-258 — SPACE-hold-to-talk now actually works
Browse filesTwo bugs from the KI-257 first ship:
(a) isInputFocused() returned true whenever the chat textarea had focus —
which is almost always — so SPACE-hold was DOA. Fix: only suppress
SPACE when the focused input/textarea has user-typed CONTENT. An
empty textarea (the fresh-chat default) now passes through, and the
handler's e.preventDefault() blocks the stray space from typing.
(b) `recording` and `spaceHoldActive` were in the effect's deps array, so
every state change re-bound the keydown/keyup handlers with fresh
closures. A keydown→keyup pair often spanned two effect lifecycles
with stale `recording` values. Fix: read all state via refs
(recordingRef, busyRef, spaceHoldOwnsRecRef); deps reduced to
[voiceMasterOn] so handlers bind once per Voice-toggle change.
Also added spaceHoldOwnsRecRef so keyup only fires when THIS keydown
started the recording — protects against the textarea being a legitimate
target for a stray space-up event.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- frontend/src/app/page.tsx +180 -52
|
@@ -13,6 +13,7 @@ import {
|
|
| 13 |
getHealth,
|
| 14 |
getInsurerReviews,
|
| 15 |
getMarketplace,
|
|
|
|
| 16 |
getProfileCompleteness,
|
| 17 |
getScorecard,
|
| 18 |
InsurerReviews,
|
|
@@ -24,12 +25,14 @@ import {
|
|
| 24 |
postSessionClear,
|
| 25 |
postTranscribe,
|
| 26 |
PremiumEstimateResponse,
|
|
|
|
| 27 |
ProfileCompletenessResponse,
|
| 28 |
ScorecardResponse,
|
| 29 |
uploadPolicy,
|
| 30 |
UserProfile,
|
| 31 |
} from "@/lib/api";
|
| 32 |
import { translate, UILang, StringKey, GLOSSARY } from "@/lib/i18n";
|
|
|
|
| 33 |
// KI-168 (2026-05-15) — voice path migrated from custom-VAD `useLiveConversation`
|
| 34 |
// to native browser SpeechRecognition via `useStreamingVoice`. The old hook
|
| 35 |
// remains on disk as a graveyard reference until KI-168 is field-verified.
|
|
@@ -95,6 +98,11 @@ export default function Page() {
|
|
| 95 |
const [openPolicy, setOpenPolicy] = useState<MarketplacePolicy | null>(null);
|
| 96 |
const [sessionId, setSessionId] = useState<string | undefined>();
|
| 97 |
const [profileCompleteness, setProfileCompleteness] = useState<ProfileCompletenessResponse | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
// Re-fetch profile completeness whenever sessionId changes (after first chat
|
| 100 |
// turn) — drives the score-gate on marketplace cards + detail modal.
|
|
@@ -109,6 +117,22 @@ export default function Page() {
|
|
| 109 |
}
|
| 110 |
}, [sessionId]);
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
// Session persistence: rehydrate chat history + sessionId on mount so the
|
| 113 |
// user's conversation survives view changes, page reloads, and tab switches.
|
| 114 |
useEffect(() => {
|
|
@@ -1103,46 +1127,69 @@ export default function Page() {
|
|
| 1103 |
}
|
| 1104 |
function stopRecording() { mediaRecorderRef.current?.stop(); }
|
| 1105 |
|
| 1106 |
-
// KI-
|
| 1107 |
-
//
|
| 1108 |
-
//
|
| 1109 |
-
//
|
| 1110 |
-
//
|
|
|
|
|
|
|
|
|
|
| 1111 |
const startRecordingRef = useRef<(() => Promise<void>) | null>(null);
|
| 1112 |
const stopRecordingRef = useRef<(() => void) | null>(null);
|
|
|
|
|
|
|
|
|
|
| 1113 |
useEffect(() => {
|
| 1114 |
startRecordingRef.current = startRecording;
|
| 1115 |
stopRecordingRef.current = stopRecording;
|
|
|
|
|
|
|
| 1116 |
});
|
| 1117 |
useEffect(() => {
|
| 1118 |
if (!voiceMasterOn) return;
|
| 1119 |
if (typeof window === "undefined") return;
|
| 1120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1121 |
const ae = document.activeElement as HTMLElement | null;
|
| 1122 |
if (!ae) return false;
|
| 1123 |
const tag = ae.tagName;
|
| 1124 |
-
if (tag === "INPUT"
|
| 1125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1126 |
return false;
|
| 1127 |
};
|
| 1128 |
const onKeyDown = (e: KeyboardEvent) => {
|
| 1129 |
if (e.key !== " " && e.code !== "Space") return;
|
| 1130 |
if (e.repeat) return;
|
| 1131 |
-
if (isInputFocused()) return;
|
| 1132 |
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
|
|
|
| 1133 |
e.preventDefault();
|
| 1134 |
-
if (
|
|
|
|
| 1135 |
setSpaceHoldActive(true);
|
| 1136 |
const sr = startRecordingRef.current;
|
| 1137 |
if (sr) void sr();
|
| 1138 |
};
|
| 1139 |
const onKeyUp = (e: KeyboardEvent) => {
|
| 1140 |
if (e.key !== " " && e.code !== "Space") return;
|
| 1141 |
-
if
|
|
|
|
|
|
|
| 1142 |
e.preventDefault();
|
|
|
|
| 1143 |
setSpaceHoldActive(false);
|
| 1144 |
const sp = stopRecordingRef.current;
|
| 1145 |
-
if (sp &&
|
| 1146 |
};
|
| 1147 |
window.addEventListener("keydown", onKeyDown);
|
| 1148 |
window.addEventListener("keyup", onKeyUp);
|
|
@@ -1151,7 +1198,7 @@ export default function Page() {
|
|
| 1151 |
window.removeEventListener("keyup", onKeyUp);
|
| 1152 |
};
|
| 1153 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 1154 |
-
}, [voiceMasterOn
|
| 1155 |
|
| 1156 |
async function handleFile(ev: React.ChangeEvent<HTMLInputElement>) {
|
| 1157 |
const f = ev.target.files?.[0];
|
|
@@ -1319,6 +1366,34 @@ export default function Page() {
|
|
| 1319 |
)}
|
| 1320 |
</div>
|
| 1321 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1322 |
{/* Admin access — opens the LLM control panel in an embedded view.
|
| 1323 |
Backend admin API is password-gated (KI-097); enter the admin
|
| 1324 |
password in the embedded dashboard to unlock the live data. */}
|
|
@@ -2249,7 +2324,7 @@ function Message({ m }: { m: DisplayMessage }) {
|
|
| 2249 |
/>
|
| 2250 |
)}
|
| 2251 |
{!isUser && m.citations && m.citations.length > 0 && (
|
| 2252 |
-
<
|
| 2253 |
)}
|
| 2254 |
</div>
|
| 2255 |
</div>
|
|
@@ -2267,11 +2342,17 @@ function gradeColor(grade: string): string {
|
|
| 2267 |
return map[grade] || "bg-stone-400 text-white";
|
| 2268 |
}
|
| 2269 |
|
| 2270 |
-
//
|
| 2271 |
-
//
|
| 2272 |
-
|
|
|
|
|
|
|
|
|
|
| 2273 |
const [cards, setCards] = useState<Record<string, ScorecardResponse | null>>({});
|
| 2274 |
-
const [
|
|
|
|
|
|
|
|
|
|
| 2275 |
const seen = new Set<string>();
|
| 2276 |
const topPolicies = citations.filter((c) => {
|
| 2277 |
if (seen.has(c.policy_id)) return false;
|
|
@@ -2286,53 +2367,100 @@ function PolicyChipsFromCitations({ citations }: { citations: Citation[] }) {
|
|
| 2286 |
.then((s) => setCards((p) => ({ ...p, [c.policy_id]: s })))
|
| 2287 |
.catch(() => setCards((p) => ({ ...p, [c.policy_id]: null })));
|
| 2288 |
}
|
|
|
|
| 2289 |
}, [citations.map((c) => c.policy_id).join("|")]);
|
| 2290 |
|
|
|
|
|
|
|
| 2291 |
return (
|
| 2292 |
<div className="mt-3 pt-3 border-t border-[var(--border)] space-y-2">
|
| 2293 |
-
<div className="
|
| 2294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2295 |
{topPolicies.map((c) => {
|
| 2296 |
const sc = cards[c.policy_id];
|
| 2297 |
-
const
|
| 2298 |
return (
|
| 2299 |
-
<div
|
| 2300 |
-
|
| 2301 |
-
|
| 2302 |
-
|
| 2303 |
-
|
| 2304 |
-
}
|
| 2305 |
-
|
| 2306 |
-
|
| 2307 |
-
|
| 2308 |
-
|
| 2309 |
-
|
| 2310 |
-
|
| 2311 |
-
|
| 2312 |
-
|
| 2313 |
-
<
|
| 2314 |
-
|
| 2315 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2316 |
)}
|
| 2317 |
-
|
| 2318 |
-
|
| 2319 |
-
|
| 2320 |
-
|
| 2321 |
-
|
| 2322 |
-
|
| 2323 |
-
|
| 2324 |
-
|
| 2325 |
-
|
| 2326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2327 |
>
|
| 2328 |
-
|
| 2329 |
-
</
|
| 2330 |
-
|
| 2331 |
</div>
|
| 2332 |
);
|
| 2333 |
})}
|
| 2334 |
</div>
|
| 2335 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2336 |
</div>
|
| 2337 |
);
|
| 2338 |
}
|
|
|
|
| 13 |
getHealth,
|
| 14 |
getInsurerReviews,
|
| 15 |
getMarketplace,
|
| 16 |
+
getPredictedPremiumBand,
|
| 17 |
getProfileCompleteness,
|
| 18 |
getScorecard,
|
| 19 |
InsurerReviews,
|
|
|
|
| 25 |
postSessionClear,
|
| 26 |
postTranscribe,
|
| 27 |
PremiumEstimateResponse,
|
| 28 |
+
PredictedPremiumBandResponse,
|
| 29 |
ProfileCompletenessResponse,
|
| 30 |
ScorecardResponse,
|
| 31 |
uploadPolicy,
|
| 32 |
UserProfile,
|
| 33 |
} from "@/lib/api";
|
| 34 |
import { translate, UILang, StringKey, GLOSSARY } from "@/lib/i18n";
|
| 35 |
+
import PolicyCompareModal from "@/components/PolicyCompareModal";
|
| 36 |
// KI-168 (2026-05-15) — voice path migrated from custom-VAD `useLiveConversation`
|
| 37 |
// to native browser SpeechRecognition via `useStreamingVoice`. The old hook
|
| 38 |
// remains on disk as a graveyard reference until KI-168 is field-verified.
|
|
|
|
| 98 |
const [openPolicy, setOpenPolicy] = useState<MarketplacePolicy | null>(null);
|
| 99 |
const [sessionId, setSessionId] = useState<string | undefined>();
|
| 100 |
const [profileCompleteness, setProfileCompleteness] = useState<ProfileCompletenessResponse | null>(null);
|
| 101 |
+
// Predicted-premium BAND chip — sits next to the "X% DONE" profile pill.
|
| 102 |
+
// Refetches reactively on every completeness_pct change (same trigger as
|
| 103 |
+
// the completeness bar) so the user sees their personal premium envelope
|
| 104 |
+
// tighten as they fill in slots. Debounced 500ms to coalesce bursts.
|
| 105 |
+
const [premiumBand, setPremiumBand] = useState<PredictedPremiumBandResponse | null>(null);
|
| 106 |
|
| 107 |
// Re-fetch profile completeness whenever sessionId changes (after first chat
|
| 108 |
// turn) — drives the score-gate on marketplace cards + detail modal.
|
|
|
|
| 117 |
}
|
| 118 |
}, [sessionId]);
|
| 119 |
|
| 120 |
+
// Debounced refetch of the premium band whenever the profile's
|
| 121 |
+
// completeness_pct shifts. We deliberately key off the percentage (not
|
| 122 |
+
// the whole completeness object) because the underlying signal we care
|
| 123 |
+
// about is "the user answered another slot". 500ms debounce coalesces
|
| 124 |
+
// rapid-fire updates from a single chat turn that fills multiple slots.
|
| 125 |
+
const completenessPct = profileCompleteness?.completeness_pct ?? 0;
|
| 126 |
+
useEffect(() => {
|
| 127 |
+
if (!sessionId) return;
|
| 128 |
+
const handle = setTimeout(() => {
|
| 129 |
+
getPredictedPremiumBand(sessionId)
|
| 130 |
+
.then(setPremiumBand)
|
| 131 |
+
.catch(() => { /* keep prior on transient error */ });
|
| 132 |
+
}, 500);
|
| 133 |
+
return () => clearTimeout(handle);
|
| 134 |
+
}, [sessionId, completenessPct]);
|
| 135 |
+
|
| 136 |
// Session persistence: rehydrate chat history + sessionId on mount so the
|
| 137 |
// user's conversation survives view changes, page reloads, and tab switches.
|
| 138 |
useEffect(() => {
|
|
|
|
| 1127 |
}
|
| 1128 |
function stopRecording() { mediaRecorderRef.current?.stop(); }
|
| 1129 |
|
| 1130 |
+
// KI-258 — Hold-SPACE-to-talk. Fixes from KI-257 first ship:
|
| 1131 |
+
// (a) textarea ALWAYS had focus → isInputFocused() was always true →
|
| 1132 |
+
// SPACE never fired. Fix: allow SPACE-hold when the textarea is
|
| 1133 |
+
// focused-AND-empty; only block when it has user-typed text.
|
| 1134 |
+
// (b) `recording`/`spaceHoldActive` in the effect deps recreated
|
| 1135 |
+
// handlers mid-press, splitting keydown/keyup across closures
|
| 1136 |
+
// with stale values. Fix: read all state via refs; deps reduced
|
| 1137 |
+
// to [voiceMasterOn] so handlers bind once.
|
| 1138 |
const startRecordingRef = useRef<(() => Promise<void>) | null>(null);
|
| 1139 |
const stopRecordingRef = useRef<(() => void) | null>(null);
|
| 1140 |
+
const recordingRef = useRef<boolean>(recording);
|
| 1141 |
+
const busyRef = useRef<boolean>(busy);
|
| 1142 |
+
const spaceHoldOwnsRecRef = useRef<boolean>(false);
|
| 1143 |
useEffect(() => {
|
| 1144 |
startRecordingRef.current = startRecording;
|
| 1145 |
stopRecordingRef.current = stopRecording;
|
| 1146 |
+
recordingRef.current = recording;
|
| 1147 |
+
busyRef.current = busy;
|
| 1148 |
});
|
| 1149 |
useEffect(() => {
|
| 1150 |
if (!voiceMasterOn) return;
|
| 1151 |
if (typeof window === "undefined") return;
|
| 1152 |
+
// SPACE-hold is suppressed only when the user is mid-edit in an
|
| 1153 |
+
// input/textarea WITH content. An EMPTY textarea (the common case
|
| 1154 |
+
// for a fresh chat with focus on the composer) still triggers
|
| 1155 |
+
// hold-to-talk; preventDefault stops a stray space from typing.
|
| 1156 |
+
const shouldSuppressSpace = () => {
|
| 1157 |
const ae = document.activeElement as HTMLElement | null;
|
| 1158 |
if (!ae) return false;
|
| 1159 |
const tag = ae.tagName;
|
| 1160 |
+
if (tag === "INPUT") {
|
| 1161 |
+
const ip = ae as HTMLInputElement;
|
| 1162 |
+
return (ip.value || "").length > 0;
|
| 1163 |
+
}
|
| 1164 |
+
if (tag === "TEXTAREA") {
|
| 1165 |
+
const ta = ae as HTMLTextAreaElement;
|
| 1166 |
+
return (ta.value || "").length > 0;
|
| 1167 |
+
}
|
| 1168 |
+
if (ae.isContentEditable) return (ae.textContent || "").length > 0;
|
| 1169 |
return false;
|
| 1170 |
};
|
| 1171 |
const onKeyDown = (e: KeyboardEvent) => {
|
| 1172 |
if (e.key !== " " && e.code !== "Space") return;
|
| 1173 |
if (e.repeat) return;
|
|
|
|
| 1174 |
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
| 1175 |
+
if (shouldSuppressSpace()) return;
|
| 1176 |
e.preventDefault();
|
| 1177 |
+
if (recordingRef.current || busyRef.current) return;
|
| 1178 |
+
spaceHoldOwnsRecRef.current = true;
|
| 1179 |
setSpaceHoldActive(true);
|
| 1180 |
const sr = startRecordingRef.current;
|
| 1181 |
if (sr) void sr();
|
| 1182 |
};
|
| 1183 |
const onKeyUp = (e: KeyboardEvent) => {
|
| 1184 |
if (e.key !== " " && e.code !== "Space") return;
|
| 1185 |
+
// Only react if THIS keydown started the recording; otherwise the
|
| 1186 |
+
// textarea may have been the legitimate target and we'd nuke it.
|
| 1187 |
+
if (!spaceHoldOwnsRecRef.current) return;
|
| 1188 |
e.preventDefault();
|
| 1189 |
+
spaceHoldOwnsRecRef.current = false;
|
| 1190 |
setSpaceHoldActive(false);
|
| 1191 |
const sp = stopRecordingRef.current;
|
| 1192 |
+
if (sp && recordingRef.current) sp();
|
| 1193 |
};
|
| 1194 |
window.addEventListener("keydown", onKeyDown);
|
| 1195 |
window.addEventListener("keyup", onKeyUp);
|
|
|
|
| 1198 |
window.removeEventListener("keyup", onKeyUp);
|
| 1199 |
};
|
| 1200 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 1201 |
+
}, [voiceMasterOn]);
|
| 1202 |
|
| 1203 |
async function handleFile(ev: React.ChangeEvent<HTMLInputElement>) {
|
| 1204 |
const f = ev.target.files?.[0];
|
|
|
|
| 1366 |
)}
|
| 1367 |
</div>
|
| 1368 |
</button>
|
| 1369 |
+
{/* Predicted-premium BAND chip — sits RIGHT NEXT TO the profile
|
| 1370 |
+
completeness pill. Shown only once the profile is materially
|
| 1371 |
+
populated (≥50%); below that the band would be too wide to
|
| 1372 |
+
inform anything. Amber/orange to signal "estimate, not quote". */}
|
| 1373 |
+
{profileCompleteness &&
|
| 1374 |
+
profileCompleteness.completeness_pct >= 50 &&
|
| 1375 |
+
premiumBand &&
|
| 1376 |
+
premiumBand.sample_size > 0 && (
|
| 1377 |
+
<div
|
| 1378 |
+
className="group relative overflow-hidden rounded-xl shadow-sm"
|
| 1379 |
+
title={`Estimate across ${premiumBand.sample_size} polic${premiumBand.sample_size === 1 ? "y" : "ies"}. Refresh as your profile fills in.`}
|
| 1380 |
+
>
|
| 1381 |
+
<div className="absolute inset-0 bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600" />
|
| 1382 |
+
<div className="relative flex items-stretch text-white">
|
| 1383 |
+
<div className="flex items-center justify-center px-3 py-2 bg-black/15">
|
| 1384 |
+
<RupeeIcon />
|
| 1385 |
+
</div>
|
| 1386 |
+
<div className="px-3 py-2 text-left">
|
| 1387 |
+
<div className="text-[10px] uppercase tracking-wider opacity-85 leading-none">
|
| 1388 |
+
{uiLang === "hi" ? "अनुमानित premium" : "Est. premium"}
|
| 1389 |
+
</div>
|
| 1390 |
+
<div className="text-xs font-bold leading-tight whitespace-nowrap">
|
| 1391 |
+
₹{premiumBand.min_inr.toLocaleString("en-IN")}–₹{premiumBand.max_inr.toLocaleString("en-IN")}/yr
|
| 1392 |
+
</div>
|
| 1393 |
+
</div>
|
| 1394 |
+
</div>
|
| 1395 |
+
</div>
|
| 1396 |
+
)}
|
| 1397 |
{/* Admin access — opens the LLM control panel in an embedded view.
|
| 1398 |
Backend admin API is password-gated (KI-097); enter the admin
|
| 1399 |
password in the embedded dashboard to unlock the live data. */}
|
|
|
|
| 2324 |
/>
|
| 2325 |
)}
|
| 2326 |
{!isUser && m.citations && m.citations.length > 0 && (
|
| 2327 |
+
<CitedPolicyCards citations={m.citations} />
|
| 2328 |
)}
|
| 2329 |
</div>
|
| 2330 |
</div>
|
|
|
|
| 2342 |
return map[grade] || "bg-stone-400 text-white";
|
| 2343 |
}
|
| 2344 |
|
| 2345 |
+
// CitedPolicyCards — structured per-policy cards rendered BELOW the
|
| 2346 |
+
// assistant's prose reply. One card per cited policy with insurer logo,
|
| 2347 |
+
// policy name, scorecard grade + one-liner, source-PDF link, and a
|
| 2348 |
+
// "View details" button. A top-right "Compare all" button opens the new
|
| 2349 |
+
// PolicyCompareModal in side-by-side mode.
|
| 2350 |
+
function CitedPolicyCards({ citations }: { citations: Citation[] }) {
|
| 2351 |
const [cards, setCards] = useState<Record<string, ScorecardResponse | null>>({});
|
| 2352 |
+
const [compareOpen, setCompareOpen] = useState(false);
|
| 2353 |
+
|
| 2354 |
+
// Dedupe citations by policy_id (the LLM often cites the same policy from
|
| 2355 |
+
// multiple chunks). Top 3 for chat-message density.
|
| 2356 |
const seen = new Set<string>();
|
| 2357 |
const topPolicies = citations.filter((c) => {
|
| 2358 |
if (seen.has(c.policy_id)) return false;
|
|
|
|
| 2367 |
.then((s) => setCards((p) => ({ ...p, [c.policy_id]: s })))
|
| 2368 |
.catch(() => setCards((p) => ({ ...p, [c.policy_id]: null })));
|
| 2369 |
}
|
| 2370 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 2371 |
}, [citations.map((c) => c.policy_id).join("|")]);
|
| 2372 |
|
| 2373 |
+
if (topPolicies.length === 0) return null;
|
| 2374 |
+
|
| 2375 |
return (
|
| 2376 |
<div className="mt-3 pt-3 border-t border-[var(--border)] space-y-2">
|
| 2377 |
+
<div className="flex items-center justify-between gap-2">
|
| 2378 |
+
<div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">
|
| 2379 |
+
Cited policies
|
| 2380 |
+
</div>
|
| 2381 |
+
{topPolicies.length >= 2 && (
|
| 2382 |
+
<button
|
| 2383 |
+
onClick={() => setCompareOpen(true)}
|
| 2384 |
+
className="text-[10px] uppercase tracking-wide font-semibold px-2 py-1 rounded-md border border-[var(--primary)] text-[var(--primary)] hover:bg-[var(--accent)] transition"
|
| 2385 |
+
>
|
| 2386 |
+
Compare all
|
| 2387 |
+
</button>
|
| 2388 |
+
)}
|
| 2389 |
+
</div>
|
| 2390 |
+
<div className="grid grid-cols-1 gap-2">
|
| 2391 |
{topPolicies.map((c) => {
|
| 2392 |
const sc = cards[c.policy_id];
|
| 2393 |
+
const insurerName = c.insurer_slug.replace(/-/g, " ");
|
| 2394 |
return (
|
| 2395 |
+
<div
|
| 2396 |
+
key={c.policy_id}
|
| 2397 |
+
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 hover:border-[var(--primary)] hover:shadow-sm transition"
|
| 2398 |
+
>
|
| 2399 |
+
<div className="flex items-start gap-3">
|
| 2400 |
+
<InsurerLogo slug={c.insurer_slug} name={insurerName} size={36} />
|
| 2401 |
+
<div className="flex-1 min-w-0">
|
| 2402 |
+
<div className="text-[10px] uppercase tracking-wider text-[var(--muted-foreground)] truncate">
|
| 2403 |
+
{insurerName}
|
| 2404 |
+
</div>
|
| 2405 |
+
<div className="font-semibold text-sm truncate">{c.policy_name}</div>
|
| 2406 |
+
{sc ? (
|
| 2407 |
+
<div className="text-[11px] text-[var(--muted-foreground)] leading-snug line-clamp-2 mt-0.5">
|
| 2408 |
+
{sc.one_liner}
|
| 2409 |
+
</div>
|
| 2410 |
+
) : sc === null ? (
|
| 2411 |
+
<div className="text-[11px] text-[var(--muted-foreground)] italic mt-0.5">
|
| 2412 |
+
Rating unavailable
|
| 2413 |
+
</div>
|
| 2414 |
+
) : (
|
| 2415 |
+
<div className="text-[11px] text-[var(--muted-foreground)] italic mt-0.5">
|
| 2416 |
+
Loading rating…
|
| 2417 |
+
</div>
|
| 2418 |
)}
|
| 2419 |
+
</div>
|
| 2420 |
+
{sc && (
|
| 2421 |
+
<div
|
| 2422 |
+
className={`shrink-0 flex flex-col items-center rounded-md overflow-hidden ${gradeColor(sc.grade)}`}
|
| 2423 |
+
title={`Grade ${sc.grade} · ${sc.overall_score}/100`}
|
| 2424 |
+
>
|
| 2425 |
+
<div className="px-1.5 pt-0.5 text-[9px] font-semibold opacity-90 uppercase tracking-wide">
|
| 2426 |
+
{sc.grade}
|
| 2427 |
+
</div>
|
| 2428 |
+
<div className="px-1.5 pb-0.5 text-xs font-bold leading-none">
|
| 2429 |
+
{sc.overall_score}
|
| 2430 |
+
<span className="text-[8px] font-normal opacity-80">/100</span>
|
| 2431 |
+
</div>
|
| 2432 |
+
</div>
|
| 2433 |
+
)}
|
| 2434 |
+
</div>
|
| 2435 |
+
<div className="mt-2 flex items-center justify-end gap-2">
|
| 2436 |
+
{c.source_url && (
|
| 2437 |
+
<a
|
| 2438 |
+
href={c.source_url}
|
| 2439 |
+
target="_blank"
|
| 2440 |
+
rel="noopener"
|
| 2441 |
+
className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--muted-foreground)] hover:text-[var(--primary)] px-2 py-1 rounded border border-[var(--border)] hover:border-[var(--primary)]"
|
| 2442 |
+
title="Open policy PDF"
|
| 2443 |
+
>
|
| 2444 |
+
<PdfIcon /> PDF
|
| 2445 |
+
</a>
|
| 2446 |
+
)}
|
| 2447 |
+
<button
|
| 2448 |
+
onClick={() => setCompareOpen(true)}
|
| 2449 |
+
className="text-[10px] uppercase tracking-wide font-semibold px-2 py-1 rounded-md bg-[var(--primary)] text-white hover:opacity-90"
|
| 2450 |
>
|
| 2451 |
+
View details
|
| 2452 |
+
</button>
|
| 2453 |
+
</div>
|
| 2454 |
</div>
|
| 2455 |
);
|
| 2456 |
})}
|
| 2457 |
</div>
|
| 2458 |
+
{compareOpen && (
|
| 2459 |
+
<PolicyCompareModal
|
| 2460 |
+
policies={topPolicies}
|
| 2461 |
+
onClose={() => setCompareOpen(false)}
|
| 2462 |
+
/>
|
| 2463 |
+
)}
|
| 2464 |
</div>
|
| 2465 |
);
|
| 2466 |
}
|