mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
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:
274
widget-src/src/chat-widgets.tsx
Normal file
274
widget-src/src/chat-widgets.tsx
Normal 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>
|
||||
);
|
||||
Reference in New Issue
Block a user