283 lines
9.3 KiB
JavaScript
283 lines
9.3 KiB
JavaScript
// ───────────────────────── 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);
|
||
|
||
/* 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(msgs || []);
|
||
} else {
|
||
// don’t crash UI on preload failure
|
||
setMessages([]);
|
||
}
|
||
} catch (e) {
|
||
console.error('[Support preload]', e);
|
||
setMessages([]);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
/* 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">
|
||
<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
|
||
<strong>Retirement Planner</strong>
|
||
</div>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
} |