feat: widget chat with generative UI, branch selection, captcha gate, lead dedup

- Streaming AI chat via Vercel AI SDK v6 UI message stream — tool-based
  generative UI (pick_branch, list_departments, show_clinic_timings,
  show_doctors, show_doctor_slots, suggest_booking). Typing indicator,
  markdown suppressed, text parts hidden when widgets are rendered.
- Centralized Preact store (store.tsx) for visitor, leadId, captchaToken,
  bookingPrefill, doctors roster, branches, selectedBranch — replaces prop
  drilling across chat/book/contact tabs.
- Cloudflare Turnstile captcha gate rendered via light-DOM portal so it
  renders correctly inside the shadow DOM (Turnstile CSS doesn't cross
  shadow boundaries).
- Lead dedup helper (findOrCreateLeadByPhone, 24h phone window) shared
  across chat-start / book / contact so one visitor == one lead. Booking
  upgrades existing lead status NEW → APPOINTMENT_SET via updateLeadStatus.
- Pre-chat name+phone form captures the visitor; chat transcript logged
  to leadActivity records after each stream.
- Booking wizard gains a branch step 0 (skipped for single-branch
  hospitals); departments + doctors filtered by selectedBranch. Chat slot
  picks prefill the booking details step and lock the branch.
- Window-level captcha gate, modal maximize mode, header badge showing
  selected branch, widget font inherits from host page (fix :host { all:
  initial } override).
- 23 FA Pro 7.1 duotone icons bundled — medical departments, nav, actions,
  hospital/location-dot for branch context.
- main.ts: resolve public/ from process.cwd() so widget.js serves in both
  dev and prod. tsconfig: exclude widget-src/public/data from server tsc.
- captcha.guard: switch from reCAPTCHA v3 to Cloudflare Turnstile verify.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 16:04:46 +05:30
parent 517b2661b0
commit aa41a2abb7
23 changed files with 2902 additions and 270 deletions

View File

@@ -1,18 +1,85 @@
import { useState, useRef, useEffect } from 'preact/hooks';
import { streamChat } from './api';
import type { ChatMessage } from './types';
import { startChatSession, streamChat } from './api';
import { readChatStream } from './chat-stream';
import { IconSpan } from './icon-span';
import { ChatToolWidget } from './chat-widgets';
import { useWidgetStore } from './store';
import type {
BookingPrefill,
ChatMessage,
ChatPart,
ChatTextPart,
ChatToolPart,
} from './types';
const QUICK_ACTIONS = [
'Doctor availability',
'What departments do you have?',
'Show me cardiologists',
'Clinic timings',
'Book appointment',
'Health packages',
'How do I book?',
];
export const Chat = () => {
type ChatProps = {
// Switches the widget to the Book tab. Chat-level handler that lives in
// the parent so slot picks can seed bookingPrefill + swap tabs atomically.
onRequestBooking: (prefill?: BookingPrefill) => void;
};
const textOf = (msg: ChatMessage): string =>
msg.parts
.filter((p): p is ChatTextPart => p.type === 'text')
.map(p => p.text)
.join('');
const updateMessage = (
setMessages: (updater: (msgs: ChatMessage[]) => ChatMessage[]) => void,
id: string,
mutator: (msg: ChatMessage) => ChatMessage,
) => {
setMessages(prev => prev.map(m => (m.id === id ? mutator(m) : m)));
};
const appendTextDelta = (parts: ChatPart[], delta: string, state: 'streaming' | 'done'): ChatPart[] => {
const last = parts[parts.length - 1];
if (last?.type === 'text') {
return [...parts.slice(0, -1), { type: 'text', text: last.text + delta, state }];
}
return [...parts, { type: 'text', text: delta, state }];
};
const upsertToolPart = (
parts: ChatPart[],
toolCallId: string,
update: Partial<ChatToolPart>,
fallback: ChatToolPart,
): ChatPart[] => {
const idx = parts.findIndex(p => p.type === 'tool' && p.toolCallId === toolCallId);
if (idx >= 0) {
const existing = parts[idx] as ChatToolPart;
const merged: ChatToolPart = { ...existing, ...update };
return [...parts.slice(0, idx), merged, ...parts.slice(idx + 1)];
}
return [...parts, { ...fallback, ...update }];
};
const genId = () => `m_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
export const Chat = ({ onRequestBooking }: ChatProps) => {
const {
visitor,
updateVisitor,
leadId,
setLeadId,
setBookingPrefill,
selectedBranch,
setSelectedBranch,
} = useWidgetStore();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [formSubmitting, setFormSubmitting] = useState(false);
const [formError, setFormError] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -21,59 +88,263 @@ export const Chat = () => {
}
}, [messages]);
const sendMessage = async (text: string) => {
if (!text.trim() || loading) return;
const submitLeadForm = async () => {
const name = visitor.name.trim();
const phone = visitor.phone.trim();
if (!name || !phone) return;
setFormSubmitting(true);
setFormError('');
try {
const { leadId: newLeadId } = await startChatSession(name, phone);
setLeadId(newLeadId);
} catch {
setFormError('Could not start chat. Please try again.');
} finally {
setFormSubmitting(false);
}
};
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
const updated = [...messages, userMsg];
setMessages(updated);
const sendMessage = async (text: string, branchOverride?: string | null) => {
if (!text.trim() || loading || !leadId) return;
const userMsg: ChatMessage = {
id: genId(),
role: 'user',
parts: [{ type: 'text', text: text.trim(), state: 'done' }],
};
const assistantId = genId();
const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', parts: [] };
const historyForBackend = [...messages, userMsg].map(m => ({
role: m.role,
content: textOf(m),
}));
setMessages(prev => [...prev, userMsg, assistantMsg]);
setInput('');
setLoading(true);
// Branch can be provided explicitly to bypass the stale closure value
// when the caller just set it (e.g., handleBranchPick immediately after
// setSelectedBranch).
const effectiveBranch =
branchOverride !== undefined ? branchOverride : selectedBranch;
try {
const stream = await streamChat(updated);
const reader = stream.getReader();
const decoder = new TextDecoder();
let assistantText = '';
const stream = await streamChat(leadId, historyForBackend, effectiveBranch);
setMessages([...updated, { role: 'assistant', content: '' }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
assistantText += decoder.decode(value, { stream: true });
setMessages([...updated, { role: 'assistant', content: assistantText }]);
for await (const chunk of readChatStream(stream)) {
switch (chunk.type) {
case 'text-delta':
if (typeof chunk.delta === 'string') {
updateMessage(setMessages, assistantId, m => ({
...m,
parts: appendTextDelta(m.parts, chunk.delta, 'streaming'),
}));
}
break;
case 'text-end':
updateMessage(setMessages, assistantId, m => ({
...m,
parts: m.parts.map(p =>
p.type === 'text' ? { ...p, state: 'done' } : p,
),
}));
break;
case 'tool-input-start':
updateMessage(setMessages, assistantId, m => ({
...m,
parts: upsertToolPart(
m.parts,
chunk.toolCallId,
{ state: 'input-streaming', toolName: chunk.toolName },
{
type: 'tool',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
state: 'input-streaming',
},
),
}));
break;
case 'tool-input-available':
updateMessage(setMessages, assistantId, m => ({
...m,
parts: upsertToolPart(
m.parts,
chunk.toolCallId,
{ state: 'input-available', toolName: chunk.toolName, input: chunk.input },
{
type: 'tool',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
state: 'input-available',
input: chunk.input,
},
),
}));
break;
case 'tool-output-available':
updateMessage(setMessages, assistantId, m => ({
...m,
parts: upsertToolPart(
m.parts,
chunk.toolCallId,
{ state: 'output-available', output: chunk.output },
{
type: 'tool',
toolCallId: chunk.toolCallId,
toolName: 'unknown',
state: 'output-available',
output: chunk.output,
},
),
}));
break;
case 'tool-output-error':
updateMessage(setMessages, assistantId, m => ({
...m,
parts: upsertToolPart(
m.parts,
chunk.toolCallId,
{ state: 'output-error', errorText: chunk.errorText },
{
type: 'tool',
toolCallId: chunk.toolCallId,
toolName: 'unknown',
state: 'output-error',
errorText: chunk.errorText,
},
),
}));
break;
case 'error':
updateMessage(setMessages, assistantId, m => ({
...m,
parts: [
...m.parts,
{
type: 'text',
text: 'Sorry, I encountered an error. Please try again.',
state: 'done',
},
],
}));
break;
default:
break;
}
}
} catch {
setMessages([...updated, { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }]);
updateMessage(setMessages, assistantId, m => ({
...m,
parts: [
...m.parts,
{
type: 'text',
text: 'Sorry, I encountered an error. Please try again.',
state: 'done',
},
],
}));
} finally {
setLoading(false);
}
};
const handleSlotPick = (prefill: BookingPrefill) => {
setBookingPrefill(prefill);
onRequestBooking(prefill);
};
const handleBranchPick = (branch: string) => {
// Store the selection so every subsequent request carries it, then
// echo the visitor's choice as a user message so the AI re-runs the
// branch-gated tool it was about to call. We pass branch explicitly
// to sidestep the stale-closure selectedBranch inside sendMessage.
setSelectedBranch(branch);
sendMessage(`I'm interested in the ${branch} branch.`, branch);
};
// Pre-chat gate — only shown if we don't yet have an active lead. Name/phone
// inputs bind to the shared store so anything typed here is immediately
// available to the Book and Contact forms too.
if (!leadId) {
return (
<div class="chat-intro">
<div class="chat-empty-icon">
<IconSpan name="hand-wave" size={40} color="#f59e0b" />
</div>
<div class="chat-empty-title">Hi! How can we help?</div>
<div class="chat-empty-text">
Share your name and phone so we can follow up if needed.
</div>
{formError && <div class="widget-error">{formError}</div>}
<div class="widget-field">
<label class="widget-label">Full Name *</label>
<input
class="widget-input"
placeholder="Your name"
value={visitor.name}
onInput={(e: any) => updateVisitor({ name: e.target.value })}
/>
</div>
<div class="widget-field">
<label class="widget-label">Phone Number *</label>
<input
class="widget-input"
placeholder="+91 9876543210"
value={visitor.phone}
onInput={(e: any) => updateVisitor({ phone: e.target.value })}
onKeyDown={(e: any) => e.key === 'Enter' && submitLeadForm()}
/>
</div>
<button
class="widget-btn"
onClick={submitLeadForm}
disabled={!visitor.name.trim() || !visitor.phone.trim() || formSubmitting}
>
{formSubmitting ? 'Starting…' : 'Start Chat'}
</button>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div class="chat-messages" ref={scrollRef}>
{messages.length === 0 && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>👋</div>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937', marginBottom: '4px' }}>
How can we help you?
<div class="chat-empty">
<div class="chat-empty-icon">
<IconSpan name="hand-wave" size={40} color="#f59e0b" />
</div>
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
<div class="chat-empty-title">
Hi {visitor.name.split(' ')[0] || 'there'}, how can we help?
</div>
<div class="chat-empty-text">
Ask about doctors, clinics, packages, or book an appointment.
</div>
<div class="quick-actions">
{QUICK_ACTIONS.map(q => (
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>{q}</button>
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div key={i} class={`chat-msg ${msg.role}`}>
<div class="chat-bubble">{msg.content || '...'}</div>
</div>
{messages.map(msg => (
<MessageRow
key={msg.id}
msg={msg}
onDepartmentClick={dept => sendMessage(`Show me doctors in ${dept}`)}
onShowDoctorSlots={doctorName =>
sendMessage(`Show available appointments for ${doctorName}`)
}
onSuggestBooking={() => onRequestBooking()}
onPickSlot={handleSlotPick}
onPickBranch={handleBranchPick}
/>
))}
</div>
<div class="chat-input-row">
@@ -85,10 +356,80 @@ export const Chat = () => {
onKeyDown={(e: any) => e.key === 'Enter' && sendMessage(input)}
disabled={loading}
/>
<button class="chat-send" onClick={() => sendMessage(input)} disabled={loading || !input.trim()}>
<button
class="chat-send"
onClick={() => sendMessage(input)}
disabled={loading || !input.trim()}
aria-label="Send message"
>
<IconSpan name="paper-plane-top" size={16} color="#fff" />
</button>
</div>
</div>
);
};
type MessageRowProps = {
msg: ChatMessage;
onDepartmentClick: (dept: string) => void;
onShowDoctorSlots: (doctorName: string) => void;
onSuggestBooking: () => void;
onPickSlot: (prefill: BookingPrefill) => void;
onPickBranch: (branch: string) => void;
};
const MessageRow = ({
msg,
onDepartmentClick,
onShowDoctorSlots,
onSuggestBooking,
onPickSlot,
onPickBranch,
}: MessageRowProps) => {
const isEmptyAssistant = msg.role === 'assistant' && msg.parts.length === 0;
// If any tool parts exist, hide text parts from the same turn to avoid
// models restating the widget's contents in prose.
const hasToolParts = msg.parts.some(p => p.type === 'tool');
const visibleParts = hasToolParts
? msg.parts.filter(p => p.type === 'tool')
: msg.parts;
return (
<div class={`chat-msg ${msg.role}`}>
<div class="chat-msg-stack">
{isEmptyAssistant && (
<div class="chat-bubble">
<TypingDots />
</div>
)}
{visibleParts.map((part, i) => {
if (part.type === 'text') {
return (
<div key={i} class="chat-bubble">
{part.text || <TypingDots />}
</div>
);
}
return (
<ChatToolWidget
key={i}
part={part}
onDepartmentClick={onDepartmentClick}
onShowDoctorSlots={onShowDoctorSlots}
onSuggestBooking={onSuggestBooking}
onPickSlot={onPickSlot}
onPickBranch={onPickBranch}
/>
);
})}
</div>
</div>
);
};
const TypingDots = () => (
<span class="chat-typing-dots" aria-label="Assistant is typing">
<span /><span /><span />
</span>
);