dev1/src/components/ChatDrawer.js
Josh 8ac77b6ae1
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
Coach and chatbot fixes
2025-10-02 13:48:54 +00:00

291 lines
9.9 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';
async function ensureSupportThread() {
// list existing
const r = await fetch('/api/chat/threads', { credentials:'include' });
if (!r.ok) throw new Error(`threads list failed: ${r.status}`);
const { threads } = await r.json();
if (threads?.length) return threads[0].id;
// create new
const r2 = await fetch('/api/chat/threads', {
method: 'POST',
credentials:'include',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ title: 'Support chat' })
});
if (!r2.ok) throw new Error(`thread create failed: ${r2.status}`);
const { id } = await r2.json();
return id;
}
/* ------------------------------------------------------------------ */
/* 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');
const [supportThreadId, setSupportThreadId] = useState(null);
/* 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);
const SUPPORT_INTRO =
"Hi — Aptiva Support here. I can help with CareerExplorer, account/billing, or technical issues. What do you need?";
/* auto-scroll on incoming messages */
useEffect(() => {
listRef.current &&
(listRef.current.scrollTop = listRef.current.scrollHeight);
}, [messages]);
useEffect(() => {
(async () => {
try {
const id = await ensureSupportThread();
setSupportThreadId(id);
// preload messages
const r = await fetch(`/api/chat/threads/${id}`, { credentials: 'include' });
if (r.ok) {
const { messages: msgs } = await r.json();
setMessages((Array.isArray(msgs) && msgs.length)
? msgs
: [{ role: 'assistant', content: SUPPORT_INTRO }]);
} else {
// dont crash UI on preload failure
setMessages([{ role: 'assistant', content: SUPPORT_INTRO }]);
}
} catch (e) {
console.error('[Support preload]', e);
setMessages([{ role: 'assistant', content: SUPPORT_INTRO }]);
}
})();
}, []);
/* 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 || !supportThreadId) return;
setMessages(m => [...m, { role:'user', content:text }]);
setPrompt('');
try {
const resp = await fetch(`/api/chat/threads/${supportThreadId}/stream`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type':'application/json', Accept:'text/event-stream' },
body: JSON.stringify({ prompt: text, pageContext, snapshot })
});
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
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 (e) {
console.error('[Support stream]', e);
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-50 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-[88vw] max-w-[380px] flex-col p-0 sm:w-[360px] 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">
{/* Persistent disclaimer */}
<div className="text-xs text-gray-600 mb-2">
Aptiva bots may be incomplete or inaccurate. Verify important details before acting.
</div>
<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>
);
}