Files
helix-engage-server/widget-src/src/booking.tsx
saridsa2 aa41a2abb7 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>
2026-04-06 16:04:46 +05:30

331 lines
14 KiB
TypeScript

import { useState, useEffect, useMemo } from 'preact/hooks';
import { fetchSlots, submitBooking } from './api';
import { departmentIcon } from './icons';
import { IconSpan } from './icon-span';
import { useWidgetStore } from './store';
import type { Doctor, TimeSlot } from './types';
type Step = 'branch' | 'department' | 'doctor' | 'datetime' | 'details' | 'success';
export const Booking = () => {
const {
visitor,
updateVisitor,
captchaToken,
bookingPrefill,
setBookingPrefill,
doctors,
doctorsLoading,
doctorsError,
branches,
selectedBranch,
setSelectedBranch,
} = useWidgetStore();
// Start on the branch step only if the visitor actually has a choice to
// make. Single-branch hospitals and chat-prefilled sessions skip it.
const needsBranchStep = branches.length > 1 && !selectedBranch;
const [step, setStep] = useState<Step>(needsBranchStep ? 'branch' : 'department');
const [selectedDept, setSelectedDept] = useState('');
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
const [selectedDate, setSelectedDate] = useState('');
const [slots, setSlots] = useState<TimeSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState('');
const [complaint, setComplaint] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [reference, setReference] = useState('');
// Scope the roster to the selected branch up front. Every downstream
// derivation (departments list, doctor filter) works off this.
const branchDoctors = useMemo(() => {
if (!selectedBranch) return doctors;
const needle = selectedBranch.toLowerCase();
return doctors.filter(d =>
String(d.clinic?.clinicName ?? '').toLowerCase().includes(needle),
);
}, [doctors, selectedBranch]);
// Derive department list from the branch-scoped roster.
const departments = useMemo(
() => [...new Set(branchDoctors.map(d => d.department).filter(Boolean))] as string[],
[branchDoctors],
);
const filteredDoctors = selectedDept
? branchDoctors.filter(d => d.department === selectedDept)
: [];
// Surface a doctors-load error if the roster failed to fetch.
useEffect(() => {
if (doctorsError) setError(doctorsError);
}, [doctorsError]);
// Consume any booking prefill from chat → jump straight to the details form.
// Also locks the branch to the picked doctor's clinic so the visitor sees
// the right header badge when they land here.
useEffect(() => {
if (!bookingPrefill || doctors.length === 0) return;
const doc = doctors.find(d => d.id === bookingPrefill.doctorId);
if (!doc) return;
if (doc.clinic?.clinicName && !selectedBranch) {
setSelectedBranch(doc.clinic.clinicName);
}
setSelectedDept(doc.department);
setSelectedDoctor(doc);
setSelectedDate(bookingPrefill.date);
setSelectedSlot(bookingPrefill.time);
setStep('details');
setBookingPrefill(null);
}, [bookingPrefill, doctors]);
const handleDoctorSelect = (doc: Doctor) => {
setSelectedDoctor(doc);
setSelectedDate(new Date().toISOString().split('T')[0]);
setStep('datetime');
};
useEffect(() => {
if (selectedDoctor && selectedDate) {
fetchSlots(selectedDoctor.id, selectedDate).then(setSlots).catch(() => {});
}
}, [selectedDoctor, selectedDate]);
const handleBook = async () => {
if (!selectedDoctor || !selectedSlot || !visitor.name.trim() || !visitor.phone.trim()) return;
setLoading(true);
setError('');
try {
const scheduledAt = `${selectedDate}T${selectedSlot}:00`;
const result = await submitBooking({
departmentId: selectedDept,
doctorId: selectedDoctor.id,
scheduledAt,
patientName: visitor.name.trim(),
patientPhone: visitor.phone.trim(),
chiefComplaint: complaint,
captchaToken,
});
setReference(result.reference);
setStep('success');
} catch {
setError('Booking failed. Please try again.');
} finally {
setLoading(false);
}
};
// Progress bar step count is dynamic: 5 dots if we need the branch step,
// 4 otherwise. The current position is derived from the flow we're in.
const flowSteps: Step[] = needsBranchStep
? ['branch', 'department', 'doctor', 'datetime', 'details']
: ['department', 'doctor', 'datetime', 'details'];
const currentStep = flowSteps.indexOf(step);
return (
<div>
{step !== 'success' && (
<div class="widget-steps">
{flowSteps.map((_, i) => (
<div key={i} class={`widget-step ${i < currentStep ? 'done' : i === currentStep ? 'active' : ''}`} />
))}
</div>
)}
{error && <div class="widget-error">{error}</div>}
{step === 'branch' && (
<div>
<div class="widget-section-title">Select Branch</div>
{doctorsLoading && branches.length === 0 && (
<div class="widget-section-sub">Loading</div>
)}
{branches.map(branch => (
<button
key={branch}
class="widget-row-btn"
onClick={() => {
setSelectedBranch(branch);
setStep('department');
}}
>
<IconSpan class="widget-row-icon" name="hospital" size={20} />
<span class="widget-row-label">{branch}</span>
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
</button>
))}
</div>
)}
{step === 'department' && (
<div>
<div class="widget-section-title">
{selectedBranch && (
<>
<IconSpan class="widget-row-icon" name="hospital" size={16} />
{selectedBranch} &nbsp;
</>
)}
Select Department
</div>
{doctorsLoading && departments.length === 0 && (
<div class="widget-section-sub">Loading</div>
)}
{departments.map(dept => (
<button
key={dept}
class="widget-row-btn"
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
>
<IconSpan class="widget-row-icon" name={departmentIcon(dept)} size={20} />
<span class="widget-row-label">{dept.replace(/_/g, ' ')}</span>
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
</button>
))}
{branches.length > 1 && (
<button
class="widget-btn widget-btn-secondary widget-btn-with-icon"
style={{ marginTop: '8px' }}
onClick={() => setStep('branch')}
>
<IconSpan name="arrow-left" size={14} />
Change branch
</button>
)}
</div>
)}
{step === 'doctor' && (
<div>
<div class="widget-section-title">
<IconSpan class="widget-row-icon" name={departmentIcon(selectedDept)} size={16} />
{selectedDept.replace(/_/g, ' ')}
</div>
{filteredDoctors.map(doc => (
<button
key={doc.id}
class="widget-row-btn widget-row-btn-stack"
onClick={() => handleDoctorSelect(doc)}
>
<div class="widget-row-main">
<div class="widget-row-label">{doc.name}</div>
<div class="widget-row-sub">
{doc.visitingHours ?? ''} {doc.clinic?.clinicName ? `${doc.clinic.clinicName}` : ''}
</div>
</div>
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
</button>
))}
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
<IconSpan name="arrow-left" size={14} />
Back
</button>
</div>
)}
{step === 'datetime' && (
<div>
<div class="widget-section-title">{selectedDoctor?.name} Pick Date & Time</div>
<div class="widget-field">
<label class="widget-label">Date</label>
<input
class="widget-input"
type="date"
value={selectedDate}
min={new Date().toISOString().split('T')[0]}
onInput={(e: any) => { setSelectedDate(e.target.value); setSelectedSlot(''); }}
/>
</div>
{slots.length > 0 && (
<div>
<label class="widget-label">Available Slots</label>
<div class="widget-slots">
{slots.map(s => (
<button
key={s.time}
class={`widget-slot ${s.time === selectedSlot ? 'selected' : ''} ${!s.available ? 'unavailable' : ''}`}
onClick={() => s.available && setSelectedSlot(s.time)}
disabled={!s.available}
>
{s.time}
</button>
))}
</div>
</div>
)}
<div class="widget-btn-row">
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" onClick={() => setStep('doctor')}>
<IconSpan name="arrow-left" size={14} />
Back
</button>
<button class="widget-btn widget-btn-with-icon" disabled={!selectedSlot} onClick={() => setStep('details')}>
Next
<IconSpan name="arrow-right" size={14} color="#fff" />
</button>
</div>
</div>
)}
{step === 'details' && (
<div>
<div class="widget-section-title">Your Details</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 })}
/>
</div>
<div class="widget-field">
<label class="widget-label">Chief Complaint</label>
<textarea
class="widget-input widget-textarea"
placeholder="Describe your concern..."
value={complaint}
onInput={(e: any) => setComplaint(e.target.value)}
/>
</div>
<div class="widget-btn-row">
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" onClick={() => setStep('datetime')}>
<IconSpan name="arrow-left" size={14} />
Back
</button>
<button
class="widget-btn"
disabled={!visitor.name.trim() || !visitor.phone.trim() || loading}
onClick={handleBook}
>
{loading ? 'Booking...' : 'Book Appointment'}
</button>
</div>
</div>
)}
{step === 'success' && (
<div class="widget-success">
<div class="widget-success-icon">
<IconSpan name="circle-check" size={56} color="#059669" />
</div>
<div class="widget-success-title">Appointment Booked!</div>
<div class="widget-success-text">
Reference: <strong>{reference}</strong><br />
{selectedDoctor?.name} {selectedDate} at {selectedSlot}<br /><br />
We'll send a confirmation SMS to your phone.
</div>
</div>
)}
</div>
);
};