Add lead collection form before chat + fix RC bot message delivery

- New ChatLeadForm component: collects name, email, company before chat starts
- GDPR consent checkbox with Privacy Policy link
- Lead info passed to backend and injected as LLM context
- Visitor name from form used in Rocket.Chat room
- RC bot messages: added logging + fallback to livechat/message endpoint
- RC room caching to avoid repeated API calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-08 18:02:08 +00:00
parent 2dc1414caa
commit a8e8d8a71b
9 changed files with 433 additions and 61 deletions

View file

@ -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"))

View file

@ -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):

View file

@ -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}")

View file

@ -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);
}

View file

@ -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<ChatLeadFormProps> = ({ onSubmit }) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [company, setCompany] = useState('');
const [consent, setConsent] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const errs: Record<string, string> = {};
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 (
<form className="chat-lead-form" onSubmit={handleSubmit}>
<div className="chat-lead-form__header">
<div className="chat-lead-form__avatar">AI</div>
<div>
<div className="chat-lead-form__title">AImpress</div>
<div className="chat-lead-form__subtitle">AI & Automation Consultancy</div>
</div>
</div>
<p className="chat-lead-form__intro">
Hi! Before we start, please introduce yourself so we can better assist you.
</p>
<div className="chat-lead-form__fields">
<div className="chat-lead-form__group">
<input
type="text"
placeholder="Your name *"
value={name}
onChange={(e) => setName(e.target.value)}
className={errors.name ? 'chat-lead-form__input--error' : ''}
maxLength={100}
/>
{errors.name && <span className="chat-lead-form__error">{errors.name}</span>}
</div>
<div className="chat-lead-form__group">
<input
type="email"
placeholder="Email *"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={errors.email ? 'chat-lead-form__input--error' : ''}
maxLength={200}
/>
{errors.email && <span className="chat-lead-form__error">{errors.email}</span>}
</div>
<div className="chat-lead-form__group">
<input
type="text"
placeholder="Company (optional)"
value={company}
onChange={(e) => setCompany(e.target.value)}
maxLength={200}
/>
</div>
<label className="chat-lead-form__consent">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
<span>
I agree to the processing of my personal data in accordance with the{' '}
<a href="/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>
</span>
</label>
{errors.consent && <span className="chat-lead-form__error">{errors.consent}</span>}
</div>
<button type="submit" className="chat-lead-form__submit">
Start Chat
</button>
</form>
);
};
export default ChatLeadForm;

View file

@ -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<number>(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 });
}}
/>
)}
</AnimatePresence>

View file

@ -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<ChatWindowProps> = ({
messages,
loading,
rateLimited,
hasLead,
onSend,
onClose,
onClear,
onLeadSubmit,
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -36,55 +42,77 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
>
<div className="chat-window__header">
<div className="chat-window__header-info">
<div className="chat-window__avatar">AI</div>
<div>
<div className="chat-window__title">AImpress</div>
<div className="chat-window__status">Online</div>
{!hasLead ? (
<>
<div className="chat-window__header">
<div className="chat-window__header-info" />
<div className="chat-window__header-actions">
<button
className="chat-window__action-btn"
onClick={onClose}
aria-label="Close chat"
>
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<ChatLeadForm onSubmit={onLeadSubmit} />
</>
) : (
<>
<div className="chat-window__header">
<div className="chat-window__header-info">
<div className="chat-window__avatar">AI</div>
<div>
<div className="chat-window__title">AImpress</div>
<div className="chat-window__status">Online</div>
</div>
</div>
<div className="chat-window__header-actions">
<button
className="chat-window__action-btn"
onClick={onClear}
aria-label="Clear chat"
title="Clear chat"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14" />
</svg>
</button>
<button
className="chat-window__action-btn"
onClick={onClose}
aria-label="Close chat"
>
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div className="chat-window__header-actions">
<button
className="chat-window__action-btn"
onClick={onClear}
aria-label="Clear chat"
title="Clear chat"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14" />
</svg>
</button>
<button
className="chat-window__action-btn"
onClick={onClose}
aria-label="Close chat"
>
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="chat-window__messages">
{messages.length === 0 && (
<div className="chat-window__welcome">
<p>Hi! I'm the AImpress AI assistant. Ask me about our AI & automation services, pricing, or book a free consultation.</p>
<div className="chat-window__messages">
{messages.length === 0 && (
<div className="chat-window__welcome">
<p>Hi! I'm the AImpress AI assistant. Ask me about our AI & automation services, pricing, or book a free consultation.</p>
</div>
)}
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
{loading && (
<div className="chat-window__typing">
<span /><span /><span />
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
{loading && (
<div className="chat-window__typing">
<span /><span /><span />
</div>
)}
<div ref={messagesEndRef} />
</div>
<ChatInput onSend={onSend} disabled={loading || rateLimited} />
<ChatInput onSend={onSend} disabled={loading || rateLimited} />
</>
)}
</motion.div>
);
};

View file

@ -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<ChatMessage[]>(loadMessages);
const [loading, setLoading] = useState(false);
const [rateLimited, setRateLimited] = useState(false);
const [lead, setLeadState] = useState<ChatLead | null>(loadLead);
const sessionId = useRef(getSessionId());
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const location = useLocation();
@ -92,14 +103,19 @@ export function useChat() {
setLoading(true);
try {
const body: Record<string, unknown> = {
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,
};

View file

@ -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 {