diff --git a/public/test.html b/public/test.html new file mode 100644 index 0000000..2c6b351 --- /dev/null +++ b/public/test.html @@ -0,0 +1,41 @@ + + + + + + Global Hospital — Widget Test + + + +

🏥 Global Hospital, Bangalore

+

Welcome to Global Hospital — Bangalore's leading multi-specialty healthcare provider.

+ +
+

Book Your Appointment Online

+

Click the chat bubble in the bottom-right corner to talk to our AI assistant, book an appointment, or request a callback.

+
+ +

Our Departments

+ + +

+ This is a test page for the Helix Engage website widget. + The widget loads from the sidecar and renders in a shadow DOM. +

+ + + + + diff --git a/public/widget.js b/public/widget.js new file mode 100644 index 0000000..6e7bbbe --- /dev/null +++ b/public/widget.js @@ -0,0 +1,136 @@ +(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; } + +* { margin: 0; padding: 0; box-sizing: border-box; } + +.widget-bubble { + width: 56px; height: 56px; border-radius: 50%; + background: ${e.colors.primary}; color: #fff; + 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; +} +.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-panel { + width: 380px; height: 520px; border-radius: 16px; + background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12); + display: flex; flex-direction: column; overflow: hidden; + border: 1px solid #e5e7eb; position: absolute; bottom: 68px; right: 0; + animation: slideUp 0.25s ease-out; +} +@keyframes slideUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.widget-header { + display: flex; align-items: center; gap: 10px; + padding: 14px 16px; background: ${e.colors.primary}; color: #fff; +} +.widget-header img { width: 32px; height: 32px; border-radius: 8px; } +.widget-header-text { flex: 1; } +.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-close:hover { opacity: 1; } + +.widget-tabs { + display: flex; border-bottom: 1px solid #e5e7eb; background: #fafafa; +} +.widget-tab { + 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; +} +.widget-tab.active { + color: ${e.colors.primary}; border-bottom-color: ${e.colors.primary}; + font-weight: 600; +} + +.widget-body { flex: 1; overflow-y: auto; padding: 16px; } + +.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-textarea { resize: vertical; min-height: 60px; font-family: inherit; } +.widget-select { + width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; + border-radius: 8px; font-size: 13px; background: #fff; outline: none; +} +.widget-label { font-size: 12px; font-weight: 500; color: #374151; margin-bottom: 4px; display: block; } +.widget-field { margin-bottom: 12px; } + +.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}; +} +.widget-btn:hover { opacity: 0.9; } +.widget-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.widget-btn-secondary { background: #f3f4f6; color: #374151; } + +.widget-slots { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0; +} +.widget-slot { + padding: 8px; text-align: center; font-size: 12px; border-radius: 6px; + 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.unavailable { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; } + +.widget-success { + text-align: center; padding: 24px 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; } + +.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.user .chat-bubble { background: ${e.colors.primary}; color: #fff; border-bottom-right-radius: 4px; } +.chat-msg.assistant .chat-bubble { background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 4px; } + +.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; + border: none; cursor: pointer; display: flex; + align-items: center; justify-content: center; font-size: 16px; +} +.chat-send:disabled { opacity: 0.5; } + +.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; 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; +} +.quick-action:hover { background: ${e.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.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()})(); diff --git a/src/app.module.ts b/src/app.module.ts index 369cf8c..8a556a6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { EventsModule } from './events/events.module'; import { CallerResolutionModule } from './caller/caller-resolution.module'; import { RulesEngineModule } from './rules-engine/rules-engine.module'; import { ConfigThemeModule } from './config/config-theme.module'; +import { WidgetModule } from './widget/widget.module'; @Module({ imports: [ @@ -44,6 +45,7 @@ import { ConfigThemeModule } from './config/config-theme.module'; CallerResolutionModule, RulesEngineModule, ConfigThemeModule, + WidgetModule, ], }) export class AppModule {} diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts index 5d72bf2..2cac038 100644 --- a/src/auth/session.service.ts +++ b/src/auth/session.service.ts @@ -60,6 +60,10 @@ export class SessionService implements OnModuleInit { await this.redis.set(key, value, 'EX', ttlSeconds); } + async setCachePersistent(key: string, value: string): Promise { + await this.redis.set(key, value); + } + async deleteCache(key: string): Promise { await this.redis.del(key); } diff --git a/src/main.ts b/src/main.ts index eb13e25..8fa4970 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,11 @@ import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { join } from 'path'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); const config = app.get(ConfigService); app.enableCors({ @@ -11,6 +13,16 @@ async function bootstrap() { credentials: true, }); + // Serve widget.js and other static files from /public + app.useStaticAssets(join(__dirname, '..', 'public'), { + setHeaders: (res, path) => { + if (path.endsWith('.js')) { + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.setHeader('Access-Control-Allow-Origin', '*'); + } + }, + }); + const port = config.get('port'); await app.listen(port); console.log(`Helix Engage Server running on port ${port}`); diff --git a/src/widget/captcha.guard.ts b/src/widget/captcha.guard.ts new file mode 100644 index 0000000..fcafc4f --- /dev/null +++ b/src/widget/captcha.guard.ts @@ -0,0 +1,45 @@ +import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common'; + +const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; + +@Injectable() +export class CaptchaGuard implements CanActivate { + private readonly logger = new Logger(CaptchaGuard.name); + private readonly secretKey: string; + + constructor() { + this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? ''; + } + + async canActivate(context: ExecutionContext): Promise { + if (!this.secretKey) { + this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled'); + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = request.body?.captchaToken; + + if (!token) throw new HttpException('Captcha token required', 400); + + try { + const res = await fetch(RECAPTCHA_VERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `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}`); + throw new HttpException('Captcha verification failed', 403); + } + + return true; + } catch (err: any) { + if (err instanceof HttpException) throw err; + this.logger.error(`Captcha verification error: ${err.message}`); + return true; + } + } +} diff --git a/src/widget/webhooks.controller.ts b/src/widget/webhooks.controller.ts new file mode 100644 index 0000000..d957bf8 --- /dev/null +++ b/src/widget/webhooks.controller.ts @@ -0,0 +1,226 @@ +import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common'; +import { WidgetService } from './widget.service'; +import { createHmac } from 'crypto'; + +@Controller('api/webhook') +export class WebhooksController { + private readonly logger = new Logger(WebhooksController.name); + private readonly googleWebhookKey: string; + private readonly fbVerifyToken: string; + + constructor(private readonly widget: WidgetService) { + this.googleWebhookKey = process.env.GOOGLE_WEBHOOK_KEY ?? ''; + this.fbVerifyToken = process.env.FB_VERIFY_TOKEN ?? 'helix-engage-verify'; + } + + // ─── Facebook / Instagram Lead Ads ─── + + // Webhook verification (Meta sends GET to verify endpoint) + @Get('facebook') + verifyFacebook( + @Query('hub.mode') mode: string, + @Query('hub.verify_token') token: string, + @Query('hub.challenge') challenge: string, + ) { + if (mode === 'subscribe' && token === this.fbVerifyToken) { + this.logger.log('[FB] Webhook verified'); + return challenge; + } + throw new HttpException('Verification failed', 403); + } + + // Receive leads from Facebook/Instagram + @Post('facebook') + async facebookLead(@Body() body: any) { + this.logger.log(`[FB] Webhook received: ${JSON.stringify(body).substring(0, 200)}`); + + if (body.object !== 'page') { + return { status: 'ignored', reason: 'not a page event' }; + } + + let leadsCreated = 0; + + for (const entry of body.entry ?? []) { + for (const change of entry.changes ?? []) { + if (change.field !== 'leadgen') continue; + + const leadData = change.value; + const leadgenId = leadData.leadgen_id; + const formId = leadData.form_id; + const pageId = leadData.page_id; + + this.logger.log(`[FB] Lead received: leadgen_id=${leadgenId} form_id=${formId} page_id=${pageId}`); + + // Fetch full lead data from Meta Graph API + const lead = await this.fetchFacebookLead(leadgenId); + if (!lead) { + this.logger.warn(`[FB] Could not fetch lead data for ${leadgenId}`); + continue; + } + + const name = this.extractFbField(lead, 'full_name') ?? 'Facebook Lead'; + const phone = this.extractFbField(lead, 'phone_number') ?? ''; + const email = this.extractFbField(lead, 'email') ?? ''; + + try { + await this.widget.createLead({ + name, + phone: phone.replace(/[^0-9+]/g, ''), + interest: `Facebook Ad (form: ${formId})`, + message: email ? `Email: ${email}` : undefined, + captchaToken: 'webhook-bypass', + }); + leadsCreated++; + this.logger.log(`[FB] Lead created: ${name} (${phone})`); + } catch (err: any) { + this.logger.error(`[FB] Lead creation failed: ${err.message}`); + } + } + } + + return { status: 'ok', leadsCreated }; + } + + private async fetchFacebookLead(leadgenId: string): Promise { + const accessToken = process.env.FB_PAGE_ACCESS_TOKEN; + if (!accessToken) { + this.logger.warn('[FB] FB_PAGE_ACCESS_TOKEN not set — cannot fetch lead details'); + return null; + } + + try { + const res = await fetch(`https://graph.facebook.com/v21.0/${leadgenId}?access_token=${accessToken}`); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } + } + + private extractFbField(lead: any, fieldName: string): string | null { + const fields = lead.field_data ?? []; + const field = fields.find((f: any) => f.name === fieldName); + return field?.values?.[0] ?? null; + } + + // ─── Google Ads Lead Form ─── + + @Post('google') + async googleLead(@Body() body: any) { + this.logger.log(`[GOOGLE] Webhook received: ${JSON.stringify(body).substring(0, 200)}`); + + // Verify webhook key if configured + if (this.googleWebhookKey && body.google_key) { + const expected = createHmac('sha256', this.googleWebhookKey) + .update(body.lead_id ?? '') + .digest('hex'); + // Google sends the key directly, not as HMAC — just compare + if (body.google_key !== this.googleWebhookKey) { + this.logger.warn('[GOOGLE] Invalid webhook key'); + throw new HttpException('Invalid webhook key', 403); + } + } + + const isTest = body.is_test === true; + const leadId = body.lead_id; + const campaignId = body.campaign_id; + const formId = body.form_id; + + // Extract user data from column data + const userData = body.user_column_data ?? []; + const name = this.extractGoogleField(userData, 'FULL_NAME') + ?? this.extractGoogleField(userData, 'FIRST_NAME') + ?? 'Google Ad Lead'; + const phone = this.extractGoogleField(userData, 'PHONE_NUMBER') ?? ''; + const email = this.extractGoogleField(userData, 'EMAIL') ?? ''; + const city = this.extractGoogleField(userData, 'CITY') ?? ''; + + this.logger.log(`[GOOGLE] Lead: ${name} | ${phone} | campaign=${campaignId} | test=${isTest}`); + + try { + const result = await this.widget.createLead({ + name, + phone: phone.replace(/[^0-9+]/g, ''), + interest: `Google Ad${isTest ? ' (TEST)' : ''} (campaign: ${campaignId ?? 'unknown'})`, + message: [email, city].filter(Boolean).join(', ') || undefined, + captchaToken: 'webhook-bypass', + }); + + this.logger.log(`[GOOGLE] Lead created: ${result.leadId}${isTest ? ' (test)' : ''}`); + return { status: 'ok', leadId: result.leadId, isTest }; + } catch (err: any) { + this.logger.error(`[GOOGLE] Lead creation failed: ${err.message}`); + throw new HttpException('Lead creation failed', 500); + } + } + + private extractGoogleField(columnData: any[], fieldName: string): string | null { + const field = columnData.find((f: any) => f.column_id === fieldName); + return field?.string_value ?? null; + } + + // ─── Ozonetel WhatsApp Callback ─── + // Configure in Ozonetel: Chat Callback URL → https://engage-api.srv1477139.hstgr.cloud/api/webhook/whatsapp + // Payload format will be adapted once Ozonetel confirms their schema + + @Post('whatsapp') + async whatsappLead(@Body() body: any) { + this.logger.log(`[WHATSAPP] Webhook received: ${JSON.stringify(body).substring(0, 300)}`); + + const phone = body.from ?? body.caller_id ?? body.phone ?? body.customerNumber ?? ''; + const name = body.name ?? body.customerName ?? ''; + const message = body.message ?? body.text ?? body.body ?? ''; + + if (!phone) { + this.logger.warn('[WHATSAPP] No phone number in payload'); + return { status: 'ignored', reason: 'no phone number' }; + } + + try { + const result = await this.widget.createLead({ + name: name || 'WhatsApp Lead', + phone: phone.replace(/[^0-9+]/g, ''), + interest: 'WhatsApp Enquiry', + message: message || undefined, + captchaToken: 'webhook-bypass', + }); + this.logger.log(`[WHATSAPP] Lead created: ${result.leadId} (${phone})`); + return { status: 'ok', leadId: result.leadId }; + } catch (err: any) { + this.logger.error(`[WHATSAPP] Lead creation failed: ${err.message}`); + return { status: 'error', message: err.message }; + } + } + + // ─── Ozonetel SMS Callback ─── + // Configure in Ozonetel: SMS Callback URL → https://engage-api.srv1477139.hstgr.cloud/api/webhook/sms + + @Post('sms') + async smsLead(@Body() body: any) { + this.logger.log(`[SMS] Webhook received: ${JSON.stringify(body).substring(0, 300)}`); + + const phone = body.from ?? body.caller_id ?? body.phone ?? body.senderNumber ?? ''; + const name = body.name ?? ''; + const message = body.message ?? body.text ?? body.body ?? ''; + + if (!phone) { + this.logger.warn('[SMS] No phone number in payload'); + return { status: 'ignored', reason: 'no phone number' }; + } + + try { + const result = await this.widget.createLead({ + name: name || 'SMS Lead', + phone: phone.replace(/[^0-9+]/g, ''), + interest: 'SMS Enquiry', + message: message || undefined, + captchaToken: 'webhook-bypass', + }); + this.logger.log(`[SMS] Lead created: ${result.leadId} (${phone})`); + return { status: 'ok', leadId: result.leadId }; + } catch (err: any) { + this.logger.error(`[SMS] Lead creation failed: ${err.message}`); + return { status: 'error', message: err.message }; + } + } +} diff --git a/src/widget/widget-key.guard.ts b/src/widget/widget-key.guard.ts new file mode 100644 index 0000000..ca37cde --- /dev/null +++ b/src/widget/widget-key.guard.ts @@ -0,0 +1,25 @@ +import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common'; +import { WidgetKeysService } from './widget-keys.service'; + +@Injectable() +export class WidgetKeyGuard implements CanActivate { + constructor(private readonly keys: WidgetKeysService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const key = request.query?.key ?? request.headers['x-widget-key']; + + if (!key) throw new HttpException('Widget key required', 401); + + const siteKey = await this.keys.validateKey(key); + if (!siteKey) throw new HttpException('Invalid widget key', 403); + + const origin = request.headers.origin ?? request.headers.referer; + if (!this.keys.validateOrigin(siteKey, origin)) { + throw new HttpException('Origin not allowed', 403); + } + + request.widgetSiteKey = siteKey; + return true; + } +} diff --git a/src/widget/widget-keys.service.ts b/src/widget/widget-keys.service.ts new file mode 100644 index 0000000..eebbeac --- /dev/null +++ b/src/widget/widget-keys.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHmac, timingSafeEqual, randomUUID } from 'crypto'; +import { SessionService } from '../auth/session.service'; +import type { WidgetSiteKey } from './widget.types'; + +const KEY_PREFIX = 'widget:keys:'; + +@Injectable() +export class WidgetKeysService { + private readonly logger = new Logger(WidgetKeysService.name); + private readonly secret: string; + + constructor( + private config: ConfigService, + private session: SessionService, + ) { + this.secret = process.env.WIDGET_SECRET ?? config.get('WIDGET_SECRET') ?? 'helix-widget-default-secret'; + } + + generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } { + const siteId = randomUUID().replace(/-/g, '').substring(0, 16); + const signature = this.sign(siteId); + const key = `${siteId}.${signature}`; + + const siteKey: WidgetSiteKey = { + siteId, + hospitalName, + allowedOrigins, + active: true, + createdAt: new Date().toISOString(), + }; + + return { key, siteKey }; + } + + async saveKey(siteKey: WidgetSiteKey): Promise { + await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey)); + this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`); + } + + async validateKey(rawKey: string): Promise { + const dotIndex = rawKey.indexOf('.'); + if (dotIndex === -1) return null; + + const siteId = rawKey.substring(0, dotIndex); + const signature = rawKey.substring(dotIndex + 1); + + const expected = this.sign(siteId); + try { + if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null; + } catch { + return null; + } + + const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`); + if (!data) return null; + + const siteKey: WidgetSiteKey = JSON.parse(data); + if (!siteKey.active) return null; + + return siteKey; + } + + validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean { + if (!origin) return true; // Allow no-origin for dev/testing + if (siteKey.allowedOrigins.length === 0) return true; + return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed)); + } + + async listKeys(): Promise { + const keys = await this.session.scanKeys(`${KEY_PREFIX}*`); + const results: WidgetSiteKey[] = []; + for (const key of keys) { + const data = await this.session.getCache(key); + if (data) results.push(JSON.parse(data)); + } + return results; + } + + async revokeKey(siteId: string): Promise { + const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`); + if (!data) return false; + const siteKey: WidgetSiteKey = JSON.parse(data); + siteKey.active = false; + await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey)); + this.logger.log(`Widget key revoked: ${siteId}`); + return true; + } + + private sign(data: string): string { + return createHmac('sha256', this.secret).update(data).digest('hex'); + } +} diff --git a/src/widget/widget.controller.ts b/src/widget/widget.controller.ts new file mode 100644 index 0000000..145702e --- /dev/null +++ b/src/widget/widget.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Post, Delete, Body, Query, Param, UseGuards, Logger, HttpException } from '@nestjs/common'; +import { WidgetService } from './widget.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'; + +@Controller('api/widget') +export class WidgetController { + private readonly logger = new Logger(WidgetController.name); + + constructor( + private readonly widget: WidgetService, + private readonly keys: WidgetKeysService, + ) {} + + @Get('init') + @UseGuards(WidgetKeyGuard) + init() { + return this.widget.getInitData(); + } + + @Get('doctors') + @UseGuards(WidgetKeyGuard) + async doctors() { + return this.widget.getDoctors(); + } + + @Get('slots') + @UseGuards(WidgetKeyGuard) + async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) { + if (!doctorId || !date) throw new HttpException('doctorId and date required', 400); + return this.widget.getSlots(doctorId, date); + } + + @Post('book') + @UseGuards(WidgetKeyGuard, CaptchaGuard) + async book(@Body() body: WidgetBookRequest) { + if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) { + throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400); + } + return this.widget.bookAppointment(body); + } + + @Post('lead') + @UseGuards(WidgetKeyGuard, CaptchaGuard) + async lead(@Body() body: WidgetLeadRequest) { + if (!body.name || !body.phone) { + throw new HttpException('name and phone required', 400); + } + return this.widget.createLead(body); + } + + // Key management (admin endpoints) + @Post('keys/generate') + async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) { + if (!body.hospitalName) throw new HttpException('hospitalName required', 400); + const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []); + await this.keys.saveKey(siteKey); + return { key, siteKey }; + } + + @Get('keys') + async listKeys() { + return this.keys.listKeys(); + } + + @Delete('keys/:siteId') + async revokeKey(@Param('siteId') siteId: string) { + const revoked = await this.keys.revokeKey(siteId); + if (!revoked) throw new HttpException('Key not found', 404); + return { status: 'revoked' }; + } +} diff --git a/src/widget/widget.module.ts b/src/widget/widget.module.ts new file mode 100644 index 0000000..0b22d62 --- /dev/null +++ b/src/widget/widget.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { WidgetController } from './widget.controller'; +import { WebhooksController } from './webhooks.controller'; +import { WidgetService } from './widget.service'; +import { WidgetKeysService } from './widget-keys.service'; +import { PlatformModule } from '../platform/platform.module'; +import { AuthModule } from '../auth/auth.module'; +import { ConfigThemeModule } from '../config/config-theme.module'; + +@Module({ + imports: [PlatformModule, AuthModule, ConfigThemeModule], + controllers: [WidgetController, WebhooksController], + providers: [WidgetService, WidgetKeysService], + exports: [WidgetKeysService], +}) +export class WidgetModule {} diff --git a/src/widget/widget.service.ts b/src/widget/widget.service.ts new file mode 100644 index 0000000..da13456 --- /dev/null +++ b/src/widget/widget.service.ts @@ -0,0 +1,152 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { ConfigService } from '@nestjs/config'; +import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types'; +import { ThemeService } from '../config/theme.service'; + +@Injectable() +export class WidgetService { + private readonly logger = new Logger(WidgetService.name); + private readonly apiKey: string; + + constructor( + private platform: PlatformGraphqlService, + private theme: ThemeService, + private config: ConfigService, + ) { + this.apiKey = config.get('platform.apiKey') ?? ''; + } + + private get auth() { + return `Bearer ${this.apiKey}`; + } + + getInitData(): WidgetInitResponse { + const t = this.theme.getTheme(); + return { + brand: { name: t.brand.hospitalName, logo: t.brand.logo }, + colors: { + primary: t.colors.brand['600'] ?? 'rgb(29 78 216)', + primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)', + text: t.colors.brand['950'] ?? 'rgb(15 23 42)', + textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)', + }, + captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '', + }; + } + + async getDoctors(): Promise { + const data = await this.platform.queryWithAuth( + `{ doctors(first: 50) { edges { node { + id name fullName { firstName lastName } department specialty visitingHours + consultationFeeNew { amountMicros currencyCode } + clinic { clinicName } + } } } }`, + undefined, this.auth, + ); + return data.doctors.edges.map((e: any) => e.node); + } + + async getSlots(doctorId: string, date: string): Promise { + const data = await this.platform.queryWithAuth( + `{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`, + undefined, this.auth, + ); + const booked = data.appointments.edges.map((e: any) => { + const dt = new Date(e.node.scheduledAt); + return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`; + }); + + const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00']; + return allSlots.map(s => ({ time: s, available: !booked.includes(s) })); + } + + async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> { + const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10); + + // Find or create patient + let patientId: string | null = null; + try { + const existing = await this.platform.queryWithAuth( + `{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`, + undefined, this.auth, + ); + patientId = existing.patients.edges[0]?.node?.id ?? null; + } catch { /* continue */ } + + if (!patientId) { + const firstName = req.patientName.split(' ')[0]; + const lastName = req.patientName.split(' ').slice(1).join(' ') || ''; + const created = await this.platform.queryWithAuth( + `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, + { data: { + fullName: { firstName, lastName }, + phones: { primaryPhoneNumber: `+91${phone}` }, + patientType: 'NEW', + } }, + this.auth, + ); + patientId = created.createPatient.id; + } + + // Create appointment + const appt = await this.platform.queryWithAuth( + `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, + { data: { + scheduledAt: req.scheduledAt, + durationMin: 30, + appointmentType: 'CONSULTATION', + status: 'SCHEDULED', + doctorId: req.doctorId, + department: req.departmentId, + reasonForVisit: req.chiefComplaint ?? '', + patientId, + } }, + 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}` }, + source: 'WEBSITE', + status: 'APPOINTMENT_SET', + interestedService: req.chiefComplaint ?? 'Appointment Booking', + patientId, + } }, + this.auth, + ).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`)); + + const reference = appt.createAppointment.id.substring(0, 8).toUpperCase(); + this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`); + + return { appointmentId: appt.createAppointment.id, reference }; + } + + 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 }; + } +} diff --git a/src/widget/widget.types.ts b/src/widget/widget.types.ts new file mode 100644 index 0000000..602ee90 --- /dev/null +++ b/src/widget/widget.types.ts @@ -0,0 +1,38 @@ +export type WidgetSiteKey = { + siteId: string; + hospitalName: string; + allowedOrigins: string[]; + active: boolean; + createdAt: string; +}; + +export type WidgetInitResponse = { + brand: { name: string; logo: string }; + colors: { primary: string; primaryLight: string; text: string; textLight: string }; + captchaSiteKey: string; +}; + +export type WidgetBookRequest = { + departmentId: string; + doctorId: string; + scheduledAt: string; + patientName: string; + patientPhone: string; + age?: string; + gender?: string; + chiefComplaint?: string; + captchaToken: string; +}; + +export type WidgetLeadRequest = { + name: string; + phone: string; + interest?: string; + message?: string; + captchaToken: string; +}; + +export type WidgetChatRequest = { + messages: Array<{ role: string; content: string }>; + captchaToken?: string; +};