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,9 +1,11 @@
import { useState } from 'preact/hooks';
import { submitLead } from './api';
import { IconSpan } from './icon-span';
import { useWidgetStore } from './store';
export const Contact = () => {
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const { visitor, updateVisitor, captchaToken } = useWidgetStore();
const [interest, setInterest] = useState('');
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
@@ -11,16 +13,16 @@ export const Contact = () => {
const [error, setError] = useState('');
const handleSubmit = async () => {
if (!name.trim() || !phone.trim()) return;
if (!visitor.name.trim() || !visitor.phone.trim()) return;
setLoading(true);
setError('');
try {
await submitLead({
name: name.trim(),
phone: phone.trim(),
name: visitor.name.trim(),
phone: visitor.phone.trim(),
interest: interest.trim() || undefined,
message: message.trim() || undefined,
captchaToken: 'dev-bypass',
captchaToken,
});
setSuccess(true);
} catch {
@@ -33,10 +35,12 @@ export const Contact = () => {
if (success) {
return (
<div class="widget-success">
<div class="widget-success-icon">🙏</div>
<div class="widget-success-icon">
<IconSpan name="hands-praying" size={56} color="#059669" />
</div>
<div class="widget-success-title">Thank you!</div>
<div class="widget-success-text">
An agent will call you shortly on {phone}.<br />
An agent will call you shortly on {visitor.phone}.<br />
We typically respond within 30 minutes during business hours.
</div>
</div>
@@ -45,22 +49,28 @@ export const Contact = () => {
return (
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: '#1f2937', marginBottom: '12px' }}>
Get in touch
</div>
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
Leave your details and we'll call you back.
</div>
<div class="widget-section-title">Get in touch</div>
<div class="widget-section-sub">Leave your details and we'll call you back.</div>
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
{error && <div class="widget-error">{error}</div>}
<div class="widget-field">
<label class="widget-label">Full Name *</label>
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
<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={phone} onInput={(e: any) => setPhone(e.target.value)} />
<input
class="widget-input"
placeholder="+91 9876543210"
value={visitor.phone}
onInput={(e: any) => updateVisitor({ phone: e.target.value })}
/>
</div>
<div class="widget-field">
<label class="widget-label">Interested In</label>
@@ -75,9 +85,18 @@ export const Contact = () => {
</div>
<div class="widget-field">
<label class="widget-label">Message</label>
<textarea class="widget-input widget-textarea" placeholder="How can we help? (optional)" value={message} onInput={(e: any) => setMessage(e.target.value)} />
<textarea
class="widget-input widget-textarea"
placeholder="How can we help? (optional)"
value={message}
onInput={(e: any) => setMessage(e.target.value)}
/>
</div>
<button class="widget-btn" disabled={!name.trim() || !phone.trim() || loading} onClick={handleSubmit}>
<button
class="widget-btn"
disabled={!visitor.name.trim() || !visitor.phone.trim() || loading}
onClick={handleSubmit}
>
{loading ? 'Sending...' : 'Send Message'}
</button>
</div>