@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/src/widget.js ADDED
@@ -0,0 +1,412 @@
1
+ /**
2
+ * DOM layer of the booking widget. Renders a self-contained booking form,
3
+ * submits through the relay client, and swaps to a status view that polls the
4
+ * booking until it resolves (or the poll budget runs out). All text is
5
+ * i18n-overridable via `options.text`; all colours via `options.theme` or the
6
+ * CSS variables (see theme.js).
7
+ *
8
+ * Imported from index.js — not meant to be used directly.
9
+ */
10
+
11
+ import { createBooking, fetchStatus, pollStatus, newExternalRef, fetchProviders } from './client.js';
12
+ import { statusCopy, TERMINAL_STATUSES } from './state.js';
13
+ import { resolveTheme } from './theme.js';
14
+ import { STYLES as STYLES_CSS } from './styles.js';
15
+
16
+ const DEFAULT_TEXT = {
17
+ title: 'Book an appointment',
18
+ subtitle: 'Request a slot and we will confirm shortly.',
19
+ name: 'Full name',
20
+ phone: 'Phone number',
21
+ email: 'Email address',
22
+ provider: 'Preferred doctor (optional)',
23
+ noPreference: 'No preference',
24
+ service: 'Service (optional)',
25
+ datetime: 'Preferred date & time',
26
+ visitType: 'Appointment type',
27
+ visitPhysical: 'In person',
28
+ visitTelemedicine: 'Video call',
29
+ visitHome: 'Home visit',
30
+ notes: 'Notes (optional)',
31
+ submit: 'Request appointment',
32
+ submitting: 'Submitting…',
33
+ bookAnother: 'Book another appointment',
34
+ requiredPhoneOrEmail: 'Please provide a phone number or an email address.',
35
+ requiredName: 'Please enter your name.',
36
+ requiredDatetime: 'Please choose a date and time.',
37
+ networkError: 'Could not reach the booking service. Please try again.',
38
+ rateLimited: 'Too many requests — please wait a moment and try again.',
39
+ };
40
+
41
+ const STYLE_ID = 'mylikita-widget-styles';
42
+ const VISIT_TYPES = ['physical', 'telemedicine', 'home_visit'];
43
+
44
+ export function createBookingWidget(element, options = {}) {
45
+ if (!element) throw new Error('createBookingWidget: a container element is required');
46
+ const opts = {
47
+ relayUrl: options.relayUrl,
48
+ websiteKey: options.websiteKey,
49
+ facilityId: options.facilityId,
50
+ providers: options.providers || [],
51
+ services: options.services || [],
52
+ // Phase C2/C3: fetch the facility's mapped provider list from the relay
53
+ // (GET /v1/providers) on mount and populate the doctor dropdown. The
54
+ // static `providers` option, when given, always wins and skips the fetch.
55
+ loadProviders: options.loadProviders === true && !(options.providers && options.providers.length),
56
+ pollIntervalMs: options.pollIntervalMs ?? 5000,
57
+ maxTries: options.maxTries ?? 12,
58
+ text: { ...DEFAULT_TEXT, ...(options.text || {}) },
59
+ theme: options.theme || {},
60
+ onStatus: typeof options.onStatus === 'function' ? options.onStatus : null,
61
+ onError: typeof options.onError === 'function' ? options.onError : null,
62
+ onBooking: typeof options.onBooking === 'function' ? options.onBooking : null,
63
+ externalRef: typeof options.externalRef === 'function' ? options.externalRef : newExternalRef,
64
+ };
65
+
66
+ if (!opts.relayUrl || !opts.websiteKey || !opts.facilityId) {
67
+ throw new Error('createBookingWidget: relayUrl, websiteKey and facilityId are required');
68
+ }
69
+
70
+ injectStyles();
71
+ const t = opts.text;
72
+
73
+ // ── root / lifecycle state ──────────────────────────────────────────────
74
+ const root = element;
75
+ root.classList.add('mylikita-widget');
76
+ applyTheme(root, opts.theme);
77
+ let alive = true; // destroy() flips this; async continuations check it
78
+ let submitting = false;
79
+ let pollCtrl = null; // AbortController for the in-flight booking/poll
80
+ // Per-instance ref key: two widgets on one page (e.g. sidebar + page) must
81
+ // never share a single external_ref (reviewer-caught — the demo had widget
82
+ // 2's booking replay widget 1's).
83
+ const instanceId = Math.random().toString(36).slice(2, 8);
84
+ const refKey = `mylikita_ref_${opts.facilityId}_${instanceId}`;
85
+
86
+ // ── DOM construction ────────────────────────────────────────────────────
87
+ const form = el('form', { className: 'mylikita-widget__form' });
88
+
89
+ const title = el('h3', { className: 'mylikita-widget__title', text: t.title });
90
+ const subtitle = el('p', { className: 'mylikita-widget__subtitle', text: t.subtitle });
91
+
92
+ const errorBox = el('div', { className: 'mylikita-widget__error', attrs: { role: 'alert' } });
93
+
94
+ // NOTE: fieldText/fieldSelect return { wrap, input } — only `.wrap` goes
95
+ // into the DOM; `.input` is the live control the submit flow reads.
96
+ const name = fieldText('name', t.name, { required: true });
97
+ const phoneEmail = el('div', { className: 'mylikita-widget__row' });
98
+ const phone = fieldText('phone', t.phone, { type: 'tel', inputmode: 'tel' });
99
+ const email = fieldText('email', t.email, { type: 'email' });
100
+ phoneEmail.append(phone.wrap, email.wrap);
101
+
102
+ const provider = fieldSelect('provider', t.provider, [
103
+ { value: '', label: t.noPreference },
104
+ ...opts.providers.map((p) => ({ value: p.external_id, label: p.label || p.name || p.external_id })),
105
+ ]);
106
+ // Repopulate the provider <select> with a fetched/static list, preserving
107
+ // the current selection when it still exists.
108
+ function setProviderList(list) {
109
+ const current = provider.input.value;
110
+ provider.input.replaceChildren();
111
+ const noPref = document.createElement('option');
112
+ noPref.value = '';
113
+ noPref.textContent = t.noPreference;
114
+ provider.input.append(noPref);
115
+ for (const p of list || []) {
116
+ const opt = document.createElement('option');
117
+ opt.value = p.external_id;
118
+ opt.textContent = p.label || p.name || p.external_id;
119
+ provider.input.append(opt);
120
+ }
121
+ if (current) provider.input.value = current;
122
+ }
123
+ const service = opts.services.length
124
+ ? fieldSelect('service', t.service, [
125
+ { value: '', label: '—' },
126
+ ...opts.services.map((s) => ({ value: s, label: s })),
127
+ ])
128
+ : fieldText('service', t.service);
129
+
130
+ const datetime = fieldText('datetime', t.datetime, { type: 'datetime-local', required: true });
131
+ datetime.input.min = toLocalInputValue(new Date());
132
+
133
+ const visitType = fieldSelect('visitType', t.visitType, [
134
+ { value: 'physical', label: t.visitPhysical },
135
+ { value: 'telemedicine', label: t.visitTelemedicine },
136
+ { value: 'home_visit', label: t.visitHome },
137
+ ]);
138
+ const notes = fieldText('notes', t.notes, { type: 'textarea', maxlength: 500 });
139
+
140
+ const submitBtn = el('button', { className: 'mylikita-widget__submit', type: 'submit', text: t.submit });
141
+ const submitRow = el('div');
142
+ submitRow.append(submitBtn);
143
+ const hint = el('p', { className: 'mylikita-widget__hint' });
144
+
145
+ form.append(errorBox, name.wrap, phoneEmail, provider.wrap, service.wrap, datetime.wrap, visitType.wrap, notes.wrap, submitRow, hint);
146
+
147
+ // ── status view (built lazily, reused for poll updates) ─────────────────
148
+ const statusView = el('div', { className: 'mylikita-widget__status', attrs: { 'aria-live': 'polite' }, hidden: true });
149
+
150
+ // ── submit flow ─────────────────────────────────────────────────────────
151
+ form.addEventListener('submit', (e) => {
152
+ e.preventDefault();
153
+ if (submitting) return;
154
+ submit();
155
+ });
156
+
157
+ async function submit() {
158
+ submitting = true;
159
+ setError(null);
160
+ submitBtn.disabled = true;
161
+ submitBtn.textContent = t.submitting;
162
+
163
+ const payload = {
164
+ facility_id: opts.facilityId,
165
+ patient_name: name.input.value.trim(),
166
+ patient_phone: phone.input.value.trim(),
167
+ patient_email: email.input.value.trim(),
168
+ provider_external_id: provider.input.value || undefined,
169
+ service_name: service.input.value.trim() || undefined,
170
+ appt_datetime: datetime.input.value,
171
+ visit_type: visitType.input.value || 'physical',
172
+ duration_mins: opts.durationMins || undefined,
173
+ notes: notes.input.value.trim() || undefined,
174
+ };
175
+
176
+ // Client-side validation (mirrors the relay's rules) BEFORE touching
177
+ // storage, so an invalid form never mints/spends an external_ref.
178
+ if (!payload.patient_name) return fail(t.requiredName);
179
+ if (!payload.patient_phone && !payload.patient_email) return fail(t.requiredPhoneOrEmail);
180
+ if (!payload.appt_datetime || Number.isNaN(Date.parse(payload.appt_datetime))) return fail(t.requiredDatetime);
181
+
182
+ // Idempotency (§4): reuse the stored ref on refresh/resubmit, mint once.
183
+ let external_ref = readStoredRef(refKey);
184
+ if (!external_ref) {
185
+ external_ref = opts.externalRef();
186
+ try { sessionStorage.setItem(refKey, external_ref); } catch (_) { /* private mode */ }
187
+ }
188
+ payload.external_ref = external_ref;
189
+
190
+ pollCtrl = new AbortController();
191
+ let bookingRef = null;
192
+ try {
193
+ const created = await createBooking({ relayUrl: opts.relayUrl, websiteKey: opts.websiteKey, payload, signal: pollCtrl.signal });
194
+ bookingRef = created.booking_ref;
195
+ if (created.duplicate) {
196
+ // §4: same slot already booked by this patient — treat as success.
197
+ hint.textContent = '';
198
+ showStatus('pending_confirmation', bookingRef, 'We found an existing booking request for this slot — checking it…');
199
+ } else {
200
+ if (opts.onBooking) safeCall(opts.onBooking, created, payload);
201
+ showStatus('pending_confirmation', bookingRef, null);
202
+ }
203
+ startPoll(bookingRef);
204
+ } catch (err) {
205
+ if (!alive || err?.name === 'AbortError') return;
206
+ const friendly = err.status === 429 ? t.rateLimited : err.message || t.networkError;
207
+ if (opts.onError) safeCall(opts.onError, err);
208
+ fail(friendly);
209
+ }
210
+ }
211
+
212
+ async function startPoll(bookingRef) {
213
+ let result;
214
+ try {
215
+ result = await pollStatus(
216
+ () => fetchStatus({ relayUrl: opts.relayUrl, websiteKey: opts.websiteKey, bookingRef, signal: pollCtrl.signal }),
217
+ { intervalMs: opts.pollIntervalMs, maxTries: opts.maxTries, signal: pollCtrl.signal },
218
+ );
219
+ } catch (err) {
220
+ if (!alive || err?.name === 'AbortError') return;
221
+ if (opts.onError) safeCall(opts.onError, err);
222
+ // The form is already hidden — surface the failure in the status view
223
+ // (the 'Book another' button lets the patient retry).
224
+ renderStatus('poll_error', bookingRef, err.message || t.networkError);
225
+ submitting = false;
226
+ return;
227
+ }
228
+ if (!alive || result.status === 'aborted') return;
229
+ if (opts.onStatus && result.data) safeCall(opts.onStatus, result.data);
230
+
231
+ if (result.resolved) {
232
+ // Terminal — a future submission must mint a fresh ref.
233
+ try { sessionStorage.removeItem(refKey); } catch (_) { /* ignore */ }
234
+ renderStatus(result.status, bookingRef);
235
+ } else {
236
+ // Still pending after the budget — show "request received" and let the
237
+ // patient keep the ref for a manual re-check after refresh.
238
+ renderStatus('pending_confirmation', bookingRef);
239
+ }
240
+ submitting = false;
241
+ }
242
+
243
+ // ── rendering helpers ───────────────────────────────────────────────────
244
+ function showStatus(status, bookingRef, messageOverride) {
245
+ form.hidden = true;
246
+ title.hidden = true;
247
+ subtitle.hidden = true;
248
+ statusView.hidden = false;
249
+ renderStatus(status, bookingRef, messageOverride);
250
+ }
251
+
252
+ function renderStatus(status, bookingRef, messageOverride) {
253
+ const c = statusCopy(status);
254
+ const icon = el('div', { className: `mylikita-widget__status-icon ${c.kind}` });
255
+ icon.textContent = iconGlyph(c.kind);
256
+ const st = el('p', { className: 'mylikita-widget__status-title', text: c.title });
257
+ const msg = el('p', { className: 'mylikita-widget__status-message', text: messageOverride || c.message });
258
+ const ref = el('p', { className: 'mylikita-widget__status-ref', text: bookingRef ? `Booking ref: ${bookingRef}` : '' });
259
+ const again = el('button', { className: 'mylikita-widget__link-btn', type: 'button', text: t.bookAnother });
260
+ again.addEventListener('click', () => reset());
261
+ statusView.replaceChildren(icon, st, msg, ref, again);
262
+ }
263
+
264
+ function fail(message) {
265
+ submitting = false;
266
+ submitBtn.disabled = false;
267
+ submitBtn.textContent = t.submit;
268
+ setError(message);
269
+ }
270
+
271
+ function setError(message) {
272
+ errorBox.textContent = message || '';
273
+ errorBox.classList.toggle('visible', Boolean(message));
274
+ }
275
+
276
+ function reset() {
277
+ try { sessionStorage.removeItem(`mylikita_ref_${opts.facilityId}`); } catch (_) { /* ignore */ }
278
+ form.reset();
279
+ setError(null);
280
+ statusView.replaceChildren();
281
+ statusView.hidden = true;
282
+ form.hidden = false;
283
+ title.hidden = false;
284
+ subtitle.hidden = false;
285
+ hint.textContent = '';
286
+ submitting = false;
287
+ submitBtn.disabled = false;
288
+ submitBtn.textContent = t.submit;
289
+ datetime.input.min = toLocalInputValue(new Date());
290
+ }
291
+
292
+ root.replaceChildren(title, subtitle, form, statusView);
293
+
294
+ // ── async provider load (Phase C2/C3) ────────────────────────────────────
295
+ // When loadProviders is on, fetch the facility's mapped doctors and fill the
296
+ // dropdown. Failures are non-fatal: the widget still works with "No
297
+ // preference" (an unmapped provider slug simply arrives unassigned). The
298
+ // abort ties the fetch to the widget's lifecycle so destroy() can't leak it.
299
+ let destroyProvidersFetch = null;
300
+ if (opts.loadProviders) {
301
+ const provCtrl = new AbortController();
302
+ (async () => {
303
+ try {
304
+ const list = await fetchProviders({
305
+ relayUrl: opts.relayUrl,
306
+ websiteKey: opts.websiteKey,
307
+ signal: provCtrl.signal,
308
+ });
309
+ if (alive && !provCtrl.signal.aborted) setProviderList(list);
310
+ } catch (err) {
311
+ if (!alive || err?.name === 'AbortError') return;
312
+ if (opts.onError) safeCall(opts.onError, err);
313
+ // keep "No preference" — a quiet fallback beats a broken form
314
+ }
315
+ })();
316
+ destroyProvidersFetch = () => provCtrl.abort();
317
+ }
318
+
319
+ // ── public API ──────────────────────────────────────────────────────────
320
+ return {
321
+ destroy() {
322
+ alive = false;
323
+ if (pollCtrl) pollCtrl.abort(); // stop the in-flight booking/poll immediately
324
+ if (destroyProvidersFetch) destroyProvidersFetch(); // cancel the provider fetch
325
+ root.replaceChildren();
326
+ root.classList.remove('mylikita-widget');
327
+ },
328
+ reset,
329
+ getForm() { return { name: name.input.value, phone: phone.input.value, email: email.input.value }; },
330
+ };
331
+ }
332
+
333
+ // ── field builders ──────────────────────────────────────────────────────────
334
+
335
+ function fieldText(name, label, { type = 'text', required = false, maxlength, inputmode } = {}) {
336
+ const wrap = el('div', { className: 'mylikita-widget__field' });
337
+ const lab = el('label', { className: 'mylikita-widget__label', attrs: { for: `mlw-${name}` } });
338
+ lab.append(document.createTextNode(label));
339
+ if (required) lab.append(el('span', { className: 'req', text: ' *' }));
340
+ let input;
341
+ if (type === 'textarea') {
342
+ input = el('textarea', { className: 'mylikita-widget__textarea', attrs: { id: `mlw-${name}`, rows: 3, maxlength: maxlength || '' } });
343
+ } else {
344
+ input = el('input', { className: 'mylikita-widget__input', attrs: { id: `mlw-${name}`, type, inputmode: inputmode || '' } });
345
+ }
346
+ if (required) input.setAttribute('required', '');
347
+ wrap.append(lab, input);
348
+ return { wrap, input };
349
+ }
350
+
351
+ function fieldSelect(name, label, options) {
352
+ const wrap = el('div', { className: 'mylikita-widget__field' });
353
+ const lab = el('label', { className: 'mylikita-widget__label', attrs: { for: `mlw-${name}` } });
354
+ lab.textContent = label;
355
+ const select = el('select', { className: 'mylikita-widget__select', attrs: { id: `mlw-${name}` } });
356
+ for (const o of options) {
357
+ // opt.value is set as a property, not an attribute, so an EMPTY value
358
+ // ("no preference") is preserved — el() skips empty attributes, which
359
+ // would otherwise make the select submit the label text as the value.
360
+ const opt = el('option', { text: o.label });
361
+ opt.value = o.value;
362
+ select.append(opt);
363
+ }
364
+ wrap.append(lab, select);
365
+ return { wrap, input: select };
366
+ }
367
+
368
+ // ── misc DOM helpers ────────────────────────────────────────────────────────
369
+
370
+ function el(tag, { className, text, attrs = {} } = {}) {
371
+ const node = document.createElement(tag);
372
+ if (className) node.className = className;
373
+ if (text !== undefined) node.textContent = text;
374
+ for (const [k, v] of Object.entries(attrs)) {
375
+ if (v === undefined || v === '') continue;
376
+ node.setAttribute(k, v);
377
+ }
378
+ return node;
379
+ }
380
+
381
+ function iconGlyph(kind) {
382
+ return kind === 'success' ? '✓' : kind === 'danger' ? '!' : '…';
383
+ }
384
+
385
+ function readStoredRef(key) {
386
+ try { return sessionStorage.getItem(key) || null; } catch (_) { return null; }
387
+ }
388
+
389
+ // Call a user callback defensively — a throwing callback must never break the
390
+ // widget's own flow (reviewer-caught: this was previously undefined).
391
+ function safeCall(fn, ...args) {
392
+ try { fn(...args); } catch (_) { /* callback errors are the host's problem */ }
393
+ }
394
+
395
+ function toLocalInputValue(date) {
396
+ const pad = (n) => String(n).padStart(2, '0');
397
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
398
+ }
399
+
400
+ function injectStyles() {
401
+ if (document.getElementById(STYLE_ID)) return;
402
+ const style = document.createElement('style');
403
+ style.id = STYLE_ID;
404
+ style.textContent = STYLES_CSS;
405
+ document.head.appendChild(style);
406
+ }
407
+
408
+ function applyTheme(rootNode, theme) {
409
+ for (const [k, v] of Object.entries(resolveTheme(theme))) {
410
+ rootNode.style.setProperty(k, v);
411
+ }
412
+ }