dev1/src/components/ChatDrawer.js
Josh 12d7f654f4
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
MySQL TLS, Retirement Chatbar, migrated AI_Risk to MySQL, client certs for MySQL from GCP
2025-08-05 11:20:48 +00:00

259 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ───────────────────────── ChatDrawer.jsx
import { useEffect, useRef, useState } from 'react';
import { Sheet, SheetTrigger, SheetContent } from './ui/sheet.js';
import { cn } from '../utils/cn.js';
import { Button } from './ui/button.js';
import { Input } from './ui/input.js';
import { MessageCircle } from 'lucide-react';
import RetirementChatBar from './RetirementChatBar.js';
/* ------------------------------------------------------------------ */
/* ChatDrawer
support-bot lives in this file (streamed from /api/chat/free)
retirement helper is just a passthrough to <RetirementChatBar>
------------------------------------------------------------------ */
export default function ChatDrawer({
/* ─ props from App.js ─ */
pageContext = 'Home',
snapshot = null,
open: controlledOpen = false,
onOpenChange,
pane: controlledPane = 'support',
setPane: setControlledPane,
retireProps = null, // { scenario, financialProfile, … }
canShowRetireBot
}) {
/* ─────────────────────────── internal / fallback state ───────── */
const [openLocal, setOpenLocal] = useState(false);
const [paneLocal, setPaneLocal] = useState('support');
/* prefer the controlled props when supplied */
const open = controlledOpen ?? openLocal;
const setOpen = onOpenChange ?? setOpenLocal;
const pane = controlledPane ?? paneLocal;
const setPane = setControlledPane ?? setPaneLocal;
/* ────────────────── free-tier support-bot state ─────────────── */
const [prompt, setPrompt] = useState('');
const [messages, setMessages] = useState([]); // { role, content }
const listRef = useRef(null);
/* auto-scroll on incoming messages */
useEffect(() => {
listRef.current &&
(listRef.current.scrollTop = listRef.current.scrollHeight);
}, [messages]);
/* helper: merge chunks while streaming */
const pushAssistant = (chunk) =>
setMessages((prev) => {
const last = prev.at(-1);
if (last?.role === 'assistant') {
const updated = [...prev];
updated[updated.length - 1] = {
...last,
content: last.content + chunk,
};
return updated;
}
return [...prev, { role: 'assistant', content: chunk }];
});
useEffect(() => {
if (!canShowRetireBot && pane === 'retire') {
setPane('support');
}
}, [canShowRetireBot, pane, setPane]);
/* ───────────────────────── send support-bot prompt ───────────── */
async function sendPrompt() {
const text = prompt.trim();
if (!text) return;
setMessages((m) => [...m, { role: 'user', content: text }]);
setPrompt('');
const body = JSON.stringify({
prompt: text,
pageContext,
chatHistory: messages,
snapshot,
});
try {
const token = localStorage.getItem('token') || '';
const headers = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const resp = await fetch('/api/chat/free', {
method: 'POST',
headers,
body,
});
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
/* eslint-disable no-await-in-loop */
const { value, done } = await reader.read();
/* eslint-enable no-await-in-loop */
if (done) break;
if (!value) continue;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) pushAssistant(line + '\n');
}
}
if (buf.trim()) pushAssistant(buf);
} catch (err) {
console.error('[ChatDrawer] stream error', err);
pushAssistant(
'Sorry — something went wrong. Please try again later.'
);
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendPrompt();
}
};
/* ---------- render ---------- */
return (
<Sheet open={open} onOpenChange={onOpenChange}>
{/* floating action button */}
<SheetTrigger asChild>
<button
type="button"
aria-label="Open chat"
className="fixed bottom-6 right-6 z-40 flex h-14 w-14
items-center justify-center rounded-full
bg-blue-600 text-white shadow-lg
hover:bg-blue-700 focus:outline-none"
/* -------- explicitly open Support pane -------- */
onClick={() => {
setPane('support');
onOpenChange(true); // < force the controlled state
}}
>
<MessageCircle className="h-6 w-6" />
</button>
</SheetTrigger>
{/* side drawer */}
<SheetContent
side="right"
className="flex h-full w-[370px] flex-col p-0 md:w-[420px]"
>
{/* header (tabs only if retirement bot is allowed) */}
<div className="flex border-b">
<button
className={
pane === 'support'
? 'flex-1 px-4 py-3 text-sm font-semibold border-b-2 border-blue-600'
: 'flex-1 px-4 py-3 text-sm text-gray-500 hover:bg-gray-50'
}
onClick={() => setPane('support')}
>
Aptiva Support
</button>
{canShowRetireBot && (
<button
className={
pane === 'retire'
? 'flex-1 px-4 py-3 text-sm font-semibold border-b-2 border-blue-600'
: 'flex-1 px-4 py-3 text-sm text-gray-500 hover:bg-gray-50'
}
onClick={() => setPane('retire')}
>
Retirement Helper
</button>
)}
</div>
{/* body */}
{pane === 'support' ? (
/* ── Support bot pane ── */
<>
<div
ref={listRef}
className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm"
>
{messages.map((m, i) => (
<div
key={i}
className={
m.role === 'user' ? 'text-right' : 'text-left text-gray-800'
}
>
{m.content}
</div>
))}
</div>
<div className="border-t p-4">
<form
onSubmit={(e) => {
e.preventDefault();
sendPrompt();
}}
className="flex gap-2"
>
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Ask me anything…"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendPrompt();
}
}}
className="flex-1"
/>
<Button type="submit" disabled={!prompt.trim()}>
Send
</Button>
</form>
</div>
</>
) : retireProps ? (
/* ── Retirement helper pane ── */
<RetirementChatBar
scenario={retireProps.scenario}
scenarios={retireProps.scenarios} // ← THIS IS MISSING
userProfile={retireProps.userProfile}
financialProfile={retireProps.financialProfile}
collegeProfile={retireProps.collegeProfile}
milestoneGrid={retireProps.milestoneGrid}
onScenarioPatch={retireProps.onScenarioPatch}
className="border-l bg-white"
/>
) : (
/* failsafe (retire tab opened before selecting a scenario) */
<div className="m-auto px-6 text-center text-sm text-gray-400">
Select a scenario in&nbsp;
<strong>Retirement Planner</strong>
</div>
)}
</SheetContent>
</Sheet>
);
}