mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +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>
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
};
|