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:
@@ -1,33 +1,83 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { fetchDoctors, fetchSlots, submitBooking } from './api';
|
||||
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 = 'department' | 'doctor' | 'datetime' | 'details' | 'success';
|
||||
type Step = 'branch' | 'department' | 'doctor' | 'datetime' | 'details' | 'success';
|
||||
|
||||
export const Booking = () => {
|
||||
const [step, setStep] = useState<Step>('department');
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [departments, setDepartments] = useState<string[]>([]);
|
||||
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 [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [complaint, setComplaint] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [reference, setReference] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDoctors().then(docs => {
|
||||
setDoctors(docs);
|
||||
setDepartments([...new Set(docs.map(d => d.department).filter(Boolean))]);
|
||||
}).catch(() => setError('Failed to load doctors'));
|
||||
}, []);
|
||||
// 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]);
|
||||
|
||||
const filteredDoctors = selectedDept ? doctors.filter(d => d.department === selectedDept) : [];
|
||||
// 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);
|
||||
@@ -42,7 +92,7 @@ export const Booking = () => {
|
||||
}, [selectedDoctor, selectedDate]);
|
||||
|
||||
const handleBook = async () => {
|
||||
if (!selectedDoctor || !selectedSlot || !name || !phone) return;
|
||||
if (!selectedDoctor || !selectedSlot || !visitor.name.trim() || !visitor.phone.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -51,10 +101,10 @@ export const Booking = () => {
|
||||
departmentId: selectedDept,
|
||||
doctorId: selectedDoctor.id,
|
||||
scheduledAt,
|
||||
patientName: name,
|
||||
patientPhone: phone,
|
||||
patientName: visitor.name.trim(),
|
||||
patientPhone: visitor.phone.trim(),
|
||||
chiefComplaint: complaint,
|
||||
captchaToken: 'dev-bypass',
|
||||
captchaToken,
|
||||
});
|
||||
setReference(result.reference);
|
||||
setStep('success');
|
||||
@@ -65,66 +115,117 @@ export const Booking = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const stepIndex = { department: 0, doctor: 1, datetime: 2, details: 3, success: 4 };
|
||||
const currentStep = stepIndex[step];
|
||||
// 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">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
{flowSteps.map((_, i) => (
|
||||
<div key={i} class={`widget-step ${i < currentStep ? 'done' : i === currentStep ? 'active' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
|
||||
{error && <div class="widget-error">{error}</div>}
|
||||
|
||||
{step === 'department' && (
|
||||
{step === 'branch' && (
|
||||
<div>
|
||||
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Select Department</div>
|
||||
{departments.map(dept => (
|
||||
<div class="widget-section-title">Select Branch</div>
|
||||
{doctorsLoading && branches.length === 0 && (
|
||||
<div class="widget-section-sub">Loading…</div>
|
||||
)}
|
||||
{branches.map(branch => (
|
||||
<button
|
||||
key={dept}
|
||||
class="widget-btn widget-btn-secondary"
|
||||
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
|
||||
key={branch}
|
||||
class="widget-row-btn"
|
||||
onClick={() => {
|
||||
setSelectedBranch(branch);
|
||||
setStep('department');
|
||||
}}
|
||||
>
|
||||
{dept.replace(/_/g, ' ')}
|
||||
<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} —
|
||||
</>
|
||||
)}
|
||||
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-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||
Select Doctor — {selectedDept.replace(/_/g, ' ')}
|
||||
<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-btn widget-btn-secondary"
|
||||
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||
class="widget-row-btn widget-row-btn-stack"
|
||||
onClick={() => handleDoctorSelect(doc)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{doc.name}</div>
|
||||
<div style={{ fontSize: '11px', color: '#6b7280' }}>
|
||||
{doc.visitingHours ?? ''} {doc.clinic?.clinicName ? `• ${doc.clinic.clinicName}` : ''}
|
||||
<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" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
|
||||
← Back
|
||||
<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-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||
{selectedDoctor?.name} — Pick Date & Time
|
||||
</div>
|
||||
<div class="widget-section-title">{selectedDoctor?.name} — Pick Date & Time</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Date</label>
|
||||
<input
|
||||
@@ -152,31 +253,59 @@ export const Booking = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('doctor')}>← Back</button>
|
||||
<button class="widget-btn" style={{ flex: 1 }} disabled={!selectedSlot} onClick={() => setStep('details')}>Next →</button>
|
||||
<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-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Your 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={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">Chief Complaint</label>
|
||||
<textarea class="widget-input widget-textarea" placeholder="Describe your concern..." value={complaint} onInput={(e: any) => setComplaint(e.target.value)} />
|
||||
<textarea
|
||||
class="widget-input widget-textarea"
|
||||
placeholder="Describe your concern..."
|
||||
value={complaint}
|
||||
onInput={(e: any) => setComplaint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('datetime')}>← Back</button>
|
||||
<button class="widget-btn" style={{ flex: 1 }} disabled={!name || !phone || loading} onClick={handleBook}>
|
||||
<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>
|
||||
@@ -185,7 +314,9 @@ export const Booking = () => {
|
||||
|
||||
{step === 'success' && (
|
||||
<div class="widget-success">
|
||||
<div class="widget-success-icon">✅</div>
|
||||
<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 />
|
||||
|
||||
Reference in New Issue
Block a user