rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
96d766d
·
1 Parent(s): ef90cd6

fix(voice): KI-258 — SPACE-hold-to-talk now actually works

Browse files

Two 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>

Files changed (1) hide show
  1. frontend/src/app/page.tsx +180 -52
frontend/src/app/page.tsx CHANGED
@@ -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-257 — Hold-SPACE-to-talk. When Voice is on AND the user is NOT
1107
- // focused on any text input, holding SPACE acts like Push-to-talk:
1108
- // press starts recording, release stops + submits. Refs avoid stale
1109
- // closures since startRecording/stopRecording are inline functions
1110
- // recreated each render.
 
 
 
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
- const isInputFocused = () => {
 
 
 
 
1121
  const ae = document.activeElement as HTMLElement | null;
1122
  if (!ae) return false;
1123
  const tag = ae.tagName;
1124
- if (tag === "INPUT" || tag === "TEXTAREA") return true;
1125
- if (ae.isContentEditable) return true;
 
 
 
 
 
 
 
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 (recording || busy) return;
 
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 (!spaceHoldActive && !recording) return;
 
 
1142
  e.preventDefault();
 
1143
  setSpaceHoldActive(false);
1144
  const sp = stopRecordingRef.current;
1145
- if (sp && recording) 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, recording, busy, spaceHoldActive]);
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
- <PolicyChipsFromCitations citations={m.citations} />
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
- // Customer-facing chip: shows the cited policies as clickable pills with their
2271
- // rating, no internal jargon (no "score X.YZ" or chunk metadata).
2272
- function PolicyChipsFromCitations({ citations }: { citations: Citation[] }) {
 
 
 
2273
  const [cards, setCards] = useState<Record<string, ScorecardResponse | null>>({});
2274
- const [openId, setOpenId] = useState<string | null>(null);
 
 
 
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="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)] font-semibold">Cited policies</div>
2294
- <div className="flex flex-wrap gap-1.5">
 
 
 
 
 
 
 
 
 
 
 
 
2295
  {topPolicies.map((c) => {
2296
  const sc = cards[c.policy_id];
2297
- const isOpen = openId === c.policy_id;
2298
  return (
2299
- <div key={c.policy_id} className="inline-flex items-center gap-1.5">
2300
- <button
2301
- onClick={() => setOpenId(isOpen ? null : c.policy_id)}
2302
- className={`text-xs px-2.5 py-1 rounded-lg border transition flex items-center gap-2 ${
2303
- isOpen ? "border-[var(--primary)] bg-[var(--accent)]" : "border-[var(--border)] bg-[var(--card)] hover:border-[var(--primary)]"
2304
- }`}
2305
- title={sc?.one_liner || c.policy_name}
2306
- >
2307
- {sc && <span className={`inline-flex items-center justify-center w-5 h-5 rounded font-bold text-[11px] ${gradeColor(sc.grade)}`}>{sc.grade}</span>}
2308
- <span className="flex flex-col items-start leading-tight">
2309
- {/* Bug B — show the insurer label above the policy name so
2310
- "Sarvah Param" reads as "ManipalCigna · Sarvah Param"
2311
- instead of an unattributed policy fragment. */}
2312
- {c.insurer_slug && (
2313
- <span className="text-[9px] uppercase tracking-wider text-[var(--muted-foreground)]">
2314
- {c.insurer_slug.replace(/-/g, " ")}
2315
- </span>
 
 
 
 
 
 
2316
  )}
2317
- <span className="font-medium truncate max-w-[160px]">{c.policy_name}</span>
2318
- </span>
2319
- </button>
2320
- {c.source_url && (
2321
- <a
2322
- href={c.source_url}
2323
- target="_blank"
2324
- rel="noopener"
2325
- title="Open policy PDF"
2326
- className="text-xs text-[var(--muted-foreground)] hover:text-[var(--primary)]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2327
  >
2328
- <PdfIcon />
2329
- </a>
2330
- )}
2331
  </div>
2332
  );
2333
  })}
2334
  </div>
2335
- {openId && cards[openId] && <ScorecardCard sc={cards[openId]!} />}
 
 
 
 
 
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
  }