playgo_next / app /components /embeddable-chat-bot.tsx
ChenyuRabbitLove's picture
feat: add embeddedable chat component
c4412d0
"use client";
import { useState, useRef, useEffect } from "react";
import { useChat } from "ai/react";
import { XMarkIcon } from "@heroicons/react/24/outline";
type MessageWithLoading = {
content: string;
role: string;
isStreaming?: boolean;
};
// Define theme CSS variables
const themeStyles = {
light: {
'--bg-primary': '#ffffff',
'--bg-secondary': 'rgba(243, 244, 246, 0.7)',
'--text-primary': '#111827',
'--text-secondary': '#6B7280',
'--border-color': 'rgba(229, 231, 235, 0.5)',
'--shadow-color': 'rgba(0, 0, 0, 0.1)',
'--message-bg': 'rgba(243, 244, 246, 0.7)',
'--input-bg': 'rgba(255, 255, 255, 0.9)',
},
dark: {
'--bg-primary': '#1F2937',
'--bg-secondary': 'rgba(31, 41, 55, 0.7)',
'--text-primary': '#F9FAFB',
'--text-secondary': '#D1D5DB',
'--border-color': 'rgba(75, 85, 99, 0.5)',
'--shadow-color': 'rgba(0, 0, 0, 0.3)',
'--message-bg': 'rgba(55, 65, 81, 0.7)',
'--input-bg': 'rgba(31, 41, 55, 0.9)',
},
} as const;
// Inject required styles into iframe
const injectStyles = `
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.playgo-chat {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.playgo-chat-header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.playgo-chat-messages {
background-color: var(--bg-primary);
}
.playgo-chat-input {
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
}
.playgo-chat-input-field {
background-color: var(--input-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 0.75rem 3rem 0.75rem 0.75rem;
width: 100%;
transition: all 0.2s;
}
.playgo-chat-input-field:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
}
.playgo-chat-input-field::placeholder {
color: var(--text-secondary);
}
.playgo-message-bubble {
box-shadow: 0 1px 2px var(--shadow-color);
max-width: 80%;
padding: 0.75rem;
border-radius: 0.75rem;
}
.playgo-message-assistant {
background-color: var(--message-bg);
color: var(--text-primary);
margin-right: 1rem;
}
.playgo-message-user {
background-color: var(--primary-color);
color: white;
margin-left: 1rem;
}
@media (prefers-color-scheme: dark) {
.playgo-chat[data-theme="system"] {
--bg-primary: #1F2937;
--bg-secondary: rgba(31, 41, 55, 0.7);
--text-primary: #F9FAFB;
--text-secondary: #D1D5DB;
--border-color: rgba(75, 85, 99, 0.5);
--shadow-color: rgba(0, 0, 0, 0.3);
--message-bg: rgba(55, 65, 81, 0.7);
--input-bg: rgba(31, 41, 55, 0.9);
}
}
`;
interface EmbeddableChatBotConfig {
apiUrl?: string;
height?: string | number;
width?: string | number;
theme?: 'light' | 'dark' | 'system';
primaryColor?: string;
placeholder?: string;
buttonText?: string;
}
// First, let's fix the CSS type error by declaring the CSS custom properties
declare module 'react' {
interface CSSProperties {
'--primary-color'?: string;
'--primary-rgb'?: string;
'--bg-primary'?: string;
'--bg-secondary'?: string;
'--text-primary'?: string;
'--text-secondary'?: string;
'--border-color'?: string;
'--shadow-color'?: string;
'--message-bg'?: string;
'--input-bg'?: string;
}
}
export default function EmbeddableChatBot({
apiUrl = "/api/landing_page_chat",
theme = 'system',
primaryColor = "#FF6B6B",
placeholder = "請問任何關於學習的問題...",
buttonText = "需要協助嗎?",
}: EmbeddableChatBotConfig) {
const chatContainerRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<MessageWithLoading[]>([]);
const [currentTheme, setCurrentTheme] = useState(theme);
// Convert hex color to RGB for CSS variables
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ?
`${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
: '255, 107, 107'; // fallback RGB for #FF6B6B
};
const {
input,
handleInputChange,
handleSubmit,
isLoading,
} = useChat({
api: apiUrl,
onError: (error) => {
console.error("Chat error:", error);
},
onFinish: () => {
setMessages((prev) =>
prev.map((msg) => ({ ...msg, isStreaming: false }))
);
},
});
// Handle system theme changes
useEffect(() => {
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
setCurrentTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handleChange);
setCurrentTheme(mediaQuery.matches ? 'dark' : 'light');
return () => mediaQuery.removeEventListener('change', handleChange);
} else {
setCurrentTheme(theme);
}
}, [theme]);
useEffect(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
}, [messages]);
// Inject styles when component mounts
useEffect(() => {
if (isOpen && chatContainerRef.current) {
const styleSheet = document.createElement('style');
styleSheet.textContent = injectStyles;
chatContainerRef.current.appendChild(styleSheet);
}
}, [isOpen]);
const containerStyle = {
...(themeStyles[currentTheme === 'system' ? 'light' : currentTheme]),
'--primary-color': primaryColor,
'--primary-rgb': hexToRgb(primaryColor),
} as React.CSSProperties;
// Send message to parent when chat state changes
useEffect(() => {
if (typeof window !== 'undefined') {
window.parent.postMessage({
type: isOpen ? 'chatOpen' : 'chatClose'
}, '*');
}
}, [isOpen]);
if (!isOpen) {
return (
<div className="w-full h-full flex items-center justify-end">
<button
onClick={() => setIsOpen(true)}
className="bg-primary text-white rounded-full p-4
transition-colors duration-200
inline-flex items-center"
style={{ backgroundColor: primaryColor }}
>
<span className="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
{buttonText}
</span>
</button>
</div>
);
}
return (
<div
ref={chatContainerRef}
className="w-full h-full rounded-2xl"
style={{
...containerStyle,
overflow: 'hidden',
}}
data-theme={theme}
>
<div
className="playgo-chat-container rounded-2xl bg-background-primary w-full h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden'
}}
>
<div className="flex flex-col h-full absolute inset-0">
<div className="playgo-chat-header p-4 flex justify-between items-center flex-shrink-0">
<h2 className="text-xl font-bold">
<span className="text-[#FF6B6B]">P</span>
<span className="text-[#4ECDC4]">l</span>
<span className="text-[#45B7D1]">a</span>
<span className="text-[#FDCB6E]">y</span>
<span className="text-[#FF6B6B]">G</span>
<span className="text-[#4ECDC4]">o</span>
<span className="ml-2 text-[#45B7D1]">A</span>
<span className="text-[#FDCB6E]">I</span>
</h2>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-background-secondary/50 rounded-full transition-colors"
>
<XMarkIcon className="w-6 h-6 text-text-secondary" />
</button>
</div>
<div className="flex-1 relative">
<div
className="playgo-chat-messages absolute inset-0 overflow-y-auto p-4 space-y-4"
style={{
overscrollBehavior: 'contain',
WebkitOverflowScrolling: 'touch'
}}
>
{messages.map((message, index) => (
<div
key={index}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`playgo-message-bubble ${
message.role === "user"
? "playgo-message-user"
: "playgo-message-assistant"
}`}
>
{message.content}
{message.isStreaming && (
<span className="ml-1 animate-pulse">...</span>
)}
</div>
</div>
))}
</div>
</div>
<form
onSubmit={handleSubmit}
className="playgo-chat-input p-4 flex-shrink-0"
>
<div className="relative">
<input
type="text"
value={input}
onChange={handleInputChange}
placeholder={placeholder}
className="playgo-chat-input-field"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2
text-primary hover:text-primary/80 disabled:opacity-50
transition-colors duration-200"
style={{ color: primaryColor }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
</button>
</div>
</form>
</div>
</div>
</div>
);
}