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:
@@ -47,10 +47,28 @@ export const submitLead = async (data: any): Promise<{ leadId: string }> => {
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
|
||||
export const startChatSession = async (name: string, phone: string): Promise<{ leadId: string }> => {
|
||||
const res = await fetch(`${baseUrl}/api/widget/chat-start?key=${widgetKey}`, {
|
||||
method: 'POST', headers: headers(),
|
||||
body: JSON.stringify({ name, phone }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Chat start failed');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Send the simplified {role, content: string}[] history to the backend.
|
||||
// Backend responds with an SSE stream of UIMessageChunk events.
|
||||
// branch (when set) is injected into the system prompt so the AI scopes
|
||||
// tool calls to that branch.
|
||||
type OutboundMessage = { role: 'user' | 'assistant'; content: string };
|
||||
export const streamChat = async (
|
||||
leadId: string,
|
||||
messages: OutboundMessage[],
|
||||
branch: string | null,
|
||||
): Promise<ReadableStream<Uint8Array>> => {
|
||||
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
|
||||
method: 'POST', headers: headers(),
|
||||
body: JSON.stringify({ messages, captchaToken }),
|
||||
body: JSON.stringify({ leadId, messages, branch }),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new Error('Chat failed');
|
||||
return res.body;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
164
widget-src/src/captcha.tsx
Normal file
164
widget-src/src/captcha.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
// Cloudflare Turnstile integration.
|
||||
//
|
||||
// Rendering strategy: Turnstile injects its layout stylesheet into document.head,
|
||||
// which does NOT cascade into our shadow DOM. When rendered inside our shadow DOM
|
||||
// the iframe exists with correct attributes but paints as zero pixels because the
|
||||
// wrapper Turnstile creates has no resolved styles. To work around this we mount
|
||||
// Turnstile into a portal div appended to document.body (light DOM), then use
|
||||
// getBoundingClientRect on the in-shadow placeholder to keep the portal visually
|
||||
// overlaid on top of the captcha gate area.
|
||||
|
||||
type TurnstileOptions = {
|
||||
sitekey: string;
|
||||
callback?: (token: string) => void;
|
||||
'error-callback'?: () => void;
|
||||
'expired-callback'?: () => void;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible' | 'invisible';
|
||||
appearance?: 'always' | 'execute' | 'interaction-only';
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (container: HTMLElement, opts: TurnstileOptions) => string;
|
||||
remove: (widgetId: string) => void;
|
||||
reset: (widgetId?: string) => void;
|
||||
};
|
||||
__helixTurnstileLoading?: Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
|
||||
export const loadTurnstile = (): Promise<void> => {
|
||||
if (typeof window === 'undefined') return Promise.resolve();
|
||||
if (window.turnstile) return Promise.resolve();
|
||||
if (window.__helixTurnstileLoading) return window.__helixTurnstileLoading;
|
||||
|
||||
window.__helixTurnstileLoading = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = SCRIPT_URL;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
const poll = () => {
|
||||
if (window.turnstile) resolve();
|
||||
else setTimeout(poll, 50);
|
||||
};
|
||||
poll();
|
||||
};
|
||||
script.onerror = () => reject(new Error('Turnstile failed to load'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return window.__helixTurnstileLoading;
|
||||
};
|
||||
|
||||
type CaptchaProps = {
|
||||
siteKey: string;
|
||||
onToken: (token: string) => void;
|
||||
onError?: () => void;
|
||||
};
|
||||
|
||||
export const Captcha = ({ siteKey, onToken, onError }: CaptchaProps) => {
|
||||
const placeholderRef = useRef<HTMLDivElement | null>(null);
|
||||
const portalRef = useRef<HTMLDivElement | null>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const onTokenRef = useRef(onToken);
|
||||
const onErrorRef = useRef(onError);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
|
||||
onTokenRef.current = onToken;
|
||||
onErrorRef.current = onError;
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey || !placeholderRef.current) return;
|
||||
let cancelled = false;
|
||||
|
||||
// Light-DOM portal so Turnstile's document.head styles actually apply.
|
||||
const portal = document.createElement('div');
|
||||
portal.setAttribute('data-helix-turnstile', '');
|
||||
portal.style.cssText = [
|
||||
'position:fixed',
|
||||
'z-index:2147483647',
|
||||
'width:300px',
|
||||
'height:65px',
|
||||
'pointer-events:auto',
|
||||
].join(';');
|
||||
document.body.appendChild(portal);
|
||||
portalRef.current = portal;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!placeholderRef.current || !portalRef.current) return;
|
||||
const rect = placeholderRef.current.getBoundingClientRect();
|
||||
portalRef.current.style.top = `${rect.top}px`;
|
||||
portalRef.current.style.left = `${rect.left}px`;
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
|
||||
// Reposition on animation frame while the gate is mounted, so the portal
|
||||
// tracks the placeholder through panel open animation and any layout shifts.
|
||||
let rafId = 0;
|
||||
const trackLoop = () => {
|
||||
updatePosition();
|
||||
rafId = requestAnimationFrame(trackLoop);
|
||||
};
|
||||
rafId = requestAnimationFrame(trackLoop);
|
||||
|
||||
loadTurnstile()
|
||||
.then(() => {
|
||||
if (cancelled || !portalRef.current || !window.turnstile) return;
|
||||
try {
|
||||
widgetIdRef.current = window.turnstile.render(portalRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: (token) => onTokenRef.current(token),
|
||||
'error-callback': () => {
|
||||
setStatus('error');
|
||||
onErrorRef.current?.();
|
||||
},
|
||||
'expired-callback': () => onTokenRef.current(''),
|
||||
theme: 'light',
|
||||
size: 'normal',
|
||||
});
|
||||
setStatus('ready');
|
||||
} catch {
|
||||
setStatus('error');
|
||||
onErrorRef.current?.();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus('error');
|
||||
onErrorRef.current?.();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
try {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
} catch {
|
||||
// ignore — widget may already be gone
|
||||
}
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
portal.remove();
|
||||
portalRef.current = null;
|
||||
};
|
||||
}, [siteKey]);
|
||||
|
||||
return (
|
||||
<div class="widget-captcha">
|
||||
<div class="widget-captcha-mount" ref={placeholderRef} />
|
||||
{status === 'loading' && <div class="widget-captcha-status">Loading verification…</div>}
|
||||
{status === 'error' && <div class="widget-captcha-status widget-captcha-error">Verification failed to load. Please refresh.</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
widget-src/src/chat-stream.ts
Normal file
61
widget-src/src/chat-stream.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Minimal SSE + UIMessageChunk parser. The backend writes
|
||||
// data: ${JSON.stringify(chunk)}\n\n
|
||||
// for each AI SDK UIMessageChunk, plus a final `data: [DONE]\n\n`.
|
||||
// We reconstruct events by buffering stream text and splitting on blank lines.
|
||||
|
||||
export type UIMessageChunk =
|
||||
| { type: 'start'; messageId?: string }
|
||||
| { type: 'start-step' }
|
||||
| { type: 'finish-step' }
|
||||
| { type: 'finish' }
|
||||
| { type: 'error'; errorText: string }
|
||||
| { type: 'text-start'; id: string }
|
||||
| { type: 'text-delta'; id: string; delta: string }
|
||||
| { type: 'text-end'; id: string }
|
||||
| { type: 'tool-input-start'; toolCallId: string; toolName: string }
|
||||
| { type: 'tool-input-delta'; toolCallId: string; inputTextDelta: string }
|
||||
| { type: 'tool-input-available'; toolCallId: string; toolName: string; input: any }
|
||||
| { type: 'tool-output-available'; toolCallId: string; output: any }
|
||||
| { type: 'tool-output-error'; toolCallId: string; errorText: string }
|
||||
| { type: string; [key: string]: any };
|
||||
|
||||
// Reads the SSE body byte stream and yields UIMessageChunk objects.
|
||||
export async function* readChatStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): AsyncGenerator<UIMessageChunk> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Each SSE event is terminated by a blank line. Split off complete
|
||||
// events and keep the trailing partial in buffer.
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
||||
const rawEvent = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
|
||||
// Grab lines starting with "data:" (there may be comments or
|
||||
// event: lines too — we ignore them).
|
||||
const lines = rawEvent.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trimStart();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
try {
|
||||
yield JSON.parse(payload) as UIMessageChunk;
|
||||
} catch {
|
||||
// Bad JSON — skip this event rather than crash the stream.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
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>
|
||||
);
|
||||
@@ -1,18 +1,85 @@
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
import { streamChat } from './api';
|
||||
import type { ChatMessage } from './types';
|
||||
import { startChatSession, streamChat } from './api';
|
||||
import { readChatStream } from './chat-stream';
|
||||
import { IconSpan } from './icon-span';
|
||||
import { ChatToolWidget } from './chat-widgets';
|
||||
import { useWidgetStore } from './store';
|
||||
import type {
|
||||
BookingPrefill,
|
||||
ChatMessage,
|
||||
ChatPart,
|
||||
ChatTextPart,
|
||||
ChatToolPart,
|
||||
} from './types';
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
'Doctor availability',
|
||||
'What departments do you have?',
|
||||
'Show me cardiologists',
|
||||
'Clinic timings',
|
||||
'Book appointment',
|
||||
'Health packages',
|
||||
'How do I book?',
|
||||
];
|
||||
|
||||
export const Chat = () => {
|
||||
type ChatProps = {
|
||||
// Switches the widget to the Book tab. Chat-level handler that lives in
|
||||
// the parent so slot picks can seed bookingPrefill + swap tabs atomically.
|
||||
onRequestBooking: (prefill?: BookingPrefill) => void;
|
||||
};
|
||||
|
||||
const textOf = (msg: ChatMessage): string =>
|
||||
msg.parts
|
||||
.filter((p): p is ChatTextPart => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.join('');
|
||||
|
||||
const updateMessage = (
|
||||
setMessages: (updater: (msgs: ChatMessage[]) => ChatMessage[]) => void,
|
||||
id: string,
|
||||
mutator: (msg: ChatMessage) => ChatMessage,
|
||||
) => {
|
||||
setMessages(prev => prev.map(m => (m.id === id ? mutator(m) : m)));
|
||||
};
|
||||
|
||||
const appendTextDelta = (parts: ChatPart[], delta: string, state: 'streaming' | 'done'): ChatPart[] => {
|
||||
const last = parts[parts.length - 1];
|
||||
if (last?.type === 'text') {
|
||||
return [...parts.slice(0, -1), { type: 'text', text: last.text + delta, state }];
|
||||
}
|
||||
return [...parts, { type: 'text', text: delta, state }];
|
||||
};
|
||||
|
||||
const upsertToolPart = (
|
||||
parts: ChatPart[],
|
||||
toolCallId: string,
|
||||
update: Partial<ChatToolPart>,
|
||||
fallback: ChatToolPart,
|
||||
): ChatPart[] => {
|
||||
const idx = parts.findIndex(p => p.type === 'tool' && p.toolCallId === toolCallId);
|
||||
if (idx >= 0) {
|
||||
const existing = parts[idx] as ChatToolPart;
|
||||
const merged: ChatToolPart = { ...existing, ...update };
|
||||
return [...parts.slice(0, idx), merged, ...parts.slice(idx + 1)];
|
||||
}
|
||||
return [...parts, { ...fallback, ...update }];
|
||||
};
|
||||
|
||||
const genId = () => `m_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
export const Chat = ({ onRequestBooking }: ChatProps) => {
|
||||
const {
|
||||
visitor,
|
||||
updateVisitor,
|
||||
leadId,
|
||||
setLeadId,
|
||||
setBookingPrefill,
|
||||
selectedBranch,
|
||||
setSelectedBranch,
|
||||
} = useWidgetStore();
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,59 +88,263 @@ export const Chat = () => {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || loading) return;
|
||||
const submitLeadForm = async () => {
|
||||
const name = visitor.name.trim();
|
||||
const phone = visitor.phone.trim();
|
||||
if (!name || !phone) return;
|
||||
setFormSubmitting(true);
|
||||
setFormError('');
|
||||
try {
|
||||
const { leadId: newLeadId } = await startChatSession(name, phone);
|
||||
setLeadId(newLeadId);
|
||||
} catch {
|
||||
setFormError('Could not start chat. Please try again.');
|
||||
} finally {
|
||||
setFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
|
||||
const updated = [...messages, userMsg];
|
||||
setMessages(updated);
|
||||
const sendMessage = async (text: string, branchOverride?: string | null) => {
|
||||
if (!text.trim() || loading || !leadId) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: text.trim(), state: 'done' }],
|
||||
};
|
||||
const assistantId = genId();
|
||||
const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', parts: [] };
|
||||
|
||||
const historyForBackend = [...messages, userMsg].map(m => ({
|
||||
role: m.role,
|
||||
content: textOf(m),
|
||||
}));
|
||||
|
||||
setMessages(prev => [...prev, userMsg, assistantMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
// Branch can be provided explicitly to bypass the stale closure value
|
||||
// when the caller just set it (e.g., handleBranchPick immediately after
|
||||
// setSelectedBranch).
|
||||
const effectiveBranch =
|
||||
branchOverride !== undefined ? branchOverride : selectedBranch;
|
||||
|
||||
try {
|
||||
const stream = await streamChat(updated);
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let assistantText = '';
|
||||
const stream = await streamChat(leadId, historyForBackend, effectiveBranch);
|
||||
|
||||
setMessages([...updated, { role: 'assistant', content: '' }]);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
assistantText += decoder.decode(value, { stream: true });
|
||||
setMessages([...updated, { role: 'assistant', content: assistantText }]);
|
||||
for await (const chunk of readChatStream(stream)) {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
if (typeof chunk.delta === 'string') {
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: appendTextDelta(m.parts, chunk.delta, 'streaming'),
|
||||
}));
|
||||
}
|
||||
break;
|
||||
case 'text-end':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: m.parts.map(p =>
|
||||
p.type === 'text' ? { ...p, state: 'done' } : p,
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-input-start':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'input-streaming', toolName: chunk.toolName },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
state: 'input-streaming',
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-input-available':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'input-available', toolName: chunk.toolName, input: chunk.input },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
state: 'input-available',
|
||||
input: chunk.input,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-output-available':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'output-available', output: chunk.output },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: 'unknown',
|
||||
state: 'output-available',
|
||||
output: chunk.output,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-output-error':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'output-error', errorText: chunk.errorText },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: 'unknown',
|
||||
state: 'output-error',
|
||||
errorText: chunk.errorText,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'error':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: [
|
||||
...m.parts,
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Sorry, I encountered an error. Please try again.',
|
||||
state: 'done',
|
||||
},
|
||||
],
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setMessages([...updated, { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }]);
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: [
|
||||
...m.parts,
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Sorry, I encountered an error. Please try again.',
|
||||
state: 'done',
|
||||
},
|
||||
],
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlotPick = (prefill: BookingPrefill) => {
|
||||
setBookingPrefill(prefill);
|
||||
onRequestBooking(prefill);
|
||||
};
|
||||
|
||||
const handleBranchPick = (branch: string) => {
|
||||
// Store the selection so every subsequent request carries it, then
|
||||
// echo the visitor's choice as a user message so the AI re-runs the
|
||||
// branch-gated tool it was about to call. We pass branch explicitly
|
||||
// to sidestep the stale-closure selectedBranch inside sendMessage.
|
||||
setSelectedBranch(branch);
|
||||
sendMessage(`I'm interested in the ${branch} branch.`, branch);
|
||||
};
|
||||
|
||||
// Pre-chat gate — only shown if we don't yet have an active lead. Name/phone
|
||||
// inputs bind to the shared store so anything typed here is immediately
|
||||
// available to the Book and Contact forms too.
|
||||
if (!leadId) {
|
||||
return (
|
||||
<div class="chat-intro">
|
||||
<div class="chat-empty-icon">
|
||||
<IconSpan name="hand-wave" size={40} color="#f59e0b" />
|
||||
</div>
|
||||
<div class="chat-empty-title">Hi! How can we help?</div>
|
||||
<div class="chat-empty-text">
|
||||
Share your name and phone so we can follow up if needed.
|
||||
</div>
|
||||
{formError && <div class="widget-error">{formError}</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 })}
|
||||
onKeyDown={(e: any) => e.key === 'Enter' && submitLeadForm()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="widget-btn"
|
||||
onClick={submitLeadForm}
|
||||
disabled={!visitor.name.trim() || !visitor.phone.trim() || formSubmitting}
|
||||
>
|
||||
{formSubmitting ? 'Starting…' : 'Start Chat'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div class="chat-messages" ref={scrollRef}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>👋</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937', marginBottom: '4px' }}>
|
||||
How can we help you?
|
||||
<div class="chat-empty">
|
||||
<div class="chat-empty-icon">
|
||||
<IconSpan name="hand-wave" size={40} color="#f59e0b" />
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||
<div class="chat-empty-title">
|
||||
Hi {visitor.name.split(' ')[0] || 'there'}, how can we help?
|
||||
</div>
|
||||
<div class="chat-empty-text">
|
||||
Ask about doctors, clinics, packages, or book an appointment.
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
{QUICK_ACTIONS.map(q => (
|
||||
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>{q}</button>
|
||||
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} class={`chat-msg ${msg.role}`}>
|
||||
<div class="chat-bubble">{msg.content || '...'}</div>
|
||||
</div>
|
||||
{messages.map(msg => (
|
||||
<MessageRow
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onDepartmentClick={dept => sendMessage(`Show me doctors in ${dept}`)}
|
||||
onShowDoctorSlots={doctorName =>
|
||||
sendMessage(`Show available appointments for ${doctorName}`)
|
||||
}
|
||||
onSuggestBooking={() => onRequestBooking()}
|
||||
onPickSlot={handleSlotPick}
|
||||
onPickBranch={handleBranchPick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
@@ -85,10 +356,80 @@ export const Chat = () => {
|
||||
onKeyDown={(e: any) => e.key === 'Enter' && sendMessage(input)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button class="chat-send" onClick={() => sendMessage(input)} disabled={loading || !input.trim()}>
|
||||
↑
|
||||
<button
|
||||
class="chat-send"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={loading || !input.trim()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<IconSpan name="paper-plane-top" size={16} color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type MessageRowProps = {
|
||||
msg: ChatMessage;
|
||||
onDepartmentClick: (dept: string) => void;
|
||||
onShowDoctorSlots: (doctorName: string) => void;
|
||||
onSuggestBooking: () => void;
|
||||
onPickSlot: (prefill: BookingPrefill) => void;
|
||||
onPickBranch: (branch: string) => void;
|
||||
};
|
||||
|
||||
const MessageRow = ({
|
||||
msg,
|
||||
onDepartmentClick,
|
||||
onShowDoctorSlots,
|
||||
onSuggestBooking,
|
||||
onPickSlot,
|
||||
onPickBranch,
|
||||
}: MessageRowProps) => {
|
||||
const isEmptyAssistant = msg.role === 'assistant' && msg.parts.length === 0;
|
||||
|
||||
// If any tool parts exist, hide text parts from the same turn to avoid
|
||||
// models restating the widget's contents in prose.
|
||||
const hasToolParts = msg.parts.some(p => p.type === 'tool');
|
||||
const visibleParts = hasToolParts
|
||||
? msg.parts.filter(p => p.type === 'tool')
|
||||
: msg.parts;
|
||||
|
||||
return (
|
||||
<div class={`chat-msg ${msg.role}`}>
|
||||
<div class="chat-msg-stack">
|
||||
{isEmptyAssistant && (
|
||||
<div class="chat-bubble">
|
||||
<TypingDots />
|
||||
</div>
|
||||
)}
|
||||
{visibleParts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<div key={i} class="chat-bubble">
|
||||
{part.text || <TypingDots />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChatToolWidget
|
||||
key={i}
|
||||
part={part}
|
||||
onDepartmentClick={onDepartmentClick}
|
||||
onShowDoctorSlots={onShowDoctorSlots}
|
||||
onSuggestBooking={onSuggestBooking}
|
||||
onPickSlot={onPickSlot}
|
||||
onPickBranch={onPickBranch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TypingDots = () => (
|
||||
<span class="chat-typing-dots" aria-label="Assistant is typing">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
widget-src/src/icon-span.tsx
Normal file
15
widget-src/src/icon-span.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { icon, type IconName } from './icons';
|
||||
|
||||
type IconSpanProps = {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
// Safe: the SVG strings in icons.ts are hard-coded FontAwesome Pro paths bundled at
|
||||
// compile time. No user input flows through here — nothing to sanitize.
|
||||
export const IconSpan = ({ name, size = 16, color = 'currentColor', class: className }: IconSpanProps) => {
|
||||
const html = icon(name, size, color);
|
||||
return <span class={className} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
};
|
||||
@@ -1,27 +1,83 @@
|
||||
// FontAwesome Pro 6.7.2 Duotone SVGs — bundled as inline strings
|
||||
// FontAwesome Pro 7.1.0 Duotone SVGs — bundled as inline strings
|
||||
// License: https://fontawesome.com/license (Commercial License)
|
||||
// Paths use fill="currentColor" so color is inherited from the <svg> element.
|
||||
|
||||
export const icons = {
|
||||
chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 28.7 28.7 0 64 0L448 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64l-138.7 0L185.6 508.8c-4.8 3.6-11.3 4.2-16.8 1.5s-8.8-8.2-8.8-14.3l0-80-96 0c-35.3 0-64-28.7-64-64L0 64zM96 208a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0z"/><path class="fa-primary" d="M96 208a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm160-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>`,
|
||||
// Navigation / UI
|
||||
'message-dots': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 128L0 352c0 53 43 96 96 96l32 0 0 72c0 13.3 10.7 24 24 24 5.2 0 10.2-1.7 14.4-4.8l115.2-86.4c4.2-3.1 9.2-4.8 14.4-4.8l120 0c53 0 96-43 96-96l0-224c0-53-43-96-96-96L96 32C43 32 0 75 0 128zM160 240a32 32 0 1 1 -64 0 32 32 0 1 1 64 0zm128 0a32 32 0 1 1 -64 0 32 32 0 1 1 64 0zm128 0a32 32 0 1 1 -64 0 32 32 0 1 1 64 0z"/><path fill="currentColor" d="M96 240a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm160-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>`,
|
||||
|
||||
calendar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 192l448 0 0 272c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 192zM119 319c-9.4 9.4-9.4 24.6 0 33.9l64 64c4.7 4.7 10.8 7 17 7s12.3-2.3 17-7L329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0z"/><path class="fa-primary" d="M128 0C110.3 0 96 14.3 96 32l0 32L48 64C21.5 64 0 85.5 0 112l0 80 448 0 0-80c0-26.5-21.5-48-48-48l-48 0 0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32L160 64l0-32c0-17.7-14.3-32-32-32zM329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L329 305z"/></svg>`,
|
||||
calendar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path opacity=".4" fill="currentColor" d="M0 160l448 0 0 272c0 26.5-21.5 48-48 48L48 480c-26.5 0-48-21.5-48-48L0 160z"/><path fill="currentColor" d="M160 32c0-17.7-14.3-32-32-32S96 14.3 96 32l0 32-48 0C21.5 64 0 85.5 0 112l0 48 448 0 0-48c0-26.5-21.5-48-48-48l-48 0 0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32-128 0 0-32z"/></svg>`,
|
||||
|
||||
phone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 311.4 200.6 512 448 512c18 0 33.8-12.1 38.6-29.5l24-88c1-3.5 1.4-7 1.4-10.5c0-15.8-9.4-30.6-24.6-36.9l-96-40c-16.3-6.8-35.2-2.1-46.3 11.6L304.7 368C234.3 334.7 177.3 277.7 144 207.3L193.3 167c13.7-11.2 18.4-30 11.6-46.3l-40-96C158.6 9.4 143.8 0 128 0c-3.5 0-7 .5-10.5 1.4l-88 24C12.1 30.2 0 46 0 64z"/><path class="fa-primary" d="M295 217c-9.4-9.4-9.4-24.6 0-33.9l135-135L384 48c-13.3 0-24-10.7-24-24s10.7-24 24-24L488 0c13.3 0 24 10.7 24 24l0 104c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-46.1L329 217c-9.4 9.4-24.6 9.4-33.9 0z"/></svg>`,
|
||||
phone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M86.8 90l19 47c5 12.3 19 18.2 31.2 13.3s18.2-19 13.3-31.2l-19-47c-5-12.3-19-18.2-31.2-13.3-12.3 5-18.2 19-13.3 31.2zm275 285c-5 12.3 1 26.3 13.3 31.2l47 19c12.3 5 26.3-1 31.2-13.3s-1-26.3-13.3-31.2l-47-19c-12.3-5-26.3 1-31.2 13.3z"/><path fill="currentColor" d="M112.1 1.4c19.7-5.4 40.3 4.7 48.1 23.5l40.5 97.3c6.9 16.5 2.1 35.6-11.8 47l-44.1 36.1c32.5 71.6 89 130 159.3 164.9L342.8 323c11.3-13.9 30.4-18.6 47-11.8L487 351.8c18.8 7.8 28.9 28.4 23.5 48.1l-1.5 5.5C491.4 470.1 428.9 525.3 352.6 509.2 177.6 472.1 39.9 334.4 2.8 159.4-13.3 83.1 41.9 20.6 106.5 2.9l5.5-1.5zM131.3 72c-5-12.3-19-18.2-31.2-13.3S81.8 77.7 86.8 90l19 47c5 12.3 19 18.2 31.2 13.3s18.2-19 13.3-31.2l-19-47zM393 361.7c-12.3-5-26.3 1-31.2 13.3s1 26.3 13.3 31.2l47 19c12.3 5 26.3-1 31.2-13.3s-1-26.3-13.3-31.2l-47-19z"/></svg>`,
|
||||
|
||||
send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M1.4 72.3c0 6.1 1.4 12.4 4.7 18.6l70 134.6c63.3 7.9 126.6 15.8 190 23.7c3.4 .4 6 3.3 6 6.7s-2.6 6.3-6 6.7l-190 23.7L6.1 421.1c-14.6 28.1 7.3 58.6 35.2 58.6c5.3 0 10.8-1.1 16.3-3.5L492.9 285.3c11.6-5.1 19.1-16.6 19.1-29.3s-7.5-24.2-19.1-29.3L57.6 35.8C29.5 23.5 1.4 45.6 1.4 72.3z"/><path class="fa-primary" d="M76.1 286.5l190-23.7c3.4-.4 6-3.3 6-6.7s-2.6-6.3-6-6.7l-190-23.7 8.2 15.7c4.8 9.3 4.8 20.3 0 29.5l-8.2 15.7z"/></svg>`,
|
||||
'paper-plane-top': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M32 479c0 18.1 14.7 33 32.8 33 4.7 0 9.4-1 13.7-3L554.2 290c13.3-6.1 21.8-19.4 21.8-34l-448 0-93.2 209.6C33 469.8 32 474.4 32 479z"/><path fill="currentColor" d="M78.5 3L554.2 222c13.3 6.1 21.8 19.4 21.8 34L128 256 34.8 46.4C33 42.2 32 37.6 32 33 32 14.8 46.7 0 64.8 0 69.5 0 74.2 1 78.5 3z"/></svg>`,
|
||||
|
||||
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6z"/></svg>`,
|
||||
xmark: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/></svg>`,
|
||||
|
||||
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 256a256 256 0 1 0 512 0A256 256 0 1 0 0 256zm136 0c0-6.1 2.3-12.3 7-17c9.4-9.4 24.6-9.4 33.9 0l47 47c37-37 74-74 111-111c4.7-4.7 10.8-7 17-7s12.3 2.3 17 7c2.3 2.3 4.1 5 5.3 7.9c.6 1.5 1 2.9 1.3 4.4c.2 1.1 .3 2.2 .3 2.2c.1 1.2 .1 1.2 .1 2.5c-.1 1.5-.1 1.9-.1 2.3c-.1 .7-.2 1.5-.3 2.2c-.3 1.5-.7 3-1.3 4.4c-1.2 2.9-2.9 5.6-5.3 7.9c-42.7 42.7-85.3 85.3-128 128c-4.7 4.7-10.8 7-17 7s-12.3-2.3-17-7c-21.3-21.3-42.7-42.7-64-64c-4.7-4.7-7-10.8-7-17z"/><path class="fa-primary" d="M369 175c9.4 9.4 9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0z"/></svg>`,
|
||||
'circle-check': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 256a256 256 0 1 0 512 0 256 256 0 1 0 -512 0zm135.1 7.1c9.4-9.4 24.6-9.4 33.9 0L221.1 315.2 340.5 151c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5L243.4 366.1c-4.1 5.7-10.5 9.3-17.5 9.8s-13.9-2-18.8-7l-72-72c-9.4-9.4-9.4-24.6 0-33.9z"/><path fill="currentColor" d="M340.5 151c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5L243.4 366.1c-4.1 5.7-10.5 9.3-17.5 9.8s-13.9-2-18.8-7l-72-72c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0L221.1 315.2 340.5 151z"/></svg>`,
|
||||
|
||||
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M320 96c0 4.8 3 9.1 7.5 10.8L384 128l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 128l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 64 426.8 7.5C425.1 3 420.8 0 416 0s-9.1 3-10.8 7.5L384 64 327.5 85.2c-4.5 1.7-7.5 6-7.5 10.8zm0 320c0 4.8 3 9.1 7.5 10.8L384 448l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 448l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 384l-21.2-56.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L384 384l-56.5 21.2c-4.5 1.7-7.5 6-7.5 10.8z"/><path class="fa-primary" d="M205.1 73.3c-2.6-5.7-8.3-9.3-14.5-9.3s-11.9 3.6-14.5 9.3L123.4 187.4 9.3 240C3.6 242.6 0 248.3 0 254.6s3.6 11.9 9.3 14.5l114.1 52.7L176 435.8c2.6 5.7 8.3 9.3 14.5 9.3s11.9-3.6 14.5-9.3l52.7-114.1 114.1-52.7c5.7-2.6 9.3-8.3 9.3-14.5s-3.6-11.9-9.3-14.5L257.8 187.4 205.1 73.3z"/></svg>`,
|
||||
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M352 448c0 4.8 3 9.1 7.5 10.8L416 480 437.2 536.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L480 480 536.5 458.8c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L480 416 458.8 359.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L416 416 359.5 437.2c-4.5 1.7-7.5 6-7.5 10.8zM384 64c0 4.8 3 9.1 7.5 10.8L448 96 469.2 152.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L512 96 568.5 74.8c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L512 32 490.8-24.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L448 32 391.5 53.2c-4.5 1.7-7.5 6-7.5 10.8z"/><path fill="currentColor" d="M205.1 73.3c-2.6-5.7-8.3-9.3-14.5-9.3s-11.9 3.6-14.5 9.3L123.4 187.4 9.3 240C3.6 242.6 0 248.3 0 254.6s3.6 11.9 9.3 14.5L123.4 321.8 176 435.8c2.6 5.7 8.3 9.3 14.5 9.3s11.9-3.6 14.5-9.3l52.7-114.1 114.1-52.7c5.7-2.6 9.3-8.3 9.3-14.5s-3.6-11.9-9.3-14.5L257.8 187.4 205.1 73.3z"/></svg>`,
|
||||
|
||||
'hands-praying': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path opacity=".4" fill="currentColor" d="M352 224l0 119.6c0 57.2 37.9 107.4 92.8 123.1l154.4 44.1c9.7 2.8 20 .8 28.1-5.2S640 490 640 480l0-96c0-13.8-8.8-26-21.9-30.4l-58.1-19.4 0-110.7c0-29-9.3-57.3-26.5-80.7L440.2 16.3C427.1-1.5 402.1-5.3 384.3 7.8s-21.6 38.1-8.5 55.9L464 183.4 464 296c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-72c0-17.7-14.3-32-32-32s-32 14.3-32 32z"/><path fill="currentColor" d="M200 320c13.3 0 24-10.7 24-24l0-72c0-17.7 14.3-32 32-32s32 14.3 32 32l0 119.6c0 57.2-37.9 107.4-92.8 123.1L40.8 510.8c-9.7 2.8-20 .8-28.1-5.2S0 490 0 480l0-96c0-13.8 8.8-26 21.9-30.4L80 334.3 80 223.6c0-29 9.3-57.3 26.5-80.7L199.8 16.3c13.1-17.8 38.1-21.6 55.9-8.5s21.6 38.1 8.5 55.9L176 183.4 176 296c0 13.3 10.7 24 24 24z"/></svg>`,
|
||||
|
||||
'hand-wave': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M73.4 265.4c-12.5 12.5-12.5 32.8 0 45.3 8.8 8.8 55.2 55.2 139.1 139.1l4.9 4.9c22.2 22.2 49.2 36.9 77.6 44.1 58 17 122.8 6.6 173.6-32.7 47.6-36.8 75.5-93.5 75.5-153.7L544 136c0-22.1-17.9-40-40-40s-40 17.9-40 40l0 77.7c0 4.7-6 7-9.4 3.7l-192-192c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L344 197.3c5.2 5.2 5.2 13.6 0 18.7s-13.6 5.2-18.7 0L182.6 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L280 261.3c5.2 5.2 5.2 13.6 0 18.7s-13.6 5.2-18.7 0L134.6 153.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L216 325.3c5.2 5.2 5.2 13.6 0 18.7s-13.6 5.2-18.7 0c-33.5-33.5-59.8-59.8-78.6-78.6-12.5-12.5-32.8-12.5-45.3 0z"/><path fill="currentColor" d="M392.2 67.4c1.9 13.1 14 22.2 27.2 20.4s22.2-14 20.4-27.2l-1.2-8.5c-5.5-38.7-36-69.1-74.7-74.7l-8.5-1.2c-13.1-1.9-25.3 7.2-27.2 20.4s7.2 25.3 20.4 27.2l8.5 1.2c17.6 2.5 31.4 16.3 33.9 33.9l1.2 8.5zM55.8 380.6c-1.9-13.1-14-22.2-27.2-20.4s-22.2 14-20.4 27.2l1.2 8.5c5.5 38.7 36 69.1 74.7 74.7l8.5 1.2c13.1 1.9 25.3-7.2 27.2-20.4s-7.2-25.3-20.4-27.2L90.9 423c-17.6-2.5-31.4-16.3-33.9-33.9l-1.2-8.5z"/></svg>`,
|
||||
|
||||
'shield-check': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M16 140c.5 99.2 41.3 280.7 213.6 363.2 16.7 8 36.1 8 52.7 0 172.4-82.5 213.2-263.9 213.7-363.2 .1-26.2-16.3-47.9-38.3-57.2L269.4 2.9C265.3 1 260.7 0 256.1 0s-9.2 1-13.4 2.9L54.3 82.8c-22 9.3-38.4 31-38.3 57.2zM166.8 293.5c-9.2-9.5-9-24.7 .6-33.9 9.5-9.2 24.7-9 33.9 .6 8.8 9.1 17.7 18.3 26.5 27.4 28.5-39.2 57.1-78.5 85.6-117.7 7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5c-34.1 46.9-68.3 93.9-102.4 140.8-4.2 5.7-10.7 9.4-17.8 9.8s-14-2.2-18.9-7.3c-15.5-16-30.9-32-46.4-48z"/><path fill="currentColor" d="M313.4 169.9c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5L249.8 338.9c-4.2 5.7-10.7 9.4-17.8 9.8s-14-2.2-18.9-7.3l-46.4-48c-9.2-9.5-9-24.7 .6-33.9s24.7-8.9 33.9 .6l26.5 27.4 85.6-117.7z"/></svg>`,
|
||||
|
||||
'arrow-left': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M77.3 256l32 32 370.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0-32 32z"/><path fill="currentColor" d="M9.4 278.6c-12.5-12.5-12.5-32.8 0-45.3l160-160c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L77.3 256 214.6 393.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-160-160z"/></svg>`,
|
||||
|
||||
'arrow-right': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 256c0 17.7 14.3 32 32 32l370.7 0 32-32-32-32-370.7 0c-17.7 0-32 14.3-32 32z"/><path fill="currentColor" d="M502.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L434.7 256 297.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"/></svg>`,
|
||||
|
||||
'up-right-and-down-left-from-center': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 344L0 488c0 13.3 10.7 24 24 24l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-39-39 87-87c9.4-9.4 9.4-24.6 0-33.9l-32-32c-9.4-9.4-24.6-9.4-33.9 0l-87 87-39-39c-6.9-6.9-17.2-8.9-26.2-5.2S0 334.3 0 344z"/><path fill="currentColor" d="M488 0L344 0c-9.7 0-18.5 5.8-22.2 14.8S320.2 34.1 327 41l39 39-87 87c-9.4 9.4-9.4 24.6 0 33.9l32 32c9.4 9.4 24.6 9.4 33.9 0l87-87 39 39c6.9 6.9 17.2 8.9 26.2 5.2S512 177.7 512 168l0-144c0-13.3-10.7-24-24-24z"/></svg>`,
|
||||
|
||||
'down-left-and-up-right-to-center': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M7.5 439c-9.4 9.4-9.4 24.6 0 33.9l32 32c9.4 9.4 24.6 9.4 33.9 0l87-87 39 39c6.9 6.9 17.2 8.9 26.2 5.2s14.8-12.5 14.8-22.2l0-144c0-13.3-10.7-24-24-24l-144 0c-9.7 0-18.5 5.8-22.2 14.8s-1.7 19.3 5.2 26.2l39 39-87 87z"/><path fill="currentColor" d="M473.5 7c-9.4-9.4-24.6-9.4-33.9 0l-87 87-39-39c-6.9-6.9-17.2-8.9-26.2-5.2S272.5 62.3 272.5 72l0 144c0 13.3 10.7 24 24 24l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-39-39 87-87c9.4-9.4 9.4-24.6 0-33.9l-32-32z"/></svg>`,
|
||||
|
||||
// Branch / location
|
||||
hospital: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M0 192L0 448c0 35.3 28.7 64 64 64l176 0 0-112c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 112 176 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64l-64 0 0-64c0-35.3-28.7-64-64-64L192 0c-35.3 0-64 28.7-64 64l0 64-64 0c-35.3 0-64 28.7-64 64zm64 16c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm0 128c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM216 152c0-8.8 7.2-16 16-16l32 0 0-32c0-8.8 7.2-16 16-16l16 0c8.8 0 16 7.2 16 16l0 32 32 0c8.8 0 16 7.2 16 16l0 16c0 8.8-7.2 16-16 16l-32 0 0 32c0 8.8-7.2 16-16 16l-16 0c-8.8 0-16-7.2-16-16l0-32-32 0c-8.8 0-16-7.2-16-16l0-16zm232 56c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm0 128c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32z"/><path fill="currentColor" d="M264 104c0-8.8 7.2-16 16-16l16 0c8.8 0 16 7.2 16 16l0 32 32 0c8.8 0 16 7.2 16 16l0 16c0 8.8-7.2 16-16 16l-32 0 0 32c0 8.8-7.2 16-16 16l-16 0c-8.8 0-16-7.2-16-16l0-32-32 0c-8.8 0-16-7.2-16-16l0-16c0-8.8 7.2-16 16-16l32 0 0-32zM112 256l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16zm16 112c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32zm112 32c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 112-96 0 0-112zm272-32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32zM496 256l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16z"/></svg>`,
|
||||
|
||||
'location-dot': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path opacity=".4" fill="currentColor" d="M0 188.6c0 119.3 120.2 262.3 170.4 316.8 11.8 12.8 31.5 12.8 43.3 0 50.2-54.5 170.4-197.5 170.4-316.8 0-104.1-86-188.6-192-188.6S0 84.4 0 188.6zM256 192a64 64 0 1 1 -128 0 64 64 0 1 1 128 0z"/><path fill="currentColor" d="M128 192a64 64 0 1 1 128 0 64 64 0 1 1 -64 0z"/></svg>`,
|
||||
|
||||
// Medical departments
|
||||
stethoscope: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M160 348.8c10.3 2.1 21 3.2 32 3.2s21.7-1.1 32-3.2l0 19.2c0 61.9 50.1 112 112 112s112-50.1 112-112l0-85.5c10 3.5 20.8 5.5 32 5.5s22-1.9 32-5.5l0 85.5c0 97.2-78.8 176-176 176S160 465.2 160 368l0-19.2z"/><path fill="currentColor" d="M80 0C53.5 0 32 21.5 32 48l0 144c0 88.4 71.6 160 160 160s160-71.6 160-160l0-144c0-26.5-21.5-48-48-48L256 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l32 0 0 128c0 53-43 96-96 96s-96-43-96-96l0-128 32 0c17.7 0 32-14.3 32-32S145.7 0 128 0L80 0zM448 192a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a96 96 0 1 0 -192 0 96 96 0 1 0 192 0z"/></svg>`,
|
||||
|
||||
'heart-pulse': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 165.1l0 2.6c0 23.6 6.2 48 16.6 72.3l106 0c3.2 0 6.1-1.9 7.4-4.9l31.8-76.3c3.7-8.8 12.3-14.6 21.8-14.8s18.3 5.4 22.2 14.1l51.3 113.9 41.4-82.8c4.1-8.1 12.4-13.3 21.5-13.3s17.4 5.1 21.5 13.3l23.2 46.3c1.4 2.7 4.1 4.4 7.2 4.4l123.6 0c10.5-24.3 16.6-48.7 16.6-72.3l0-2.6C512 91.6 452.4 32 378.9 32 336.2 32 296 52.5 271 87.1l-15 20.7-15-20.7C216 52.5 175.9 32 133.1 32 59.6 32 0 91.6 0 165.1zM42.5 288c47.2 73.8 123 141.7 170.4 177.9 12.4 9.4 27.6 14.1 43.1 14.1s30.8-4.6 43.1-14.1C346.6 429.7 422.4 361.8 469.6 288l-97.8 0c-21.2 0-40.6-12-50.1-31l-1.7-3.4-42.5 85.1c-4.1 8.3-12.7 13.5-22 13.3s-17.6-5.7-21.4-14.1l-49.3-109.5-10.5 25.2c-8.7 20.9-29.1 34.5-51.7 34.5l-80.2 0z"/><path fill="currentColor" d="M42.5 288c-10.1-15.8-18.9-31.9-25.8-48l106 0c3.2 0 6.1-1.9 7.4-4.9l31.8-76.3c3.7-8.8 12.3-14.6 21.8-14.8s18.3 5.4 22.2 14.1l51.3 113.9 41.4-82.8c4.1-8.1 12.4-13.3 21.5-13.3s17.4 5.1 21.5 13.3l23.2 46.3c1.4 2.7 4.1 4.4 7.2 4.4l123.6 0c-6.9 16.1-15.7 32.2-25.8 48l-97.8 0c-21.2 0-40.6-12-50.1-31l-1.7-3.4-42.5 85.1c-4.1 8.3-12.7 13.5-22 13.3s-17.6-5.7-21.4-14.1l-49.3-109.5-10.5 25.2c-8.7 20.9-29.1 34.5-51.7 34.5l-80.2 0z"/></svg>`,
|
||||
|
||||
bone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M197.4 160c-3.9 0-7.2-2.8-8.1-6.6-10.2-42.1-48.1-73.4-93.3-73.4-53 0-96 43-96 96 0 29.1 12.9 55.1 33.3 72.7 4.3 3.7 4.3 10.8 0 14.5-20.4 17.6-33.3 43.7-33.3 72.7 0 53 43 96 96 96 45.2 0 83.1-31.3 93.3-73.4 .9-3.8 4.2-6.6 8.1-6.6l245.1 0c3.9 0 7.2 2.8 8.1 6.6 10.2 42.1 48.1 73.4 93.3 73.4 53 0 96-43 96-96 0-29.1-12.9-55.1-33.3-72.7-4.3-3.7-4.3-10.8 0-14.5 20.4-17.6 33.3-43.7 33.3-72.7 0-53-43-96-96-96-45.2 0-83.1 31.3-93.3 73.4-.9 3.8-4.2 6.6-8.1 6.6l-245.1 0z"/></svg>`,
|
||||
|
||||
'person-pregnant': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path opacity=".4" fill="currentColor" d="M136 24a56 56 0 1 0 112 0 56 56 0 1 0 -112 0z"/><path fill="currentColor" d="M74.6 305.8l29-43.5-30.5 113.5c-2.6 9.6-.6 19.9 5.5 27.8S94 416 104 416l8 0 0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96 32 0 0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-110.8c8.6-4.5 16.8-10 24.3-16.5l4-3.4c22.6-19.4 35.7-47.7 35.7-77.6 0-35.9-18.8-69.1-49.6-87.6l-30.4-18.2 0-1.8c0-46.5-37.7-84.1-84.1-84.1-28.1 0-54.4 14.1-70 37.5L21.4 270.2c-9.8 14.7-5.8 34.6 8.9 44.4s34.6 5.8 44.4-8.9z"/></svg>`,
|
||||
|
||||
ear: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path opacity=".4" fill="currentColor" d="M0 192L0 384c0 70.7 57.3 128 128 128l9.3 0c52.3 0 99.4-31.9 118.8-80.5l20.1-50.2c5.5-13.7 15.8-24.8 27.8-33.4 48.4-34.9 80-91.7 80-156 0-106-86-192-192-192S0 86 0 192zm64 0c0-70.7 57.3-128 128-128s128 57.3 128 128l0 8c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-8c0-44.2-35.8-80-80-80s-80 35.8-80 80l0 16.4c36 4 64 34.5 64 71.6 0 39.8-32.2 72-72 72l-16 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l16 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-16 0c-13.3 0-24-10.7-24-24l0-40z"/><path fill="currentColor" d="M192 112c-44.2 0-80 35.8-80 80l0 16.4c36 4 64 34.5 64 71.6 0 39.8-32.2 72-72 72l-16 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l16 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-16 0c-13.3 0-24-10.7-24-24l0-40c0-70.7 57.3-128 128-128s128 57.3 128 128l0 8c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-8c0-44.2-35.8-80-80-80z"/></svg>`,
|
||||
|
||||
baby: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path opacity=".4" fill="currentColor" d="M7.7 144.5c13-17.9 38-21.8 55.9-8.8L99.8 162c26.8 19.5 59.1 30 92.2 30s65.4-10.5 92.2-30l36.2-26.4c17.9-13 42.9-9 55.9 8.8s9 42.9-8.8 55.9l-36.2 26.4c-13.6 9.9-28.1 18.2-43.3 25l0 36.3-192 0 0-36.3c-15.2-6.7-29.7-15.1-43.3-25L16.5 200.3c-17.9-13-21.8-38-8.8-55.9zM47.2 401.1l50.2-71.8c20.2 17.7 40.4 35.3 60.6 53l-26 37.2 24.3 24.3c15.6 15.6 15.6 40.9 0 56.6s-40.9 15.6-56.6 0l-48-48C38 438.6 36.1 417 47.2 401.1zM264 88a72 72 0 1 1 -144 0 72 72 0 1 1 144 0zM226 382.3c20.2-17.7 40.4-35.3 60.6-53l50.2 71.8c11.1 15.9 9.2 37.5-4.5 51.2l-48 48c-15.6 15.6-40.9 15.6-56.6 0s-15.6-40.9 0-56.6l24.3-24.3-26-37.2z"/><path fill="currentColor" d="M160 384l-64-56 0-40 192 0 0 40-64 56-64 0z"/></svg>`,
|
||||
|
||||
brain: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M8 272c0 26.2 12.6 49.4 32 64-10 13.4-16 30-16 48 0 44.2 35.8 80 80 80 .7 0 1.3 0 2 0 7.1 27.6 32.2 48 62 48l32 0c17.7 0 32-14.3 32-32l0-448c0-17.7-14.3-32-32-32L176 0c-30.9 0-56 25.1-56 56l0 24c-44.2 0-80 35.8-80 80 0 15 4.1 29 11.2 40.9-25.7 13.3-43.2 40.1-43.2 71.1zM280 32l0 448c0 17.7 14.3 32 32 32l32 0c29.8 0 54.9-20.4 62-48 .7 0 1.3 0 2 0 44.2 0 80-35.8 80-80 0-18-6-34.6-16-48 19.4-14.6 32-37.8 32-64 0-30.9-17.6-57.8-43.2-71.1 7.1-12 11.2-26 11.2-40.9 0-44.2-35.8-80-80-80l0-24c0-30.9-25.1-56-56-56L312 0c-17.7 0-32 14.3-32 32z"/><path fill="currentColor" d="M232 32l48 0 0 448-48 0 0-448z"/></svg>`,
|
||||
|
||||
eye: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6 14.9 35.7 46.2 87.7 93 131.1 47.1 43.7 111.8 80.6 192.6 80.6s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1 3.3-7.9 3.3-16.7 0-24.6-14.9-35.7-46.2-87.7-93-131.1-47.1-43.7-111.8-80.6-192.6-80.6S142.5 68.8 95.4 112.6C48.6 156 17.3 208 2.5 243.7zM432 256a144 144 0 1 1 -288 0 144 144 0 1 1 288 0z"/><path fill="currentColor" d="M288 192c0 35.3-28.7 64-64 64-11.5 0-22.3-3-31.7-8.4-1 10.9-.1 22.1 2.9 33.2 13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-12.2-45.7-55.5-74.8-101.1-70.8 5.3 9.3 8.4 20.1 8.4 31.7z"/></svg>`,
|
||||
|
||||
tooth: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M145 5.7L224 32 303 5.7C314.3 1.9 326 0 337.9 0 398.7 0 448 49.3 448 110.1l0 68.5c0 29.4-9.5 58.1-27.2 81.6l-1.1 1.5c-12.9 17.2-21.3 37.4-24.3 58.7L373.7 471.9c-3.3 23-23 40.1-46.2 40.1-22.8 0-42.3-16.5-46-39L261.3 351.6c-3-18.2-18.8-31.6-37.3-31.6s-34.2 13.4-37.3 31.6L166.5 473c-3.8 22.5-23.2 39-46 39-23.2 0-42.9-17.1-46.2-40.1L52.6 320.5c-3-21.3-11.4-41.5-24.3-58.7l-1.1-1.5C9.5 236.7 0 208.1 0 178.7l0-68.5C0 49.3 49.3 0 110.1 0 122 0 133.7 1.9 145 5.7z"/></svg>`,
|
||||
};
|
||||
|
||||
// Render an icon as an HTML string with given size and color
|
||||
export const icon = (name: keyof typeof icons, size = 16, color = 'currentColor'): string => {
|
||||
const svg = icons[name];
|
||||
return svg
|
||||
.replace('<svg', `<svg width="${size}" height="${size}" style="fill:${color};vertical-align:middle;"`)
|
||||
.replace(/\.fa-primary/g, '.p')
|
||||
.replace(/\.fa-secondary\{opacity:\.4\}/g, `.s{opacity:.4;fill:${color}}`);
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
// Render an icon as an HTML string with given size and color.
|
||||
// Color cascades to paths via fill="currentColor".
|
||||
export const icon = (name: IconName, size = 16, color = 'currentColor'): string => {
|
||||
return icons[name].replace(
|
||||
'<svg',
|
||||
`<svg width="${size}" height="${size}" style="vertical-align:middle;color:${color};flex-shrink:0"`,
|
||||
);
|
||||
};
|
||||
|
||||
// Map a department name to a medical icon. Keyword-based with stethoscope fallback.
|
||||
export const departmentIcon = (department: string): IconName => {
|
||||
const key = department.toLowerCase().replace(/_/g, ' ');
|
||||
if (key.includes('cardio') || key.includes('heart')) return 'heart-pulse';
|
||||
if (key.includes('ortho') || key.includes('bone') || key.includes('spine')) return 'bone';
|
||||
if (key.includes('gyn') || key.includes('obstet') || key.includes('maternity') || key.includes('pregnan')) return 'person-pregnant';
|
||||
if (key.includes('ent') || key.includes('otolaryn') || key.includes('ear') || key.includes('nose') || key.includes('throat')) return 'ear';
|
||||
if (key.includes('pediatric') || key.includes('paediatric') || key.includes('child') || key.includes('neonat')) return 'baby';
|
||||
if (key.includes('neuro') || key.includes('psych') || key.includes('mental')) return 'brain';
|
||||
if (key.includes('ophthal') || key.includes('eye') || key.includes('vision') || key.includes('retina')) return 'eye';
|
||||
if (key.includes('dental') || key.includes('dent') || key.includes('tooth')) return 'tooth';
|
||||
return 'stethoscope';
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render } from 'preact';
|
||||
import { initApi, fetchInit } from './api';
|
||||
import { loadTurnstile } from './captcha';
|
||||
import { Widget } from './widget';
|
||||
import type { WidgetConfig } from './types';
|
||||
|
||||
@@ -20,10 +21,19 @@ const init = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preload Turnstile script so the captcha gate renders quickly on first open.
|
||||
// No-op if siteKey is empty (dev mode — backend fails open).
|
||||
if (config.captchaSiteKey) {
|
||||
loadTurnstile().catch(() => {
|
||||
console.warn('[HelixWidget] Turnstile preload failed — gate will retry on open');
|
||||
});
|
||||
}
|
||||
|
||||
// Create shadow DOM host
|
||||
// No font-family here — we want the widget to inherit from the host page.
|
||||
const host = document.createElement('div');
|
||||
host.id = 'helix-widget-host';
|
||||
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;font-family:-apple-system,sans-serif;';
|
||||
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;';
|
||||
document.body.appendChild(host);
|
||||
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
|
||||
142
widget-src/src/store.tsx
Normal file
142
widget-src/src/store.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createContext } from 'preact';
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { fetchDoctors } from './api';
|
||||
import type { BookingPrefill, Doctor } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Centralized widget state.
|
||||
//
|
||||
// One source of truth for everything that's shared across the three tabs
|
||||
// (chat, book, contact): visitor identity, active lead, captcha token,
|
||||
// booking prefill handoff, and the doctors roster. Tab-local state (wizard
|
||||
// steps, message lists, form drafts for tab-specific fields) stays in its
|
||||
// own component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Visitor = { name: string; phone: string };
|
||||
|
||||
type WidgetStore = {
|
||||
// Visitor identity — populated by whichever form the user fills first.
|
||||
// Name/phone inputs in every tab bind directly to this.
|
||||
visitor: Visitor;
|
||||
updateVisitor: (patch: Partial<Visitor>) => void;
|
||||
|
||||
// Lead lifecycle. Set after the first backend call that commits the
|
||||
// visitor (chat-start, book, or contact — chat-start is the common case).
|
||||
// When non-null, the chat pre-chat gate auto-skips.
|
||||
leadId: string | null;
|
||||
setLeadId: (id: string | null) => void;
|
||||
|
||||
// Cloudflare Turnstile token from the window-level gate. Consumed by
|
||||
// booking + contact submit flows (chat uses WidgetKeyGuard only).
|
||||
captchaToken: string;
|
||||
setCaptchaToken: (t: string) => void;
|
||||
|
||||
// Transient handoff: when the user picks a slot in the chat widget, this
|
||||
// carries the doctor/date/time into the Book tab so it lands on the
|
||||
// details form with everything preselected. Booking clears it after consuming.
|
||||
bookingPrefill: BookingPrefill | null;
|
||||
setBookingPrefill: (p: BookingPrefill | null) => void;
|
||||
|
||||
// Doctors roster — fetched once when the provider mounts. Replaces the
|
||||
// per-component fetch that used to live in booking.tsx.
|
||||
doctors: Doctor[];
|
||||
doctorsLoading: boolean;
|
||||
doctorsError: string;
|
||||
|
||||
// Unique branch names derived from the doctors roster (via doctor.clinic.clinicName).
|
||||
branches: string[];
|
||||
|
||||
// Currently selected branch. Session-only, shared across chat + book.
|
||||
// Auto-set on mount if exactly one branch exists. Cleared on session end.
|
||||
selectedBranch: string | null;
|
||||
setSelectedBranch: (branch: string | null) => void;
|
||||
};
|
||||
|
||||
const WidgetStoreContext = createContext<WidgetStore | null>(null);
|
||||
|
||||
type ProviderProps = { children: ComponentChildren };
|
||||
|
||||
export const WidgetStoreProvider = ({ children }: ProviderProps) => {
|
||||
const [visitor, setVisitor] = useState<Visitor>({ name: '', phone: '' });
|
||||
const [leadId, setLeadId] = useState<string | null>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState('');
|
||||
const [bookingPrefill, setBookingPrefill] = useState<BookingPrefill | null>(null);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [doctorsLoading, setDoctorsLoading] = useState(false);
|
||||
const [doctorsError, setDoctorsError] = useState('');
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||
|
||||
const updateVisitor = useCallback((patch: Partial<Visitor>) => {
|
||||
setVisitor(prev => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
// Unique branches derived from the doctors roster. Memoized so stable
|
||||
// references flow down to components that depend on it.
|
||||
const branches = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const d of doctors) {
|
||||
const name = d.clinic?.clinicName?.trim();
|
||||
if (name) set.add(name);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [doctors]);
|
||||
|
||||
// Single-branch hospitals get silent auto-select. Multi-branch ones leave
|
||||
// selectedBranch null and rely on the chat/booking flows to prompt.
|
||||
useEffect(() => {
|
||||
if (selectedBranch) return;
|
||||
if (branches.length === 1) setSelectedBranch(branches[0]);
|
||||
}, [branches, selectedBranch]);
|
||||
|
||||
// Fetch the doctors roster once on mount. We intentionally don't refetch
|
||||
// unless the widget is fully reloaded — the roster doesn't change during
|
||||
// a single visitor session.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDoctorsLoading(true);
|
||||
setDoctorsError('');
|
||||
fetchDoctors()
|
||||
.then(docs => {
|
||||
if (cancelled) return;
|
||||
setDoctors(docs);
|
||||
setDoctorsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setDoctorsError('Failed to load doctors');
|
||||
setDoctorsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const store: WidgetStore = {
|
||||
visitor,
|
||||
updateVisitor,
|
||||
leadId,
|
||||
setLeadId,
|
||||
captchaToken,
|
||||
setCaptchaToken,
|
||||
bookingPrefill,
|
||||
setBookingPrefill,
|
||||
doctors,
|
||||
doctorsLoading,
|
||||
doctorsError,
|
||||
branches,
|
||||
selectedBranch,
|
||||
setSelectedBranch,
|
||||
};
|
||||
|
||||
return <WidgetStoreContext.Provider value={store}>{children}</WidgetStoreContext.Provider>;
|
||||
};
|
||||
|
||||
export const useWidgetStore = (): WidgetStore => {
|
||||
const store = useContext(WidgetStoreContext);
|
||||
if (!store) {
|
||||
throw new Error('useWidgetStore must be used inside a WidgetStoreProvider');
|
||||
}
|
||||
return store;
|
||||
};
|
||||
@@ -1,20 +1,34 @@
|
||||
import type { WidgetConfig } from './types';
|
||||
|
||||
export const getStyles = (config: WidgetConfig) => `
|
||||
:host { all: initial; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
/* all: initial isolates the widget from host-page style bleed, but we then
|
||||
explicitly re-enable font-family inheritance so the widget picks up the
|
||||
host page's font stack instead of falling back to system default. */
|
||||
:host {
|
||||
all: initial;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
input, select, textarea, button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
|
||||
.widget-bubble {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
background: #fff; color: ${config.colors.primary};
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transition: transform 0.2s; border: none; outline: none;
|
||||
cursor: pointer; border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 6px 20px rgba(17, 24, 39, 0.15), 0 2px 4px rgba(17, 24, 39, 0.08);
|
||||
transition: transform 0.2s, box-shadow 0.2s; outline: none;
|
||||
}
|
||||
.widget-bubble:hover { transform: scale(1.08); }
|
||||
.widget-bubble img { width: 28px; height: 28px; border-radius: 6px; }
|
||||
.widget-bubble svg { width: 24px; height: 24px; fill: currentColor; }
|
||||
.widget-bubble:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 10px 28px rgba(17, 24, 39, 0.2), 0 4px 8px rgba(17, 24, 39, 0.1);
|
||||
}
|
||||
.widget-bubble img { width: 32px; height: 32px; border-radius: 6px; }
|
||||
.widget-bubble svg { width: 26px; height: 26px; }
|
||||
|
||||
.widget-panel {
|
||||
width: 380px; height: 520px; border-radius: 16px;
|
||||
@@ -22,25 +36,57 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
border: 1px solid #e5e7eb; position: absolute; bottom: 68px; right: 0;
|
||||
animation: slideUp 0.25s ease-out;
|
||||
transition: width 0.25s ease, height 0.25s ease, border-radius 0.25s ease;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes widgetFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Maximized modal mode */
|
||||
.widget-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(17, 24, 39, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: widgetFadeIn 0.2s ease-out;
|
||||
z-index: 1;
|
||||
}
|
||||
.widget-panel-maximized {
|
||||
position: fixed;
|
||||
top: 50%; left: 50%; right: auto; bottom: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(960px, 92vw);
|
||||
height: min(720px, 88vh);
|
||||
max-width: 92vw; max-height: 88vh;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.25);
|
||||
z-index: 2;
|
||||
animation: widgetFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 16px; background: ${config.colors.primary}; color: #fff;
|
||||
}
|
||||
.widget-header img { width: 32px; height: 32px; border-radius: 8px; }
|
||||
.widget-header-text { flex: 1; }
|
||||
.widget-header-text { flex: 1; min-width: 0; }
|
||||
.widget-header-name { font-size: 14px; font-weight: 600; }
|
||||
.widget-header-sub { font-size: 11px; opacity: 0.8; }
|
||||
.widget-close {
|
||||
background: none; border: none; color: #fff; cursor: pointer;
|
||||
font-size: 18px; padding: 4px; opacity: 0.8;
|
||||
.widget-header-sub { font-size: 11px; opacity: 0.85; }
|
||||
.widget-header-branch {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.widget-close:hover { opacity: 1; }
|
||||
.widget-header-btn {
|
||||
background: none; border: none; color: #fff; cursor: pointer;
|
||||
padding: 6px; opacity: 0.8; display: flex; align-items: center;
|
||||
justify-content: center; border-radius: 6px; margin-left: 2px;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.widget-header-btn:hover { opacity: 1; background: rgba(255,255,255,0.15); }
|
||||
|
||||
.widget-tabs {
|
||||
display: flex; border-bottom: 1px solid #e5e7eb; background: #fafafa;
|
||||
@@ -49,7 +95,8 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
flex: 1; padding: 10px 0; text-align: center; font-size: 12px;
|
||||
font-weight: 500; cursor: pointer; border: none; background: none;
|
||||
color: #6b7280; border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.15s; display: inline-flex; align-items: center;
|
||||
justify-content: center; gap: 6px;
|
||||
}
|
||||
.widget-tab.active {
|
||||
color: ${config.colors.primary}; border-bottom-color: ${config.colors.primary};
|
||||
@@ -57,6 +104,9 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
}
|
||||
|
||||
.widget-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.widget-panel-maximized .widget-body { padding: 24px 32px; }
|
||||
.widget-panel-maximized .widget-tabs { padding: 0 16px; }
|
||||
.widget-panel-maximized .widget-tab { padding: 14px 0; font-size: 13px; }
|
||||
|
||||
.widget-input {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
|
||||
@@ -72,6 +122,19 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
.widget-label { font-size: 12px; font-weight: 500; color: #374151; margin-bottom: 4px; display: block; }
|
||||
.widget-field { margin-bottom: 12px; }
|
||||
|
||||
.widget-section-title {
|
||||
font-size: 13px; font-weight: 600; color: #1f2937;
|
||||
margin-bottom: 10px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.widget-section-sub {
|
||||
font-size: 12px; color: #6b7280; margin-bottom: 16px;
|
||||
}
|
||||
.widget-error {
|
||||
color: #dc2626; font-size: 12px; margin-bottom: 8px;
|
||||
padding: 8px 10px; background: #fef2f2; border-radius: 6px;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.widget-btn {
|
||||
width: 100%; padding: 10px 16px; border: none; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
@@ -80,6 +143,40 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
.widget-btn:hover { opacity: 0.9; }
|
||||
.widget-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.widget-btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
.widget-btn-with-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.widget-btn-row {
|
||||
display: flex; gap: 8px; margin-top: 12px;
|
||||
}
|
||||
.widget-btn-row > .widget-btn { flex: 1; }
|
||||
|
||||
/* Row buttons — department list, doctor list, etc. */
|
||||
.widget-row-btn {
|
||||
width: 100%; display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 14px; margin-bottom: 6px; border: 1px solid #e5e7eb;
|
||||
border-radius: 10px; background: #fff; cursor: pointer;
|
||||
text-align: left; color: #1f2937; transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.widget-row-btn:hover {
|
||||
border-color: ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight};
|
||||
}
|
||||
.widget-row-btn.widget-row-btn-stack { align-items: flex-start; }
|
||||
.widget-row-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
background: ${config.colors.primaryLight}; color: ${config.colors.primary};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.widget-row-main { flex: 1; min-width: 0; }
|
||||
.widget-row-label { font-size: 13px; font-weight: 600; color: #1f2937; }
|
||||
.widget-row-sub { font-size: 11px; color: #6b7280; margin-top: 2px; }
|
||||
.widget-row-chevron {
|
||||
display: inline-flex; color: #9ca3af; flex-shrink: 0;
|
||||
}
|
||||
.widget-row-btn:hover .widget-row-chevron { color: ${config.colors.primary}; }
|
||||
|
||||
.widget-slots {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0;
|
||||
@@ -94,38 +191,232 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
.widget-slot.unavailable { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
|
||||
|
||||
.widget-success {
|
||||
text-align: center; padding: 24px 16px;
|
||||
text-align: center; padding: 32px 16px;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.widget-success-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: #ecfdf5; margin-bottom: 16px;
|
||||
}
|
||||
.widget-success-icon { font-size: 40px; margin-bottom: 12px; }
|
||||
.widget-success-title { font-size: 16px; font-weight: 600; color: #059669; margin-bottom: 8px; }
|
||||
.widget-success-text { font-size: 13px; color: #6b7280; }
|
||||
.widget-success-text { font-size: 13px; color: #6b7280; line-height: 1.6; }
|
||||
|
||||
/* Chat empty state */
|
||||
.chat-empty {
|
||||
text-align: center; padding: 32px 8px 16px;
|
||||
}
|
||||
.chat-intro {
|
||||
padding: 24px 4px 8px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.chat-intro .chat-empty-icon { align-self: center; }
|
||||
.chat-intro .chat-empty-title { text-align: center; font-size: 15px; font-weight: 600; color: #1f2937; margin-bottom: 6px; }
|
||||
.chat-intro .chat-empty-text { text-align: center; font-size: 12px; color: #6b7280; margin-bottom: 20px; line-height: 1.5; }
|
||||
.chat-empty-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chat-empty-title {
|
||||
font-size: 15px; font-weight: 600; color: #1f2937; margin-bottom: 6px;
|
||||
}
|
||||
.chat-empty-text {
|
||||
font-size: 12px; color: #6b7280; margin-bottom: 18px; line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 12px 0; }
|
||||
.chat-msg { margin-bottom: 10px; display: flex; }
|
||||
.chat-msg.user { justify-content: flex-end; }
|
||||
.chat-msg.assistant { justify-content: flex-start; }
|
||||
.chat-msg-stack {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
max-width: 85%;
|
||||
}
|
||||
.chat-msg.user .chat-msg-stack { align-items: flex-end; }
|
||||
.chat-msg.assistant .chat-msg-stack { align-items: flex-start; }
|
||||
.chat-bubble {
|
||||
max-width: 80%; padding: 10px 14px; border-radius: 12px;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
padding: 10px 14px; border-radius: 12px;
|
||||
font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.chat-msg.user .chat-bubble { background: ${config.colors.primary}; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.chat-msg.assistant .chat-bubble { background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 4px; }
|
||||
|
||||
/* Typing indicator (animated dots) */
|
||||
.chat-typing-dots {
|
||||
display: inline-flex; gap: 4px; align-items: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.chat-typing-dots > span {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #9ca3af; display: inline-block;
|
||||
animation: chatDot 1.4s ease-in-out infinite both;
|
||||
}
|
||||
.chat-typing-dots > span:nth-child(2) { animation-delay: 0.16s; }
|
||||
.chat-typing-dots > span:nth-child(3) { animation-delay: 0.32s; }
|
||||
@keyframes chatDot {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.35; }
|
||||
40% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Generic chat widget (tool UI) container */
|
||||
.chat-widget {
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
|
||||
padding: 12px; font-size: 12px; color: #1f2937;
|
||||
width: 100%; max-width: 300px;
|
||||
}
|
||||
.chat-widget-title {
|
||||
font-size: 12px; font-weight: 600; color: #374151;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
}
|
||||
.chat-widget-loading {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; background: #f3f4f6; border-radius: 10px;
|
||||
font-size: 12px; color: #6b7280;
|
||||
}
|
||||
.chat-widget-loading-label { font-style: italic; }
|
||||
.chat-widget-empty { font-size: 12px; color: #6b7280; font-style: italic; }
|
||||
.chat-widget-error { font-size: 12px; color: #dc2626; padding: 8px 12px; background: #fef2f2; border-radius: 8px; border: 1px solid #fecaca; }
|
||||
|
||||
/* Branch picker cards */
|
||||
.chat-widget-branches .chat-widget-branch-card {
|
||||
width: 100%; display: block; text-align: left;
|
||||
padding: 10px 12px; margin-bottom: 6px;
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
cursor: pointer; font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.chat-widget-branches .chat-widget-branch-card:last-child { margin-bottom: 0; }
|
||||
.chat-widget-branches .chat-widget-branch-card:hover {
|
||||
border-color: ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight};
|
||||
}
|
||||
.chat-widget-branch-name { font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 2px; }
|
||||
.chat-widget-branch-meta { font-size: 11px; color: #6b7280; }
|
||||
|
||||
/* Department chip grid */
|
||||
.chat-widget-dept-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
}
|
||||
.chat-widget-dept-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; border-radius: 999px;
|
||||
border: 1px solid ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight};
|
||||
color: ${config.colors.primary};
|
||||
font-size: 11px; font-weight: 500; cursor: pointer;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.chat-widget-dept-chip:hover {
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
}
|
||||
|
||||
/* Doctor cards */
|
||||
.chat-widget-doctor-card {
|
||||
padding: 10px; background: #f9fafb; border-radius: 8px;
|
||||
border: 1px solid #f3f4f6; margin-bottom: 6px;
|
||||
}
|
||||
.chat-widget-doctor-card:last-child { margin-bottom: 0; }
|
||||
.chat-widget-doctor-name { font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 2px; }
|
||||
.chat-widget-doctor-meta { font-size: 11px; color: #6b7280; line-height: 1.4; }
|
||||
.chat-widget-doctor-action {
|
||||
margin-top: 8px; width: 100%;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 6px 10px; font-size: 11px; font-weight: 600;
|
||||
color: ${config.colors.primary};
|
||||
background: #fff;
|
||||
border: 1px solid ${config.colors.primary};
|
||||
border-radius: 6px; cursor: pointer;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.chat-widget-doctor-action:hover {
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
}
|
||||
|
||||
/* Clinic timings widget */
|
||||
.chat-widget-timings .chat-widget-timing-dept {
|
||||
margin-bottom: 10px; padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.chat-widget-timings .chat-widget-timing-dept:last-child {
|
||||
margin-bottom: 0; padding-bottom: 0; border-bottom: 0;
|
||||
}
|
||||
.chat-widget-timing-dept-name {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; font-weight: 600; color: ${config.colors.primary};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chat-widget-timing-row {
|
||||
padding: 4px 0 4px 22px;
|
||||
}
|
||||
.chat-widget-timing-doctor {
|
||||
font-size: 12px; font-weight: 500; color: #1f2937;
|
||||
}
|
||||
.chat-widget-timing-hours {
|
||||
font-size: 11px; color: #4b5563; line-height: 1.4;
|
||||
}
|
||||
.chat-widget-timing-clinic {
|
||||
font-size: 11px; color: #9ca3af; font-style: italic;
|
||||
}
|
||||
|
||||
/* Slots grid widget */
|
||||
.chat-widget-slots-doctor { font-size: 13px; font-weight: 600; color: #1f2937; }
|
||||
.chat-widget-slots-meta { font-size: 11px; color: #6b7280; margin-bottom: 8px; }
|
||||
.chat-widget-slots-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
|
||||
}
|
||||
.chat-widget-slot-btn {
|
||||
padding: 8px 6px; font-size: 12px; font-weight: 500;
|
||||
color: ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight};
|
||||
border: 1px solid ${config.colors.primary};
|
||||
border-radius: 6px; cursor: pointer;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.chat-widget-slot-btn:hover {
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
}
|
||||
.chat-widget-slot-btn.unavailable {
|
||||
color: #9ca3af; background: #f3f4f6;
|
||||
border-color: #e5e7eb; cursor: not-allowed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Booking suggestion card */
|
||||
.chat-widget-booking {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
background: ${config.colors.primaryLight};
|
||||
border-color: ${config.colors.primary};
|
||||
}
|
||||
.chat-widget-booking-icon {
|
||||
flex-shrink: 0; width: 40px; height: 40px;
|
||||
border-radius: 10px; background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: ${config.colors.primary};
|
||||
}
|
||||
.chat-widget-booking-body { flex: 1; min-width: 0; }
|
||||
.chat-widget-booking-title { font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 2px; }
|
||||
.chat-widget-booking-reason { font-size: 12px; color: #4b5563; line-height: 1.5; margin-bottom: 6px; }
|
||||
.chat-widget-booking-dept { font-size: 11px; color: ${config.colors.primary}; font-weight: 500; margin-bottom: 8px; }
|
||||
.chat-widget-booking .widget-btn { padding: 8px 14px; font-size: 12px; }
|
||||
|
||||
.chat-input-row { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; }
|
||||
.chat-input { flex: 1; }
|
||||
.chat-send {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
border: none; cursor: pointer; display: flex;
|
||||
align-items: center; justify-content: center; font-size: 16px;
|
||||
align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-send:disabled { opacity: 0.5; }
|
||||
.chat-send:hover { opacity: 0.9; }
|
||||
.chat-send:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 12px; }
|
||||
.quick-action {
|
||||
padding: 6px 12px; border-radius: 16px; font-size: 11px;
|
||||
border: 1px solid ${config.colors.primary}; color: ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight}; cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.15s; font-family: inherit;
|
||||
}
|
||||
.quick-action:hover { background: ${config.colors.primary}; color: #fff; }
|
||||
|
||||
@@ -135,4 +426,37 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
}
|
||||
.widget-step.active { background: ${config.colors.primary}; }
|
||||
.widget-step.done { background: #059669; }
|
||||
|
||||
/* Captcha gate — full-panel verification screen */
|
||||
.widget-captcha-gate {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 32px 24px; text-align: center;
|
||||
background: #fafafa;
|
||||
}
|
||||
.widget-captcha-gate-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 96px; height: 96px; border-radius: 50%;
|
||||
background: ${config.colors.primaryLight}; margin-bottom: 20px;
|
||||
}
|
||||
.widget-captcha-gate-title {
|
||||
font-size: 17px; font-weight: 600; color: #1f2937; margin-bottom: 8px;
|
||||
}
|
||||
.widget-captcha-gate-text {
|
||||
font-size: 13px; color: #6b7280; margin-bottom: 24px; line-height: 1.5;
|
||||
max-width: 280px;
|
||||
}
|
||||
.widget-captcha {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 8px; width: 100%;
|
||||
}
|
||||
/* Placeholder reserves space for the Turnstile widget which is portaled to
|
||||
document.body (light DOM) and visually positioned over this element. */
|
||||
.widget-captcha-mount {
|
||||
width: 300px; height: 65px;
|
||||
display: block;
|
||||
}
|
||||
.widget-captcha-status {
|
||||
font-size: 11px; color: #6b7280; text-align: center;
|
||||
}
|
||||
.widget-captcha-error { color: #dc2626; }
|
||||
`;
|
||||
|
||||
@@ -20,7 +20,75 @@ export type TimeSlot = {
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
// A doctor card rendered inside a show_doctors tool result.
|
||||
export type ChatDoctor = {
|
||||
id: string;
|
||||
name: string;
|
||||
specialty: string | null;
|
||||
visitingHours: string | null;
|
||||
clinic: string | null;
|
||||
};
|
||||
|
||||
export type ChatSlot = { time: string; available: boolean };
|
||||
|
||||
export type ClinicTimingDept = {
|
||||
name: string;
|
||||
entries: Array<{ name: string; hours: string; clinic: string | null }>;
|
||||
};
|
||||
|
||||
export type BranchOption = {
|
||||
name: string;
|
||||
doctorCount: number;
|
||||
departmentCount: number;
|
||||
};
|
||||
|
||||
// Per-tool output payload shapes (matching what the backend tool.execute returns).
|
||||
export type ToolOutputs = {
|
||||
pick_branch: { branches: BranchOption[] };
|
||||
list_departments: { branch: string | null; departments: string[] };
|
||||
show_clinic_timings: { branch: string | null; departments: ClinicTimingDept[] };
|
||||
show_doctors: { department: string; branch: string | null; doctors: ChatDoctor[] };
|
||||
show_doctor_slots: {
|
||||
doctor: { id: string; name: string; department: string | null; clinic: string | null } | null;
|
||||
date: string;
|
||||
slots: ChatSlot[];
|
||||
error?: string;
|
||||
};
|
||||
suggest_booking: { reason: string; department: string | null };
|
||||
};
|
||||
|
||||
// Seed data passed from chat → booking flow when a visitor picks a slot.
|
||||
export type BookingPrefill = {
|
||||
doctorId: string;
|
||||
date: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
export type ToolName = keyof ToolOutputs;
|
||||
|
||||
// UI parts are Vercel-style: an assistant message is a sequence of
|
||||
// TextPart | ToolPart that we render in order.
|
||||
export type ChatTextPart = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
state: 'streaming' | 'done';
|
||||
};
|
||||
|
||||
export type ChatToolPart = {
|
||||
type: 'tool';
|
||||
toolCallId: string;
|
||||
toolName: ToolName | string; // unknown tool names are rendered as fallback
|
||||
state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
|
||||
input?: any;
|
||||
output?: any;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type ChatPart = ChatTextPart | ChatToolPart;
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
parts: ChatPart[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { WidgetConfig } from './types';
|
||||
import { getStyles } from './styles';
|
||||
import { icon } from './icons';
|
||||
import { IconSpan } from './icon-span';
|
||||
import { Captcha } from './captcha';
|
||||
import { Chat } from './chat';
|
||||
import { Booking } from './booking';
|
||||
import { Contact } from './contact';
|
||||
import { WidgetStoreProvider, useWidgetStore } from './store';
|
||||
|
||||
type Tab = 'chat' | 'book' | 'contact';
|
||||
|
||||
@@ -13,11 +15,9 @@ type WidgetProps = {
|
||||
shadow: ShadowRoot;
|
||||
};
|
||||
|
||||
// Outer wrapper — owns the shadow-DOM style injection and the store provider.
|
||||
// Everything inside WidgetShell reads shared state via useWidgetStore().
|
||||
export const Widget = ({ config, shadow }: WidgetProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('chat');
|
||||
|
||||
// Inject styles into shadow DOM
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = getStyles(config);
|
||||
@@ -25,53 +25,139 @@ export const Widget = ({ config, shadow }: WidgetProps) => {
|
||||
return () => { shadow.removeChild(style); };
|
||||
}, [config, shadow]);
|
||||
|
||||
return (
|
||||
<WidgetStoreProvider>
|
||||
<WidgetShell config={config} shadow={shadow} />
|
||||
</WidgetStoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetShell = ({ config, shadow }: WidgetProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('chat');
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const { captchaToken, setCaptchaToken, selectedBranch } = useWidgetStore();
|
||||
|
||||
// Maximized mode: host becomes a fullscreen fixed container so the modal
|
||||
// backdrop covers the page. Restored on exit.
|
||||
useEffect(() => {
|
||||
if (!maximized) return;
|
||||
const host = shadow.host as HTMLElement;
|
||||
const prevHostStyle = host.getAttribute('style');
|
||||
host.style.cssText = 'position:fixed;inset:0;z-index:999999;';
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
if (prevHostStyle !== null) host.setAttribute('style', prevHostStyle);
|
||||
else host.removeAttribute('style');
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [maximized, shadow]);
|
||||
|
||||
// Esc to exit maximized mode.
|
||||
useEffect(() => {
|
||||
if (!maximized) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMaximized(false); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [maximized]);
|
||||
|
||||
const requiresGate = Boolean(config.captchaSiteKey) && !captchaToken;
|
||||
|
||||
const close = () => {
|
||||
setMaximized(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Floating bubble */}
|
||||
{!open && (
|
||||
<button class="widget-bubble" onClick={() => setOpen(true)}>
|
||||
<button class="widget-bubble" onClick={() => setOpen(true)} aria-label="Open chat">
|
||||
{config.brand.logo ? (
|
||||
<img src={config.brand.logo} alt={config.brand.name} />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>
|
||||
<IconSpan name="message-dots" size={26} color={config.colors.primary} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
{open && (
|
||||
<div class="widget-panel">
|
||||
{/* Header */}
|
||||
<div class="widget-header">
|
||||
{config.brand.logo && <img src={config.brand.logo} alt="" />}
|
||||
<div class="widget-header-text">
|
||||
<div class="widget-header-name">{config.brand.name}</div>
|
||||
<div class="widget-header-sub">We're here to help</div>
|
||||
<>
|
||||
{maximized && <div class="widget-backdrop" onClick={() => setMaximized(false)} />}
|
||||
<div class={`widget-panel ${maximized ? 'widget-panel-maximized' : ''}`}>
|
||||
{/* Header */}
|
||||
<div class="widget-header">
|
||||
{config.brand.logo && <img src={config.brand.logo} alt="" />}
|
||||
<div class="widget-header-text">
|
||||
<div class="widget-header-name">{config.brand.name}</div>
|
||||
<div class="widget-header-sub">
|
||||
We're here to help
|
||||
{selectedBranch && (
|
||||
<>
|
||||
{' • '}
|
||||
<span class="widget-header-branch">
|
||||
<IconSpan name="location-dot" size={10} color="#fff" />
|
||||
{selectedBranch}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="widget-header-btn"
|
||||
onClick={() => setMaximized(m => !m)}
|
||||
aria-label={maximized ? 'Restore' : 'Maximize'}
|
||||
title={maximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
<IconSpan
|
||||
name={maximized ? 'down-left-and-up-right-to-center' : 'up-right-and-down-left-from-center'}
|
||||
size={14}
|
||||
color="#fff"
|
||||
/>
|
||||
</button>
|
||||
<button class="widget-header-btn" onClick={close} aria-label="Close" title="Close">
|
||||
<IconSpan name="xmark" size={16} color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="widget-close" onClick={() => setOpen(false)}>✕</button>
|
||||
{/* Icons bundled from FontAwesome Pro SVGs — static, not user input */}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div class="widget-tabs">
|
||||
<button class={`widget-tab ${tab === 'chat' ? 'active' : ''}`} onClick={() => setTab('chat')}>
|
||||
<span innerHTML={icon('chat', 14)} /> Chat
|
||||
</button>
|
||||
<button class={`widget-tab ${tab === 'book' ? 'active' : ''}`} onClick={() => setTab('book')}>
|
||||
<span innerHTML={icon('calendar', 14)} /> Book
|
||||
</button>
|
||||
<button class={`widget-tab ${tab === 'contact' ? 'active' : ''}`} onClick={() => setTab('contact')}>
|
||||
<span innerHTML={icon('phone', 14)} /> Contact
|
||||
</button>
|
||||
</div>
|
||||
{requiresGate ? (
|
||||
<div class="widget-captcha-gate">
|
||||
<div class="widget-captcha-gate-icon">
|
||||
<IconSpan name="shield-check" size={56} color={config.colors.primary} />
|
||||
</div>
|
||||
<div class="widget-captcha-gate-title">Quick security check</div>
|
||||
<div class="widget-captcha-gate-text">
|
||||
Please verify you're not a bot to continue.
|
||||
</div>
|
||||
<Captcha siteKey={config.captchaSiteKey} onToken={setCaptchaToken} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div class="widget-tabs">
|
||||
<button class={`widget-tab ${tab === 'chat' ? 'active' : ''}`} onClick={() => setTab('chat')}>
|
||||
<IconSpan name="message-dots" size={14} /> Chat
|
||||
</button>
|
||||
<button class={`widget-tab ${tab === 'book' ? 'active' : ''}`} onClick={() => setTab('book')}>
|
||||
<IconSpan name="calendar" size={14} /> Book
|
||||
</button>
|
||||
<button class={`widget-tab ${tab === 'contact' ? 'active' : ''}`} onClick={() => setTab('contact')}>
|
||||
<IconSpan name="phone" size={14} /> Contact
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div class="widget-body">
|
||||
{tab === 'chat' && <Chat />}
|
||||
{tab === 'book' && <Booking />}
|
||||
{tab === 'contact' && <Contact />}
|
||||
{/* Body */}
|
||||
<div class="widget-body">
|
||||
{tab === 'chat' && <Chat onRequestBooking={() => setTab('book')} />}
|
||||
{tab === 'book' && <Booking />}
|
||||
{tab === 'contact' && <Contact />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user