@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 ADDED
@@ -0,0 +1,169 @@
1
+ # @mylikita/booking-widget
2
+
3
+ Drop-in appointment booking for hospital websites built on MyLikita. A
4
+ zero-dependency vanilla-JS widget (no framework required) that:
5
+
6
+ 1. renders a booking form into any element on your page,
7
+ 2. submits through the **MyLikita relay** (`POST /v1/bookings`),
8
+ 3. polls the booking (`GET /v1/bookings/:ref`) until the hospital confirms,
9
+ cancels, reschedules, marks no-show, or the request expires.
10
+
11
+ It implements the [MyLikita Website Booking API (v1)](/backend/WEBSITE_BOOKING_API.md)
12
+ exactly — same auth, same fields, same idempotency rules, same statuses.
13
+
14
+ ---
15
+
16
+ ## Install
17
+
18
+ **Script tag (simplest for agency sites):**
19
+
20
+ ```html
21
+ <script src="https://unpkg.com/@mylikita/booking-widget"></script>
22
+ <div id="booking"></div>
23
+ <script>
24
+ MyLikitaBookingWidget.createBookingWidget(document.getElementById('booking'), {
25
+ relayUrl: 'https://api.mylikita.clinic',
26
+ websiteKey: 'wk_9f2k…', // public client id — not a secret
27
+ facilityId: 'F1',
28
+ });
29
+ </script>
30
+ ```
31
+
32
+ **npm (for bundlers):**
33
+
34
+ ```bash
35
+ npm install @mylikita/booking-widget
36
+ ```
37
+
38
+ ```js
39
+ import { createBookingWidget } from '@mylikita/booking-widget';
40
+ createBookingWidget(document.getElementById('booking'), { relayUrl, websiteKey, facilityId });
41
+ ```
42
+
43
+ **React sites:** use [`@mylikita/booking-widget-react`](/packages/booking-widget-react) — the
44
+ same widget as a `<BookingWidget relayUrl=… websiteKey=… facilityId=… />`
45
+ component (all options are props, SSR-safe, imperative ref).
46
+
47
+ ## Options
48
+
49
+ | Option | Type | Default | Description |
50
+ |---|---|---|---|
51
+ | `relayUrl` | string | — | **required** — relay base URL, e.g. `https://api.mylikita.clinic` |
52
+ | `websiteKey` | string | — | **required** — your public client id (Bearer on every request) |
53
+ | `facilityId` | string | — | **required** — the hospital's public facility id |
54
+ | `providers` | array | `[]` | `[{ external_id, label }]` — shown as a "Preferred doctor" select; unmapped slugs simply arrive unassigned (never block on them). When provided, it wins and no fetch happens |
55
+ | `loadProviders` | bool | `false` | **Phase C2/C3** — fetch the facility's mapped providers from the relay (`GET /v1/providers`) on mount and populate the doctor dropdown automatically. Ignored when `providers` is non-empty. A fetch failure is non-fatal: the widget keeps "No preference" and still works |
56
+ | `services` | array | `[]` | `['General consultation', …]` — shown as a service select (free-text input otherwise) |
57
+ | `durationMins` | number | `30` | sent with the booking |
58
+ | `pollIntervalMs` | number | `5000` | how often to poll the booking status |
59
+ | `maxTries` | number | `12` | poll budget (~1 min at the default); the widget then shows "request received" |
60
+ | `theme` | object | defaults | see [Theming](#theming) |
61
+ | `text` | object | defaults | i18n overrides for every label/message (see `src/widget.js` `DEFAULT_TEXT`) |
62
+ | `externalRef` | fn | auto | override the idempotency-key generator |
63
+ | `onBooking` | fn | — | `(booking, payload) => …` called once the relay accepted it |
64
+ | `onStatus` | fn | — | `(statusObj) => …` called on each poll result |
65
+ | `onError` | fn | — | `(err) => …` on create failure |
66
+
67
+ Returns `{ destroy(), reset(), getForm() }`.
68
+
69
+ ## Provider list (doctor dropdown) — `loadProviders`
70
+
71
+ The hospital controls which doctors appear on your website. Staff map a
72
+ website slug (`external_id`) to each doctor in the hub **Providers** tab; the
73
+ hospital server pushes those mapped providers to the relay on every sync cycle
74
+ (`POST /v1/out/providers`), and the widget fetches them on mount
75
+ (`GET /v1/providers`):
76
+
77
+ ```js
78
+ createBookingWidget(el, {
79
+ relayUrl, websiteKey, facilityId,
80
+ loadProviders: true, // fetch the mapped doctor list instead of hardcoding
81
+ });
82
+ ```
83
+
84
+ - Only **mapped, active** providers are served — the hospital decides what the
85
+ website sees (names + slugs only; no phones, no emails, no PHI).
86
+ - The dropdown appears the moment the fetch resolves; a slow/offline relay
87
+ degrades gracefully to "No preference" (a submitted booking with no doctor
88
+ simply arrives unassigned — the hospital assigns one).
89
+ - Unmapping a doctor on the hospital side removes them from the dropdown on
90
+ the next sync cycle (~2 min).
91
+
92
+ ## Idempotency & double-submit (built in)
93
+
94
+ - An `external_ref` is minted (`BK-<ts>-<rand>`) and stored in `sessionStorage`
95
+ **before** submitting, so a page refresh resubmits the *same* booking instead
96
+ of creating a duplicate — the relay returns the original `booking_ref`.
97
+ - A genuine double-click on a fresh page hits the relay's
98
+ `409 duplicate_booking`; the widget treats that as success and polls the
99
+ existing booking, showing "We found an existing booking request for this slot".
100
+ - The stored ref is cleared once the booking reaches a terminal state, so the
101
+ next booking mints a fresh one.
102
+
103
+ ## Statuses
104
+
105
+ | Widget shows | When |
106
+ |---|---|
107
+ | Request received (spinner) | hospital hasn't collected it, or still pending after the poll budget |
108
+ | Confirmed ✓ | `confirmed` |
109
+ | Cancelled / Rescheduled / Missed | corresponding terminal status |
110
+ | Request expired | hospital never collected within 72 h (offline server) — "please call the clinic" |
111
+
112
+ ## Theming
113
+
114
+ Every colour, radius and font is a CSS custom property scoped to
115
+ `.mylikita-widget`, so you can restyle it from your own stylesheet without
116
+ specificity fights:
117
+
118
+ ```css
119
+ .mylikita-widget {
120
+ --mlw-primary: #0d9488; /* buttons, focus rings, accents */
121
+ --mlw-primary-dark: #0f766e; /* hover state */
122
+ --mlw-primary-text: #ffffff; /* button label */
123
+ --mlw-bg: #ffffff; /* widget background */
124
+ --mlw-text: #1e293b; /* labels + values */
125
+ --mlw-muted: #64748b; /* secondary text */
126
+ --mlw-border: #e2e8f0; /* inputs + widget border */
127
+ --mlw-danger: #dc3545;
128
+ --mlw-success: #15803d;
129
+ --mlw-radius: 10px; /* input + button radius */
130
+ --mlw-font: system-ui, …;
131
+ }
132
+ ```
133
+
134
+ Or programmatically:
135
+
136
+ ```js
137
+ createBookingWidget(el, {
138
+ relayUrl, websiteKey, facilityId,
139
+ theme: { primary: '#e91e63', radius: 6, bg: '#fffaf5' },
140
+ });
141
+ ```
142
+
143
+ ## Demo
144
+
145
+ `demo/demo.html` runs the widget **fully offline** against an in-page mock of
146
+ the relay — submit a booking and watch it flip to *confirmed* after two polls.
147
+ Open it in a browser directly:
148
+
149
+ ```bash
150
+ open packages/booking-widget/demo/demo.html
151
+ ```
152
+
153
+ ## Development
154
+
155
+ ```bash
156
+ npm run build # esbuild → dist/ (iife, iife.min, esm, cjs)
157
+ npm test # mocked-fetch suite for the core logic
158
+ ```
159
+
160
+ `dist/` is committed so the package works before any install; rebuild after
161
+ touching `src/`.
162
+
163
+ ## Security notes
164
+
165
+ - `websiteKey` is a **public client id by design** (it ships in browser JS).
166
+ It is rate-limited per key on the relay and scoped to the facility. Real
167
+ protection against abuse is the relay's per-key rate limits, not secrecy.
168
+ - The widget never receives or stores hospital patient data — only what the
169
+ patient typed into your form.
package/dist/demo.html ADDED
@@ -0,0 +1,335 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>@mylikita/booking-widget — demo</title>
7
+ <style>
8
+ :root { color-scheme: light; }
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ margin: 0;
12
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
13
+ background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
14
+ min-height: 100vh;
15
+ display: grid;
16
+ place-items: center;
17
+ padding: 40px 16px;
18
+ }
19
+ .demo { width: 100%; max-width: 860px; }
20
+ h1 { font-size: 20px; margin: 0 0 4px; color: #0f172a; }
21
+ .tagline { font-size: 13px; color: #64748b; margin: 0 0 20px; }
22
+ .cards { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
23
+ @media (max-width: 760px) { .cards { grid-template-columns: 1fr; } }
24
+ .panel {
25
+ background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 18px;
26
+ box-shadow: 0 10px 30px rgba(15, 23, 42, .06);
27
+ }
28
+ .panel h2 { font-size: 14px; margin: 0 0 10px; color: #334155; }
29
+ .theme-row { display: flex; gap: 8px; margin-top: 8px; }
30
+ .theme-row button {
31
+ font: inherit; font-size: 12px; padding: 6px 12px; border-radius: 8px; cursor: pointer;
32
+ border: 1px solid #cbd5e1; background: #f8fafc; color: #334155;
33
+ }
34
+ .theme-row button:hover { border-color: #0d6efd; color: #0d6efd; }
35
+ .log {
36
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
37
+ font-size: 12px; color: #475569; background: #f8fafc;
38
+ border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; height: 130px;
39
+ overflow: auto; margin: 12px 0 0; white-space: pre-wrap;
40
+ }
41
+ .log b { color: #0d6efd; font-weight: 600; }
42
+ </style>
43
+ </head>
44
+ <body>
45
+ <div class="demo">
46
+ <h1>@mylikita/booking-widget — offline demo</h1>
47
+ <p class="tagline">
48
+ Runs entirely in the page against an in-page mock of the MyLikita relay: submit a booking,
49
+ watch the widget poll, and see it flip to <b>confirmed</b> after two polls. No backend needed.
50
+ </p>
51
+
52
+ <div class="cards">
53
+ <div class="panel">
54
+ <h2>Widget (default theme)</h2>
55
+ <div id="booking"></div>
56
+ </div>
57
+
58
+ <div class="panel">
59
+ <h2>Second widget (custom theme) + live log</h2>
60
+ <div id="booking2"></div>
61
+ <div class="theme-row">
62
+ <button data-theme="default">Reset theme</button>
63
+ <button data-theme="teal">Teal</button>
64
+ <button data-theme="sunset">Sunset</button>
65
+ </div>
66
+ <pre class="log" id="log"></pre>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <script>
72
+ /*! @mylikita/booking-widget v0.1.0 | MIT */
73
+ 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=`
74
+ .mylikita-widget {
75
+ --mlw-primary: #0d6efd;
76
+ --mlw-primary-dark: #0b5ed7;
77
+ --mlw-primary-text: #ffffff;
78
+ --mlw-bg: #ffffff;
79
+ --mlw-text: #1e293b;
80
+ --mlw-muted: #64748b;
81
+ --mlw-border: #e2e8f0;
82
+ --mlw-danger: #dc3545;
83
+ --mlw-success: #15803d;
84
+ --mlw-radius: 10px;
85
+ --mlw-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
86
+ font-family: var(--mlw-font);
87
+ color: var(--mlw-text);
88
+ background: var(--mlw-bg);
89
+ border: 1px solid var(--mlw-border);
90
+ border-radius: calc(var(--mlw-radius) + 2px);
91
+ padding: 22px;
92
+ max-width: 480px;
93
+ box-sizing: border-box;
94
+ line-height: 1.5;
95
+ }
96
+ .mylikita-widget *,
97
+ .mylikita-widget *::before,
98
+ .mylikita-widget *::after { box-sizing: border-box; }
99
+
100
+ .mylikita-widget__title {
101
+ font-size: 18px;
102
+ font-weight: 700;
103
+ margin: 0 0 4px;
104
+ color: var(--mlw-text);
105
+ }
106
+ .mylikita-widget__subtitle {
107
+ font-size: 13px;
108
+ color: var(--mlw-muted);
109
+ margin: 0 0 16px;
110
+ }
111
+
112
+ .mylikita-widget__field { margin-bottom: 12px; }
113
+ .mylikita-widget__label {
114
+ display: block;
115
+ font-size: 12px;
116
+ font-weight: 600;
117
+ color: var(--mlw-text);
118
+ margin-bottom: 5px;
119
+ }
120
+ .mylikita-widget__label .req { color: var(--mlw-danger); }
121
+ .mylikita-widget__input,
122
+ .mylikita-widget__select,
123
+ .mylikita-widget__textarea {
124
+ width: 100%;
125
+ font: inherit;
126
+ font-size: 14px;
127
+ color: var(--mlw-text);
128
+ background: var(--mlw-bg);
129
+ border: 1px solid var(--mlw-border);
130
+ border-radius: var(--mlw-radius);
131
+ padding: 9px 11px;
132
+ outline: none;
133
+ transition: border-color .15s ease, box-shadow .15s ease;
134
+ }
135
+ .mylikita-widget__input:focus,
136
+ .mylikita-widget__select:focus,
137
+ .mylikita-widget__textarea:focus {
138
+ border-color: var(--mlw-primary);
139
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--mlw-primary) 22%, transparent);
140
+ }
141
+ .mylikita-widget__row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
142
+ @media (max-width: 420px) { .mylikita-widget__row { grid-template-columns: 1fr; } }
143
+
144
+ .mylikita-widget__error {
145
+ display: none;
146
+ font-size: 13px;
147
+ color: var(--mlw-danger);
148
+ background: color-mix(in srgb, var(--mlw-danger) 8%, transparent);
149
+ border: 1px solid color-mix(in srgb, var(--mlw-danger) 35%, transparent);
150
+ border-radius: var(--mlw-radius);
151
+ padding: 9px 12px;
152
+ margin-bottom: 12px;
153
+ }
154
+ .mylikita-widget__error.visible { display: block; }
155
+
156
+ .mylikita-widget__submit {
157
+ width: 100%;
158
+ font: inherit;
159
+ font-size: 14px;
160
+ font-weight: 600;
161
+ color: var(--mlw-primary-text);
162
+ background: var(--mlw-primary);
163
+ border: none;
164
+ border-radius: var(--mlw-radius);
165
+ padding: 11px 16px;
166
+ cursor: pointer;
167
+ transition: background .15s ease, transform .05s ease;
168
+ }
169
+ .mylikita-widget__submit:hover { background: var(--mlw-primary-dark); }
170
+ .mylikita-widget__submit:active { transform: translateY(1px); }
171
+ .mylikita-widget__submit:disabled { opacity: .6; cursor: wait; }
172
+
173
+ .mylikita-widget__hint { font-size: 12px; color: var(--mlw-muted); margin: 8px 0 0; }
174
+
175
+ /* status view */
176
+ .mylikita-widget__status { text-align: center; padding: 8px 4px; }
177
+ .mylikita-widget__status-icon {
178
+ width: 46px; height: 46px;
179
+ border-radius: 50%;
180
+ display: inline-flex; align-items: center; justify-content: center;
181
+ font-size: 22px; margin-bottom: 10px;
182
+ }
183
+ .mylikita-widget__status-icon.info { background: color-mix(in srgb, var(--mlw-primary) 12%, transparent); }
184
+ .mylikita-widget__status-icon.success { background: color-mix(in srgb, var(--mlw-success) 14%, transparent); }
185
+ .mylikita-widget__status-icon.danger { background: color-mix(in srgb, var(--mlw-danger) 12%, transparent); }
186
+ .mylikita-widget__status-title { font-size: 16px; font-weight: 700; margin: 0 0 4px; }
187
+ .mylikita-widget__status-message { font-size: 13px; color: var(--mlw-muted); margin: 0 0 14px; }
188
+ .mylikita-widget__status-ref { font-size: 12px; color: var(--mlw-muted); margin: 0 0 14px; }
189
+
190
+ .mylikita-widget__spinner {
191
+ width: 18px; height: 18px;
192
+ display: inline-block;
193
+ border: 2px solid color-mix(in srgb, var(--mlw-primary-text) 40%, transparent);
194
+ border-top-color: var(--mlw-primary-text);
195
+ border-radius: 50%;
196
+ animation: mylikita-widget-spin .7s linear infinite;
197
+ vertical-align: -3px;
198
+ margin-right: 7px;
199
+ }
200
+ @keyframes mylikita-widget-spin { to { transform: rotate(360deg); } }
201
+
202
+ .mylikita-widget__link-btn {
203
+ background: none;
204
+ border: 1px solid var(--mlw-border);
205
+ border-radius: var(--mlw-radius);
206
+ color: var(--mlw-text);
207
+ font: inherit;
208
+ font-size: 13px;
209
+ padding: 8px 14px;
210
+ cursor: pointer;
211
+ }
212
+ .mylikita-widget__link-btn:hover { border-color: var(--mlw-primary); color: var(--mlw-primary); }
213
+ `;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);})();
214
+
215
+ </script>
216
+ <script>
217
+ // ── in-page mock relay (implements the v1 contract: POST /v1/bookings,
218
+ // GET /v1/bookings/:ref). Swapped in by overriding global fetch ─────
219
+ // NOTE: demo-only. A production page must NOT override fetch — point the
220
+ // widget at the real relay URL instead.
221
+ const bookings = new Map(); // booking_ref -> { payload, polls }
222
+ const log = (line) => {
223
+ const el = document.getElementById('log');
224
+ el.innerHTML = line + '\n' + el.innerHTML;
225
+ };
226
+
227
+ const mockRelay = async (url, init) => {
228
+ const method = (init && init.method) || 'GET';
229
+ const u = new URL(url, location.href);
230
+
231
+ if (method === 'POST' && u.pathname === '/v1/bookings') {
232
+ const body = JSON.parse(init.body);
233
+ const existing = [...bookings.values()].find((b) => b.external_ref === body.external_ref);
234
+ if (existing) {
235
+ log('<b>[mock relay]</b> idempotent replay — same external_ref, returning existing booking');
236
+ return json(201, { booking_ref: existing.booking_ref, status: 'pending_confirmation' });
237
+ }
238
+ const dup = [...bookings.values()].find(
239
+ (b) => b.phone === body.patient_phone && b.datetime === body.appt_datetime
240
+ );
241
+ if (dup) {
242
+ log('<b>[mock relay]</b> duplicate slot → 409 duplicate_booking');
243
+ return json(409, { error: 'duplicate_booking', message: 'This patient already has this appointment slot booked', booking_ref: dup.booking_ref });
244
+ }
245
+ const booking_ref = 'MLB-' + Date.now().toString(36).toUpperCase();
246
+ bookings.set(booking_ref, { ...body, booking_ref, external_ref: body.external_ref, phone: body.patient_phone, datetime: body.appt_datetime, polls: 0 });
247
+ log('<b>[mock relay]</b> booking ' + booking_ref + ' stored (pending_confirmation)');
248
+ return json(201, { booking_ref, status: 'pending_confirmation' });
249
+ }
250
+
251
+ if (method === 'GET' && u.pathname === '/v1/providers') {
252
+ log('<b>[mock relay]</b> GET /v1/providers — serving the mapped provider list');
253
+ return json(200, {
254
+ facility_id: FACILITY,
255
+ providers: [
256
+ { external_id: 'dr-khalil', name: 'Dr. Khalil', specialty: 'General medicine' },
257
+ { external_id: 'dr-amina', name: 'Dr. Amina', specialty: 'Dentistry' },
258
+ ],
259
+ });
260
+ }
261
+
262
+ if (method === 'GET' && u.pathname.startsWith('/v1/bookings/')) {
263
+ const ref = decodeURIComponent(u.pathname.split('/').pop());
264
+ const b = bookings.get(ref);
265
+ if (!b) return json(404, { error: 'not_found', message: 'Unknown booking_ref' });
266
+ b.polls += 1;
267
+ // Transition to confirmed on the second poll so the demo shows the poll loop.
268
+ const status = b.polls >= 2 ? 'confirmed' : 'pending_confirmation';
269
+ log('<b>[mock relay]</b> poll ' + ref + ' → ' + status);
270
+ return json(200, { booking_ref: ref, status, appt_ref: status === 'confirmed' ? 'APT-DEMO123' : undefined });
271
+ }
272
+
273
+ return json(500, { error: 'server_error', message: 'mock relay: unhandled ' + method + ' ' + url });
274
+ };
275
+
276
+ const realFetch = window.fetch.bind(window);
277
+ window.fetch = (url, init) => mockRelay(url, init);
278
+
279
+ function json(status, body) {
280
+ return Promise.resolve(new Response(JSON.stringify(body), {
281
+ status,
282
+ headers: { 'Content-Type': 'application/json' },
283
+ }));
284
+ }
285
+
286
+ // ── mount two widgets ──────────────────────────────────────────────────
287
+ const KEY = 'wk_demo_public_key';
288
+ const FACILITY = 'F1';
289
+
290
+ MyLikitaBookingWidget.createBookingWidget(document.getElementById('booking'), {
291
+ // The in-page mock relay overrides fetch, so this URL is never actually
292
+ // contacted — the widget still requires a non-empty relay URL.
293
+ relayUrl: 'https://api.mylikita.clinic',
294
+ websiteKey: KEY,
295
+ facilityId: FACILITY,
296
+ providers: [{ external_id: 'dr-khalil', label: 'Dr. Khalil' }, { external_id: 'dr-amina', label: 'Dr. Amina' }],
297
+ services: ['General consultation', 'Dental check-up', 'X-ray'],
298
+ pollIntervalMs: 800,
299
+ maxTries: 20,
300
+ onStatus: (s) => log('<b>[widget 1]</b> status → ' + s.status + (s.appt_ref ? ' (' + s.appt_ref + ')' : '')),
301
+ onBooking: (b) => log('<b>[widget 1]</b> booked → ' + b.booking_ref),
302
+ });
303
+
304
+ const w2 = MyLikitaBookingWidget.createBookingWidget(document.getElementById('booking2'), {
305
+ relayUrl: 'https://api.mylikita.clinic',
306
+ websiteKey: KEY,
307
+ facilityId: FACILITY,
308
+ // Phase C2/C3: no static providers — load them from the relay's
309
+ // GET /v1/providers on mount (the mock serves the mapped list).
310
+ loadProviders: true,
311
+ pollIntervalMs: 800,
312
+ maxTries: 20,
313
+ theme: { primary: '#0d9488', primaryDark: '#0f766e' },
314
+ onStatus: (s) => log('<b>[widget 2]</b> status → ' + s.status),
315
+ onError: (e) => log('<b>[widget 2]</b> provider fetch failed (non-fatal): ' + e.message),
316
+ });
317
+
318
+ document.querySelectorAll('[data-theme]').forEach((btn) => {
319
+ btn.addEventListener('click', () => {
320
+ const preset = btn.dataset.theme;
321
+ const vars = {
322
+ teal: { '--mlw-primary': '#0d9488', '--mlw-primary-dark': '#0f766e' },
323
+ sunset: { '--mlw-primary': '#ea580c', '--mlw-primary-dark': '#c2410c', '--mlw-bg': '#fff7ed' },
324
+ }[preset] || {};
325
+ const node = document.getElementById('booking2');
326
+ for (const k of ['--mlw-primary', '--mlw-primary-dark', '--mlw-bg']) node.style.removeProperty(k);
327
+ Object.entries(vars).forEach(([k, v]) => node.style.setProperty(k, v));
328
+ log('<b>[demo]</b> theme applied via CSS variables on .mylikita-widget');
329
+ });
330
+ });
331
+
332
+ log('<b>[demo]</b> ready — submit a booking; it flips to confirmed after two polls.');
333
+ </script>
334
+ </body>
335
+ </html>