diff --git a/public/widget.js b/public/widget.js index 6e7bbbe..5f7aa5f 100644 --- a/public/widget.js +++ b/public/widget.js @@ -1,18 +1,32 @@ -(function(){"use strict";var O,b,ue,M,he,fe,ge,ee,F,W,me,te,ne,oe,j={},R=[],Ue=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,q=Array.isArray;function L(e,t){for(var n in t)e[n]=t[n];return e}function ie(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function Oe(e,t,n){var r,l,o,a={};for(o in t)o=="key"?r=t[o]:o=="ref"?l=t[o]:a[o]=t[o];if(arguments.length>2&&(a.children=arguments.length>3?O.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(o in e.defaultProps)a[o]===void 0&&(a[o]=e.defaultProps[o]);return K(e,a,r,l,null)}function K(e,t,n,r,l){var o={type:e,props:t,key:n,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:l??++ue,__i:-1,__u:0};return l==null&&b.vnode!=null&&b.vnode(o),o}function Y(e){return e.children}function J(e,t){this.props=e,this.context=t}function D(e,t){if(t==null)return e.__?D(e.__,e.__i+1):null;for(var n;tt&&M.sort(ge),e=M.shift(),t=M.length,Fe(e)}finally{M.length=V.__r=0}}function ve(e,t,n,r,l,o,a,d,_,c,p){var s,h,u,m,w,x,y,g=r&&r.__k||R,C=t.length;for(_=je(n,t,g,_,C),s=0;s0?a=e.__k[o]=K(a.type,a.props,a.key,a.ref?a.ref:null,a.__v):e.__k[o]=a,_=o+h,a.__=e,a.__b=e.__b+1,d=null,(c=a.__i=Re(a,n,_,s))!=-1&&(s--,(d=n[c])&&(d.__u|=2)),d==null||d.__v==null?(c==-1&&(l>p?h--:l_?h--:h++,a.__u|=4))):e.__k[o]=null;if(s)for(o=0;o(p?1:0)){for(l=n-1,o=n+1;l>=0||o=0?l--:o++])!=null&&(2&c.__u)==0&&d==c.key&&_==c.type)return a}return-1}function xe(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Ue.test(t)?n:n+"px"}function G(e,t,n,r,l){var o,a;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||xe(e.style,t,"");if(n)for(t in n)r&&n[t]==r[t]||xe(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")o=t!=(t=t.replace(me,"$1")),a=t.toLowerCase(),t=a in e||t=="onFocusOut"||t=="onFocusIn"?a.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+o]=n,n?r?n[W]=r[W]:(n[W]=te,e.addEventListener(t,o?oe:ne,o)):e.removeEventListener(t,o?oe:ne,o);else{if(l=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function ke(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t[F]==null)t[F]=te++;else if(t[F]0?e:q(e)?e.map($e):L({},e)}function qe(e,t,n,r,l,o,a,d,_){var c,p,s,h,u,m,w,x=n.props||j,y=t.props,g=t.type;if(g=="svg"?l="http://www.w3.org/2000/svg":g=="math"?l="http://www.w3.org/1998/Math/MathML":l||(l="http://www.w3.org/1999/xhtml"),o!=null){for(c=0;c{H=e,T=t},ae=()=>({"Content-Type":"application/json","X-Widget-Key":T}),Ge=async()=>{const e=await fetch(`${H}/api/widget/init?key=${T}`);if(!e.ok)throw new Error("Widget init failed");return e.json()},Qe=async()=>{const e=await fetch(`${H}/api/widget/doctors?key=${T}`);if(!e.ok)throw new Error("Failed to load doctors");return e.json()},Xe=async(e,t)=>{const n=await fetch(`${H}/api/widget/slots?key=${T}&doctorId=${e}&date=${t}`);if(!n.ok)throw new Error("Failed to load slots");return n.json()},Ze=async e=>{const t=await fetch(`${H}/api/widget/book?key=${T}`,{method:"POST",headers:ae(),body:JSON.stringify(e)});if(!t.ok)throw new Error("Booking failed");return t.json()},et=async e=>{const t=await fetch(`${H}/api/widget/lead?key=${T}`,{method:"POST",headers:ae(),body:JSON.stringify(e)});if(!t.ok)throw new Error("Submission failed");return t.json()},tt=async(e,t)=>{const n=await fetch(`${H}/api/widget/chat?key=${T}`,{method:"POST",headers:ae(),body:JSON.stringify({messages:e,captchaToken:t})});if(!n.ok||!n.body)throw new Error("Chat failed");return n.body};var E,k,ce,ze,Q=0,Le=[],S=b,Me=S.__b,Te=S.__r,Be=S.diffed,He=S.__c,Pe=S.unmount,Ne=S.__;function de(e,t){S.__h&&S.__h(k,e,Q||t),Q=0;var n=k.__H||(k.__H={__:[],__h:[]});return e>=n.__.length&&n.__.push({}),n.__[e]}function v(e){return Q=1,nt(Ae,e)}function nt(e,t,n){var r=de(E++,2);if(r.t=e,!r.__c&&(r.__=[Ae(void 0,t),function(d){var _=r.__N?r.__N[0]:r.__[0],c=r.t(_,d);_!==c&&(r.__N=[c,r.__[1]],r.__c.setState({}))}],r.__c=k,!k.__f)){var l=function(d,_,c){if(!r.__c.__H)return!0;var p=r.__c.__H.__.filter(function(h){return h.__c});if(p.every(function(h){return!h.__N}))return!o||o.call(this,d,_,c);var s=r.__c.props!==d;return p.some(function(h){if(h.__N){var u=h.__[0];h.__=h.__N,h.__N=void 0,u!==h.__[0]&&(s=!0)}}),o&&o.call(this,d,_,c)||s};k.__f=!0;var o=k.shouldComponentUpdate,a=k.componentWillUpdate;k.componentWillUpdate=function(d,_,c){if(this.__e){var p=o;o=void 0,l(d,_,c),o=p}a&&a.call(this,d,_,c)},k.shouldComponentUpdate=l}return r.__N||r.__}function X(e,t){var n=de(E++,3);!S.__s&&Ie(n.__H,t)&&(n.__=e,n.u=t,k.__H.__h.push(n))}function ot(e){return Q=5,it(function(){return{current:e}},[])}function it(e,t){var n=de(E++,7);return Ie(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function rt(){for(var e;e=Le.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(Z),t.__h.some(_e),t.__h=[]}catch(n){t.__h=[],S.__e(n,e.__v)}}}S.__b=function(e){k=null,Me&&Me(e)},S.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Ne&&Ne(e,t)},S.__r=function(e){Te&&Te(e),E=0;var t=(k=e.__c).__H;t&&(ce===k?(t.__h=[],k.__h=[],t.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.some(Z),t.__h.some(_e),t.__h=[],E=0)),ce=k},S.diffed=function(e){Be&&Be(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(Le.push(t)!==1&&ze===S.requestAnimationFrame||((ze=S.requestAnimationFrame)||st)(rt)),t.__H.__.some(function(n){n.u&&(n.__H=n.u),n.u=void 0})),ce=k=null},S.__c=function(e,t){t.some(function(n){try{n.__h.some(Z),n.__h=n.__h.filter(function(r){return!r.__||_e(r)})}catch(r){t.some(function(l){l.__h&&(l.__h=[])}),t=[],S.__e(r,n.__v)}}),He&&He(e,t)},S.unmount=function(e){Pe&&Pe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.some(function(r){try{Z(r)}catch(l){t=l}}),n.__H=void 0,t&&S.__e(t,n.__v))};var De=typeof requestAnimationFrame=="function";function st(e){var t,n=function(){clearTimeout(r),De&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,35);De&&(t=requestAnimationFrame(n))}function Z(e){var t=k,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),k=t}function _e(e){var t=k;e.__c=e.__(),k=t}function Ie(e,t){return!e||e.length!==t.length||t.some(function(n,r){return n!==e[r]})}function Ae(e,t){return typeof t=="function"?t(e):t}const lt=e=>` -:host { all: initial; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; } +(function(){"use strict";var tt,C,$t,j,Mt,Tt,Bt,_t,et,J,It,wt,bt,xt,Pt,it={},nt=[],le=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ot=Array.isArray;function A(t,e){for(var n in e)t[n]=e[n];return t}function vt(t){t&&t.parentNode&&t.parentNode.removeChild(t)}function ae(t,e,n){var o,l,r,s={};for(r in e)r=="key"?o=e[r]:r=="ref"?l=e[r]:s[r]=e[r];if(arguments.length>2&&(s.children=arguments.length>3?tt.call(arguments,2):n),typeof t=="function"&&t.defaultProps!=null)for(r in t.defaultProps)s[r]===void 0&&(s[r]=t.defaultProps[r]);return rt(t,s,o,l,null)}function rt(t,e,n,o,l){var r={type:t,props:e,key:n,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:l??++$t,__i:-1,__u:0};return l==null&&C.vnode!=null&&C.vnode(r),r}function O(t){return t.children}function lt(t,e){this.props=t,this.context=e}function q(t,e){if(e==null)return t.__?q(t.__,t.__i+1):null;for(var n;ee&&j.sort(Bt),t=j.shift(),e=j.length,ce(t)}finally{j.length=at.__r=0}}function Dt(t,e,n,o,l,r,s,p,u,c,d){var a,g,h,m,k,w,x,_=o&&o.__k||nt,I=e.length;for(u=se(n,e,_,u,I),a=0;a0?s=t.__k[r]=rt(s.type,s.props,s.key,s.ref?s.ref:null,s.__v):t.__k[r]=s,u=r+g,s.__=t,s.__b=t.__b+1,p=null,(c=s.__i=de(s,n,u,a))!=-1&&(a--,(p=n[c])&&(p.__u|=2)),p==null||p.__v==null?(c==-1&&(l>d?g--:lu?g--:g++,s.__u|=4))):t.__k[r]=null;if(a)for(r=0;r(d?1:0)){for(l=n-1,r=n+1;l>=0||r=0?l--:r++])!=null&&(2&c.__u)==0&&p==c.key&&u==c.type)return s}return-1}function Et(t,e,n){e[0]=="-"?t.setProperty(e,n??""):t[e]=n==null?"":typeof n!="number"||le.test(e)?n:n+"px"}function ct(t,e,n,o,l){var r,s;t:if(e=="style")if(typeof n=="string")t.style.cssText=n;else{if(typeof o=="string"&&(t.style.cssText=o=""),o)for(e in o)n&&e in n||Et(t.style,e,"");if(n)for(e in n)o&&n[e]==o[e]||Et(t.style,e,n[e])}else if(e[0]=="o"&&e[1]=="n")r=e!=(e=e.replace(It,"$1")),s=e.toLowerCase(),e=s in t||e=="onFocusOut"||e=="onFocusIn"?s.slice(2):e.slice(2),t.l||(t.l={}),t.l[e+r]=n,n?o?n[J]=o[J]:(n[J]=wt,t.addEventListener(e,r?xt:bt,r)):t.removeEventListener(e,r?xt:bt,r);else{if(l=="http://www.w3.org/2000/svg")e=e.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(e!="width"&&e!="height"&&e!="href"&&e!="list"&&e!="form"&&e!="tabIndex"&&e!="download"&&e!="rowSpan"&&e!="colSpan"&&e!="role"&&e!="popover"&&e in t)try{t[e]=n??"";break t}catch{}typeof n=="function"||(n==null||n===!1&&e[4]!="-"?t.removeAttribute(e):t.setAttribute(e,e=="popover"&&n==1?"":n))}}function Wt(t){return function(e){if(this.l){var n=this.l[e.type+t];if(e[et]==null)e[et]=wt++;else if(e[et]0?t:ot(t)?t.map(Ft):A({},t)}function pe(t,e,n,o,l,r,s,p,u){var c,d,a,g,h,m,k,w=n.props||it,x=e.props,_=e.type;if(_=="svg"?l="http://www.w3.org/2000/svg":_=="math"?l="http://www.w3.org/1998/Math/MathML":l||(l="http://www.w3.org/1999/xhtml"),r!=null){for(c=0;c{U=t,F=e},st=()=>({"Content-Type":"application/json","X-Widget-Key":F}),_e=async()=>{const t=await fetch(`${U}/api/widget/init?key=${F}`);if(!t.ok)throw new Error("Widget init failed");return t.json()},we=async()=>{const t=await fetch(`${U}/api/widget/doctors?key=${F}`);if(!t.ok)throw new Error("Failed to load doctors");return t.json()},be=async(t,e)=>{const n=await fetch(`${U}/api/widget/slots?key=${F}&doctorId=${t}&date=${e}`);if(!n.ok)throw new Error("Failed to load slots");return n.json()},xe=async t=>{const e=await fetch(`${U}/api/widget/book?key=${F}`,{method:"POST",headers:st(),body:JSON.stringify(t)});if(!e.ok)throw new Error("Booking failed");return e.json()},ve=async t=>{const e=await fetch(`${U}/api/widget/lead?key=${F}`,{method:"POST",headers:st(),body:JSON.stringify(t)});if(!e.ok)throw new Error("Submission failed");return e.json()},ye=async(t,e)=>{const n=await fetch(`${U}/api/widget/chat-start?key=${F}`,{method:"POST",headers:st(),body:JSON.stringify({name:t,phone:e})});if(!n.ok)throw new Error("Chat start failed");return n.json()},ke=async(t,e,n)=>{const o=await fetch(`${U}/api/widget/chat?key=${F}`,{method:"POST",headers:st(),body:JSON.stringify({leadId:t,messages:e,branch:n})});if(!o.ok||!o.body)throw new Error("Chat failed");return o.body};var K,L,St,Ot,G=0,Ut=[],M=C,Rt=M.__b,qt=M.__r,Kt=M.diffed,Vt=M.__c,Yt=M.unmount,Jt=M.__;function dt(t,e){M.__h&&M.__h(L,t,G||e),G=0;var n=L.__H||(L.__H={__:[],__h:[]});return t>=n.__.length&&n.__.push({}),n.__[t]}function v(t){return G=1,Ce(Xt,t)}function Ce(t,e,n){var o=dt(K++,2);if(o.t=t,!o.__c&&(o.__=[Xt(void 0,e),function(p){var u=o.__N?o.__N[0]:o.__[0],c=o.t(u,p);u!==c&&(o.__N=[c,o.__[1]],o.__c.setState({}))}],o.__c=L,!L.__f)){var l=function(p,u,c){if(!o.__c.__H)return!0;var d=o.__c.__H.__.filter(function(g){return g.__c});if(d.every(function(g){return!g.__N}))return!r||r.call(this,p,u,c);var a=o.__c.props!==p;return d.some(function(g){if(g.__N){var h=g.__[0];g.__=g.__N,g.__N=void 0,h!==g.__[0]&&(a=!0)}}),r&&r.call(this,p,u,c)||a};L.__f=!0;var r=L.shouldComponentUpdate,s=L.componentWillUpdate;L.componentWillUpdate=function(p,u,c){if(this.__e){var d=r;r=void 0,l(p,u,c),r=d}s&&s.call(this,p,u,c)},L.shouldComponentUpdate=l}return o.__N||o.__}function W(t,e){var n=dt(K++,3);!M.__s&&Qt(n.__H,e)&&(n.__=t,n.u=e,L.__H.__h.push(n))}function V(t){return G=5,Q(function(){return{current:t}},[])}function Q(t,e){var n=dt(K++,7);return Qt(n.__H,e)&&(n.__=t(),n.__H=e,n.__h=t),n.__}function ze(t,e){return G=8,Q(function(){return t},e)}function Se(t){var e=L.context[t.__c],n=dt(K++,9);return n.c=t,e?(n.__==null&&(n.__=!0,e.sub(L)),e.props.value):t.__}function Le(){for(var t;t=Ut.shift();){var e=t.__H;if(t.__P&&e)try{e.__h.some(pt),e.__h.some(Lt),e.__h=[]}catch(n){e.__h=[],M.__e(n,t.__v)}}}M.__b=function(t){L=null,Rt&&Rt(t)},M.__=function(t,e){t&&e.__k&&e.__k.__m&&(t.__m=e.__k.__m),Jt&&Jt(t,e)},M.__r=function(t){qt&&qt(t),K=0;var e=(L=t.__c).__H;e&&(St===L?(e.__h=[],L.__h=[],e.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(e.__h.some(pt),e.__h.some(Lt),e.__h=[],K=0)),St=L},M.diffed=function(t){Kt&&Kt(t);var e=t.__c;e&&e.__H&&(e.__H.__h.length&&(Ut.push(e)!==1&&Ot===M.requestAnimationFrame||((Ot=M.requestAnimationFrame)||$e)(Le)),e.__H.__.some(function(n){n.u&&(n.__H=n.u),n.u=void 0})),St=L=null},M.__c=function(t,e){e.some(function(n){try{n.__h.some(pt),n.__h=n.__h.filter(function(o){return!o.__||Lt(o)})}catch(o){e.some(function(l){l.__h&&(l.__h=[])}),e=[],M.__e(o,n.__v)}}),Vt&&Vt(t,e)},M.unmount=function(t){Yt&&Yt(t);var e,n=t.__c;n&&n.__H&&(n.__H.__.some(function(o){try{pt(o)}catch(l){e=l}}),n.__H=void 0,e&&M.__e(e,n.__v))};var Gt=typeof requestAnimationFrame=="function";function $e(t){var e,n=function(){clearTimeout(o),Gt&&cancelAnimationFrame(e),setTimeout(t)},o=setTimeout(n,35);Gt&&(e=requestAnimationFrame(n))}function pt(t){var e=L,n=t.__c;typeof n=="function"&&(t.__c=void 0,n()),L=e}function Lt(t){var e=L;t.__c=t.__(),L=e}function Qt(t,e){return!t||t.length!==e.length||e.some(function(n,o){return n!==t[o]})}function Xt(t,e){return typeof e=="function"?e(t):e}const Me="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",Zt=()=>typeof window>"u"||window.turnstile?Promise.resolve():(window.__helixTurnstileLoading||(window.__helixTurnstileLoading=new Promise((t,e)=>{const n=document.createElement("script");n.src=Me,n.async=!0,n.defer=!0,n.onload=()=>{const o=()=>{window.turnstile?t():setTimeout(o,50)};o()},n.onerror=()=>e(new Error("Turnstile failed to load")),document.head.appendChild(n)})),window.__helixTurnstileLoading),Te=({siteKey:t,onToken:e,onError:n})=>{const o=V(null),l=V(null),r=V(null),s=V(e),p=V(n),[u,c]=v("loading");return s.current=e,p.current=n,W(()=>{if(!t||!o.current)return;let d=!1;const a=document.createElement("div");a.setAttribute("data-helix-turnstile",""),a.style.cssText=["position:fixed","z-index:2147483647","width:300px","height:65px","pointer-events:auto"].join(";"),document.body.appendChild(a),l.current=a;const g=()=>{if(!o.current||!l.current)return;const k=o.current.getBoundingClientRect();l.current.style.top=`${k.top}px`,l.current.style.left=`${k.left}px`};g(),window.addEventListener("resize",g),window.addEventListener("scroll",g,!0);let h=0;const m=()=>{g(),h=requestAnimationFrame(m)};return h=requestAnimationFrame(m),Zt().then(()=>{var k;if(!(d||!l.current||!window.turnstile))try{r.current=window.turnstile.render(l.current,{sitekey:t,callback:w=>s.current(w),"error-callback":()=>{var w;c("error"),(w=p.current)==null||w.call(p)},"expired-callback":()=>s.current(""),theme:"light",size:"normal"}),c("ready")}catch{c("error"),(k=p.current)==null||k.call(p)}}).catch(()=>{var k;c("error"),(k=p.current)==null||k.call(p)}),()=>{if(d=!0,cancelAnimationFrame(h),window.removeEventListener("resize",g),window.removeEventListener("scroll",g,!0),r.current&&window.turnstile){try{window.turnstile.remove(r.current)}catch{}r.current=null}a.remove(),l.current=null}},[t]),i("div",{class:"widget-captcha",children:[i("div",{class:"widget-captcha-mount",ref:o}),u==="loading"&&i("div",{class:"widget-captcha-status",children:"Loading verification…"}),u==="error"&&i("div",{class:"widget-captcha-status widget-captcha-error",children:"Verification failed to load. Please refresh."})]})},Be=t=>` +/* 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: ${e.colors.primary}; color: #fff; + background: #fff; color: ${t.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; @@ -20,25 +34,57 @@ 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: ${e.colors.primary}; color: #fff; + padding: 14px 16px; background: ${t.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; @@ -47,21 +93,25 @@ 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: ${e.colors.primary}; border-bottom-color: ${e.colors.primary}; + color: ${t.colors.primary}; border-bottom-color: ${t.colors.primary}; font-weight: 600; } .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; border-radius: 8px; font-size: 13px; outline: none; transition: border-color 0.15s; } -.widget-input:focus { border-color: ${e.colors.primary}; } +.widget-input:focus { border-color: ${t.colors.primary}; } .widget-textarea { resize: vertical; min-height: 60px; font-family: inherit; } .widget-select { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; @@ -70,14 +120,61 @@ .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; - transition: opacity 0.15s; color: #fff; background: ${e.colors.primary}; + transition: opacity 0.15s; color: #fff; background: ${t.colors.primary}; } .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: ${t.colors.primary}; + background: ${t.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: ${t.colors.primaryLight}; color: ${t.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: ${t.colors.primary}; } .widget-slots { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0; @@ -87,50 +184,280 @@ border: 1px solid #e5e7eb; cursor: pointer; background: #fff; transition: all 0.15s; } -.widget-slot:hover { border-color: ${e.colors.primary}; } -.widget-slot.selected { background: ${e.colors.primary}; color: #fff; border-color: ${e.colors.primary}; } +.widget-slot:hover { border-color: ${t.colors.primary}; } +.widget-slot.selected { background: ${t.colors.primary}; color: #fff; border-color: ${t.colors.primary}; } .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-bubble { - max-width: 80%; padding: 10px 14px; border-radius: 12px; - font-size: 13px; line-height: 1.5; +.chat-msg.assistant { justify-content: flex-start; } +.chat-msg-stack { + display: flex; flex-direction: column; gap: 6px; + max-width: 85%; } -.chat-msg.user .chat-bubble { background: ${e.colors.primary}; color: #fff; border-bottom-right-radius: 4px; } +.chat-msg.user .chat-msg-stack { align-items: flex-end; } +.chat-msg.assistant .chat-msg-stack { align-items: flex-start; } +.chat-bubble { + 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: ${t.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: ${t.colors.primary}; + background: ${t.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 ${t.colors.primary}; + background: ${t.colors.primaryLight}; + color: ${t.colors.primary}; + font-size: 11px; font-weight: 500; cursor: pointer; + font-family: inherit; transition: all 0.15s; +} +.chat-widget-dept-chip:hover { + background: ${t.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: ${t.colors.primary}; + background: #fff; + border: 1px solid ${t.colors.primary}; + border-radius: 6px; cursor: pointer; + font-family: inherit; transition: all 0.15s; +} +.chat-widget-doctor-action:hover { + background: ${t.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: ${t.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: ${t.colors.primary}; + background: ${t.colors.primaryLight}; + border: 1px solid ${t.colors.primary}; + border-radius: 6px; cursor: pointer; + font-family: inherit; transition: all 0.15s; +} +.chat-widget-slot-btn:hover { + background: ${t.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: ${t.colors.primaryLight}; + border-color: ${t.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: ${t.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: ${t.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: ${e.colors.primary}; color: #fff; + background: ${t.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 ${e.colors.primary}; color: ${e.colors.primary}; - background: ${e.colors.primaryLight}; cursor: pointer; - transition: all 0.15s; + border: 1px solid ${t.colors.primary}; color: ${t.colors.primary}; + background: ${t.colors.primaryLight}; cursor: pointer; + transition: all 0.15s; font-family: inherit; } -.quick-action:hover { background: ${e.colors.primary}; color: #fff; } +.quick-action:hover { background: ${t.colors.primary}; color: #fff; } .widget-steps { display: flex; gap: 4px; margin-bottom: 16px; } .widget-step { flex: 1; height: 3px; border-radius: 2px; background: #e5e7eb; } -.widget-step.active { background: ${e.colors.primary}; } +.widget-step.active { background: ${t.colors.primary}; } .widget-step.done { background: #059669; } -`,at={chat:'',calendar:'',phone:'',send:'',close:'',check:'',sparkles:''},pe=(e,t=16,n="currentColor")=>at[e].replace("{const[e,t]=v([]),[n,r]=v(""),[l,o]=v(!1),a=ot(null);X(()=>{a.current&&(a.current.scrollTop=a.current.scrollHeight)},[e]);const d=async _=>{if(!_.trim()||l)return;const c={role:"user",content:_.trim()},p=[...e,c];t(p),r(""),o(!0);try{const h=(await tt(p)).getReader(),u=new TextDecoder;let m="";for(t([...p,{role:"assistant",content:""}]);;){const{done:w,value:x}=await h.read();if(w)break;m+=u.decode(x,{stream:!0}),t([...p,{role:"assistant",content:m}])}}catch{t([...p,{role:"assistant",content:"Sorry, I encountered an error. Please try again."}])}finally{o(!1)}};return i("div",{style:{display:"flex",flexDirection:"column",height:"100%"},children:[i("div",{class:"chat-messages",ref:a,children:[e.length===0&&i("div",{style:{textAlign:"center",padding:"20px 0"},children:[i("div",{style:{fontSize:"24px",marginBottom:"8px"},children:"πŸ‘‹"}),i("div",{style:{fontSize:"14px",fontWeight:600,color:"#1f2937",marginBottom:"4px"},children:"How can we help you?"}),i("div",{style:{fontSize:"12px",color:"#6b7280",marginBottom:"16px"},children:"Ask about doctors, clinics, packages, or book an appointment."}),i("div",{class:"quick-actions",children:ct.map(_=>i("button",{class:"quick-action",onClick:()=>d(_),children:_},_))})]}),e.map((_,c)=>i("div",{class:`chat-msg ${_.role}`,children:i("div",{class:"chat-bubble",children:_.content||"..."})},c))]}),i("div",{class:"chat-input-row",children:[i("input",{class:"widget-input chat-input",placeholder:"Type a message...",value:n,onInput:_=>r(_.target.value),onKeyDown:_=>_.key==="Enter"&&d(n),disabled:l}),i("button",{class:"chat-send",onClick:()=>d(n),disabled:l||!n.trim(),children:"↑"})]})]})},_t=()=>{const[e,t]=v("department"),[n,r]=v([]),[l,o]=v([]),[a,d]=v(""),[_,c]=v(null),[p,s]=v(""),[h,u]=v([]),[m,w]=v(""),[x,y]=v(""),[g,C]=v(""),[z,P]=v(""),[U,N]=v(!1),[I,$]=v(""),[B,ht]=v("");X(()=>{Qe().then(f=>{r(f),o([...new Set(f.map(A=>A.department).filter(Boolean))])}).catch(()=>$("Failed to load doctors"))},[]);const ft=a?n.filter(f=>f.department===a):[],gt=f=>{c(f),s(new Date().toISOString().split("T")[0]),t("datetime")};X(()=>{_&&p&&Xe(_.id,p).then(u).catch(()=>{})},[_,p]);const mt=async()=>{if(!(!_||!m||!x||!g)){N(!0),$("");try{const f=`${p}T${m}:00`,A=await Ze({departmentId:a,doctorId:_.id,scheduledAt:f,patientName:x,patientPhone:g,chiefComplaint:z,captchaToken:"dev-bypass"});ht(A.reference),t("success")}catch{$("Booking failed. Please try again.")}finally{N(!1)}}},Ee={department:0,doctor:1,datetime:2,details:3,success:4}[e];return i("div",{children:[e!=="success"&&i("div",{class:"widget-steps",children:[0,1,2,3].map(f=>i("div",{class:`widget-step ${fi("button",{class:"widget-btn widget-btn-secondary",style:{marginBottom:"6px",textAlign:"left"},onClick:()=>{d(f),t("doctor")},children:f.replace(/_/g," ")},f))]}),e==="doctor"&&i("div",{children:[i("div",{class:"widget-label",style:{marginBottom:"8px",fontSize:"13px",fontWeight:600},children:["Select Doctor β€” ",a.replace(/_/g," ")]}),ft.map(f=>{var A;return i("button",{class:"widget-btn widget-btn-secondary",style:{marginBottom:"6px",textAlign:"left"},onClick:()=>gt(f),children:[i("div",{style:{fontWeight:600},children:f.name}),i("div",{style:{fontSize:"11px",color:"#6b7280"},children:[f.visitingHours??""," ",(A=f.clinic)!=null&&A.clinicName?`β€’ ${f.clinic.clinicName}`:""]})]},f.id)}),i("button",{class:"widget-btn widget-btn-secondary",style:{marginTop:"8px"},onClick:()=>t("department"),children:"← Back"})]}),e==="datetime"&&i("div",{children:[i("div",{class:"widget-label",style:{marginBottom:"8px",fontSize:"13px",fontWeight:600},children:[_==null?void 0:_.name," β€” Pick Date & Time"]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Date"}),i("input",{class:"widget-input",type:"date",value:p,min:new Date().toISOString().split("T")[0],onInput:f=>{s(f.target.value),w("")}})]}),h.length>0&&i("div",{children:[i("label",{class:"widget-label",children:"Available Slots"}),i("div",{class:"widget-slots",children:h.map(f=>i("button",{class:`widget-slot ${f.time===m?"selected":""} ${f.available?"":"unavailable"}`,onClick:()=>f.available&&w(f.time),disabled:!f.available,children:f.time},f.time))})]}),i("div",{style:{display:"flex",gap:"8px",marginTop:"12px"},children:[i("button",{class:"widget-btn widget-btn-secondary",style:{flex:1},onClick:()=>t("doctor"),children:"← Back"}),i("button",{class:"widget-btn",style:{flex:1},disabled:!m,onClick:()=>t("details"),children:"Next β†’"})]})]}),e==="details"&&i("div",{children:[i("div",{class:"widget-label",style:{marginBottom:"8px",fontSize:"13px",fontWeight:600},children:"Your Details"}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Full Name *"}),i("input",{class:"widget-input",placeholder:"Your name",value:x,onInput:f=>y(f.target.value)})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Phone Number *"}),i("input",{class:"widget-input",placeholder:"+91 9876543210",value:g,onInput:f=>C(f.target.value)})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Chief Complaint"}),i("textarea",{class:"widget-input widget-textarea",placeholder:"Describe your concern...",value:z,onInput:f=>P(f.target.value)})]}),i("div",{style:{display:"flex",gap:"8px"},children:[i("button",{class:"widget-btn widget-btn-secondary",style:{flex:1},onClick:()=>t("datetime"),children:"← Back"}),i("button",{class:"widget-btn",style:{flex:1},disabled:!x||!g||U,onClick:mt,children:U?"Booking...":"Book Appointment"})]})]}),e==="success"&&i("div",{class:"widget-success",children:[i("div",{class:"widget-success-icon",children:"βœ…"}),i("div",{class:"widget-success-title",children:"Appointment Booked!"}),i("div",{class:"widget-success-text",children:["Reference: ",i("strong",{children:B}),i("br",{}),_==null?void 0:_.name," β€’ ",p," at ",m,i("br",{}),i("br",{}),"We'll send a confirmation SMS to your phone."]})]})]})},pt=()=>{const[e,t]=v(""),[n,r]=v(""),[l,o]=v(""),[a,d]=v(""),[_,c]=v(!1),[p,s]=v(!1),[h,u]=v(""),m=async()=>{if(!(!e.trim()||!n.trim())){c(!0),u("");try{await et({name:e.trim(),phone:n.trim(),interest:l.trim()||void 0,message:a.trim()||void 0,captchaToken:"dev-bypass"}),s(!0)}catch{u("Submission failed. Please try again.")}finally{c(!1)}}};return p?i("div",{class:"widget-success",children:[i("div",{class:"widget-success-icon",children:"πŸ™"}),i("div",{class:"widget-success-title",children:"Thank you!"}),i("div",{class:"widget-success-text",children:["An agent will call you shortly on ",n,".",i("br",{}),"We typically respond within 30 minutes during business hours."]})]}):i("div",{children:[i("div",{style:{fontSize:"13px",fontWeight:600,color:"#1f2937",marginBottom:"12px"},children:"Get in touch"}),i("div",{style:{fontSize:"12px",color:"#6b7280",marginBottom:"16px"},children:"Leave your details and we'll call you back."}),h&&i("div",{style:{color:"#dc2626",fontSize:"12px",marginBottom:"8px"},children:h}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Full Name *"}),i("input",{class:"widget-input",placeholder:"Your name",value:e,onInput:w=>t(w.target.value)})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Phone Number *"}),i("input",{class:"widget-input",placeholder:"+91 9876543210",value:n,onInput:w=>r(w.target.value)})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Interested In"}),i("select",{class:"widget-select",value:l,onChange:w=>o(w.target.value),children:[i("option",{value:"",children:"Select (optional)"}),i("option",{value:"Consultation",children:"General Consultation"}),i("option",{value:"Health Checkup",children:"Health Checkup"}),i("option",{value:"Surgery",children:"Surgery"}),i("option",{value:"Second Opinion",children:"Second Opinion"}),i("option",{value:"Other",children:"Other"})]})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Message"}),i("textarea",{class:"widget-input widget-textarea",placeholder:"How can we help? (optional)",value:a,onInput:w=>d(w.target.value)})]}),i("button",{class:"widget-btn",disabled:!e.trim()||!n.trim()||_,onClick:m,children:_?"Sending...":"Send Message"})]})},ut=({config:e,shadow:t})=>{const[n,r]=v(!1),[l,o]=v("chat");return X(()=>{const a=document.createElement("style");return a.textContent=lt(e),t.appendChild(a),()=>{t.removeChild(a)}},[e,t]),i("div",{children:[!n&&i("button",{class:"widget-bubble",onClick:()=>r(!0),children:e.brand.logo?i("img",{src:e.brand.logo,alt:e.brand.name}):i("svg",{viewBox:"0 0 24 24",children:i("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"})})}),n&&i("div",{class:"widget-panel",children:[i("div",{class:"widget-header",children:[e.brand.logo&&i("img",{src:e.brand.logo,alt:""}),i("div",{class:"widget-header-text",children:[i("div",{class:"widget-header-name",children:e.brand.name}),i("div",{class:"widget-header-sub",children:"We're here to help"})]}),i("button",{class:"widget-close",onClick:()=>r(!1),children:"βœ•"})]}),i("div",{class:"widget-tabs",children:[i("button",{class:`widget-tab ${l==="chat"?"active":""}`,onClick:()=>o("chat"),children:[i("span",{innerHTML:pe("chat",14)})," Chat"]}),i("button",{class:`widget-tab ${l==="book"?"active":""}`,onClick:()=>o("book"),children:[i("span",{innerHTML:pe("calendar",14)})," Book"]}),i("button",{class:`widget-tab ${l==="contact"?"active":""}`,onClick:()=>o("contact"),children:[i("span",{innerHTML:pe("phone",14)})," Contact"]})]}),i("div",{class:"widget-body",children:[l==="chat"&&i(dt,{}),l==="book"&&i(_t,{}),l==="contact"&&i(pt,{})]})]})]})},We=async()=>{const e=document.querySelector("script[data-key]");if(!e){console.error("[HelixWidget] Missing data-key attribute");return}const t=e.getAttribute("data-key")??"",n=e.src.replace(/\/widget\.js.*$/,"");Ve(n,t);let r;try{r=await Ge()}catch(d){console.error("[HelixWidget] Init failed:",d);return}const l=document.createElement("div");l.id="helix-widget-host",l.style.cssText="position:fixed;bottom:20px;right:20px;z-index:999999;font-family:-apple-system,sans-serif;",document.body.appendChild(l);const o=l.attachShadow({mode:"open"}),a=document.createElement("div");o.appendChild(a),Ye(i(ut,{config:r,shadow:o}),a)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",We):We()})(); + +/* 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: ${t.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; } +`,Ie={"message-dots":'',calendar:'',phone:'',"paper-plane-top":'',xmark:'',"circle-check":'',sparkles:'',"hands-praying":'',"hand-wave":'',"shield-check":'',"arrow-left":'',"arrow-right":'',"up-right-and-down-left-from-center":'',"down-left-and-up-right-to-center":'',hospital:'',"location-dot":'',stethoscope:'',"heart-pulse":'',bone:'',"person-pregnant":'',ear:'',baby:'',brain:'',eye:'',tooth:''},Pe=(t,e=16,n="currentColor")=>Ie[t].replace("{const e=t.toLowerCase().replace(/_/g," ");return e.includes("cardio")||e.includes("heart")?"heart-pulse":e.includes("ortho")||e.includes("bone")||e.includes("spine")?"bone":e.includes("gyn")||e.includes("obstet")||e.includes("maternity")||e.includes("pregnan")?"person-pregnant":e.includes("ent")||e.includes("otolaryn")||e.includes("ear")||e.includes("nose")||e.includes("throat")?"ear":e.includes("pediatric")||e.includes("paediatric")||e.includes("child")||e.includes("neonat")?"baby":e.includes("neuro")||e.includes("psych")||e.includes("mental")?"brain":e.includes("ophthal")||e.includes("eye")||e.includes("vision")||e.includes("retina")?"eye":e.includes("dental")||e.includes("dent")||e.includes("tooth")?"tooth":"stethoscope"},y=({name:t,size:e=16,color:n="currentColor",class:o})=>{const l=Pe(t,e,n);return i("span",{class:o,dangerouslySetInnerHTML:{__html:l}})};async function*Ne(t){const e=t.getReader(),n=new TextDecoder;let o="";try{for(;;){const{done:l,value:r}=await e.read();if(l)break;o+=n.decode(r,{stream:!0});let s;for(;(s=o.indexOf(` + +`))!==-1;){const p=o.slice(0,s);o=o.slice(s+2);const u=p.split(` +`);for(const c of u){if(!c.startsWith("data:"))continue;const d=c.slice(5).trimStart();if(!(!d||d==="[DONE]"))try{yield JSON.parse(d)}catch{}}}}}finally{e.releaseLock()}}const De=({part:t,onDepartmentClick:e,onShowDoctorSlots:n,onSuggestBooking:o,onPickSlot:l,onPickBranch:r})=>{var s,p,u,c;if(t.state==="input-streaming"||t.state==="input-available")return i(Ee,{toolName:t.toolName});if(t.state==="output-error")return i("div",{class:"chat-widget-error",children:["Couldn't load: ",t.errorText??"unknown error"]});switch(t.toolName){case"pick_branch":{const d=t.output;return(s=d==null?void 0:d.branches)!=null&&s.length?i(We,{branches:d.branches,onPick:r}):null}case"list_departments":{const d=t.output;return(p=d==null?void 0:d.departments)!=null&&p.length?i(Ae,{departments:d.departments,onPick:e}):null}case"show_clinic_timings":{const d=t.output;return(u=d==null?void 0:d.departments)!=null&&u.length?i(Fe,{departments:d.departments}):null}case"show_doctors":{const d=t.output;return(c=d==null?void 0:d.doctors)!=null&&c.length?i(je,{department:d.department,doctors:d.doctors,onPickDoctor:n}):i("div",{class:"chat-widget-empty",children:["No doctors found in ",(d==null?void 0:d.department)??"this department","."]})}case"show_doctor_slots":{const d=t.output;return d?d.error||!d.doctor?i("div",{class:"chat-widget-empty",children:d.error??"Doctor not found."}):i(Oe,{data:d,onPickSlot:l}):null}case"suggest_booking":{const d=t.output;return i(Re,{reason:(d==null?void 0:d.reason)??"Book an appointment.",department:(d==null?void 0:d.department)??null,onBook:o})}default:return null}},He={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…"},Ee=({toolName:t})=>i("div",{class:"chat-widget-loading",children:[i("span",{class:"chat-typing-dots","aria-hidden":"true",children:[i("span",{}),i("span",{}),i("span",{})]}),i("span",{class:"chat-widget-loading-label",children:He[t]??"Working…"})]}),We=({branches:t,onPick:e})=>i("div",{class:"chat-widget chat-widget-branches",children:[i("div",{class:"chat-widget-title",children:"Which branch?"}),t.map(n=>i("button",{class:"chat-widget-branch-card",onClick:()=>e(n.name),children:[i("div",{class:"chat-widget-branch-name",children:n.name}),i("div",{class:"chat-widget-branch-meta",children:[n.doctorCount," ",n.doctorCount===1?"doctor":"doctors",n.departmentCount>0?` β€’ ${n.departmentCount} ${n.departmentCount===1?"department":"departments"}`:""]})]},n.name))]}),Ae=({departments:t,onPick:e})=>i("div",{class:"chat-widget chat-widget-departments",children:[i("div",{class:"chat-widget-title",children:"Departments"}),i("div",{class:"chat-widget-dept-grid",children:t.map(n=>i("button",{class:"chat-widget-dept-chip",onClick:()=>e(n),title:`Show doctors in ${n}`,children:[i(y,{name:X(n),size:16}),i("span",{children:n})]},n))})]}),Fe=({departments:t})=>i("div",{class:"chat-widget chat-widget-timings",children:[i("div",{class:"chat-widget-title",children:[i(y,{name:"calendar",size:14})," Clinic hours"]}),t.map(e=>i("div",{class:"chat-widget-timing-dept",children:[i("div",{class:"chat-widget-timing-dept-name",children:[i(y,{name:X(e.name),size:14}),i("span",{children:e.name})]}),e.entries.map(n=>i("div",{class:"chat-widget-timing-row",children:[i("div",{class:"chat-widget-timing-doctor",children:n.name}),i("div",{class:"chat-widget-timing-hours",children:n.hours}),n.clinic&&i("div",{class:"chat-widget-timing-clinic",children:n.clinic})]},`${e.name}-${n.name}`))]},e.name))]}),je=({department:t,doctors:e,onPickDoctor:n})=>i("div",{class:"chat-widget chat-widget-doctors",children:[i("div",{class:"chat-widget-title",children:[i(y,{name:X(t),size:14})," ",t]}),e.map(o=>i("div",{class:"chat-widget-doctor-card",children:[i("div",{class:"chat-widget-doctor-name",children:o.name}),o.specialty&&i("div",{class:"chat-widget-doctor-meta",children:o.specialty}),o.visitingHours&&i("div",{class:"chat-widget-doctor-meta",children:o.visitingHours}),o.clinic&&i("div",{class:"chat-widget-doctor-meta",children:o.clinic}),i("button",{class:"chat-widget-doctor-action",onClick:()=>n(o.name),children:[i(y,{name:"calendar",size:12})," See available appointments"]})]},o.id))]}),Oe=({data:t,onPickSlot:e})=>{if(!t.doctor)return null;const n=t.doctor,l=t.slots.filter(r=>r.available).length>0;return i("div",{class:"chat-widget chat-widget-slots",children:[i("div",{class:"chat-widget-title",children:[i(y,{name:"calendar",size:14})," Available slots"]}),i("div",{class:"chat-widget-slots-doctor",children:n.name}),i("div",{class:"chat-widget-slots-meta",children:[Ue(t.date),n.clinic?` β€’ ${n.clinic}`:""]}),l?i("div",{class:"chat-widget-slots-grid",children:t.slots.map(r=>i("button",{class:`chat-widget-slot-btn ${r.available?"":"unavailable"}`,disabled:!r.available,onClick:()=>r.available&&e({doctorId:n.id,date:t.date,time:r.time}),children:r.time},r.time))}):i("div",{class:"chat-widget-empty",children:"No slots available on this date."})]})},Ue=t=>{const e=new Date(t+"T00:00:00");return isNaN(e.getTime())?t:e.toLocaleDateString(void 0,{weekday:"short",day:"numeric",month:"short"})},Re=({reason:t,department:e,onBook:n})=>i("div",{class:"chat-widget chat-widget-booking",children:[i("div",{class:"chat-widget-booking-icon",children:i(y,{name:"calendar",size:28})}),i("div",{class:"chat-widget-booking-body",children:[i("div",{class:"chat-widget-booking-title",children:"Book an appointment"}),i("div",{class:"chat-widget-booking-reason",children:t}),e&&i("div",{class:"chat-widget-booking-dept",children:["Suggested: ",e]}),i("button",{class:"widget-btn",onClick:n,children:"Book now"})]})]}),te=ge(null),qe=({children:t})=>{const[e,n]=v({name:"",phone:""}),[o,l]=v(null),[r,s]=v(""),[p,u]=v(null),[c,d]=v([]),[a,g]=v(!1),[h,m]=v(""),[k,w]=v(null),x=ze(z=>{n(N=>({...N,...z}))},[]),_=Q(()=>{var N,D;const z=new Set;for(const b of c){const T=(D=(N=b.clinic)==null?void 0:N.clinicName)==null?void 0:D.trim();T&&z.add(T)}return Array.from(z).sort()},[c]);W(()=>{k||_.length===1&&w(_[0])},[_,k]),W(()=>{let z=!1;return g(!0),m(""),we().then(N=>{z||(d(N),g(!1))}).catch(()=>{z||(m("Failed to load doctors"),g(!1))}),()=>{z=!0}},[]);const I={visitor:e,updateVisitor:x,leadId:o,setLeadId:l,captchaToken:r,setCaptchaToken:s,bookingPrefill:p,setBookingPrefill:u,doctors:c,doctorsLoading:a,doctorsError:h,branches:_,selectedBranch:k,setSelectedBranch:w};return i(te.Provider,{value:I,children:t})},ht=()=>{const t=Se(te);if(!t)throw new Error("useWidgetStore must be used inside a WidgetStoreProvider");return t},Ke=["What departments do you have?","Show me cardiologists","Clinic timings","How do I book?"],Ve=t=>t.parts.filter(e=>e.type==="text").map(e=>e.text).join(""),R=(t,e,n)=>{t(o=>o.map(l=>l.id===e?n(l):l))},Ye=(t,e,n)=>{const o=t[t.length-1];return(o==null?void 0:o.type)==="text"?[...t.slice(0,-1),{type:"text",text:o.text+e,state:n}]:[...t,{type:"text",text:e,state:n}]},ut=(t,e,n,o)=>{const l=t.findIndex(r=>r.type==="tool"&&r.toolCallId===e);if(l>=0){const s={...t[l],...n};return[...t.slice(0,l),s,...t.slice(l+1)]}return[...t,{...o,...n}]},ee=()=>`m_${Date.now()}_${Math.random().toString(36).slice(2,8)}`,Je=({onRequestBooking:t})=>{const{visitor:e,updateVisitor:n,leadId:o,setLeadId:l,setBookingPrefill:r,selectedBranch:s,setSelectedBranch:p}=ht(),[u,c]=v([]),[d,a]=v(""),[g,h]=v(!1),[m,k]=v(!1),[w,x]=v(""),_=V(null);W(()=>{_.current&&(_.current.scrollTop=_.current.scrollHeight)},[u]);const I=async()=>{const b=e.name.trim(),T=e.phone.trim();if(!(!b||!T)){k(!0),x("");try{const{leadId:P}=await ye(b,T);l(P)}catch{x("Could not start chat. Please try again.")}finally{k(!1)}}},z=async(b,T)=>{if(!b.trim()||g||!o)return;const P={id:ee(),role:"user",parts:[{type:"text",text:b.trim(),state:"done"}]},B=ee(),gt={id:B,role:"assistant",parts:[]},ft=[...u,P].map(E=>({role:E.role,content:Ve(E)}));c(E=>[...E,P,gt]),a(""),h(!0);const Z=T!==void 0?T:s;try{const E=await ke(o,ft,Z);for await(const $ of Ne(E))switch($.type){case"text-delta":typeof $.delta=="string"&&R(c,B,S=>({...S,parts:Ye(S.parts,$.delta,"streaming")}));break;case"text-end":R(c,B,S=>({...S,parts:S.parts.map(Y=>Y.type==="text"?{...Y,state:"done"}:Y)}));break;case"tool-input-start":R(c,B,S=>({...S,parts:ut(S.parts,$.toolCallId,{state:"input-streaming",toolName:$.toolName},{type:"tool",toolCallId:$.toolCallId,toolName:$.toolName,state:"input-streaming"})}));break;case"tool-input-available":R(c,B,S=>({...S,parts:ut(S.parts,$.toolCallId,{state:"input-available",toolName:$.toolName,input:$.input},{type:"tool",toolCallId:$.toolCallId,toolName:$.toolName,state:"input-available",input:$.input})}));break;case"tool-output-available":R(c,B,S=>({...S,parts:ut(S.parts,$.toolCallId,{state:"output-available",output:$.output},{type:"tool",toolCallId:$.toolCallId,toolName:"unknown",state:"output-available",output:$.output})}));break;case"tool-output-error":R(c,B,S=>({...S,parts:ut(S.parts,$.toolCallId,{state:"output-error",errorText:$.errorText},{type:"tool",toolCallId:$.toolCallId,toolName:"unknown",state:"output-error",errorText:$.errorText})}));break;case"error":R(c,B,S=>({...S,parts:[...S.parts,{type:"text",text:"Sorry, I encountered an error. Please try again.",state:"done"}]}));break;default:break}}catch{R(c,B,E=>({...E,parts:[...E.parts,{type:"text",text:"Sorry, I encountered an error. Please try again.",state:"done"}]}))}finally{h(!1)}},N=b=>{r(b),t(b)},D=b=>{p(b),z(`I'm interested in the ${b} branch.`,b)};return o?i("div",{style:{display:"flex",flexDirection:"column",height:"100%"},children:[i("div",{class:"chat-messages",ref:_,children:[u.length===0&&i("div",{class:"chat-empty",children:[i("div",{class:"chat-empty-icon",children:i(y,{name:"hand-wave",size:40,color:"#f59e0b"})}),i("div",{class:"chat-empty-title",children:["Hi ",e.name.split(" ")[0]||"there",", how can we help?"]}),i("div",{class:"chat-empty-text",children:"Ask about doctors, clinics, packages, or book an appointment."}),i("div",{class:"quick-actions",children:Ke.map(b=>i("button",{class:"quick-action",onClick:()=>z(b),children:b},b))})]}),u.map(b=>i(Ge,{msg:b,onDepartmentClick:T=>z(`Show me doctors in ${T}`),onShowDoctorSlots:T=>z(`Show available appointments for ${T}`),onSuggestBooking:()=>t(),onPickSlot:N,onPickBranch:D},b.id))]}),i("div",{class:"chat-input-row",children:[i("input",{class:"widget-input chat-input",placeholder:"Type a message...",value:d,onInput:b=>a(b.target.value),onKeyDown:b=>b.key==="Enter"&&z(d),disabled:g}),i("button",{class:"chat-send",onClick:()=>z(d),disabled:g||!d.trim(),"aria-label":"Send message",children:i(y,{name:"paper-plane-top",size:16,color:"#fff"})})]})]}):i("div",{class:"chat-intro",children:[i("div",{class:"chat-empty-icon",children:i(y,{name:"hand-wave",size:40,color:"#f59e0b"})}),i("div",{class:"chat-empty-title",children:"Hi! How can we help?"}),i("div",{class:"chat-empty-text",children:"Share your name and phone so we can follow up if needed."}),w&&i("div",{class:"widget-error",children:w}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Full Name *"}),i("input",{class:"widget-input",placeholder:"Your name",value:e.name,onInput:b=>n({name:b.target.value})})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Phone Number *"}),i("input",{class:"widget-input",placeholder:"+91 9876543210",value:e.phone,onInput:b=>n({phone:b.target.value}),onKeyDown:b=>b.key==="Enter"&&I()})]}),i("button",{class:"widget-btn",onClick:I,disabled:!e.name.trim()||!e.phone.trim()||m,children:m?"Starting…":"Start Chat"})]})},Ge=({msg:t,onDepartmentClick:e,onShowDoctorSlots:n,onSuggestBooking:o,onPickSlot:l,onPickBranch:r})=>{const s=t.role==="assistant"&&t.parts.length===0,u=t.parts.some(c=>c.type==="tool")?t.parts.filter(c=>c.type==="tool"):t.parts;return i("div",{class:`chat-msg ${t.role}`,children:i("div",{class:"chat-msg-stack",children:[s&&i("div",{class:"chat-bubble",children:i(ie,{})}),u.map((c,d)=>c.type==="text"?i("div",{class:"chat-bubble",children:c.text||i(ie,{})},d):i(De,{part:c,onDepartmentClick:e,onShowDoctorSlots:n,onSuggestBooking:o,onPickSlot:l,onPickBranch:r},d))]})})},ie=()=>i("span",{class:"chat-typing-dots","aria-label":"Assistant is typing",children:[i("span",{}),i("span",{}),i("span",{})]}),Qe=()=>{const{visitor:t,updateVisitor:e,captchaToken:n,bookingPrefill:o,setBookingPrefill:l,doctors:r,doctorsLoading:s,doctorsError:p,branches:u,selectedBranch:c,setSelectedBranch:d}=ht(),a=u.length>1&&!c,[g,h]=v(a?"branch":"department"),[m,k]=v(""),[w,x]=v(null),[_,I]=v(""),[z,N]=v([]),[D,b]=v(""),[T,P]=v(""),[B,gt]=v(!1),[ft,Z]=v(""),[E,$]=v(""),S=Q(()=>{if(!c)return r;const f=c.toLowerCase();return r.filter(H=>{var mt;return String(((mt=H.clinic)==null?void 0:mt.clinicName)??"").toLowerCase().includes(f)})},[r,c]),Y=Q(()=>[...new Set(S.map(f=>f.department).filter(Boolean))],[S]),ei=m?S.filter(f=>f.department===m):[];W(()=>{p&&Z(p)},[p]),W(()=>{var H;if(!o||r.length===0)return;const f=r.find(mt=>mt.id===o.doctorId);f&&((H=f.clinic)!=null&&H.clinicName&&!c&&d(f.clinic.clinicName),k(f.department),x(f),I(o.date),b(o.time),h("details"),l(null))},[o,r]);const ii=f=>{x(f),I(new Date().toISOString().split("T")[0]),h("datetime")};W(()=>{w&&_&&be(w.id,_).then(N).catch(()=>{})},[w,_]);const ni=async()=>{if(!(!w||!D||!t.name.trim()||!t.phone.trim())){gt(!0),Z("");try{const f=`${_}T${D}:00`,H=await xe({departmentId:m,doctorId:w.id,scheduledAt:f,patientName:t.name.trim(),patientPhone:t.phone.trim(),chiefComplaint:T,captchaToken:n});$(H.reference),h("success")}catch{Z("Booking failed. Please try again.")}finally{gt(!1)}}},oe=a?["branch","department","doctor","datetime","details"]:["department","doctor","datetime","details"],re=oe.indexOf(g);return i("div",{children:[g!=="success"&&i("div",{class:"widget-steps",children:oe.map((f,H)=>i("div",{class:`widget-step ${Hi("button",{class:"widget-row-btn",onClick:()=>{d(f),h("department")},children:[i(y,{class:"widget-row-icon",name:"hospital",size:20}),i("span",{class:"widget-row-label",children:f}),i(y,{class:"widget-row-chevron",name:"arrow-right",size:14})]},f))]}),g==="department"&&i("div",{children:[i("div",{class:"widget-section-title",children:[c&&i(O,{children:[i(y,{class:"widget-row-icon",name:"hospital",size:16}),c," β€”Β "]}),"Select Department"]}),s&&Y.length===0&&i("div",{class:"widget-section-sub",children:"Loading…"}),Y.map(f=>i("button",{class:"widget-row-btn",onClick:()=>{k(f),h("doctor")},children:[i(y,{class:"widget-row-icon",name:X(f),size:20}),i("span",{class:"widget-row-label",children:f.replace(/_/g," ")}),i(y,{class:"widget-row-chevron",name:"arrow-right",size:14})]},f)),u.length>1&&i("button",{class:"widget-btn widget-btn-secondary widget-btn-with-icon",style:{marginTop:"8px"},onClick:()=>h("branch"),children:[i(y,{name:"arrow-left",size:14}),"Change branch"]})]}),g==="doctor"&&i("div",{children:[i("div",{class:"widget-section-title",children:[i(y,{class:"widget-row-icon",name:X(m),size:16}),m.replace(/_/g," ")]}),ei.map(f=>{var H;return i("button",{class:"widget-row-btn widget-row-btn-stack",onClick:()=>ii(f),children:[i("div",{class:"widget-row-main",children:[i("div",{class:"widget-row-label",children:f.name}),i("div",{class:"widget-row-sub",children:[f.visitingHours??""," ",(H=f.clinic)!=null&&H.clinicName?`β€’ ${f.clinic.clinicName}`:""]})]}),i(y,{class:"widget-row-chevron",name:"arrow-right",size:14})]},f.id)}),i("button",{class:"widget-btn widget-btn-secondary widget-btn-with-icon",style:{marginTop:"8px"},onClick:()=>h("department"),children:[i(y,{name:"arrow-left",size:14}),"Back"]})]}),g==="datetime"&&i("div",{children:[i("div",{class:"widget-section-title",children:[w==null?void 0:w.name," β€” Pick Date & Time"]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Date"}),i("input",{class:"widget-input",type:"date",value:_,min:new Date().toISOString().split("T")[0],onInput:f=>{I(f.target.value),b("")}})]}),z.length>0&&i("div",{children:[i("label",{class:"widget-label",children:"Available Slots"}),i("div",{class:"widget-slots",children:z.map(f=>i("button",{class:`widget-slot ${f.time===D?"selected":""} ${f.available?"":"unavailable"}`,onClick:()=>f.available&&b(f.time),disabled:!f.available,children:f.time},f.time))})]}),i("div",{class:"widget-btn-row",children:[i("button",{class:"widget-btn widget-btn-secondary widget-btn-with-icon",onClick:()=>h("doctor"),children:[i(y,{name:"arrow-left",size:14}),"Back"]}),i("button",{class:"widget-btn widget-btn-with-icon",disabled:!D,onClick:()=>h("details"),children:["Next",i(y,{name:"arrow-right",size:14,color:"#fff"})]})]})]}),g==="details"&&i("div",{children:[i("div",{class:"widget-section-title",children:"Your Details"}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Full Name *"}),i("input",{class:"widget-input",placeholder:"Your name",value:t.name,onInput:f=>e({name:f.target.value})})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Phone Number *"}),i("input",{class:"widget-input",placeholder:"+91 9876543210",value:t.phone,onInput:f=>e({phone:f.target.value})})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Chief Complaint"}),i("textarea",{class:"widget-input widget-textarea",placeholder:"Describe your concern...",value:T,onInput:f=>P(f.target.value)})]}),i("div",{class:"widget-btn-row",children:[i("button",{class:"widget-btn widget-btn-secondary widget-btn-with-icon",onClick:()=>h("datetime"),children:[i(y,{name:"arrow-left",size:14}),"Back"]}),i("button",{class:"widget-btn",disabled:!t.name.trim()||!t.phone.trim()||B,onClick:ni,children:B?"Booking...":"Book Appointment"})]})]}),g==="success"&&i("div",{class:"widget-success",children:[i("div",{class:"widget-success-icon",children:i(y,{name:"circle-check",size:56,color:"#059669"})}),i("div",{class:"widget-success-title",children:"Appointment Booked!"}),i("div",{class:"widget-success-text",children:["Reference: ",i("strong",{children:E}),i("br",{}),w==null?void 0:w.name," β€’ ",_," at ",D,i("br",{}),i("br",{}),"We'll send a confirmation SMS to your phone."]})]})]})},Xe=()=>{const{visitor:t,updateVisitor:e,captchaToken:n}=ht(),[o,l]=v(""),[r,s]=v(""),[p,u]=v(!1),[c,d]=v(!1),[a,g]=v(""),h=async()=>{if(!(!t.name.trim()||!t.phone.trim())){u(!0),g("");try{await ve({name:t.name.trim(),phone:t.phone.trim(),interest:o.trim()||void 0,message:r.trim()||void 0,captchaToken:n}),d(!0)}catch{g("Submission failed. Please try again.")}finally{u(!1)}}};return c?i("div",{class:"widget-success",children:[i("div",{class:"widget-success-icon",children:i(y,{name:"hands-praying",size:56,color:"#059669"})}),i("div",{class:"widget-success-title",children:"Thank you!"}),i("div",{class:"widget-success-text",children:["An agent will call you shortly on ",t.phone,".",i("br",{}),"We typically respond within 30 minutes during business hours."]})]}):i("div",{children:[i("div",{class:"widget-section-title",children:"Get in touch"}),i("div",{class:"widget-section-sub",children:"Leave your details and we'll call you back."}),a&&i("div",{class:"widget-error",children:a}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Full Name *"}),i("input",{class:"widget-input",placeholder:"Your name",value:t.name,onInput:m=>e({name:m.target.value})})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Phone Number *"}),i("input",{class:"widget-input",placeholder:"+91 9876543210",value:t.phone,onInput:m=>e({phone:m.target.value})})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Interested In"}),i("select",{class:"widget-select",value:o,onChange:m=>l(m.target.value),children:[i("option",{value:"",children:"Select (optional)"}),i("option",{value:"Consultation",children:"General Consultation"}),i("option",{value:"Health Checkup",children:"Health Checkup"}),i("option",{value:"Surgery",children:"Surgery"}),i("option",{value:"Second Opinion",children:"Second Opinion"}),i("option",{value:"Other",children:"Other"})]})]}),i("div",{class:"widget-field",children:[i("label",{class:"widget-label",children:"Message"}),i("textarea",{class:"widget-input widget-textarea",placeholder:"How can we help? (optional)",value:r,onInput:m=>s(m.target.value)})]}),i("button",{class:"widget-btn",disabled:!t.name.trim()||!t.phone.trim()||p,onClick:h,children:p?"Sending...":"Send Message"})]})},Ze=({config:t,shadow:e})=>(W(()=>{const n=document.createElement("style");return n.textContent=Be(t),e.appendChild(n),()=>{e.removeChild(n)}},[t,e]),i(qe,{children:i(ti,{config:t,shadow:e})})),ti=({config:t,shadow:e})=>{const[n,o]=v(!1),[l,r]=v("chat"),[s,p]=v(!1),{captchaToken:u,setCaptchaToken:c,selectedBranch:d}=ht();W(()=>{if(!s)return;const h=e.host,m=h.getAttribute("style");h.style.cssText="position:fixed;inset:0;z-index:999999;";const k=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{m!==null?h.setAttribute("style",m):h.removeAttribute("style"),document.body.style.overflow=k}},[s,e]),W(()=>{if(!s)return;const h=m=>{m.key==="Escape"&&p(!1)};return window.addEventListener("keydown",h),()=>window.removeEventListener("keydown",h)},[s]);const a=!!t.captchaSiteKey&&!u,g=()=>{p(!1),o(!1)};return i("div",{children:[!n&&i("button",{class:"widget-bubble",onClick:()=>o(!0),"aria-label":"Open chat",children:t.brand.logo?i("img",{src:t.brand.logo,alt:t.brand.name}):i(y,{name:"message-dots",size:26,color:t.colors.primary})}),n&&i(O,{children:[s&&i("div",{class:"widget-backdrop",onClick:()=>p(!1)}),i("div",{class:`widget-panel ${s?"widget-panel-maximized":""}`,children:[i("div",{class:"widget-header",children:[t.brand.logo&&i("img",{src:t.brand.logo,alt:""}),i("div",{class:"widget-header-text",children:[i("div",{class:"widget-header-name",children:t.brand.name}),i("div",{class:"widget-header-sub",children:["We're here to help",d&&i(O,{children:[" β€’ ",i("span",{class:"widget-header-branch",children:[i(y,{name:"location-dot",size:10,color:"#fff"}),d]})]})]})]}),i("button",{class:"widget-header-btn",onClick:()=>p(h=>!h),"aria-label":s?"Restore":"Maximize",title:s?"Restore":"Maximize",children:i(y,{name:s?"down-left-and-up-right-to-center":"up-right-and-down-left-from-center",size:14,color:"#fff"})}),i("button",{class:"widget-header-btn",onClick:g,"aria-label":"Close",title:"Close",children:i(y,{name:"xmark",size:16,color:"#fff"})})]}),a?i("div",{class:"widget-captcha-gate",children:[i("div",{class:"widget-captcha-gate-icon",children:i(y,{name:"shield-check",size:56,color:t.colors.primary})}),i("div",{class:"widget-captcha-gate-title",children:"Quick security check"}),i("div",{class:"widget-captcha-gate-text",children:"Please verify you're not a bot to continue."}),i(Te,{siteKey:t.captchaSiteKey,onToken:c})]}):i(O,{children:[i("div",{class:"widget-tabs",children:[i("button",{class:`widget-tab ${l==="chat"?"active":""}`,onClick:()=>r("chat"),children:[i(y,{name:"message-dots",size:14})," Chat"]}),i("button",{class:`widget-tab ${l==="book"?"active":""}`,onClick:()=>r("book"),children:[i(y,{name:"calendar",size:14})," Book"]}),i("button",{class:`widget-tab ${l==="contact"?"active":""}`,onClick:()=>r("contact"),children:[i(y,{name:"phone",size:14})," Contact"]})]}),i("div",{class:"widget-body",children:[l==="chat"&&i(Je,{onRequestBooking:()=>r("book")}),l==="book"&&i(Qe,{}),l==="contact"&&i(Xe,{})]})]})]})]})]})},ne=async()=>{const t=document.querySelector("script[data-key]");if(!t){console.error("[HelixWidget] Missing data-key attribute");return}const e=t.getAttribute("data-key")??"",n=t.src.replace(/\/widget\.js.*$/,"");me(n,e);let o;try{o=await _e()}catch(p){console.error("[HelixWidget] Init failed:",p);return}o.captchaSiteKey&&Zt().catch(()=>{console.warn("[HelixWidget] Turnstile preload failed β€” gate will retry on open")});const l=document.createElement("div");l.id="helix-widget-host",l.style.cssText="position:fixed;bottom:20px;right:20px;z-index:999999;",document.body.appendChild(l);const r=l.attachShadow({mode:"open"}),s=document.createElement("div");r.appendChild(s),ue(i(Ze,{config:o,shadow:r}),s)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",ne):ne()})(); diff --git a/src/main.ts b/src/main.ts index 8fa4970..1f8e6cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,7 +14,8 @@ async function bootstrap() { }); // Serve widget.js and other static files from /public - app.useStaticAssets(join(__dirname, '..', 'public'), { + // In dev mode __dirname = src/, in prod __dirname = dist/ β€” resolve from process.cwd() + app.useStaticAssets(join(process.cwd(), 'public'), { setHeaders: (res, path) => { if (path.endsWith('.js')) { res.setHeader('Cache-Control', 'public, max-age=3600'); diff --git a/src/widget/captcha.guard.ts b/src/widget/captcha.guard.ts index fcafc4f..9981882 100644 --- a/src/widget/captcha.guard.ts +++ b/src/widget/captcha.guard.ts @@ -1,6 +1,7 @@ import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common'; -const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; +// Cloudflare Turnstile verification endpoint +const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; @Injectable() export class CaptchaGuard implements CanActivate { @@ -23,15 +24,15 @@ export class CaptchaGuard implements CanActivate { if (!token) throw new HttpException('Captcha token required', 400); try { - const res = await fetch(RECAPTCHA_VERIFY_URL, { + const res = await fetch(TURNSTILE_VERIFY_URL, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `secret=${this.secretKey}&response=${token}`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ secret: this.secretKey, response: token }), }); const data = await res.json(); - if (!data.success || (data.score != null && data.score < 0.3)) { - this.logger.warn(`Captcha failed: score=${data.score} success=${data.success}`); + if (!data.success) { + this.logger.warn(`Captcha failed: success=${data.success} errors=${JSON.stringify(data['error-codes'] ?? [])}`); throw new HttpException('Captcha verification failed', 403); } diff --git a/src/widget/widget-chat.service.ts b/src/widget/widget-chat.service.ts new file mode 100644 index 0000000..cf62ccb --- /dev/null +++ b/src/widget/widget-chat.service.ts @@ -0,0 +1,407 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { streamText, tool, stepCountIs } from 'ai'; +import { z } from 'zod'; +import type { LanguageModel, ModelMessage } from 'ai'; +import { createAiModel } from '../ai/ai-provider'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { WidgetService } from './widget.service'; + +@Injectable() +export class WidgetChatService { + private readonly logger = new Logger(WidgetChatService.name); + private readonly aiModel: LanguageModel | null; + private readonly apiKey: string; + private knowledgeBase: string | null = null; + private kbLoadedAt = 0; + private readonly kbTtlMs = 5 * 60 * 1000; + + constructor( + private config: ConfigService, + private platform: PlatformGraphqlService, + private widget: WidgetService, + ) { + this.aiModel = createAiModel(config); + this.apiKey = config.get('platform.apiKey') ?? ''; + if (!this.aiModel) { + this.logger.warn('AI not configured β€” widget chat will return fallback replies'); + } + } + + private get auth() { + return `Bearer ${this.apiKey}`; + } + + hasAiModel(): boolean { + return this.aiModel !== null; + } + + // Find-or-create a lead by phone. Delegates to WidgetService so there's + // a single source of truth for the dedup window + lead shape across + // chat + book + contact. + async findOrCreateLead(name: string, phone: string): Promise { + return this.widget.findOrCreateLeadByPhone(name, phone, { + source: 'WEBSITE', + status: 'NEW', + interestedService: 'Website Chat', + }); + } + + // Fetch the first name of the lead's primary contact so we can greet the + // visitor by name in the system prompt. Returns 'there' on any failure. + async getLeadFirstName(leadId: string): Promise { + try { + const data = await this.platform.queryWithAuth( + `query($id: UUID!) { + leads(filter: { id: { eq: $id } }, first: 1) { + edges { node { id contactName { firstName } } } + } + }`, + { id: leadId }, + this.auth, + ); + const firstName = data?.leads?.edges?.[0]?.node?.contactName?.firstName; + return (typeof firstName === 'string' && firstName.trim()) || 'there'; + } catch (err) { + this.logger.warn(`Failed to fetch lead name for ${leadId}: ${err}`); + return 'there'; + } + } + + // Append an exchange to the lead's activity log. One activity record per + // user/assistant turn. Safe to call in the background (we don't block the + // stream on this). + async logExchange(leadId: string, userText: string, aiText: string): Promise { + const summary = `User: ${userText}\n\nAI: ${aiText}`.slice(0, 4000); + try { + await this.platform.queryWithAuth( + `mutation($data: LeadActivityCreateInput!) { + createLeadActivity(data: $data) { id } + }`, + { + data: { + leadId, + activityType: 'NOTE_ADDED', + channel: 'SYSTEM', + summary, + occurredAt: new Date().toISOString(), + performedBy: 'Website Chat', + outcome: 'SUCCESSFUL', + }, + }, + this.auth, + ); + } catch (err) { + this.logger.warn(`Failed to log chat activity for lead ${leadId}: ${err}`); + } + } + + // Build a compact knowledge base of doctor/department info for the system + // prompt. Cached for 5 min to avoid a doctors query on every chat. + private async getKnowledgeBase(): Promise { + const now = Date.now(); + if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) { + return this.knowledgeBase; + } + + try { + const doctors = await this.widget.getDoctors(); + const byDept = new Map(); + for (const d of doctors) { + const dept = (d.department ?? 'Other').replace(/_/g, ' '); + if (!byDept.has(dept)) byDept.set(dept, []); + byDept.get(dept)!.push({ + name: d.name, + visitingHours: d.visitingHours, + clinic: d.clinic?.clinicName, + }); + } + + const lines: string[] = ['DEPARTMENTS AND DOCTORS:']; + for (const [dept, docs] of byDept) { + lines.push(`\n${dept}:`); + for (const doc of docs) { + const extras: string[] = []; + if (doc.visitingHours) extras.push(doc.visitingHours); + if (doc.clinic) extras.push(doc.clinic); + lines.push(` - ${doc.name}${extras.length ? ` (${extras.join(' β€’ ')})` : ''}`); + } + } + this.knowledgeBase = lines.join('\n'); + this.kbLoadedAt = now; + } catch (err) { + this.logger.warn(`Failed to build widget KB: ${err}`); + this.knowledgeBase = 'DEPARTMENTS AND DOCTORS: (unavailable)'; + this.kbLoadedAt = now; + } + return this.knowledgeBase; + } + + async buildSystemPrompt(userName: string, selectedBranch: string | null): Promise { + const init = this.widget.getInitData(); + const kb = await this.getKnowledgeBase(); + + // Branch context flips the tool-usage rules: no branch = must call + // pick_branch first; branch set = always pass it to branch-aware tools. + const branchContext = selectedBranch + ? [ + `CURRENT BRANCH: ${selectedBranch}`, + `The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`, + 'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.', + ] + : [ + 'BRANCH STATUS: NOT SET', + 'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,', + 'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.', + 'Only skip this if the user asks a pure general question that does not need branch-specific data.', + ]; + + return [ + `You are a helpful, concise assistant for ${init.brand.name}.`, + `You are chatting with a website visitor named ${userName}.`, + '', + ...branchContext, + '', + 'TOOL USAGE RULES (STRICT):', + '- When the user asks about departments, call list_departments and DO NOT also list departments in prose.', + '- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.', + '- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.', + '- When they ask about a specific doctor\'s availability or want to book with them, call show_doctor_slots.', + '- When the conversation is trending toward booking, call suggest_booking.', + '- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence', + ' (under 15 words) framing the widget, or no text at all. The widget already shows the data.', + '- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,', + ' STOP and call the appropriate tool instead.', + '- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in', + ' non-tool replies.', + '- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".', + ' If the visitor asks about a future date, tell them to use the Book tab\'s date picker.', + '', + 'OTHER RULES:', + '- Answer other questions (directions, general info) concisely in prose.', + '- If you do not know something, say so and suggest they call the hospital.', + '- Never quote prices. No medical advice. For clinical questions, defer to a doctor.', + '', + kb, + ].join('\n'); + } + + // Streams the assistant reply as an async iterable of UIMessageChunk-shaped + // objects. The controller writes these as SSE `data: ${json}\n\n` lines + // over the HTTP response. Tools return structured payloads the widget + // frontend renders as generative-UI cards. + async *streamReply(systemPrompt: string, messages: ModelMessage[]): AsyncGenerator { + if (!this.aiModel) throw new Error('AI not configured'); + + const platform = this.platform; + const widgetSvc = this.widget; + + // Small helper: does a doctor's clinic match the branch filter? + // Case-insensitive substring match so "Indiranagar" matches + // "Indiranagar Clinic" etc. + const matchesBranch = (d: any, branch: string | undefined): boolean => { + if (!branch) return true; + const clinicName = String(d.clinic?.clinicName ?? '').toLowerCase(); + return clinicName.includes(branch.toLowerCase()); + }; + + const tools = { + pick_branch: tool({ + description: + 'Show the list of hospital branches so the visitor can pick which one they are interested in. Call this BEFORE any branch-sensitive tool (list_departments, show_clinic_timings, show_doctors, show_doctor_slots) when CURRENT BRANCH is NOT SET.', + inputSchema: z.object({}), + execute: async () => { + const doctors = await widgetSvc.getDoctors(); + const byBranch = new Map }>(); + for (const d of doctors) { + const name = d.clinic?.clinicName?.trim(); + if (!name) continue; + if (!byBranch.has(name)) { + byBranch.set(name, { doctorCount: 0, departments: new Set() }); + } + const entry = byBranch.get(name)!; + entry.doctorCount += 1; + if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' ')); + } + return { + branches: Array.from(byBranch.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, { doctorCount, departments }]) => ({ + name, + doctorCount, + departmentCount: departments.size, + })), + }; + }, + }), + list_departments: tool({ + description: + 'List the departments the hospital has. Use when the visitor asks what departments or specialities are available. Pass branch if CURRENT BRANCH is set.', + inputSchema: z.object({ + branch: z + .string() + .optional() + .describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'), + }), + execute: async ({ branch }) => { + const doctors = await widgetSvc.getDoctors(); + const filtered = doctors.filter((d: any) => matchesBranch(d, branch)); + const deps = Array.from( + new Set(filtered.map((d: any) => d.department).filter(Boolean)), + ) as string[]; + return { + branch: branch ?? null, + departments: deps.map(d => d.replace(/_/g, ' ')), + }; + }, + }), + show_clinic_timings: tool({ + description: + 'Show the clinic hours / visiting times for all departments with the doctors who visit during those hours. Use when the visitor asks about clinic timings, visiting hours, when the clinic is open, or what time a department is available. Pass branch if CURRENT BRANCH is set.', + inputSchema: z.object({ + branch: z + .string() + .optional() + .describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'), + }), + execute: async ({ branch }) => { + const doctors = await widgetSvc.getDoctors(); + const filtered = doctors.filter((d: any) => matchesBranch(d, branch)); + const byDept = new Map< + string, + Array<{ name: string; hours: string; clinic: string | null }> + >(); + for (const d of filtered) { + const dept = (d.department ?? 'Other').replace(/_/g, ' '); + if (!byDept.has(dept)) byDept.set(dept, []); + if (d.visitingHours) { + byDept.get(dept)!.push({ + name: d.name, + hours: d.visitingHours, + clinic: d.clinic?.clinicName ?? null, + }); + } + } + return { + branch: branch ?? null, + departments: Array.from(byDept.entries()) + .filter(([, entries]) => entries.length > 0) + .map(([name, entries]) => ({ name, entries })), + }; + }, + }), + show_doctors: tool({ + description: + 'Show the list of doctors in a specific department with their visiting hours and clinic. Use when the visitor asks about doctors in a department. Pass branch if CURRENT BRANCH is set.', + inputSchema: z.object({ + department: z + .string() + .describe('Department name, e.g., "Cardiology", "ENT", "General Medicine".'), + branch: z + .string() + .optional() + .describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'), + }), + execute: async ({ department, branch }) => { + const doctors = await widgetSvc.getDoctors(); + const deptKey = department.toLowerCase().replace(/\s+/g, '').replace(/_/g, ''); + const matches = doctors + .filter((d: any) => { + const key = String(d.department ?? '') + .toLowerCase() + .replace(/\s+/g, '') + .replace(/_/g, ''); + return key.includes(deptKey) && matchesBranch(d, branch); + }) + .map((d: any) => ({ + id: d.id, + name: d.name, + specialty: d.specialty ?? null, + visitingHours: d.visitingHours ?? null, + clinic: d.clinic?.clinicName ?? null, + })); + return { department, branch: branch ?? null, doctors: matches }; + }, + }), + show_doctor_slots: tool({ + description: + "Show today's available appointment slots for a specific doctor. Use when the visitor wants to see when a doctor is free or wants to book with a specific doctor. The date is always today β€” do NOT try to specify a date. Pass branch if CURRENT BRANCH is set to disambiguate doctors with the same name across branches.", + inputSchema: z.object({ + doctorName: z + .string() + .describe('Full name of the doctor, e.g., "Dr. Lakshmi Reddy".'), + branch: z + .string() + .optional() + .describe('Branch name to disambiguate. Pass the CURRENT BRANCH when set.'), + }), + execute: async ({ doctorName, branch }) => { + // Always use the server's current date. Never trust anything from + // the model here β€” older LLMs hallucinate their training-data + // "today" and return slots for the wrong day. + const targetDate = new Date().toISOString().slice(0, 10); + const doctors = await widgetSvc.getDoctors(); + const scoped = doctors.filter((d: any) => matchesBranch(d, branch)); + // Fuzzy match: lowercase + strip "Dr." prefix + collapse spaces. + const norm = (s: string) => + s.toLowerCase().replace(/^dr\.?\s*/i, '').replace(/\s+/g, ' ').trim(); + const target = norm(doctorName); + const doc = + scoped.find((d: any) => norm(d.name) === target) ?? + scoped.find((d: any) => norm(d.name).includes(target)) ?? + scoped.find((d: any) => target.includes(norm(d.name))); + if (!doc) { + return { + doctor: null, + date: targetDate, + slots: [], + error: `No doctor matching "${doctorName}"${branch ? ` at ${branch}` : ''} was found.`, + }; + } + const slots = await widgetSvc.getSlots(doc.id, targetDate); + return { + doctor: { + id: doc.id, + name: doc.name, + department: doc.department ?? null, + clinic: doc.clinic?.clinicName ?? null, + }, + date: targetDate, + slots, + }; + }, + }), + suggest_booking: tool({ + description: + 'Suggest that the visitor book an appointment. Use when the conversation is trending toward booking, the user has identified a concern, or asks "how do I book".', + inputSchema: z.object({ + reason: z.string().describe('Short reason why booking is a good next step.'), + department: z + .string() + .optional() + .describe('Suggested department, if known.'), + }), + execute: async ({ reason, department }) => { + return { reason, department: department ?? null }; + }, + }), + }; + + // Bookings / leads are not in scope for the AI β€” we only wire read/ + // suggest tools here. The CC agent/AP engineering team can book. + void platform; + + const result = streamText({ + model: this.aiModel, + system: systemPrompt, + messages, + tools, + stopWhen: stepCountIs(4), + }); + + const uiStream = result.toUIMessageStream(); + for await (const chunk of uiStream) { + yield chunk; + } + } +} diff --git a/src/widget/widget.controller.ts b/src/widget/widget.controller.ts index 145702e..0d3b971 100644 --- a/src/widget/widget.controller.ts +++ b/src/widget/widget.controller.ts @@ -1,16 +1,23 @@ -import { Controller, Get, Post, Delete, Body, Query, Param, UseGuards, Logger, HttpException } from '@nestjs/common'; +import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { ModelMessage } from 'ai'; import { WidgetService } from './widget.service'; +import { WidgetChatService } from './widget-chat.service'; import { WidgetKeysService } from './widget-keys.service'; import { WidgetKeyGuard } from './widget-key.guard'; import { CaptchaGuard } from './captcha.guard'; import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types'; +type ChatStartBody = { name?: string; phone?: string }; +type ChatStreamBody = { leadId?: string; messages?: ModelMessage[]; branch?: string | null }; + @Controller('api/widget') export class WidgetController { private readonly logger = new Logger(WidgetController.name); constructor( private readonly widget: WidgetService, + private readonly chat: WidgetChatService, private readonly keys: WidgetKeysService, ) {} @@ -51,6 +58,97 @@ export class WidgetController { return this.widget.createLead(body); } + // Start (or resume) a chat session. Dedups by phone in the last 24h so a + // single visitor who books + contacts + chats doesn't create three leads. + // No CaptchaGuard: the window-level gate already verified humanity, and + // Turnstile tokens are single-use so reusing them on every endpoint breaks + // the multi-action flow. + @Post('chat-start') + @UseGuards(WidgetKeyGuard) + async chatStart(@Body() body: ChatStartBody) { + if (!body.name?.trim() || !body.phone?.trim()) { + throw new HttpException('name and phone required', 400); + } + try { + const leadId = await this.chat.findOrCreateLead(body.name.trim(), body.phone.trim()); + return { leadId }; + } catch (err: any) { + this.logger.error(`chatStart failed: ${err?.message ?? err}`); + throw new HttpException('Failed to start chat session', 500); + } + } + + // Stream the AI reply. Requires an active leadId from chat-start. The + // conversation is logged to leadActivity after the stream completes so the + // CC agent can review the transcript when they call the visitor back. + @Post('chat') + @UseGuards(WidgetKeyGuard) + async chat_(@Req() req: Request, @Res() res: Response) { + const body = req.body as ChatStreamBody; + const leadId = body?.leadId?.trim(); + const messages = body?.messages ?? []; + const selectedBranch = body?.branch?.trim() || null; + if (!leadId) { + res.status(400).json({ error: 'leadId required' }); + return; + } + if (!messages.length) { + res.status(400).json({ error: 'messages required' }); + return; + } + if (!this.chat.hasAiModel()) { + res.status(503).json({ error: 'AI not configured' }); + return; + } + + // Find the last user message up-front so we can log it after the + // stream finishes (reverse-walking messages is cheap). + const lastUser = [...messages].reverse().find(m => m.role === 'user'); + const userText = typeof lastUser?.content === 'string' + ? lastUser.content + : ''; + + // Fetch the visitor's first name from the lead so the AI can personalize. + const userName = await this.chat.getLeadFirstName(leadId); + + // SSE framing β€” each UIMessageChunk is serialized as a `data:` event. + // See AI SDK v6 UI_MESSAGE_STREAM_HEADERS for the canonical values. + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.setHeader('X-Vercel-Ai-Ui-Message-Stream', 'v1'); + + let aiText = ''; + try { + const systemPrompt = await this.chat.buildSystemPrompt(userName, selectedBranch); + for await (const chunk of this.chat.streamReply(systemPrompt, messages)) { + // Track accumulated text for transcript logging. + if (chunk?.type === 'text-delta' && typeof chunk.delta === 'string') { + aiText += chunk.delta; + } + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.write('data: [DONE]\n\n'); + res.end(); + } catch (err: any) { + this.logger.error(`Chat stream failed for lead ${leadId}: ${err?.message ?? err}`); + if (!res.headersSent) { + res.status(500).json({ error: 'Chat failed' }); + } else { + res.write(`data: ${JSON.stringify({ type: 'error', errorText: 'Chat failed' })}\n\n`); + res.end(); + } + return; + } + + // Fire-and-forget transcript logging. We intentionally do not await + // this so the stream response is not delayed. + if (userText && aiText) { + void this.chat.logExchange(leadId, userText, aiText); + } + } + // Key management (admin endpoints) @Post('keys/generate') async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) { diff --git a/src/widget/widget.module.ts b/src/widget/widget.module.ts index 0b22d62..59e8c3b 100644 --- a/src/widget/widget.module.ts +++ b/src/widget/widget.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { WidgetController } from './widget.controller'; import { WebhooksController } from './webhooks.controller'; import { WidgetService } from './widget.service'; +import { WidgetChatService } from './widget-chat.service'; import { WidgetKeysService } from './widget-keys.service'; import { PlatformModule } from '../platform/platform.module'; import { AuthModule } from '../auth/auth.module'; @@ -10,7 +11,7 @@ import { ConfigThemeModule } from '../config/config-theme.module'; @Module({ imports: [PlatformModule, AuthModule, ConfigThemeModule], controllers: [WidgetController, WebhooksController], - providers: [WidgetService, WidgetKeysService], + providers: [WidgetService, WidgetChatService, WidgetKeysService], exports: [WidgetKeysService], }) export class WidgetModule {} diff --git a/src/widget/widget.service.ts b/src/widget/widget.service.ts index da13456..c27bfb1 100644 --- a/src/widget/widget.service.ts +++ b/src/widget/widget.service.ts @@ -4,6 +4,17 @@ import { ConfigService } from '@nestjs/config'; import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types'; import { ThemeService } from '../config/theme.service'; +// Dedup window: any lead created for this phone within the last 24h is +// considered the same visitor's lead β€” chat + book + contact by the same +// phone all roll into one record in the CRM. +const LEAD_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000; + +export type FindOrCreateLeadOpts = { + source?: string; + status?: string; + interestedService?: string; +}; + @Injectable() export class WidgetService { private readonly logger = new Logger(WidgetService.name); @@ -21,6 +32,91 @@ export class WidgetService { return `Bearer ${this.apiKey}`; } + private normalizePhone(raw: string): string { + return raw.replace(/[^0-9]/g, '').slice(-10); + } + + // Shared lead dedup: finds a lead created in the last 24h for the same + // phone, or creates a new one. Public so WidgetChatService can reuse it. + async findOrCreateLeadByPhone( + name: string, + rawPhone: string, + opts: FindOrCreateLeadOpts = {}, + ): Promise { + const phone = this.normalizePhone(rawPhone); + if (!phone) throw new Error('Invalid phone number'); + + const since = new Date(Date.now() - LEAD_DEDUP_WINDOW_MS).toISOString(); + + try { + const existing = await this.platform.queryWithAuth( + `query($phone: String!, $since: DateTime!) { + leads( + first: 1, + filter: { + contactPhone: { primaryPhoneNumber: { like: $phone } }, + createdAt: { gte: $since } + }, + orderBy: [{ createdAt: DescNullsLast }] + ) { edges { node { id createdAt } } } + }`, + { phone: `%${phone}`, since }, + this.auth, + ); + const match = existing?.leads?.edges?.[0]?.node; + if (match?.id) { + this.logger.log(`Lead dedup: reusing ${match.id} for phone ${phone}`); + return match.id as string; + } + } catch (err) { + this.logger.warn(`Lead dedup lookup failed, falling through to create: ${err}`); + } + + const firstName = name.split(' ')[0] || name; + const lastName = name.split(' ').slice(1).join(' ') || ''; + + const created = await this.platform.queryWithAuth( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { + data: { + name, + contactName: { firstName, lastName }, + contactPhone: { primaryPhoneNumber: `+91${phone}` }, + source: opts.source ?? 'WEBSITE', + status: opts.status ?? 'NEW', + interestedService: opts.interestedService ?? 'Website Enquiry', + }, + }, + this.auth, + ); + const id = created?.createLead?.id; + if (!id) throw new Error('Lead creation returned no id'); + this.logger.log(`Lead dedup: created ${id} for ${name} (${phone})`); + return id as string; + } + + // Upgrade a lead's status β€” used when an existing lead is promoted from + // NEW/chat to APPOINTMENT_SET after the visitor books. Non-fatal on failure. + async updateLeadStatus(leadId: string, status: string, interestedService?: string): Promise { + try { + await this.platform.queryWithAuth( + `mutation($id: UUID!, $data: LeadUpdateInput!) { + updateLead(id: $id, data: $data) { id } + }`, + { + id: leadId, + data: { + status, + ...(interestedService ? { interestedService } : {}), + }, + }, + this.auth, + ); + } catch (err) { + this.logger.warn(`Failed to update lead ${leadId} status β†’ ${status}: ${err}`); + } + } + getInitData(): WidgetInitResponse { const t = this.theme.getTheme(); return { @@ -62,7 +158,7 @@ export class WidgetService { } async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> { - const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10); + const phone = this.normalizePhone(req.patientPhone); // Find or create patient let patientId: string | null = null; @@ -105,22 +201,25 @@ export class WidgetService { this.auth, ); - // Create lead - const firstName = req.patientName.split(' ')[0]; - const lastName = req.patientName.split(' ').slice(1).join(' ') || ''; - await this.platform.queryWithAuth( - `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, - { data: { - name: req.patientName, - contactName: { firstName, lastName }, - contactPhone: { primaryPhoneNumber: `+91${phone}` }, + // Find-or-create lead (dedups within 24h across chat + contact + book) + // and upgrade its status to APPOINTMENT_SET. Non-fatal on failure β€” + // we don't want to fail the booking if lead bookkeeping hiccups. + try { + const leadId = await this.findOrCreateLeadByPhone(req.patientName, phone, { source: 'WEBSITE', status: 'APPOINTMENT_SET', interestedService: req.chiefComplaint ?? 'Appointment Booking', - patientId, - } }, - this.auth, - ).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`)); + }); + // Idempotent upgrade: if the lead was reused from an earlier chat/ + // contact, promote its status and reflect the new interest. + await this.updateLeadStatus( + leadId, + 'APPOINTMENT_SET', + req.chiefComplaint ?? 'Appointment Booking', + ); + } catch (err) { + this.logger.warn(`Widget lead upsert failed during booking: ${err}`); + } const reference = appt.createAppointment.id.substring(0, 8).toUpperCase(); this.logger.log(`Widget booking: ${req.patientName} β†’ ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`); @@ -129,24 +228,12 @@ export class WidgetService { } async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> { - const phone = req.phone.replace(/[^0-9]/g, '').slice(-10); - const firstName = req.name.split(' ')[0]; - const lastName = req.name.split(' ').slice(1).join(' ') || ''; - - const data = await this.platform.queryWithAuth( - `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, - { data: { - name: req.name, - contactName: { firstName, lastName }, - contactPhone: { primaryPhoneNumber: `+91${phone}` }, - source: 'WEBSITE', - status: 'NEW', - interestedService: req.interest ?? 'Website Enquiry', - } }, - this.auth, - ); - - this.logger.log(`Widget lead: ${req.name} (${phone}) β€” ${req.interest ?? 'general'}`); - return { leadId: data.createLead.id }; + const leadId = await this.findOrCreateLeadByPhone(req.name, req.phone, { + source: 'WEBSITE', + status: 'NEW', + interestedService: req.interest ?? 'Website Enquiry', + }); + this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) β€” ${req.interest ?? 'general'}`); + return { leadId }; } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..b9d2a4a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "widget-src", "public", "data", "**/*spec.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 57f9635..8b6cc70 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,6 @@ "noImplicitAny": true, "strictBindCallApply": true, "noFallthroughCasesInSwitch": true - } + }, + "exclude": ["widget-src", "public", "data"] } diff --git a/widget-src/src/api.ts b/widget-src/src/api.ts index f6cca13..e358b55 100644 --- a/widget-src/src/api.ts +++ b/widget-src/src/api.ts @@ -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 => { +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> => { 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; diff --git a/widget-src/src/booking.tsx b/widget-src/src/booking.tsx index 1ce78e1..9c0d5bb 100644 --- a/widget-src/src/booking.tsx +++ b/widget-src/src/booking.tsx @@ -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('department'); - const [doctors, setDoctors] = useState([]); - const [departments, setDepartments] = useState([]); + 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(needsBranchStep ? 'branch' : 'department'); const [selectedDept, setSelectedDept] = useState(''); const [selectedDoctor, setSelectedDoctor] = useState(null); const [selectedDate, setSelectedDate] = useState(''); const [slots, setSlots] = useState([]); 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 (
{step !== 'success' && (
- {[0, 1, 2, 3].map(i => ( + {flowSteps.map((_, i) => (
))}
)} - {error &&
{error}
} + {error &&
{error}
} - {step === 'department' && ( + {step === 'branch' && (
-
Select Department
- {departments.map(dept => ( +
Select Branch
+ {doctorsLoading && branches.length === 0 && ( +
Loading…
+ )} + {branches.map(branch => ( ))}
)} + {step === 'department' && ( +
+
+ {selectedBranch && ( + <> + + {selectedBranch} β€”  + + )} + Select Department +
+ {doctorsLoading && departments.length === 0 && ( +
Loading…
+ )} + {departments.map(dept => ( + + ))} + {branches.length > 1 && ( + + )} +
+ )} + {step === 'doctor' && (
-
- Select Doctor β€” {selectedDept.replace(/_/g, ' ')} +
+ + {selectedDept.replace(/_/g, ' ')}
{filteredDoctors.map(doc => ( ))} -
)} {step === 'datetime' && (
-
- {selectedDoctor?.name} β€” Pick Date & Time -
+
{selectedDoctor?.name} β€” Pick Date & Time
{
)} -
- - +
+ +
)} {step === 'details' && (
-
Your Details
+
Your Details
- setName(e.target.value)} /> + updateVisitor({ name: e.target.value })} + />
- setPhone(e.target.value)} /> + updateVisitor({ phone: e.target.value })} + />
-