dev1/src/components/ChatDrawer.js
Josh 5a0c7efd25
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
Fixed drawer layering for hidden buttons
2025-09-22 16:02:19 +00:00

283 lines
9.3 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);
/* 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 {
// dont 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&nbsp;
<strong>Retirement Planner</strong>
</div>
)}
</SheetContent>
</Sheet>
);
}