diff --git a/chatbot-api/main.py b/chatbot-api/main.py index 5705105..cdd6e1f 100644 --- a/chatbot-api/main.py +++ b/chatbot-api/main.py @@ -78,6 +78,16 @@ async def chat(req: ChatRequest): # Store user message and get conversation history messages = await store_messages(req.session_id, "user", message) + # If lead info provided, prepend context for LLM + if req.lead and req.lead.name: + lead_context = f"[System: The visitor has introduced themselves. Name: {req.lead.name}" + if req.lead.email: + lead_context += f", Email: {req.lead.email}" + if req.lead.company: + lead_context += f", Company: {req.lead.company}" + lead_context += ". Use this info naturally — greet them by name. Don't re-ask for info you already have.]" + messages = [{"role": "user", "content": lead_context}, {"role": "assistant", "content": "Understood."}] + messages + # Get AI response try: reply, lead_data = await get_ai_response(messages) @@ -90,7 +100,8 @@ async def chat(req: ChatRequest): await store_messages(req.session_id, "assistant", reply) # Mirror to Rocket.Chat (async, don't block response) - room_id = await get_or_create_room(req.session_id) + visitor_name = req.lead.name if req.lead and req.lead.name else "Website Visitor" + room_id = await get_or_create_room(req.session_id, visitor_name) if room_id: asyncio.create_task(send_message(room_id, req.session_id, message, "visitor")) asyncio.create_task(send_message(room_id, req.session_id, reply, "bot")) diff --git a/chatbot-api/models.py b/chatbot-api/models.py index e014cd5..a4e487e 100644 --- a/chatbot-api/models.py +++ b/chatbot-api/models.py @@ -1,10 +1,17 @@ from pydantic import BaseModel, Field +class ChatLead(BaseModel): + name: str = "" + email: str = "" + company: str = "" + + class ChatRequest(BaseModel): session_id: str = Field(..., min_length=1, max_length=64) message: str = Field(..., min_length=1, max_length=500) page_context: str = Field(default="/", max_length=200) + lead: ChatLead | None = None class ChatResponse(BaseModel): diff --git a/chatbot-api/rocketchat.py b/chatbot-api/rocketchat.py index c21b80d..6f5e247 100644 --- a/chatbot-api/rocketchat.py +++ b/chatbot-api/rocketchat.py @@ -1,7 +1,12 @@ import httpx +import logging from config import settings +logger = logging.getLogger("rocketchat") + HEADERS: dict[str, str] = {} +# Cache: session_id -> room_id +_room_cache: dict[str, str] = {} def _get_headers() -> dict[str, str]: @@ -18,6 +23,11 @@ async def get_or_create_room(session_id: str, visitor_name: str = "Website Visit """Get or create a Rocket.Chat livechat room for a session.""" if not settings.rocketchat_auth_token: return None + + # Return cached room + if session_id in _room_cache: + return _room_cache[session_id] + try: async with httpx.AsyncClient() as http: # Register visitor @@ -42,8 +52,12 @@ async def get_or_create_room(session_id: str, visitor_name: str = "Website Visit timeout=10, ) resp.raise_for_status() - return resp.json().get("room", {}).get("_id") - except Exception: + room_id = resp.json().get("room", {}).get("_id") + if room_id: + _room_cache[session_id] = room_id + return room_id + except Exception as e: + logger.error(f"RC get_or_create_room error: {e}") return None @@ -54,7 +68,8 @@ async def send_message(room_id: str, session_id: str, text: str, sender: str = " try: async with httpx.AsyncClient() as http: if sender == "visitor": - await http.post( + # Visitor sends via livechat visitor API + resp = await http.post( f"{settings.rocketchat_url}/api/v1/livechat/message", headers={"Content-Type": "application/json"}, json={ @@ -64,17 +79,35 @@ async def send_message(room_id: str, session_id: str, text: str, sender: str = " }, timeout=10, ) + if resp.status_code != 200: + logger.error(f"RC visitor msg error: {resp.status_code} {resp.text}") else: - await http.post( + # Bot/agent sends via authenticated API + resp = await http.post( f"{settings.rocketchat_url}/api/v1/chat.sendMessage", headers=_get_headers(), json={ "message": { "rid": room_id, - "msg": f"[{sender.upper()}] {text}", + "msg": f"🤖 {text}", } }, timeout=10, ) - except Exception: - pass + if resp.status_code != 200: + logger.error(f"RC bot msg error: {resp.status_code} {resp.text}") + # Fallback: try livechat/message as agent + resp2 = await http.post( + f"{settings.rocketchat_url}/api/v1/livechat/message", + headers=_get_headers(), + json={ + "token": session_id, + "rid": room_id, + "msg": f"🤖 {text}", + }, + timeout=10, + ) + if resp2.status_code != 200: + logger.error(f"RC bot msg fallback error: {resp2.status_code} {resp2.text}") + except Exception as e: + logger.error(f"RC send_message exception: {e}") diff --git a/src/components/ChatLeadForm.css b/src/components/ChatLeadForm.css new file mode 100644 index 0000000..9eccd98 --- /dev/null +++ b/src/components/ChatLeadForm.css @@ -0,0 +1,149 @@ +.chat-lead-form { + display: flex; + flex-direction: column; + padding: 24px 20px; + height: 100%; + overflow-y: auto; +} + +.chat-lead-form__header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.chat-lead-form__avatar { + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--orange-100); + color: #fff; + font-size: 15px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.chat-lead-form__title { + font-size: 16px; + font-weight: 600; + color: var(--light-grey-100); +} + +.chat-lead-form__subtitle { + font-size: 12px; + color: rgba(211, 221, 222, 0.5); + margin-top: 1px; +} + +.chat-lead-form__intro { + font-size: 14px; + color: rgba(211, 221, 222, 0.75); + line-height: 1.5; + margin-bottom: 20px; +} + +.chat-lead-form__fields { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; +} + +.chat-lead-form__group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.chat-lead-form__group input[type="text"], +.chat-lead-form__group input[type="email"] { + background: rgba(211, 221, 222, 0.08); + border: 1px solid rgba(211, 221, 222, 0.15); + border-radius: 10px; + padding: 11px 14px; + color: var(--light-grey-100); + font-family: var(--font-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s; + width: 100%; + box-sizing: border-box; +} + +.chat-lead-form__group input::placeholder { + color: rgba(211, 221, 222, 0.4); +} + +.chat-lead-form__group input:focus { + border-color: var(--orange-100); +} + +.chat-lead-form__group input.chat-lead-form__input--error { + border-color: #e74c3c; +} + +.chat-lead-form__error { + font-size: 12px; + color: #e74c3c; + padding-left: 2px; +} + +.chat-lead-form__consent { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + margin-top: 4px; +} + +.chat-lead-form__consent input[type="checkbox"] { + width: 16px; + height: 16px; + margin-top: 2px; + flex-shrink: 0; + accent-color: var(--orange-100); + cursor: pointer; +} + +.chat-lead-form__consent span { + font-size: 12px; + color: rgba(211, 221, 222, 0.6); + line-height: 1.4; +} + +.chat-lead-form__consent a { + color: var(--orange-100); + text-decoration: none; +} + +.chat-lead-form__consent a:hover { + text-decoration: underline; +} + +.chat-lead-form__submit { + margin-top: 16px; + width: 100%; + padding: 12px; + border: none; + border-radius: 10px; + background: var(--orange-100); + color: #fff; + font-family: var(--font-primary); + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.chat-lead-form__submit:hover { + background: var(--yellow-100); + color: var(--dark-grey-100); +} + +.chat-lead-form__submit:active { + transform: scale(0.98); +} diff --git a/src/components/ChatLeadForm.tsx b/src/components/ChatLeadForm.tsx new file mode 100644 index 0000000..8fbb2c6 --- /dev/null +++ b/src/components/ChatLeadForm.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; +import './ChatLeadForm.css'; + +export interface LeadInfo { + name: string; + email: string; + company: string; +} + +interface ChatLeadFormProps { + onSubmit: (lead: LeadInfo) => void; +} + +const ChatLeadForm: React.FC = ({ onSubmit }) => { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [company, setCompany] = useState(''); + const [consent, setConsent] = useState(false); + const [errors, setErrors] = useState>({}); + + const validate = () => { + const errs: Record = {}; + if (!name.trim()) errs.name = 'Please enter your name'; + if (!email.trim()) errs.email = 'Please enter your email'; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errs.email = 'Please enter a valid email'; + if (!consent) errs.consent = 'Please accept to continue'; + setErrors(errs); + return Object.keys(errs).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + onSubmit({ name: name.trim(), email: email.trim(), company: company.trim() }); + }; + + return ( +
+
+
AI
+
+
AImpress
+
AI & Automation Consultancy
+
+
+ +

+ Hi! Before we start, please introduce yourself so we can better assist you. +

+ +
+
+ setName(e.target.value)} + className={errors.name ? 'chat-lead-form__input--error' : ''} + maxLength={100} + /> + {errors.name && {errors.name}} +
+ +
+ setEmail(e.target.value)} + className={errors.email ? 'chat-lead-form__input--error' : ''} + maxLength={200} + /> + {errors.email && {errors.email}} +
+ +
+ setCompany(e.target.value)} + maxLength={200} + /> +
+ + + {errors.consent && {errors.consent}} +
+ + +
+ ); +}; + +export default ChatLeadForm; diff --git a/src/components/ChatWidget.tsx b/src/components/ChatWidget.tsx index 58c3925..1e28900 100644 --- a/src/components/ChatWidget.tsx +++ b/src/components/ChatWidget.tsx @@ -13,7 +13,7 @@ const GREETING_DELAY = 30000; // 30 seconds const ChatWidget: React.FC = () => { const [isOpen, setIsOpen] = useState(false); const [showGreeting, setShowGreeting] = useState(false); - const { messages, loading, rateLimited, sendMessage, clearChat, sessionId } = useChat(); + const { messages, loading, rateLimited, lead, sendMessage, setLead, clearChat, sessionId } = useChat(); const openedAtRef = React.useRef(0); // Auto-greeting after 30s (once per session) @@ -67,9 +67,14 @@ const ChatWidget: React.FC = () => { messages={messages} loading={loading} rateLimited={rateLimited} + hasLead={!!lead} onSend={handleSend} onClose={handleClose} onClear={clearChat} + onLeadSubmit={(leadInfo) => { + setLead(leadInfo); + track('Chat Lead Form Submitted', { has_company: !!leadInfo.company }); + }} /> )} diff --git a/src/components/ChatWindow.tsx b/src/components/ChatWindow.tsx index 8d77e9e..890190d 100644 --- a/src/components/ChatWindow.tsx +++ b/src/components/ChatWindow.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useRef } from 'react'; import { motion } from 'framer-motion'; import ChatMessage from './ChatMessage'; import ChatInput from './ChatInput'; +import ChatLeadForm from './ChatLeadForm'; +import type { LeadInfo } from './ChatLeadForm'; import type { ChatMessage as ChatMessageType } from '../types/chat'; import './ChatWindow.css'; @@ -9,18 +11,22 @@ interface ChatWindowProps { messages: ChatMessageType[]; loading: boolean; rateLimited: boolean; + hasLead: boolean; onSend: (text: string) => void; onClose: () => void; onClear: () => void; + onLeadSubmit: (lead: LeadInfo) => void; } const ChatWindow: React.FC = ({ messages, loading, rateLimited, + hasLead, onSend, onClose, onClear, + onLeadSubmit, }) => { const messagesEndRef = useRef(null); @@ -36,55 +42,77 @@ const ChatWindow: React.FC = ({ exit={{ opacity: 0, y: 20, scale: 0.95 }} transition={{ duration: 0.25, ease: 'easeOut' }} > -
-
-
AI
-
-
AImpress
-
Online
+ {!hasLead ? ( + <> +
+
+
+ +
+
+ + + ) : ( + <> +
+
+
AI
+
+
AImpress
+
Online
+
+
+
+ + +
-
-
- - -
-
-
- {messages.length === 0 && ( -
-

Hi! I'm the AImpress AI assistant. Ask me about our AI & automation services, pricing, or book a free consultation.

+
+ {messages.length === 0 && ( +
+

Hi! I'm the AImpress AI assistant. Ask me about our AI & automation services, pricing, or book a free consultation.

+
+ )} + {messages.map((msg) => ( + + ))} + {loading && ( +
+ +
+ )} +
- )} - {messages.map((msg) => ( - - ))} - {loading && ( -
- -
- )} -
-
- + + + )} ); }; diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index 9fa90e6..ba7af5c 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -1,9 +1,10 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import type { ChatMessage, ChatAPIResponse, PendingMessage } from '../types/chat'; +import type { ChatMessage, ChatAPIResponse, ChatLead, PendingMessage } from '../types/chat'; const STORAGE_KEY = 'aimpress_chat'; const SESSION_KEY = 'aimpress_chat_session'; +const LEAD_KEY = 'aimpress_chat_lead'; const MAX_MESSAGE_LENGTH = 500; function generateSessionId(): string { @@ -36,10 +37,20 @@ function saveMessages(messages: ChatMessage[]) { } } +function loadLead(): ChatLead | null { + try { + const raw = sessionStorage.getItem(LEAD_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + export function useChat() { const [messages, setMessages] = useState(loadMessages); const [loading, setLoading] = useState(false); const [rateLimited, setRateLimited] = useState(false); + const [lead, setLeadState] = useState(loadLead); const sessionId = useRef(getSessionId()); const pollRef = useRef | null>(null); const location = useLocation(); @@ -92,14 +103,19 @@ export function useChat() { setLoading(true); try { + const body: Record = { + session_id: sessionId.current, + message: trimmed, + page_context: location.pathname, + }; + // Include lead info on first message of session + if (lead && messages.length === 0) { + body.lead = lead; + } const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: sessionId.current, - message: trimmed, - page_context: location.pathname, - }), + body: JSON.stringify(body), }); if (!res.ok) throw new Error('Chat API error'); @@ -131,14 +147,21 @@ export function useChat() { setLoading(false); } }, - [loading, rateLimited, location.pathname], + [loading, rateLimited, lead, messages.length, location.pathname], ); + const setLead = useCallback((leadInfo: ChatLead) => { + setLeadState(leadInfo); + sessionStorage.setItem(LEAD_KEY, JSON.stringify(leadInfo)); + }, []); + const clearChat = useCallback(() => { setMessages([]); setRateLimited(false); + setLeadState(null); localStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(SESSION_KEY); + sessionStorage.removeItem(LEAD_KEY); sessionId.current = generateSessionId(); sessionStorage.setItem(SESSION_KEY, sessionId.current); }, []); @@ -147,7 +170,9 @@ export function useChat() { messages, loading, rateLimited, + lead, sendMessage, + setLead, clearChat, sessionId: sessionId.current, }; diff --git a/src/types/chat.ts b/src/types/chat.ts index 575590e..61eed0e 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -6,10 +6,17 @@ export interface ChatMessage { timestamp: number; } +export interface ChatLead { + name: string; + email: string; + company: string; +} + export interface ChatAPIRequest { session_id: string; message: string; page_context: string; + lead?: ChatLead; } export interface ChatAPIResponse {