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

@@ -0,0 +1,274 @@
import { IconSpan } from './icon-span';
import { departmentIcon } from './icons';
import type { BookingPrefill, ChatToolPart, ToolOutputs } from './types';
type WidgetProps = {
part: ChatToolPart;
onDepartmentClick: (department: string) => void;
onShowDoctorSlots: (doctorName: string) => void;
onSuggestBooking: () => void;
onPickSlot: (prefill: BookingPrefill) => void;
onPickBranch: (branch: string) => void;
};
// Dispatcher — renders the right widget for a tool part based on its name and state.
export const ChatToolWidget = ({
part,
onDepartmentClick,
onShowDoctorSlots,
onSuggestBooking,
onPickSlot,
onPickBranch,
}: WidgetProps) => {
if (part.state === 'input-streaming' || part.state === 'input-available') {
return <ToolLoadingRow toolName={part.toolName} />;
}
if (part.state === 'output-error') {
return <div class="chat-widget-error">Couldn't load: {part.errorText ?? 'unknown error'}</div>;
}
switch (part.toolName) {
case 'pick_branch': {
const out = part.output as ToolOutputs['pick_branch'] | undefined;
if (!out?.branches?.length) return null;
return <BranchPickerWidget branches={out.branches} onPick={onPickBranch} />;
}
case 'list_departments': {
const out = part.output as ToolOutputs['list_departments'] | undefined;
if (!out?.departments?.length) return null;
return <DepartmentListWidget departments={out.departments} onPick={onDepartmentClick} />;
}
case 'show_clinic_timings': {
const out = part.output as ToolOutputs['show_clinic_timings'] | undefined;
if (!out?.departments?.length) return null;
return <ClinicTimingsWidget departments={out.departments} />;
}
case 'show_doctors': {
const out = part.output as ToolOutputs['show_doctors'] | undefined;
if (!out?.doctors?.length) {
return (
<div class="chat-widget-empty">
No doctors found in {out?.department ?? 'this department'}.
</div>
);
}
return (
<DoctorsListWidget
department={out.department}
doctors={out.doctors}
onPickDoctor={onShowDoctorSlots}
/>
);
}
case 'show_doctor_slots': {
const out = part.output as ToolOutputs['show_doctor_slots'] | undefined;
if (!out) return null;
if (out.error || !out.doctor) {
return <div class="chat-widget-empty">{out.error ?? 'Doctor not found.'}</div>;
}
return <DoctorSlotsWidget data={out} onPickSlot={onPickSlot} />;
}
case 'suggest_booking': {
const out = part.output as ToolOutputs['suggest_booking'] | undefined;
return (
<BookingSuggestionWidget
reason={out?.reason ?? 'Book an appointment.'}
department={out?.department ?? null}
onBook={onSuggestBooking}
/>
);
}
default:
return null;
}
};
const TOOL_LABELS: Record<string, string> = {
pick_branch: 'Fetching branches',
list_departments: 'Looking up departments',
show_clinic_timings: 'Fetching clinic hours',
show_doctors: 'Looking up doctors',
show_doctor_slots: 'Checking availability',
suggest_booking: 'Thinking about booking options',
};
const ToolLoadingRow = ({ toolName }: { toolName: string }) => (
<div class="chat-widget-loading">
<span class="chat-typing-dots" aria-hidden="true">
<span /><span /><span />
</span>
<span class="chat-widget-loading-label">{TOOL_LABELS[toolName] ?? 'Working'}</span>
</div>
);
type BranchPickerProps = {
branches: ToolOutputs['pick_branch']['branches'];
onPick: (branch: string) => void;
};
const BranchPickerWidget = ({ branches, onPick }: BranchPickerProps) => (
<div class="chat-widget chat-widget-branches">
<div class="chat-widget-title">Which branch?</div>
{branches.map(b => (
<button key={b.name} class="chat-widget-branch-card" onClick={() => onPick(b.name)}>
<div class="chat-widget-branch-name">{b.name}</div>
<div class="chat-widget-branch-meta">
{b.doctorCount} {b.doctorCount === 1 ? 'doctor' : 'doctors'}
{b.departmentCount > 0 ? ` • ${b.departmentCount} ${b.departmentCount === 1 ? 'department' : 'departments'}` : ''}
</div>
</button>
))}
</div>
);
type DepartmentListProps = {
departments: string[];
onPick: (department: string) => void;
};
const DepartmentListWidget = ({ departments, onPick }: DepartmentListProps) => (
<div class="chat-widget chat-widget-departments">
<div class="chat-widget-title">Departments</div>
<div class="chat-widget-dept-grid">
{departments.map(dept => (
<button
key={dept}
class="chat-widget-dept-chip"
onClick={() => onPick(dept)}
title={`Show doctors in ${dept}`}
>
<IconSpan name={departmentIcon(dept)} size={16} />
<span>{dept}</span>
</button>
))}
</div>
</div>
);
type ClinicTimingsProps = {
departments: ToolOutputs['show_clinic_timings']['departments'];
};
const ClinicTimingsWidget = ({ departments }: ClinicTimingsProps) => (
<div class="chat-widget chat-widget-timings">
<div class="chat-widget-title">
<IconSpan name="calendar" size={14} /> Clinic hours
</div>
{departments.map(dept => (
<div key={dept.name} class="chat-widget-timing-dept">
<div class="chat-widget-timing-dept-name">
<IconSpan name={departmentIcon(dept.name)} size={14} />
<span>{dept.name}</span>
</div>
{dept.entries.map(entry => (
<div key={`${dept.name}-${entry.name}`} class="chat-widget-timing-row">
<div class="chat-widget-timing-doctor">{entry.name}</div>
<div class="chat-widget-timing-hours">{entry.hours}</div>
{entry.clinic && (
<div class="chat-widget-timing-clinic">{entry.clinic}</div>
)}
</div>
))}
</div>
))}
</div>
);
type DoctorsListProps = {
department: string;
doctors: ToolOutputs['show_doctors']['doctors'];
onPickDoctor: (doctorName: string) => void;
};
const DoctorsListWidget = ({ department, doctors, onPickDoctor }: DoctorsListProps) => (
<div class="chat-widget chat-widget-doctors">
<div class="chat-widget-title">
<IconSpan name={departmentIcon(department)} size={14} /> {department}
</div>
{doctors.map(doc => (
<div key={doc.id} class="chat-widget-doctor-card">
<div class="chat-widget-doctor-name">{doc.name}</div>
{doc.specialty && <div class="chat-widget-doctor-meta">{doc.specialty}</div>}
{doc.visitingHours && <div class="chat-widget-doctor-meta">{doc.visitingHours}</div>}
{doc.clinic && <div class="chat-widget-doctor-meta">{doc.clinic}</div>}
<button
class="chat-widget-doctor-action"
onClick={() => onPickDoctor(doc.name)}
>
<IconSpan name="calendar" size={12} /> See available appointments
</button>
</div>
))}
</div>
);
type DoctorSlotsProps = {
data: ToolOutputs['show_doctor_slots'];
onPickSlot: (prefill: BookingPrefill) => void;
};
const DoctorSlotsWidget = ({ data, onPickSlot }: DoctorSlotsProps) => {
if (!data.doctor) return null;
const doctor = data.doctor;
const available = data.slots.filter(s => s.available);
const hasAny = available.length > 0;
return (
<div class="chat-widget chat-widget-slots">
<div class="chat-widget-title">
<IconSpan name="calendar" size={14} /> Available slots
</div>
<div class="chat-widget-slots-doctor">{doctor.name}</div>
<div class="chat-widget-slots-meta">
{formatDate(data.date)}
{doctor.clinic ? ` • ${doctor.clinic}` : ''}
</div>
{hasAny ? (
<div class="chat-widget-slots-grid">
{data.slots.map(s => (
<button
key={s.time}
class={`chat-widget-slot-btn ${s.available ? '' : 'unavailable'}`}
disabled={!s.available}
onClick={() =>
s.available &&
onPickSlot({ doctorId: doctor.id, date: data.date, time: s.time })
}
>
{s.time}
</button>
))}
</div>
) : (
<div class="chat-widget-empty">No slots available on this date.</div>
)}
</div>
);
};
const formatDate = (iso: string): string => {
// iso is YYYY-MM-DD from the backend. Render as e.g. "Mon, 6 Apr".
const d = new Date(iso + 'T00:00:00');
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
};
type BookingSuggestionProps = {
reason: string;
department: string | null;
onBook: () => void;
};
const BookingSuggestionWidget = ({ reason, department, onBook }: BookingSuggestionProps) => (
<div class="chat-widget chat-widget-booking">
<div class="chat-widget-booking-icon">
<IconSpan name="calendar" size={28} />
</div>
<div class="chat-widget-booking-body">
<div class="chat-widget-booking-title">Book an appointment</div>
<div class="chat-widget-booking-reason">{reason}</div>
{department && <div class="chat-widget-booking-dept">Suggested: {department}</div>}
<button class="widget-btn" onClick={onBook}>Book now</button>
</div>
</div>
);