mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
- 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>
105 lines
4.0 KiB
TypeScript
105 lines
4.0 KiB
TypeScript
import { useState } from 'preact/hooks';
|
|
import { submitLead } from './api';
|
|
import { IconSpan } from './icon-span';
|
|
import { useWidgetStore } from './store';
|
|
|
|
export const Contact = () => {
|
|
const { visitor, updateVisitor, captchaToken } = useWidgetStore();
|
|
|
|
const [interest, setInterest] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [success, setSuccess] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const handleSubmit = async () => {
|
|
if (!visitor.name.trim() || !visitor.phone.trim()) return;
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
await submitLead({
|
|
name: visitor.name.trim(),
|
|
phone: visitor.phone.trim(),
|
|
interest: interest.trim() || undefined,
|
|
message: message.trim() || undefined,
|
|
captchaToken,
|
|
});
|
|
setSuccess(true);
|
|
} catch {
|
|
setError('Submission failed. Please try again.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (success) {
|
|
return (
|
|
<div class="widget-success">
|
|
<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 {visitor.phone}.<br />
|
|
We typically respond within 30 minutes during business hours.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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 class="widget-error">{error}</div>}
|
|
|
|
<div class="widget-field">
|
|
<label class="widget-label">Full Name *</label>
|
|
<input
|
|
class="widget-input"
|
|
placeholder="Your name"
|
|
value={visitor.name}
|
|
onInput={(e: any) => updateVisitor({ name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div class="widget-field">
|
|
<label class="widget-label">Phone Number *</label>
|
|
<input
|
|
class="widget-input"
|
|
placeholder="+91 9876543210"
|
|
value={visitor.phone}
|
|
onInput={(e: any) => updateVisitor({ phone: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div class="widget-field">
|
|
<label class="widget-label">Interested In</label>
|
|
<select class="widget-select" value={interest} onChange={(e: any) => setInterest(e.target.value)}>
|
|
<option value="">Select (optional)</option>
|
|
<option value="Consultation">General Consultation</option>
|
|
<option value="Health Checkup">Health Checkup</option>
|
|
<option value="Surgery">Surgery</option>
|
|
<option value="Second Opinion">Second Opinion</option>
|
|
<option value="Other">Other</option>
|
|
</select>
|
|
</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)}
|
|
/>
|
|
</div>
|
|
<button
|
|
class="widget-btn"
|
|
disabled={!visitor.name.trim() || !visitor.phone.trim() || loading}
|
|
onClick={handleSubmit}
|
|
>
|
|
{loading ? 'Sending...' : 'Send Message'}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|