@mylikita/booking-widget 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/dist/demo.html +335 -0
- package/dist/mylikita-booking-widget.cjs +679 -0
- package/dist/mylikita-booking-widget.esm.js +658 -0
- package/dist/mylikita-booking-widget.js +681 -0
- package/dist/mylikita-booking-widget.min.js +142 -0
- package/package.json +44 -0
- package/src/client.js +128 -0
- package/src/index.js +30 -0
- package/src/state.js +49 -0
- package/src/styles.js +147 -0
- package/src/theme.js +47 -0
- package/src/widget.js +412 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/*! @mylikita/booking-widget v0.1.0 | MIT */
|
|
2
|
+
var MyLikitaBookingWidget=(()=>{var Y=Object.defineProperty;var pe=Object.getOwnPropertyDescriptor;var fe=Object.getOwnPropertyNames;var ge=Object.prototype.hasOwnProperty;var we=(i,e)=>{for(var t in e)Y(i,t,{get:e[t],enumerable:!0})},ye=(i,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let a of fe(e))!ge.call(i,a)&&a!==t&&Y(i,a,{get:()=>e[a],enumerable:!(r=pe(e,a))||r.enumerable});return i};var _e=i=>ye(Y({},"__esModule",{value:!0}),i);var Ee={};we(Ee,{DEFAULT_THEME:()=>W,STATUS_COPY:()=>C,TERMINAL_STATUSES:()=>ie,createBooking:()=>S,createBookingWidget:()=>oe,fetchProviders:()=>E,fetchStatus:()=>T,newExternalRef:()=>P,pollStatus:()=>N,resolveTheme:()=>$,statusCopy:()=>A});async function S({relayUrl:i,websiteKey:e,payload:t,signal:r}){let a=await fetch(`${H(i)}/v1/bookings`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e}`},body:JSON.stringify(t),signal:r}),o=await F(a);if(a.status===409)return{ok:!0,duplicate:!0,booking_ref:o?.booking_ref,error:o?.message};if(!a.ok)throw O(a.status,o,"create");return{ok:!0,duplicate:!1,booking_ref:o?.booking_ref,status:o?.status||"pending_confirmation"}}async function E({relayUrl:i,websiteKey:e,signal:t}){let r=await fetch(`${H(i)}/v1/providers`,{headers:{Authorization:`Bearer ${e}`},signal:t}),a=await F(r);if(!r.ok)throw O(r.status,a,"providers");return(Array.isArray(a?.providers)?a.providers:[]).map(d=>({external_id:d.external_id,name:d.name||d.external_id,specialty:d.specialty||null,module:d.module||"general"}))}async function T({relayUrl:i,websiteKey:e,bookingRef:t,signal:r}){let a=await fetch(`${H(i)}/v1/bookings/${encodeURIComponent(t)}`,{headers:{Authorization:`Bearer ${e}`},signal:r}),o=await F(a);if(!a.ok)throw O(a.status,o,"status");return{booking_ref:o?.booking_ref,status:o?.status||"pending_confirmation",appt_ref:o?.appt_ref||null}}async function N(i,{intervalMs:e=5e3,maxTries:t=12,signal:r}={}){for(let a=0;a<t;a++){if(r?.aborted)return{status:"aborted",resolved:!1,data:null};let o=await i();if(o.status!=="pending_confirmation")return{status:o.status,resolved:!0,data:o};a<t-1&&await be(e,r)}return{status:"pending_confirmation",resolved:!1,data:null}}function P(){return`BK-${Date.now()}-${Math.random().toString(36).slice(2,6)}`}function H(i){return String(i||"").replace(/\/+$/,"")}function O(i,e,t){let r=new Error(e?.message||e?.error||`Relay ${t} failed (HTTP ${i})`);return r.code=e?.error||"http_error",r.status=i,r}async function F(i){try{return await i.json()}catch{return null}}function be(i,e){return new Promise(t=>{if(e?.aborted)return t();let r=()=>{clearTimeout(a),t()},a=setTimeout(()=>{e?.removeEventListener("abort",r),t()},i);e?.addEventListener("abort",r,{once:!0})})}var C={pending_confirmation:{title:"Request received",message:"We've received your booking request \u2014 we'll confirm shortly.",kind:"info"},confirmed:{title:"Appointment confirmed",message:"Your appointment is confirmed. See you at the clinic!",kind:"success"},cancelled:{title:"Appointment cancelled",message:"This appointment was cancelled. Please contact the clinic if this was unexpected.",kind:"danger"},rescheduled:{title:"Appointment rescheduled",message:"This appointment was moved \u2014 the new time was sent to you.",kind:"info"},no_show:{title:"Missed appointment",message:"This appointment was marked as missed.",kind:"danger"},expired:{title:"Request expired",message:"This booking request expired \u2014 please call the clinic to book.",kind:"danger"},poll_error:{title:"Something went wrong",message:"We could not check your booking right now. Please try again shortly.",kind:"danger"}};function A(i){return C[i]||C.pending_confirmation}var ie=["confirmed","cancelled","rescheduled","no_show","expired"];var W={primary:"#0d6efd",primaryDark:"#0b5ed7",primaryText:"#ffffff",bg:"#ffffff",text:"#1e293b",muted:"#64748b",border:"#e2e8f0",danger:"#dc3545",success:"#15803d",radius:10,font:"system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif"};function $(i={}){let e={...W,...i||{}};return{"--mlw-primary":e.primary,"--mlw-primary-dark":e.primaryDark,"--mlw-primary-text":e.primaryText,"--mlw-bg":e.bg,"--mlw-text":e.text,"--mlw-muted":e.muted,"--mlw-border":e.border,"--mlw-danger":e.danger,"--mlw-success":e.success,"--mlw-radius":`${e.radius}px`,"--mlw-font":e.font}}var re=`
|
|
3
|
+
.mylikita-widget {
|
|
4
|
+
--mlw-primary: #0d6efd;
|
|
5
|
+
--mlw-primary-dark: #0b5ed7;
|
|
6
|
+
--mlw-primary-text: #ffffff;
|
|
7
|
+
--mlw-bg: #ffffff;
|
|
8
|
+
--mlw-text: #1e293b;
|
|
9
|
+
--mlw-muted: #64748b;
|
|
10
|
+
--mlw-border: #e2e8f0;
|
|
11
|
+
--mlw-danger: #dc3545;
|
|
12
|
+
--mlw-success: #15803d;
|
|
13
|
+
--mlw-radius: 10px;
|
|
14
|
+
--mlw-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
|
15
|
+
font-family: var(--mlw-font);
|
|
16
|
+
color: var(--mlw-text);
|
|
17
|
+
background: var(--mlw-bg);
|
|
18
|
+
border: 1px solid var(--mlw-border);
|
|
19
|
+
border-radius: calc(var(--mlw-radius) + 2px);
|
|
20
|
+
padding: 22px;
|
|
21
|
+
max-width: 480px;
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
line-height: 1.5;
|
|
24
|
+
}
|
|
25
|
+
.mylikita-widget *,
|
|
26
|
+
.mylikita-widget *::before,
|
|
27
|
+
.mylikita-widget *::after { box-sizing: border-box; }
|
|
28
|
+
|
|
29
|
+
.mylikita-widget__title {
|
|
30
|
+
font-size: 18px;
|
|
31
|
+
font-weight: 700;
|
|
32
|
+
margin: 0 0 4px;
|
|
33
|
+
color: var(--mlw-text);
|
|
34
|
+
}
|
|
35
|
+
.mylikita-widget__subtitle {
|
|
36
|
+
font-size: 13px;
|
|
37
|
+
color: var(--mlw-muted);
|
|
38
|
+
margin: 0 0 16px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.mylikita-widget__field { margin-bottom: 12px; }
|
|
42
|
+
.mylikita-widget__label {
|
|
43
|
+
display: block;
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
font-weight: 600;
|
|
46
|
+
color: var(--mlw-text);
|
|
47
|
+
margin-bottom: 5px;
|
|
48
|
+
}
|
|
49
|
+
.mylikita-widget__label .req { color: var(--mlw-danger); }
|
|
50
|
+
.mylikita-widget__input,
|
|
51
|
+
.mylikita-widget__select,
|
|
52
|
+
.mylikita-widget__textarea {
|
|
53
|
+
width: 100%;
|
|
54
|
+
font: inherit;
|
|
55
|
+
font-size: 14px;
|
|
56
|
+
color: var(--mlw-text);
|
|
57
|
+
background: var(--mlw-bg);
|
|
58
|
+
border: 1px solid var(--mlw-border);
|
|
59
|
+
border-radius: var(--mlw-radius);
|
|
60
|
+
padding: 9px 11px;
|
|
61
|
+
outline: none;
|
|
62
|
+
transition: border-color .15s ease, box-shadow .15s ease;
|
|
63
|
+
}
|
|
64
|
+
.mylikita-widget__input:focus,
|
|
65
|
+
.mylikita-widget__select:focus,
|
|
66
|
+
.mylikita-widget__textarea:focus {
|
|
67
|
+
border-color: var(--mlw-primary);
|
|
68
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--mlw-primary) 22%, transparent);
|
|
69
|
+
}
|
|
70
|
+
.mylikita-widget__row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
71
|
+
@media (max-width: 420px) { .mylikita-widget__row { grid-template-columns: 1fr; } }
|
|
72
|
+
|
|
73
|
+
.mylikita-widget__error {
|
|
74
|
+
display: none;
|
|
75
|
+
font-size: 13px;
|
|
76
|
+
color: var(--mlw-danger);
|
|
77
|
+
background: color-mix(in srgb, var(--mlw-danger) 8%, transparent);
|
|
78
|
+
border: 1px solid color-mix(in srgb, var(--mlw-danger) 35%, transparent);
|
|
79
|
+
border-radius: var(--mlw-radius);
|
|
80
|
+
padding: 9px 12px;
|
|
81
|
+
margin-bottom: 12px;
|
|
82
|
+
}
|
|
83
|
+
.mylikita-widget__error.visible { display: block; }
|
|
84
|
+
|
|
85
|
+
.mylikita-widget__submit {
|
|
86
|
+
width: 100%;
|
|
87
|
+
font: inherit;
|
|
88
|
+
font-size: 14px;
|
|
89
|
+
font-weight: 600;
|
|
90
|
+
color: var(--mlw-primary-text);
|
|
91
|
+
background: var(--mlw-primary);
|
|
92
|
+
border: none;
|
|
93
|
+
border-radius: var(--mlw-radius);
|
|
94
|
+
padding: 11px 16px;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
transition: background .15s ease, transform .05s ease;
|
|
97
|
+
}
|
|
98
|
+
.mylikita-widget__submit:hover { background: var(--mlw-primary-dark); }
|
|
99
|
+
.mylikita-widget__submit:active { transform: translateY(1px); }
|
|
100
|
+
.mylikita-widget__submit:disabled { opacity: .6; cursor: wait; }
|
|
101
|
+
|
|
102
|
+
.mylikita-widget__hint { font-size: 12px; color: var(--mlw-muted); margin: 8px 0 0; }
|
|
103
|
+
|
|
104
|
+
/* status view */
|
|
105
|
+
.mylikita-widget__status { text-align: center; padding: 8px 4px; }
|
|
106
|
+
.mylikita-widget__status-icon {
|
|
107
|
+
width: 46px; height: 46px;
|
|
108
|
+
border-radius: 50%;
|
|
109
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
110
|
+
font-size: 22px; margin-bottom: 10px;
|
|
111
|
+
}
|
|
112
|
+
.mylikita-widget__status-icon.info { background: color-mix(in srgb, var(--mlw-primary) 12%, transparent); }
|
|
113
|
+
.mylikita-widget__status-icon.success { background: color-mix(in srgb, var(--mlw-success) 14%, transparent); }
|
|
114
|
+
.mylikita-widget__status-icon.danger { background: color-mix(in srgb, var(--mlw-danger) 12%, transparent); }
|
|
115
|
+
.mylikita-widget__status-title { font-size: 16px; font-weight: 700; margin: 0 0 4px; }
|
|
116
|
+
.mylikita-widget__status-message { font-size: 13px; color: var(--mlw-muted); margin: 0 0 14px; }
|
|
117
|
+
.mylikita-widget__status-ref { font-size: 12px; color: var(--mlw-muted); margin: 0 0 14px; }
|
|
118
|
+
|
|
119
|
+
.mylikita-widget__spinner {
|
|
120
|
+
width: 18px; height: 18px;
|
|
121
|
+
display: inline-block;
|
|
122
|
+
border: 2px solid color-mix(in srgb, var(--mlw-primary-text) 40%, transparent);
|
|
123
|
+
border-top-color: var(--mlw-primary-text);
|
|
124
|
+
border-radius: 50%;
|
|
125
|
+
animation: mylikita-widget-spin .7s linear infinite;
|
|
126
|
+
vertical-align: -3px;
|
|
127
|
+
margin-right: 7px;
|
|
128
|
+
}
|
|
129
|
+
@keyframes mylikita-widget-spin { to { transform: rotate(360deg); } }
|
|
130
|
+
|
|
131
|
+
.mylikita-widget__link-btn {
|
|
132
|
+
background: none;
|
|
133
|
+
border: 1px solid var(--mlw-border);
|
|
134
|
+
border-radius: var(--mlw-radius);
|
|
135
|
+
color: var(--mlw-text);
|
|
136
|
+
font: inherit;
|
|
137
|
+
font-size: 13px;
|
|
138
|
+
padding: 8px 14px;
|
|
139
|
+
cursor: pointer;
|
|
140
|
+
}
|
|
141
|
+
.mylikita-widget__link-btn:hover { border-color: var(--mlw-primary); color: var(--mlw-primary); }
|
|
142
|
+
`;var xe={title:"Book an appointment",subtitle:"Request a slot and we will confirm shortly.",name:"Full name",phone:"Phone number",email:"Email address",provider:"Preferred doctor (optional)",noPreference:"No preference",service:"Service (optional)",datetime:"Preferred date & time",visitType:"Appointment type",visitPhysical:"In person",visitTelemedicine:"Video call",visitHome:"Home visit",notes:"Notes (optional)",submit:"Request appointment",submitting:"Submitting\u2026",bookAnother:"Book another appointment",requiredPhoneOrEmail:"Please provide a phone number or an email address.",requiredName:"Please enter your name.",requiredDatetime:"Please choose a date and time.",networkError:"Could not reach the booking service. Please try again.",rateLimited:"Too many requests \u2014 please wait a moment and try again."},ae="mylikita-widget-styles";function oe(i,e={}){if(!i)throw new Error("createBookingWidget: a container element is required");let t={relayUrl:e.relayUrl,websiteKey:e.websiteKey,facilityId:e.facilityId,providers:e.providers||[],services:e.services||[],loadProviders:e.loadProviders===!0&&!(e.providers&&e.providers.length),pollIntervalMs:e.pollIntervalMs??5e3,maxTries:e.maxTries??12,text:{...xe,...e.text||{}},theme:e.theme||{},onStatus:typeof e.onStatus=="function"?e.onStatus:null,onError:typeof e.onError=="function"?e.onError:null,onBooking:typeof e.onBooking=="function"?e.onBooking:null,externalRef:typeof e.externalRef=="function"?e.externalRef:P};if(!t.relayUrl||!t.websiteKey||!t.facilityId)throw new Error("createBookingWidget: relayUrl, websiteKey and facilityId are required");ke();let r=t.text,a=i;a.classList.add("mylikita-widget"),Se(a,t.theme);let o=!0,d=!1,u=null,f=Math.random().toString(36).slice(2,8),I=`mylikita_ref_${t.facilityId}_${f}`,y=l("form",{className:"mylikita-widget__form"}),B=l("h3",{className:"mylikita-widget__title",text:r.title}),U=l("p",{className:"mylikita-widget__subtitle",text:r.subtitle}),L=l("div",{className:"mylikita-widget__error",attrs:{role:"alert"}}),q=_("name",r.name,{required:!0}),V=l("div",{className:"mylikita-widget__row"}),z=_("phone",r.phone,{type:"tel",inputmode:"tel"}),R=_("email",r.email,{type:"email"});V.append(z.wrap,R.wrap);let g=j("provider",r.provider,[{value:"",label:r.noPreference},...t.providers.map(n=>({value:n.external_id,label:n.label||n.name||n.external_id}))]);function se(n){let s=g.input.value;g.input.replaceChildren();let c=document.createElement("option");c.value="",c.textContent=r.noPreference,g.input.append(c);for(let m of n||[]){let p=document.createElement("option");p.value=m.external_id,p.textContent=m.label||m.name||m.external_id,g.input.append(p)}s&&(g.input.value=s)}let J=t.services.length?j("service",r.service,[{value:"",label:"\u2014"},...t.services.map(n=>({value:n,label:n}))]):_("service",r.service),v=_("datetime",r.datetime,{type:"datetime-local",required:!0});v.input.min=ne(new Date);let G=j("visitType",r.visitType,[{value:"physical",label:r.visitPhysical},{value:"telemedicine",label:r.visitTelemedicine},{value:"home_visit",label:r.visitHome}]),X=_("notes",r.notes,{type:"textarea",maxlength:500}),w=l("button",{className:"mylikita-widget__submit",type:"submit",text:r.submit}),Q=l("div");Q.append(w);let M=l("p",{className:"mylikita-widget__hint"});y.append(L,q.wrap,V,g.wrap,J.wrap,v.wrap,G.wrap,X.wrap,Q,M);let b=l("div",{className:"mylikita-widget__status",attrs:{"aria-live":"polite"},hidden:!0});y.addEventListener("submit",n=>{n.preventDefault(),!d&&le()});async function le(){d=!0,D(null),w.disabled=!0,w.textContent=r.submitting;let n={facility_id:t.facilityId,patient_name:q.input.value.trim(),patient_phone:z.input.value.trim(),patient_email:R.input.value.trim(),provider_external_id:g.input.value||void 0,service_name:J.input.value.trim()||void 0,appt_datetime:v.input.value,visit_type:G.input.value||"physical",duration_mins:t.durationMins||void 0,notes:X.input.value.trim()||void 0};if(!n.patient_name)return k(r.requiredName);if(!n.patient_phone&&!n.patient_email)return k(r.requiredPhoneOrEmail);if(!n.appt_datetime||Number.isNaN(Date.parse(n.appt_datetime)))return k(r.requiredDatetime);let s=he(I);if(!s){s=t.externalRef();try{sessionStorage.setItem(I,s)}catch{}}n.external_ref=s,u=new AbortController;let c=null;try{let m=await S({relayUrl:t.relayUrl,websiteKey:t.websiteKey,payload:n,signal:u.signal});c=m.booking_ref,m.duplicate?(M.textContent="",Z("pending_confirmation",c,"We found an existing booking request for this slot \u2014 checking it\u2026")):(t.onBooking&&x(t.onBooking,m,n),Z("pending_confirmation",c,null)),de(c)}catch(m){if(!o||m?.name==="AbortError")return;let p=m.status===429?r.rateLimited:m.message||r.networkError;t.onError&&x(t.onError,m),k(p)}}async function de(n){let s;try{s=await N(()=>T({relayUrl:t.relayUrl,websiteKey:t.websiteKey,bookingRef:n,signal:u.signal}),{intervalMs:t.pollIntervalMs,maxTries:t.maxTries,signal:u.signal})}catch(c){if(!o||c?.name==="AbortError")return;t.onError&&x(t.onError,c),h("poll_error",n,c.message||r.networkError),d=!1;return}if(!(!o||s.status==="aborted")){if(t.onStatus&&s.data&&x(t.onStatus,s.data),s.resolved){try{sessionStorage.removeItem(I)}catch{}h(s.status,n)}else h("pending_confirmation",n);d=!1}}function Z(n,s,c){y.hidden=!0,B.hidden=!0,U.hidden=!0,b.hidden=!1,h(n,s,c)}function h(n,s,c){let m=A(n),p=l("div",{className:`mylikita-widget__status-icon ${m.kind}`});p.textContent=ve(m.kind);let me=l("p",{className:"mylikita-widget__status-title",text:m.title}),ce=l("p",{className:"mylikita-widget__status-message",text:c||m.message}),ue=l("p",{className:"mylikita-widget__status-ref",text:s?`Booking ref: ${s}`:""}),te=l("button",{className:"mylikita-widget__link-btn",type:"button",text:r.bookAnother});te.addEventListener("click",()=>ee()),b.replaceChildren(p,me,ce,ue,te)}function k(n){d=!1,w.disabled=!1,w.textContent=r.submit,D(n)}function D(n){L.textContent=n||"",L.classList.toggle("visible",!!n)}function ee(){try{sessionStorage.removeItem(`mylikita_ref_${t.facilityId}`)}catch{}y.reset(),D(null),b.replaceChildren(),b.hidden=!0,y.hidden=!1,B.hidden=!1,U.hidden=!1,M.textContent="",d=!1,w.disabled=!1,w.textContent=r.submit,v.input.min=ne(new Date)}a.replaceChildren(B,U,y,b);let K=null;if(t.loadProviders){let n=new AbortController;(async()=>{try{let s=await E({relayUrl:t.relayUrl,websiteKey:t.websiteKey,signal:n.signal});o&&!n.signal.aborted&&se(s)}catch(s){if(!o||s?.name==="AbortError")return;t.onError&&x(t.onError,s)}})(),K=()=>n.abort()}return{destroy(){o=!1,u&&u.abort(),K&&K(),a.replaceChildren(),a.classList.remove("mylikita-widget")},reset:ee,getForm(){return{name:q.input.value,phone:z.input.value,email:R.input.value}}}}function _(i,e,{type:t="text",required:r=!1,maxlength:a,inputmode:o}={}){let d=l("div",{className:"mylikita-widget__field"}),u=l("label",{className:"mylikita-widget__label",attrs:{for:`mlw-${i}`}});u.append(document.createTextNode(e)),r&&u.append(l("span",{className:"req",text:" *"}));let f;return t==="textarea"?f=l("textarea",{className:"mylikita-widget__textarea",attrs:{id:`mlw-${i}`,rows:3,maxlength:a||""}}):f=l("input",{className:"mylikita-widget__input",attrs:{id:`mlw-${i}`,type:t,inputmode:o||""}}),r&&f.setAttribute("required",""),d.append(u,f),{wrap:d,input:f}}function j(i,e,t){let r=l("div",{className:"mylikita-widget__field"}),a=l("label",{className:"mylikita-widget__label",attrs:{for:`mlw-${i}`}});a.textContent=e;let o=l("select",{className:"mylikita-widget__select",attrs:{id:`mlw-${i}`}});for(let d of t){let u=l("option",{text:d.label});u.value=d.value,o.append(u)}return r.append(a,o),{wrap:r,input:o}}function l(i,{className:e,text:t,attrs:r={}}={}){let a=document.createElement(i);e&&(a.className=e),t!==void 0&&(a.textContent=t);for(let[o,d]of Object.entries(r))d===void 0||d===""||a.setAttribute(o,d);return a}function ve(i){return i==="success"?"\u2713":i==="danger"?"!":"\u2026"}function he(i){try{return sessionStorage.getItem(i)||null}catch{return null}}function x(i,...e){try{i(...e)}catch{}}function ne(i){let e=t=>String(t).padStart(2,"0");return`${i.getFullYear()}-${e(i.getMonth()+1)}-${e(i.getDate())}T${e(i.getHours())}:${e(i.getMinutes())}`}function ke(){if(document.getElementById(ae))return;let i=document.createElement("style");i.id=ae,i.textContent=re,document.head.appendChild(i)}function Se(i,e){for(let[t,r]of Object.entries($(e)))i.style.setProperty(t,r)}return _e(Ee);})();
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mylikita/booking-widget",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drop-in appointment booking widget for MyLikita hospital websites. Submits bookings through the MyLikita relay (POST /v1/bookings) and polls status (GET /v1/bookings/:ref). Themeable via CSS variables.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/mylikita-booking-widget.cjs",
|
|
7
|
+
"module": "dist/mylikita-booking-widget.esm.js",
|
|
8
|
+
"browser": "dist/mylikita-booking-widget.js",
|
|
9
|
+
"unpkg": "dist/mylikita-booking-widget.min.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/mylikita-booking-widget.esm.js",
|
|
13
|
+
"require": "./dist/mylikita-booking-widget.cjs",
|
|
14
|
+
"default": "./dist/mylikita-booking-widget.esm.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "node build.mjs",
|
|
24
|
+
"build:demo": "node build-demo.mjs",
|
|
25
|
+
"test": "node test/widget.test.js",
|
|
26
|
+
"prepublishOnly": "npm run build && npm test"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"mylikita",
|
|
30
|
+
"appointment",
|
|
31
|
+
"booking",
|
|
32
|
+
"widget",
|
|
33
|
+
"hospital",
|
|
34
|
+
"relay",
|
|
35
|
+
"healthcare"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"esbuild": "^0.24.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay API client (pure — no DOM). Implements the exact v1 contract from
|
|
3
|
+
* WEBSITE_BOOKING_API.md:
|
|
4
|
+
*
|
|
5
|
+
* POST /v1/bookings → { booking_ref, status } | 400/401/403/409/429/500
|
|
6
|
+
* GET /v1/bookings/:ref → { booking_ref, status, appt_ref? }
|
|
7
|
+
* GET /v1/providers → { facility_id, providers: [{external_id, name, ...}] }
|
|
8
|
+
*
|
|
9
|
+
* Auth is `Authorization: Bearer <website_key>` on every request. The
|
|
10
|
+
* website_key is a public client id by design (never a secret).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a booking. Returns `{ ok: true, booking_ref, status }` on success.
|
|
15
|
+
* A 409 `duplicate_booking` is NOT an error — the patient double-submitted or
|
|
16
|
+
* refreshed — it resolves with `{ ok: true, duplicate: true, booking_ref }`
|
|
17
|
+
* and the caller treats it as success (per the contract §4) so the widget can
|
|
18
|
+
* poll the existing booking. Other failures throw an Error with `.code` and
|
|
19
|
+
* `.status`.
|
|
20
|
+
*/
|
|
21
|
+
export async function createBooking({ relayUrl, websiteKey, payload, signal }) {
|
|
22
|
+
const res = await fetch(`${trimUrl(relayUrl)}/v1/bookings`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
Authorization: `Bearer ${websiteKey}`,
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify(payload),
|
|
29
|
+
signal,
|
|
30
|
+
});
|
|
31
|
+
const body = await readJson(res);
|
|
32
|
+
|
|
33
|
+
if (res.status === 409) {
|
|
34
|
+
return { ok: true, duplicate: true, booking_ref: body?.booking_ref, error: body?.message };
|
|
35
|
+
}
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
throw apiError(res.status, body, 'create');
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, duplicate: false, booking_ref: body?.booking_ref, status: body?.status || 'pending_confirmation' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fetch the facility's mapped provider list (Phase C2/C3) — the doctors the
|
|
44
|
+
* hospital has published for the website's dropdown. The hospital pushes this
|
|
45
|
+
* registry to the relay on its sync cycle; the website only ever sees mapped,
|
|
46
|
+
* active providers (names + slugs, no PHI). Returns a normalized array
|
|
47
|
+
* `[{ external_id, name, specialty, module }]`; an empty list is valid (the
|
|
48
|
+
* widget falls back to "No preference").
|
|
49
|
+
*/
|
|
50
|
+
export async function fetchProviders({ relayUrl, websiteKey, signal }) {
|
|
51
|
+
const res = await fetch(`${trimUrl(relayUrl)}/v1/providers`, {
|
|
52
|
+
headers: { Authorization: `Bearer ${websiteKey}` },
|
|
53
|
+
signal,
|
|
54
|
+
});
|
|
55
|
+
const body = await readJson(res);
|
|
56
|
+
if (!res.ok) throw apiError(res.status, body, 'providers');
|
|
57
|
+
const list = Array.isArray(body?.providers) ? body.providers : [];
|
|
58
|
+
return list.map((p) => ({
|
|
59
|
+
external_id: p.external_id,
|
|
60
|
+
name: p.name || p.external_id,
|
|
61
|
+
specialty: p.specialty || null,
|
|
62
|
+
module: p.module || 'general',
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fetch a booking's current status. */
|
|
67
|
+
export async function fetchStatus({ relayUrl, websiteKey, bookingRef, signal }) {
|
|
68
|
+
const res = await fetch(`${trimUrl(relayUrl)}/v1/bookings/${encodeURIComponent(bookingRef)}`, {
|
|
69
|
+
headers: { Authorization: `Bearer ${websiteKey}` },
|
|
70
|
+
signal,
|
|
71
|
+
});
|
|
72
|
+
const body = await readJson(res);
|
|
73
|
+
if (!res.ok) throw apiError(res.status, body, 'status');
|
|
74
|
+
return { booking_ref: body?.booking_ref, status: body?.status || 'pending_confirmation', appt_ref: body?.appt_ref || null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bounded status poll. Calls `client()` (a thunk returning a status object)
|
|
79
|
+
* every `intervalMs` until the status leaves `pending_confirmation` or
|
|
80
|
+
* `maxTries` attempts run out.
|
|
81
|
+
*
|
|
82
|
+
* Cancellation: pass an `AbortSignal`. Aborting resolves early with
|
|
83
|
+
* `{ status: 'aborted', resolved: false }` — the caller should treat that as
|
|
84
|
+
* "stop, the widget was destroyed", not as an error.
|
|
85
|
+
*
|
|
86
|
+
* @returns {Promise<{status:string, resolved:boolean, data:object}>}
|
|
87
|
+
*/
|
|
88
|
+
export async function pollStatus(client, { intervalMs = 5000, maxTries = 12, signal } = {}) {
|
|
89
|
+
for (let i = 0; i < maxTries; i++) {
|
|
90
|
+
if (signal?.aborted) return { status: 'aborted', resolved: false, data: null };
|
|
91
|
+
const data = await client();
|
|
92
|
+
if (data.status !== 'pending_confirmation') {
|
|
93
|
+
return { status: data.status, resolved: true, data };
|
|
94
|
+
}
|
|
95
|
+
if (i < maxTries - 1) await sleep(intervalMs, signal);
|
|
96
|
+
}
|
|
97
|
+
return { status: 'pending_confirmation', resolved: false, data: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function newExternalRef() {
|
|
101
|
+
return `BK-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── internals ───────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function trimUrl(url) {
|
|
107
|
+
return String(url || '').replace(/\/+$/, '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function apiError(status, body, phase) {
|
|
111
|
+
const err = new Error(body?.message || body?.error || `Relay ${phase} failed (HTTP ${status})`);
|
|
112
|
+
err.code = body?.error || 'http_error';
|
|
113
|
+
err.status = status;
|
|
114
|
+
return err;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readJson(res) {
|
|
118
|
+
try { return await res.json(); } catch (_) { return null; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sleep(ms, signal) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
if (signal?.aborted) return resolve();
|
|
124
|
+
const onAbort = () => { clearTimeout(t); resolve(); };
|
|
125
|
+
const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
126
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
127
|
+
});
|
|
128
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mylikita/booking-widget
|
|
3
|
+
*
|
|
4
|
+
* Drop-in appointment booking for MyLikita hospital websites. Mounts a
|
|
5
|
+
* self-contained booking form into any element; submissions go through the
|
|
6
|
+
* MyLikita relay (POST /v1/bookings) and the widget polls the booking until
|
|
7
|
+
* the hospital confirms, cancels, reschedules, no-shows or the request
|
|
8
|
+
* expires (GET /v1/bookings/:ref).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
*
|
|
12
|
+
* <div id="booking"></div>
|
|
13
|
+
* <script src="mylikita-booking-widget.min.js"></script>
|
|
14
|
+
* <script>
|
|
15
|
+
* MyLikitaBookingWidget.createBookingWidget(document.getElementById('booking'), {
|
|
16
|
+
* relayUrl: 'https://api.mylikita.clinic',
|
|
17
|
+
* websiteKey: 'wk_...', // public client id — not a secret
|
|
18
|
+
* facilityId: 'F1',
|
|
19
|
+
* providers: [{ external_id: 'dr-khalil', label: 'Dr. Khalil' }],
|
|
20
|
+
* theme: { primary: '#0d6efd' },
|
|
21
|
+
* });
|
|
22
|
+
* </script>
|
|
23
|
+
*
|
|
24
|
+
* See README.md for the full option reference and theming guide.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export { createBookingWidget } from './widget.js';
|
|
28
|
+
export { createBooking, fetchStatus, pollStatus, newExternalRef, fetchProviders } from './client.js';
|
|
29
|
+
export { STATUS_COPY, statusCopy, TERMINAL_STATUSES } from './state.js';
|
|
30
|
+
export { DEFAULT_THEME, resolveTheme } from './theme.js';
|
package/src/state.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patient-facing copy for each booking status, mirroring the "Patient-facing
|
|
3
|
+
* hint" column of WEBSITE_BOOKING_API.md §3. `kind` drives the badge colour.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const STATUS_COPY = {
|
|
7
|
+
pending_confirmation: {
|
|
8
|
+
title: 'Request received',
|
|
9
|
+
message: "We've received your booking request — we'll confirm shortly.",
|
|
10
|
+
kind: 'info',
|
|
11
|
+
},
|
|
12
|
+
confirmed: {
|
|
13
|
+
title: 'Appointment confirmed',
|
|
14
|
+
message: 'Your appointment is confirmed. See you at the clinic!',
|
|
15
|
+
kind: 'success',
|
|
16
|
+
},
|
|
17
|
+
cancelled: {
|
|
18
|
+
title: 'Appointment cancelled',
|
|
19
|
+
message: 'This appointment was cancelled. Please contact the clinic if this was unexpected.',
|
|
20
|
+
kind: 'danger',
|
|
21
|
+
},
|
|
22
|
+
rescheduled: {
|
|
23
|
+
title: 'Appointment rescheduled',
|
|
24
|
+
message: 'This appointment was moved — the new time was sent to you.',
|
|
25
|
+
kind: 'info',
|
|
26
|
+
},
|
|
27
|
+
no_show: {
|
|
28
|
+
title: 'Missed appointment',
|
|
29
|
+
message: 'This appointment was marked as missed.',
|
|
30
|
+
kind: 'danger',
|
|
31
|
+
},
|
|
32
|
+
expired: {
|
|
33
|
+
title: 'Request expired',
|
|
34
|
+
message: 'This booking request expired — please call the clinic to book.',
|
|
35
|
+
kind: 'danger',
|
|
36
|
+
},
|
|
37
|
+
// Widget-internal: the poll itself failed (network, rotated key, etc.).
|
|
38
|
+
poll_error: {
|
|
39
|
+
title: 'Something went wrong',
|
|
40
|
+
message: 'We could not check your booking right now. Please try again shortly.',
|
|
41
|
+
kind: 'danger',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function statusCopy(status) {
|
|
46
|
+
return STATUS_COPY[status] || STATUS_COPY.pending_confirmation;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const TERMINAL_STATUSES = ['confirmed', 'cancelled', 'rescheduled', 'no_show', 'expired'];
|
package/src/styles.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget styles, injected once per document as a <style id="mylikita-widget-styles">.
|
|
3
|
+
* Everything is scoped under `.mylikita-widget` and driven by CSS custom
|
|
4
|
+
* properties (see theme.js) so host pages can restyle without fighting
|
|
5
|
+
* specificity wars.
|
|
6
|
+
*/
|
|
7
|
+
export const STYLES = `
|
|
8
|
+
.mylikita-widget {
|
|
9
|
+
--mlw-primary: #0d6efd;
|
|
10
|
+
--mlw-primary-dark: #0b5ed7;
|
|
11
|
+
--mlw-primary-text: #ffffff;
|
|
12
|
+
--mlw-bg: #ffffff;
|
|
13
|
+
--mlw-text: #1e293b;
|
|
14
|
+
--mlw-muted: #64748b;
|
|
15
|
+
--mlw-border: #e2e8f0;
|
|
16
|
+
--mlw-danger: #dc3545;
|
|
17
|
+
--mlw-success: #15803d;
|
|
18
|
+
--mlw-radius: 10px;
|
|
19
|
+
--mlw-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
|
20
|
+
font-family: var(--mlw-font);
|
|
21
|
+
color: var(--mlw-text);
|
|
22
|
+
background: var(--mlw-bg);
|
|
23
|
+
border: 1px solid var(--mlw-border);
|
|
24
|
+
border-radius: calc(var(--mlw-radius) + 2px);
|
|
25
|
+
padding: 22px;
|
|
26
|
+
max-width: 480px;
|
|
27
|
+
box-sizing: border-box;
|
|
28
|
+
line-height: 1.5;
|
|
29
|
+
}
|
|
30
|
+
.mylikita-widget *,
|
|
31
|
+
.mylikita-widget *::before,
|
|
32
|
+
.mylikita-widget *::after { box-sizing: border-box; }
|
|
33
|
+
|
|
34
|
+
.mylikita-widget__title {
|
|
35
|
+
font-size: 18px;
|
|
36
|
+
font-weight: 700;
|
|
37
|
+
margin: 0 0 4px;
|
|
38
|
+
color: var(--mlw-text);
|
|
39
|
+
}
|
|
40
|
+
.mylikita-widget__subtitle {
|
|
41
|
+
font-size: 13px;
|
|
42
|
+
color: var(--mlw-muted);
|
|
43
|
+
margin: 0 0 16px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.mylikita-widget__field { margin-bottom: 12px; }
|
|
47
|
+
.mylikita-widget__label {
|
|
48
|
+
display: block;
|
|
49
|
+
font-size: 12px;
|
|
50
|
+
font-weight: 600;
|
|
51
|
+
color: var(--mlw-text);
|
|
52
|
+
margin-bottom: 5px;
|
|
53
|
+
}
|
|
54
|
+
.mylikita-widget__label .req { color: var(--mlw-danger); }
|
|
55
|
+
.mylikita-widget__input,
|
|
56
|
+
.mylikita-widget__select,
|
|
57
|
+
.mylikita-widget__textarea {
|
|
58
|
+
width: 100%;
|
|
59
|
+
font: inherit;
|
|
60
|
+
font-size: 14px;
|
|
61
|
+
color: var(--mlw-text);
|
|
62
|
+
background: var(--mlw-bg);
|
|
63
|
+
border: 1px solid var(--mlw-border);
|
|
64
|
+
border-radius: var(--mlw-radius);
|
|
65
|
+
padding: 9px 11px;
|
|
66
|
+
outline: none;
|
|
67
|
+
transition: border-color .15s ease, box-shadow .15s ease;
|
|
68
|
+
}
|
|
69
|
+
.mylikita-widget__input:focus,
|
|
70
|
+
.mylikita-widget__select:focus,
|
|
71
|
+
.mylikita-widget__textarea:focus {
|
|
72
|
+
border-color: var(--mlw-primary);
|
|
73
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--mlw-primary) 22%, transparent);
|
|
74
|
+
}
|
|
75
|
+
.mylikita-widget__row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
76
|
+
@media (max-width: 420px) { .mylikita-widget__row { grid-template-columns: 1fr; } }
|
|
77
|
+
|
|
78
|
+
.mylikita-widget__error {
|
|
79
|
+
display: none;
|
|
80
|
+
font-size: 13px;
|
|
81
|
+
color: var(--mlw-danger);
|
|
82
|
+
background: color-mix(in srgb, var(--mlw-danger) 8%, transparent);
|
|
83
|
+
border: 1px solid color-mix(in srgb, var(--mlw-danger) 35%, transparent);
|
|
84
|
+
border-radius: var(--mlw-radius);
|
|
85
|
+
padding: 9px 12px;
|
|
86
|
+
margin-bottom: 12px;
|
|
87
|
+
}
|
|
88
|
+
.mylikita-widget__error.visible { display: block; }
|
|
89
|
+
|
|
90
|
+
.mylikita-widget__submit {
|
|
91
|
+
width: 100%;
|
|
92
|
+
font: inherit;
|
|
93
|
+
font-size: 14px;
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
color: var(--mlw-primary-text);
|
|
96
|
+
background: var(--mlw-primary);
|
|
97
|
+
border: none;
|
|
98
|
+
border-radius: var(--mlw-radius);
|
|
99
|
+
padding: 11px 16px;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
transition: background .15s ease, transform .05s ease;
|
|
102
|
+
}
|
|
103
|
+
.mylikita-widget__submit:hover { background: var(--mlw-primary-dark); }
|
|
104
|
+
.mylikita-widget__submit:active { transform: translateY(1px); }
|
|
105
|
+
.mylikita-widget__submit:disabled { opacity: .6; cursor: wait; }
|
|
106
|
+
|
|
107
|
+
.mylikita-widget__hint { font-size: 12px; color: var(--mlw-muted); margin: 8px 0 0; }
|
|
108
|
+
|
|
109
|
+
/* status view */
|
|
110
|
+
.mylikita-widget__status { text-align: center; padding: 8px 4px; }
|
|
111
|
+
.mylikita-widget__status-icon {
|
|
112
|
+
width: 46px; height: 46px;
|
|
113
|
+
border-radius: 50%;
|
|
114
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
115
|
+
font-size: 22px; margin-bottom: 10px;
|
|
116
|
+
}
|
|
117
|
+
.mylikita-widget__status-icon.info { background: color-mix(in srgb, var(--mlw-primary) 12%, transparent); }
|
|
118
|
+
.mylikita-widget__status-icon.success { background: color-mix(in srgb, var(--mlw-success) 14%, transparent); }
|
|
119
|
+
.mylikita-widget__status-icon.danger { background: color-mix(in srgb, var(--mlw-danger) 12%, transparent); }
|
|
120
|
+
.mylikita-widget__status-title { font-size: 16px; font-weight: 700; margin: 0 0 4px; }
|
|
121
|
+
.mylikita-widget__status-message { font-size: 13px; color: var(--mlw-muted); margin: 0 0 14px; }
|
|
122
|
+
.mylikita-widget__status-ref { font-size: 12px; color: var(--mlw-muted); margin: 0 0 14px; }
|
|
123
|
+
|
|
124
|
+
.mylikita-widget__spinner {
|
|
125
|
+
width: 18px; height: 18px;
|
|
126
|
+
display: inline-block;
|
|
127
|
+
border: 2px solid color-mix(in srgb, var(--mlw-primary-text) 40%, transparent);
|
|
128
|
+
border-top-color: var(--mlw-primary-text);
|
|
129
|
+
border-radius: 50%;
|
|
130
|
+
animation: mylikita-widget-spin .7s linear infinite;
|
|
131
|
+
vertical-align: -3px;
|
|
132
|
+
margin-right: 7px;
|
|
133
|
+
}
|
|
134
|
+
@keyframes mylikita-widget-spin { to { transform: rotate(360deg); } }
|
|
135
|
+
|
|
136
|
+
.mylikita-widget__link-btn {
|
|
137
|
+
background: none;
|
|
138
|
+
border: 1px solid var(--mlw-border);
|
|
139
|
+
border-radius: var(--mlw-radius);
|
|
140
|
+
color: var(--mlw-text);
|
|
141
|
+
font: inherit;
|
|
142
|
+
font-size: 13px;
|
|
143
|
+
padding: 8px 14px;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
}
|
|
146
|
+
.mylikita-widget__link-btn:hover { border-color: var(--mlw-primary); color: var(--mlw-primary); }
|
|
147
|
+
`;
|
package/src/theme.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme hooks.
|
|
3
|
+
*
|
|
4
|
+
* The widget renders inside a `.mylikita-widget` container and every colour,
|
|
5
|
+
* radius and font is a CSS custom property on that container. Agencies can
|
|
6
|
+
* theme it two ways:
|
|
7
|
+
*
|
|
8
|
+
* 1. Programmatically: pass `theme: { primary: '#e91e63', ... }` to
|
|
9
|
+
* createBookingWidget() — the widget sets the CSS vars itself.
|
|
10
|
+
* 2. Pure CSS: any stylesheet can set the vars on `.mylikita-widget` and
|
|
11
|
+
* they win (the widget only writes vars for values you passed).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_THEME = {
|
|
15
|
+
primary: '#0d6efd',
|
|
16
|
+
primaryDark: '#0b5ed7',
|
|
17
|
+
primaryText: '#ffffff',
|
|
18
|
+
bg: '#ffffff',
|
|
19
|
+
text: '#1e293b',
|
|
20
|
+
muted: '#64748b',
|
|
21
|
+
border: '#e2e8f0',
|
|
22
|
+
danger: '#dc3545',
|
|
23
|
+
success: '#15803d',
|
|
24
|
+
radius: 10,
|
|
25
|
+
font: "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map a (partial) theme object to CSS custom properties, merged over the
|
|
30
|
+
* defaults. Unknown keys are ignored; `radius` is a number → `${n}px`.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveTheme(theme = {}) {
|
|
33
|
+
const t = { ...DEFAULT_THEME, ...(theme || {}) };
|
|
34
|
+
return {
|
|
35
|
+
'--mlw-primary': t.primary,
|
|
36
|
+
'--mlw-primary-dark': t.primaryDark,
|
|
37
|
+
'--mlw-primary-text': t.primaryText,
|
|
38
|
+
'--mlw-bg': t.bg,
|
|
39
|
+
'--mlw-text': t.text,
|
|
40
|
+
'--mlw-muted': t.muted,
|
|
41
|
+
'--mlw-border': t.border,
|
|
42
|
+
'--mlw-danger': t.danger,
|
|
43
|
+
'--mlw-success': t.success,
|
|
44
|
+
'--mlw-radius': `${t.radius}px`,
|
|
45
|
+
'--mlw-font': t.font,
|
|
46
|
+
};
|
|
47
|
+
}
|