domma-cms 0.102.0 → 0.103.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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/message-actions.js +56 -0
  3. package/plugins/_lib/admin/mail/reader-view.js +30 -1
  4. package/plugins/_lib/call/ice.js +58 -0
  5. package/plugins/_lib/call/rooms.js +232 -0
  6. package/plugins/_lib/call/routes.js +88 -0
  7. package/plugins/_lib/public/call/call.css +1 -0
  8. package/plugins/_lib/public/call/call.js +468 -0
  9. package/plugins/blog/lib/layouts.js +4 -2
  10. package/plugins/blog/plugin.json +1 -1
  11. package/plugins/blog/tests/layouts.test.js +6 -0
  12. package/plugins/booking/CLAUDE.md +123 -0
  13. package/plugins/booking/admin/css/index.css +192 -0
  14. package/plugins/booking/admin/lib/details.js +58 -0
  15. package/plugins/booking/admin/lib/diary-layout.js +140 -0
  16. package/plugins/booking/admin/lib/extension-sections.js +160 -0
  17. package/plugins/booking/admin/lib/kit.js +249 -0
  18. package/plugins/booking/admin/templates/booking.html +1 -0
  19. package/plugins/booking/admin/views/booking.js +177 -0
  20. package/plugins/booking/admin/views/bookings.js +127 -0
  21. package/plugins/booking/admin/views/diary.js +374 -0
  22. package/plugins/booking/admin/views/panels.js +516 -0
  23. package/plugins/booking/admin/views/people.js +281 -0
  24. package/plugins/booking/admin/views/services.js +222 -0
  25. package/plugins/booking/admin/views/settings.js +203 -0
  26. package/plugins/booking/collections/booking-bookables.json +17 -0
  27. package/plugins/booking/collections/booking-bookings.json +18 -0
  28. package/plugins/booking/collections/booking-services.json +17 -0
  29. package/plugins/booking/config.js +36 -0
  30. package/plugins/booking/docs/guide.md +69 -0
  31. package/plugins/booking/inject/body.html +1 -0
  32. package/plugins/booking/lib/call.js +58 -0
  33. package/plugins/booking/lib/extensions.js +150 -0
  34. package/plugins/booking/lib/mail.js +113 -0
  35. package/plugins/booking/lib/store.js +384 -0
  36. package/plugins/booking/plugin.js +760 -0
  37. package/plugins/booking/plugin.json +80 -0
  38. package/plugins/booking/plugin.public.js +157 -0
  39. package/plugins/booking/public/booking.css +159 -0
  40. package/plugins/booking/public/booking.js +808 -0
  41. package/plugins/booking/public/lib/clean.js +141 -0
  42. package/plugins/booking/public/lib/page-format.js +121 -0
  43. package/plugins/booking/public/lib/slots.js +247 -0
  44. package/plugins/booking/public/lib/time.js +131 -0
  45. package/plugins/booking/templates/booking.html +5 -0
  46. package/plugins/booking/tests/admin-diary.test.js +73 -0
  47. package/plugins/booking/tests/api.test.js +356 -0
  48. package/plugins/booking/tests/public.test.js +71 -0
  49. package/plugins/booking/tests/pure.test.js +189 -0
  50. package/plugins/free-tier.lock.json +48 -5
  51. package/server/routes/api/related.js +20 -5
  52. package/server/server.js +3 -3
  53. package/server/services/hooks.js +35 -8
  54. package/server/services/markdown.js +10 -12
@@ -0,0 +1,808 @@
1
+ /**
2
+ * Booking - the booking page in the browser.
3
+ *
4
+ * Mounts on every `[data-booking]` element: the booking page's own (rendered
5
+ * by plugin.public.js) and any the [booking] shortcode put on a page. The
6
+ * element's JSON config says what to do:
7
+ *
8
+ * {base, service?, with?, embedded} book: service -> who -> day & time -> details -> done
9
+ * (a full class -> join its waiting list, when the site has one)
10
+ * {base, mode: 'manage', id, t} a customer's own booking: move or cancel it
11
+ *
12
+ * The page is meaningful without this script (services as links, the
13
+ * booking's details); this replaces that with the live flow.
14
+ *
15
+ * The screen is domma-reactive (window.M, which Domma puts on every public
16
+ * page): one template holding every step, one `M.applyBindings`, and plain
17
+ * code that only fetches, decides and writes observables. Plain DOM calls are
18
+ * left only where bindings cannot reach: moving focus to a step's heading or
19
+ * to the first field with an error, and scrolling back up to the step. Every
20
+ * piece of text from the server goes in through bindings (textContent,
21
+ * attributes), never as HTML. Without window.M the server's page stays as it is.
22
+ */
23
+
24
+ import {answersFor, dayLabel, dayParts, icsUrl, lengthLabel, placesText, tokenFrom, weekMoves, weekOf, zoneNote} from './lib/page-format.js';
25
+
26
+ const M = window.M;
27
+ const API = '/api/plugins/booking/public';
28
+ const VIEWER_ZONE = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch { return ''; } })();
29
+
30
+ async function request(url, {method = 'GET', body} = {}) {
31
+ const res = await fetch(url, {
32
+ method,
33
+ headers: body ? {'Content-Type': 'application/json', Accept: 'application/json'} : {Accept: 'application/json'},
34
+ body: body ? JSON.stringify(body) : undefined,
35
+ credentials: 'same-origin'
36
+ });
37
+ let data = null;
38
+ try { data = await res.json(); } catch { /* not JSON */ }
39
+ if (!res.ok) {
40
+ const err = new Error(data?.error || (res.status === 429 ? 'Too many tries just now. Please wait a few minutes.' : 'Something went wrong. Please try again.'));
41
+ err.status = res.status;
42
+ err.code = data?.code || '';
43
+ err.errors = data?.errors || {};
44
+ throw err;
45
+ }
46
+ return data;
47
+ }
48
+
49
+ // ---- The screen: every step, shown one at a time --------------------------------------
50
+ //
51
+ // %U% is this mount's id prefix (made here, never from the server). Item templates sit
52
+ // inside their data-each so they are compiled per row; nothing in them uses {{ }}.
53
+
54
+ const BACK = '<span aria-hidden="true">‹ </span>';
55
+ const SUMMARY = '<p class="bk-summary" hidden data-bind-hidden="!summary.value" data-bind-text="summary.value"></p>';
56
+ const ZONE = '<p class="bk-zone" hidden data-bind-hidden="!zone.value" data-bind-text="zone.value"></p>';
57
+ const DETAILS = '<dl class="bk-details" data-each="detailRows key=k"><div><dt data-bind-text="k"></dt><dd data-bind-text="v"></dd></div></dl>';
58
+ const LABEL_TAIL = '<span class="bk-req" aria-hidden="true" data-if="required"> *</span><span class="bk-opt" data-if="!required"> (optional)</span>';
59
+ const Q_HELP = '<p class="bk-hint" data-if="help" data-bind-id="fid + \'-help\'" data-bind-text="help"></p>';
60
+ const Q_ERROR = '<p class="bk-error" hidden data-bind-hidden="!error.value" data-bind-id="fid + \'-err\'" data-bind-text="error.value"></p>';
61
+ const Q_ARIA = 'data-bind-aria-invalid="error.value ? \'true\' : false" data-bind-aria-describedby="error.value ? fid + \'-err\' : (help ? fid + \'-help\' : false)"';
62
+
63
+ /** One customer field: label, optional hint, control, error - the error bound to `errors.value[key]`. */
64
+ function customerField(key, label, control, {tail = '<span class="bk-req" aria-hidden="true"> *</span>', hint = '', attrs = ''} = {}) {
65
+ const err = `errors.value.${key}`;
66
+ return `<div class="bk-field" data-bind-class="${err} && 'has-error'" ${attrs}>`
67
+ + `<label for="%U%-${key}">${label}${tail}</label>${hint ? `<p class="bk-hint">${hint}</p>` : ''}`
68
+ + control.replace('>', ` id="%U%-${key}" name="${key}" class="bk-input" data-model="f.${key}.value" data-bind-aria-invalid="${err} ? 'true' : false" data-bind-aria-describedby="${err} ? '%U%-${key}-err' : false">`)
69
+ + `<p class="bk-error" id="%U%-${key}-err" hidden data-bind-hidden="!${err}" data-bind-text="${err}"></p></div>`;
70
+ }
71
+
72
+ const OPTIONAL = '<span class="bk-opt"> (optional)</span>';
73
+
74
+ const TEMPLATE = `
75
+ <p class="bk-live" role="status" aria-live="polite" data-bind-text="live.value" data-bind-class="liveTone.value && ('is-' + liveTone.value)"></p>
76
+ <div class="bk-panel">
77
+
78
+ <section class="bk-step bk-step-none" aria-labelledby="%U%-none" hidden data-bind-hidden="step.value !== 'none'">
79
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-none">Booking</h2>
80
+ <p class="bk-note">That service cannot be booked just now.</p>
81
+ </section>
82
+
83
+ <section class="bk-step bk-step-service" aria-labelledby="%U%-service" hidden data-bind-hidden="step.value !== 'service'">
84
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-service" data-bind-text="hasServices.value ? 'Choose a service' : 'Book'"></h2>
85
+ <p class="bk-note" hidden data-bind-hidden="hasServices.value">Nothing can be booked online just now. Please contact us.</p>
86
+ <ul class="bk-services" data-each="services key=id"><li><button type="button" class="bk-service" data-on-click="$parent.pickService($data)"><span class="bk-service-name" data-bind-text="name"></span><span class="bk-service-meta" data-bind-text="meta"></span><span class="bk-service-desc" data-if="description" data-bind-text="description"></span></button></li></ul>
87
+ </section>
88
+
89
+ <section class="bk-step bk-step-who" aria-labelledby="%U%-who" hidden data-bind-hidden="step.value !== 'who'">
90
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-who" data-bind-text="whoTitle.value"></h2>
91
+ <button type="button" class="bk-back" hidden data-bind-hidden="direct.value" data-on-click="toServices">${BACK}Services</button>
92
+ ${SUMMARY}
93
+ <ul class="bk-whos" data-each="whos key=key"><li><button type="button" class="bk-who" data-on-click="$parent.pickWho($data)"><span class="bk-who-name" data-bind-text="name"></span><span class="bk-who-meta" data-if="meta" data-bind-text="meta"></span></button></li></ul>
94
+ </section>
95
+
96
+ <section class="bk-step bk-step-when" aria-labelledby="%U%-when" hidden data-bind-hidden="step.value !== 'when'">
97
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-when" data-bind-text="moving.value ? 'Choose a new time' : 'Choose a day and time'"></h2>
98
+ <button type="button" class="bk-back" hidden data-bind-hidden="!whenBack.value" data-on-click="whenBackGo">${BACK}<span data-bind-text="whenBack.value"></span></button>
99
+ ${SUMMARY}
100
+ <div class="bk-week" role="group" aria-label="Choose a day">
101
+ <button type="button" class="bk-week-move bk-week-prev" aria-label="Earlier week" data-bind-disabled="!prevWeek.value" data-on-click="weekPrev">‹</button>
102
+ <div class="bk-days" data-each="days key=date"><button type="button" class="bk-day" data-bind-class="$parent.chosen.value === date && 'is-on'" data-bind-disabled="!n" data-bind-aria-pressed="$parent.chosen.value === date ? 'true' : 'false'" data-bind-aria-label="label" data-on-click="$parent.pickDay($data)"><span class="bk-day-name" data-bind-text="weekday"></span><span class="bk-day-num" data-bind-text="day"></span><span class="bk-day-month" data-bind-text="month"></span></button></div>
103
+ <button type="button" class="bk-week-move bk-week-next" aria-label="Later week" data-bind-disabled="!nextWeek.value" data-on-click="weekNext">›</button>
104
+ </div>
105
+ <div class="bk-times-wrap" hidden data-bind-hidden="!chosen.value">
106
+ <h3 class="bk-times-title" data-bind-text="dateLabel.value"></h3>
107
+ <div class="bk-times" role="group" data-bind-aria-label="'Times on ' + dateLabel.value" data-each="times key=start"><button type="button" class="bk-time" data-bind-class="full && 'is-full'" aria-pressed="false" data-bind-aria-label="label" data-on-click="$parent.pickTime($data)"><span data-bind-text="time"></span><small data-if="small" data-bind-text="small"></small></button></div>
108
+ </div>
109
+ <p class="bk-note" hidden data-bind-hidden="chosen.value">Nothing is free this week. Try a later week.</p>
110
+ ${ZONE}
111
+ </section>
112
+
113
+ <section class="bk-step" data-bind-class="waiting.value ? 'bk-step-waitlist' : 'bk-step-details'" aria-labelledby="%U%-form" hidden data-bind-hidden="step.value !== 'details' && step.value !== 'waitlist'">
114
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-form" data-bind-text="waiting.value ? 'Join the waiting list' : 'Your details'"></h2>
115
+ <button type="button" class="bk-back" data-on-click="toTimes">${BACK}Times</button>
116
+ ${SUMMARY}
117
+ ${ZONE}
118
+ <form class="bk-form" novalidate data-on-submit="submitForm">
119
+ <p class="bk-lead" hidden data-bind-hidden="!waiting.value">This class is full. Leave your details: if a place comes free, it is booked for you straight away and you get a confirmation email.</p>
120
+ ${customerField('name', 'Your name', '<input type="text" required autocomplete="name" maxlength="120">')}
121
+ ${customerField('email', 'Email', '<input type="email" required autocomplete="email" maxlength="200">', {hint: 'We send the confirmation here.'})}
122
+ ${customerField('phone', 'Phone', '<input type="tel" autocomplete="tel" maxlength="120" data-bind-required="phoneRequired.value">', {
123
+ tail: '<span class="bk-req" aria-hidden="true" hidden data-bind-hidden="!phoneRequired.value"> *</span><span class="bk-opt" data-bind-hidden="phoneRequired.value"> (optional)</span>',
124
+ attrs: 'data-bind-hidden="!askPhone.value"'})}
125
+ ${customerField('notes', 'Anything we should know?', '<textarea rows="3" maxlength="2000"></textarea>', {tail: OPTIONAL})}
126
+ <fieldset class="bk-questions" hidden data-bind-hidden="!hasQuestions.value"><legend>A few questions</legend>
127
+ <div class="bk-questions-list" data-each="questions key=id">
128
+ <div class="bk-field bk-check-field" data-if="isCheck" data-bind-class="error.value && 'has-error'">
129
+ <label class="bk-check" data-bind-for="fid"><input type="checkbox" data-bind-id="fid" data-bind-name="fname" data-model="value.value" ${Q_ARIA}> <span data-bind-text="label"></span><span class="bk-req" aria-hidden="true" data-if="required"> *</span></label>
130
+ ${Q_HELP}${Q_ERROR}
131
+ </div>
132
+ <div class="bk-field" data-if="isChoice" data-bind-class="error.value && 'has-error'">
133
+ <label data-bind-for="fid"><span data-bind-text="label"></span>${LABEL_TAIL}</label>${Q_HELP}
134
+ <select class="bk-input" data-bind-id="fid" data-bind-name="fname" data-bind-required="required" data-options="options" data-options-caption="'Choose…'" data-model="value.value" ${Q_ARIA}></select>
135
+ ${Q_ERROR}
136
+ </div>
137
+ <div class="bk-field" data-if="isText" data-bind-class="error.value && 'has-error'">
138
+ <label data-bind-for="fid"><span data-bind-text="label"></span>${LABEL_TAIL}</label>${Q_HELP}
139
+ <input class="bk-input" maxlength="300" data-bind-type="inputType" data-bind-id="fid" data-bind-name="fname" data-bind-required="required" data-model="value.value" ${Q_ARIA}>
140
+ ${Q_ERROR}
141
+ </div>
142
+ <div class="bk-field" data-if="isLong" data-bind-class="error.value && 'has-error'">
143
+ <label data-bind-for="fid"><span data-bind-text="label"></span>${LABEL_TAIL}</label>${Q_HELP}
144
+ <textarea class="bk-input" rows="3" maxlength="4000" data-bind-id="fid" data-bind-name="fname" data-model="value.value" ${Q_ARIA}></textarea>
145
+ ${Q_ERROR}
146
+ </div>
147
+ </div>
148
+ </fieldset>
149
+ <div class="bk-hp" aria-hidden="true"><label>Website<input type="text" name="website" tabindex="-1" autocomplete="off" data-model="f.website.value"></label></div>
150
+ <p class="bk-terms" hidden data-bind-hidden="waiting.value || !termsUrl.value">By booking you agree to our <a data-bind-href="termsUrl.value || false" target="_blank" rel="noopener">terms</a>.</p>
151
+ <div class="bk-actions"><button type="submit" class="bk-primary" data-bind-disabled="busy.value" data-bind-text="busy.value ? (waiting.value ? 'Joining…' : 'Booking…') : (waiting.value ? 'Join the waiting list' : 'Confirm booking')"></button></div>
152
+ </form>
153
+ </section>
154
+
155
+ <section class="bk-step bk-step-waitlisted" aria-labelledby="%U%-waitlisted" hidden data-bind-hidden="step.value !== 'waitlisted'">
156
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-waitlisted">You are on the waiting list</h2>
157
+ <p class="bk-lead" data-bind-text="waitLead.value"></p>
158
+ ${SUMMARY}
159
+ <div class="bk-actions">
160
+ <button type="button" class="bk-secondary" data-on-click="toTimes">See other times</button>
161
+ <button type="button" class="bk-link" hidden data-bind-hidden="direct.value" data-on-click="toServices">Book something else</button>
162
+ </div>
163
+ </section>
164
+
165
+ <section class="bk-step bk-step-done" aria-labelledby="%U%-done" hidden data-bind-hidden="step.value !== 'done'">
166
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-done">You are booked</h2>
167
+ <p class="bk-lead" data-bind-text="doneLead.value"></p>
168
+ ${DETAILS}
169
+ ${ZONE}
170
+ <p class="bk-note" hidden data-bind-hidden="!callUrl.value">This is an online appointment. The call opens 10 minutes before the start; the link is in your email too.</p>
171
+ <div class="bk-actions">
172
+ <a class="bk-primary bk-btn" hidden data-bind-hidden="!callUrl.value" data-bind-href="callUrl.value || false">Join the call</a>
173
+ <a class="bk-secondary" hidden data-bind-hidden="!icsHref.value" data-bind-href="icsHref.value || false" download="booking.ics">Add to calendar</a>
174
+ <a class="bk-secondary" hidden data-bind-hidden="!manageHref.value" data-bind-href="manageHref.value || false">Change or cancel</a>
175
+ <button type="button" class="bk-link" data-on-click="another">Make another booking</button>
176
+ </div>
177
+ </section>
178
+
179
+ <section class="bk-step bk-step-manage" aria-labelledby="%U%-manage" hidden data-bind-hidden="step.value !== 'manage'">
180
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-manage" data-bind-text="mg.value.cancelled ? 'Booking cancelled' : 'Your booking'"></h2>
181
+ <p class="bk-lead" hidden data-bind-hidden="!manageNote.value" data-bind-text="manageNote.value"></p>
182
+ <p class="bk-status is-cancelled" hidden data-bind-hidden="mg.value.why !== 'cancelled'">This booking has been cancelled.</p>
183
+ <p class="bk-note" hidden data-bind-hidden="mg.value.why !== 'past'">This booking has passed.</p>
184
+ <p class="bk-note" hidden data-bind-hidden="mg.value.why !== 'locked'">This booking can no longer be changed online. Please contact us if you need to change it.</p>
185
+ ${DETAILS}
186
+ ${ZONE}
187
+ <div class="bk-actions" hidden data-bind-hidden="!mg.value.hasActions">
188
+ <a class="bk-primary bk-btn" hidden data-bind-hidden="!mg.value.join" data-bind-href="mg.value.join || false">Join the call</a>
189
+ <a class="bk-secondary" hidden data-bind-hidden="mg.value.cancelled" data-bind-href="mg.value.ics || false" download="booking.ics">Add to calendar</a>
190
+ <button type="button" class="bk-secondary" hidden data-bind-hidden="!mg.value.canMove" data-on-click="startMove">Change time</button>
191
+ <button type="button" class="bk-danger" hidden data-bind-hidden="!mg.value.canCancel" data-on-click="askCancel">Cancel booking</button>
192
+ </div>
193
+ <p hidden data-bind-hidden="!mg.value.cancelled"><a class="bk-link" data-bind-href="base">Make a new booking</a></p>
194
+ </section>
195
+
196
+ <section class="bk-step bk-step-confirm-move" aria-labelledby="%U%-confirm-move" hidden data-bind-hidden="step.value !== 'confirm-move'">
197
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-confirm-move">Move your booking?</h2>
198
+ <button type="button" class="bk-back" data-on-click="toTimes">${BACK}Times</button>
199
+ <dl class="bk-details"><div><dt>From</dt><dd data-bind-text="moveFrom.value"></dd></div><div><dt>To</dt><dd data-bind-text="moveTo.value"></dd></div></dl>
200
+ ${ZONE}
201
+ <div class="bk-actions"><button type="button" class="bk-primary" data-bind-disabled="busy.value" data-on-click="doMove">Move my booking</button></div>
202
+ </section>
203
+
204
+ <section class="bk-step bk-step-cancel" aria-labelledby="%U%-cancel" hidden data-bind-hidden="step.value !== 'cancel'">
205
+ <h2 class="bk-step-title" tabindex="-1" id="%U%-cancel">Cancel this booking?</h2>
206
+ ${DETAILS}
207
+ <div class="bk-field"><label for="%U%-reason">Reason${OPTIONAL}</label><textarea id="%U%-reason" class="bk-input" rows="2" maxlength="300" data-model="reason.value"></textarea></div>
208
+ <div class="bk-actions">
209
+ <button type="button" class="bk-danger" data-bind-disabled="busy.value" data-on-click="doCancel">Yes, cancel it</button>
210
+ <button type="button" class="bk-secondary" data-on-click="keepIt">Keep it</button>
211
+ </div>
212
+ </section>
213
+ </div>`;
214
+
215
+ // ---- One mounted booking app ------------------------------------------------------
216
+
217
+ /** The rows of a booking's details list. */
218
+ function detailRowsOf(b) {
219
+ return [['What', b.serviceName], ['When', b.date ? `${dayLabel(b.date)}, ${b.time}` : ''], ['Where', b.online ? 'Online - a video call in your browser' : ''],
220
+ ['With', b.bookableName], ['Name', b.name], ['Email', b.email], ['Phone', b.phone]]
221
+ .filter(([, v]) => v).map(([k, v]) => ({k, v: String(v)}));
222
+ }
223
+
224
+ function mount(root, cfg) {
225
+ const uid = `bk${Math.random().toString(36).slice(2, 8)}`;
226
+
227
+ // ---- What the code decides with (plain) ----
228
+ const st = {
229
+ info: null, // GET /services
230
+ service: null,
231
+ bookableId: cfg.with || '',
232
+ direct: false,
233
+ mode: '', // 'move' while choosing a new time for a booking
234
+ ignore: '', // the booking being moved (its own time is free to it)
235
+ range: null,
236
+ week: '', // first date of the strip
237
+ days: new Map(), // date -> slots
238
+ date: '',
239
+ slot: null,
240
+ booking: null, // manage mode
241
+ quiet: false // the next step does not take focus (page load)
242
+ };
243
+
244
+ // ---- What the screen shows (observables) ----
245
+ const live = M.observable('');
246
+ const liveTone = M.observable('');
247
+ const step = M.observable('');
248
+ const summary = M.observable('');
249
+ const zone = M.observable('');
250
+ const direct = M.observable(false);
251
+ const moving = M.observable(false);
252
+ const busy = M.observable(false);
253
+ const services = M.observableArray([]);
254
+ const hasServices = M.observable(false);
255
+ const whos = M.observableArray([]);
256
+ const whoTitle = M.observable('');
257
+ const whenBack = M.observable('');
258
+ const days = M.observableArray([]);
259
+ const chosen = M.observable('');
260
+ const dateLabel = M.observable('');
261
+ const times = M.observableArray([]);
262
+ const prevWeek = M.observable('');
263
+ const nextWeek = M.observable('');
264
+ const waiting = M.observable(false);
265
+ const f = {name: M.observable(''), email: M.observable(''), phone: M.observable(''), notes: M.observable(''), website: M.observable('')};
266
+ const askPhone = M.observable(true);
267
+ const phoneRequired = M.observable(false);
268
+ const termsUrl = M.observable('');
269
+ const errors = M.observable({});
270
+ const questions = M.observableArray([]); // {id, fid, fname, label, help, required, kind flags, value: observable, error: observable}
271
+ const hasQuestions = M.observable(false);
272
+ const waitLead = M.observable('');
273
+ const doneLead = M.observable('');
274
+ const callUrl = M.observable('');
275
+ const icsHref = M.observable('');
276
+ const manageHref = M.observable('');
277
+ const detailRows = M.observableArray([]);
278
+ const manageNote = M.observable('');
279
+ const mg = M.observable({});
280
+ const moveFrom = M.observable('');
281
+ const moveTo = M.observable('');
282
+ const reason = M.observable('');
283
+
284
+ // ---- Frame: status line and one step at a time ----
285
+
286
+ function say(text, tone = '') {
287
+ live.value = text || '';
288
+ liveTone.value = tone;
289
+ }
290
+
291
+ function paintSummary() {
292
+ const bits = [];
293
+ if (st.service) bits.push(`${st.service.name} · ${lengthLabel(st.service.duration)}`);
294
+ const who = st.service?.bookables.find(b => b.id === st.bookableId);
295
+ if (who) bits.push(`with ${who.name}`);
296
+ if (st.slot) bits.push(`${dayLabel(st.date)}, ${st.slot.time}`);
297
+ summary.value = bits.join(' · ');
298
+ }
299
+
300
+ /** Show a step, its heading focused so a screen reader and the keyboard start there. */
301
+ function show(name) {
302
+ paintSummary();
303
+ busy.value = false;
304
+ step.value = name;
305
+ M.flush?.();
306
+ const id = name === 'details' || name === 'waitlist' ? 'form' : name;
307
+ if (!st.quiet) root.querySelector(`#${uid}-${id}`)?.focus({preventScroll: true});
308
+ st.quiet = false;
309
+ // A long step replaced by a short one can leave the reader below it.
310
+ const top = root.getBoundingClientRect().top;
311
+ if (top < 0) window.scrollTo({top: top + window.scrollY - 80, behavior: 'smooth'});
312
+ }
313
+
314
+ // ---- Booking ----
315
+
316
+ async function start() {
317
+ step.value = '';
318
+ st.quiet = true; // do not steal focus on page load
319
+ say('Loading…');
320
+ try {
321
+ st.info = await request(`${API}/services`);
322
+ } catch (err) {
323
+ say(err.message, 'error');
324
+ return;
325
+ }
326
+ say('');
327
+ zone.value = zoneNote(st.info?.timeZone, VIEWER_ZONE);
328
+ const list = st.info.services || [];
329
+ services.value = list.map(s => ({id: s.id, name: s.name, description: s.description || '',
330
+ meta: `${lengthLabel(s.duration)}${s.capacity > 1 ? ` · a class of up to ${s.capacity}` : ''}${s.online ? ' · online' : ''}`}));
331
+ hasServices.value = list.length > 0;
332
+ if (cfg.service) {
333
+ const svc = list.find(s => s.id === cfg.service);
334
+ if (!svc) return show('none');
335
+ return pickService(svc, {direct: true});
336
+ }
337
+ toServices();
338
+ }
339
+
340
+ function toServices() {
341
+ st.service = null;
342
+ st.slot = null;
343
+ show('service');
344
+ }
345
+
346
+ /** The service's extra questions (Booking Pro); answers kept while the service stays the same. */
347
+ function setQuestions(fields, keep) {
348
+ const before = new Map(questions.peek().map(r => [r.id, r.value.peek()]));
349
+ const rows = (fields || []).map(q => {
350
+ const blank = q.kind === 'yesno' ? false : '';
351
+ return {
352
+ id: q.id, fid: `${uid}-q-${String(q.id).replace(/[^\w-]/g, '_')}`, fname: `q:${q.id}`,
353
+ label: q.label, help: q.help || '', required: Boolean(q.required), options: q.options || [],
354
+ isCheck: q.kind === 'yesno', isChoice: q.kind === 'choice', isLong: q.kind === 'longtext',
355
+ isText: !['yesno', 'choice', 'longtext'].includes(q.kind),
356
+ inputType: {number: 'number', date: 'date', email: 'email', phone: 'tel'}[q.kind] || 'text',
357
+ value: M.observable(keep && before.has(q.id) ? before.get(q.id) : blank),
358
+ error: M.observable('')
359
+ };
360
+ });
361
+ questions.splice(0, questions.peek().length, ...rows);
362
+ }
363
+
364
+ function pickService(svc, {direct: isDirect = false} = {}) {
365
+ setQuestions(svc.fields, st.service?.id === svc.id);
366
+ st.service = svc;
367
+ st.direct = isDirect;
368
+ direct.value = isDirect;
369
+ st.days.clear();
370
+ st.range = null;
371
+ st.date = '';
372
+ st.slot = null;
373
+ const withOk = svc.bookables.some(b => b.id === cfg.with);
374
+ st.bookableId = withOk ? cfg.with : '';
375
+ if (svc.chooseBookable && !withOk) return who();
376
+ return when();
377
+ }
378
+
379
+ function who() {
380
+ const people = st.service.bookables;
381
+ const allRooms = people.every(b => b.kind === 'resource');
382
+ whos.value = [
383
+ {key: '*', id: '', name: allRooms ? 'Any that is free' : 'Anyone', meta: 'The most times to choose from'},
384
+ ...people.map(b => ({key: `b-${b.id}`, id: b.id, name: b.name, meta: b.description || ''}))
385
+ ];
386
+ whoTitle.value = allRooms ? 'Which one?' : 'Who would you like?';
387
+ show('who');
388
+ }
389
+
390
+ function pickWho(row) {
391
+ st.bookableId = row.id;
392
+ st.days.clear();
393
+ st.range = null;
394
+ st.date = '';
395
+ st.slot = null;
396
+ when();
397
+ }
398
+
399
+ /** Fetch a week of free times (7 days from `from`); remembers the bookable range. */
400
+ async function loadWeek(from) {
401
+ const q = new URLSearchParams({service: st.service.id, from, to: weekOf(from)[6]});
402
+ if (st.bookableId) q.set('with', st.bookableId);
403
+ if (st.ignore) q.set('ignore', st.ignore);
404
+ const res = await request(`${API}/slots?${q}`);
405
+ st.range = res.range || st.range;
406
+ for (const d of res.days || []) st.days.set(d.date, d.slots || []);
407
+ // Days the answer left out (outside the range) have nothing.
408
+ for (const d of weekOf(from)) if (!st.days.has(d)) st.days.set(d, []);
409
+ }
410
+
411
+ /** The first week (from `from`) with a free time, looking up to ~2 months ahead. */
412
+ async function firstWeek(from) {
413
+ await loadWeek(from);
414
+ let first = st.range?.first && st.range.first > from ? st.range.first : from;
415
+ if (first !== from) await loadWeek(first);
416
+ for (let i = 0; i < 9; i++) {
417
+ if (weekOf(first).some(d => (st.days.get(d) || []).length)) return first;
418
+ const {next} = weekMoves(first, st.range);
419
+ if (!next) break;
420
+ first = next;
421
+ await loadWeek(first);
422
+ }
423
+ return st.range?.first && st.range.first > from ? st.range.first : from;
424
+ }
425
+
426
+ const firstFreeDay = () => weekOf(st.week).find(d => (st.days.get(d) || []).length) || '';
427
+
428
+ async function when({keepWeek = false, note = ''} = {}) {
429
+ st.slot = null;
430
+ say('Finding free times…');
431
+ try {
432
+ if (!keepWeek || !st.week) st.week = await firstWeek(st.info.today);
433
+ else await loadWeek(st.week);
434
+ } catch (err) {
435
+ say(err.message, 'error');
436
+ return;
437
+ }
438
+ say(note, note ? 'error' : '');
439
+ if (!st.date || !(st.days.get(st.date) || []).length || !weekOf(st.week).includes(st.date)) st.date = firstFreeDay();
440
+ paintWeek();
441
+ whenBack.value = st.mode === 'move' ? 'Your booking'
442
+ : (st.service.chooseBookable && !st.service.bookables.some(b => b.id === cfg.with)) ? 'Who'
443
+ : st.direct ? '' : 'Services';
444
+ show('when');
445
+ }
446
+
447
+ /** The week strip and the chosen day's times, from what is loaded. */
448
+ function paintWeek() {
449
+ const moves = weekMoves(st.week, st.range || {});
450
+ prevWeek.value = moves.prev;
451
+ nextWeek.value = moves.next;
452
+ days.value = weekOf(st.week).map(d => {
453
+ const n = (st.days.get(d) || []).length;
454
+ return {date: d, n, ...dayParts(d), label: `${dayLabel(d)}${n ? `, ${n} time${n === 1 ? '' : 's'} free` : ', nothing free'}`};
455
+ });
456
+ paintTimes();
457
+ }
458
+
459
+ /** A time: plain, with a class's places left, or full (to join its waiting list). */
460
+ function paintTimes() {
461
+ chosen.value = st.date;
462
+ dateLabel.value = dayLabel(st.date);
463
+ times.value = (st.days.get(st.date) || []).map(s => {
464
+ if (s.full) return {start: s.start, time: s.time, full: true, small: 'Full - join the waiting list', label: `${s.time}, full - join the waiting list`};
465
+ const classy = st.service?.capacity > 1 && Number.isFinite(Number(s.left));
466
+ return {start: s.start, time: s.time, full: false, small: classy ? placesText(s.left) : '', label: classy ? `${s.time}, ${placesText(s.left)}` : false};
467
+ });
468
+ }
469
+
470
+ async function moveWeek(to) {
471
+ if (!to) return;
472
+ const back = to === prevWeek.peek();
473
+ st.week = to;
474
+ st.date = '';
475
+ say('Finding free times…');
476
+ try { await loadWeek(to); } catch (err) { say(err.message, 'error'); return; }
477
+ say('');
478
+ st.date = firstFreeDay();
479
+ paintWeek();
480
+ M.flush?.();
481
+ root.querySelector(back ? '.bk-week-prev' : '.bk-week-next')?.focus();
482
+ }
483
+
484
+ function pickDay(row) {
485
+ if (!row.n) return;
486
+ st.date = row.date;
487
+ paintTimes();
488
+ M.flush?.();
489
+ root.querySelector('.bk-day.is-on')?.focus();
490
+ }
491
+
492
+ function pickTime(row) {
493
+ const slot = (st.days.get(st.date) || []).find(s => s.start === row.start);
494
+ if (!slot) return;
495
+ if (slot.full) return openForm('waitlist', slot);
496
+ st.slot = slot;
497
+ if (st.mode === 'move') return confirmMove();
498
+ openForm('details', slot);
499
+ }
500
+
501
+ // ---- Details (and the waiting list's form, which is the same one) ----
502
+
503
+ function setErrors(errs = {}) {
504
+ errors.value = {...errs};
505
+ for (const row of questions.peek()) row.error.value = errs[`answers.${row.id}`] || '';
506
+ }
507
+
508
+ /** Focus the first field with an error (server keys: name, email, answers.<id>). */
509
+ function focusError(errs) {
510
+ const first = Object.keys(errs)[0];
511
+ if (!first) return;
512
+ const name = first.startsWith('answers.') ? `q:${first.slice(8)}` : first;
513
+ M.flush?.();
514
+ root.querySelector(`.bk-form [name="${CSS.escape(name)}"]`)?.focus();
515
+ }
516
+
517
+ function openForm(kind, slot) {
518
+ st.slot = slot;
519
+ waiting.value = kind === 'waitlist';
520
+ askPhone.value = st.info.askPhone !== false;
521
+ phoneRequired.value = st.info.phoneRequired === true;
522
+ termsUrl.value = st.info.termsUrl || '';
523
+ hasQuestions.value = questions.peek().length > 0;
524
+ setErrors({});
525
+ show(kind);
526
+ }
527
+
528
+ /** What the page can check before sending: the customer's details and the questions. */
529
+ function checkForm() {
530
+ const v = (k) => String(f[k].peek() ?? '').trim();
531
+ const errs = {};
532
+ if (!v('name')) errs.name = 'Your name, please.';
533
+ if (!/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(v('email'))) errs.email = 'An email address we can reach you on.';
534
+ if (st.info.askPhone !== false && st.info.phoneRequired && !v('phone')) errs.phone = 'A phone number, please.';
535
+ const values = Object.fromEntries(questions.peek().map(r => [r.id, r.value.peek()]));
536
+ const {answers, errors: qErrors} = answersFor(st.service?.fields, values);
537
+ return {errors: {...errs, ...qErrors}, answers, customer: {name: v('name'), email: v('email'), phone: v('phone'), notes: v('notes')}};
538
+ }
539
+
540
+ /** Checked here first; a problem stays on the form with the first bad field focused. */
541
+ function checked() {
542
+ const out = checkForm();
543
+ if (Object.keys(out.errors).length) {
544
+ say('Please check the highlighted fields.', 'error');
545
+ setErrors(out.errors);
546
+ focusError(out.errors);
547
+ return null;
548
+ }
549
+ setErrors({});
550
+ return out;
551
+ }
552
+
553
+ function formFailed(err) {
554
+ busy.value = false;
555
+ setErrors(err.errors);
556
+ focusError(err.errors);
557
+ say(err.message, 'error');
558
+ }
559
+
560
+ async function book() {
561
+ const ok = checked();
562
+ if (!ok) return;
563
+ const {answers, customer} = ok;
564
+ busy.value = true;
565
+ say('Booking…');
566
+ try {
567
+ const res = await request(`${API}/book`, {method: 'POST', body: {
568
+ serviceId: st.service.id,
569
+ bookableId: st.bookableId || undefined,
570
+ startAt: st.slot.start,
571
+ customer,
572
+ ...(Object.keys(answers).length ? {answers} : {}),
573
+ website: f.website.peek() || ''
574
+ }});
575
+ say('');
576
+ done(res);
577
+ } catch (err) {
578
+ busy.value = false;
579
+ // A rule said no (e.g. one booking every so many days): say why, here, not "taken".
580
+ if (err.code === 'refused') {
581
+ setErrors({});
582
+ say(err.message, 'error');
583
+ return;
584
+ }
585
+ if (err.code === 'slot-taken' || err.status === 409) {
586
+ st.days.clear();
587
+ return when({keepWeek: true, note: 'Sorry, that time has just been taken. Please choose another.'});
588
+ }
589
+ if (err.status === 400 && Object.keys(err.errors || {}).length) return formFailed(err);
590
+ say(err.message, 'error');
591
+ }
592
+ }
593
+
594
+ // ---- Waiting list (a full class, Booking Pro) ----
595
+
596
+ async function joinWaitlist() {
597
+ const ok = checked();
598
+ if (!ok) return;
599
+ const {answers, customer} = ok;
600
+ const url = st.info.waitlistUrl;
601
+ if (!url) { say('This class has no waiting list.', 'error'); return; }
602
+ busy.value = true;
603
+ say('Joining the waiting list…');
604
+ try {
605
+ const res = await request(url, {method: 'POST', body: {
606
+ serviceId: st.service.id, startAt: st.slot.start, bookableId: st.bookableId || undefined,
607
+ customer, ...(Object.keys(answers).length ? {answers} : {}),
608
+ website: f.website.peek() || ''
609
+ }});
610
+ say('');
611
+ const place = Number(res?.place) || 0;
612
+ waitLead.value = `Thank you, ${customer.name}. ${place ? `You are number ${place} on the waiting list. ` : ''}If a place comes free, it is booked for you and you get an email. There is nothing else to do.`;
613
+ show('waitlisted');
614
+ } catch (err) {
615
+ busy.value = false;
616
+ if (err.code === 'not-full') {
617
+ st.days.clear();
618
+ return when({keepWeek: true, note: 'Good news: a place has come free. You can book it now.'});
619
+ }
620
+ if (err.status === 400 && Object.keys(err.errors || {}).length) return formFailed(err);
621
+ say(err.message, 'error');
622
+ }
623
+ }
624
+
625
+ function done(res) {
626
+ const b = res.booking || {};
627
+ const t = tokenFrom(res.manage);
628
+ doneLead.value = `Thank you, ${b.name || ''}. We have sent a confirmation to ${b.email || 'your email'}.`;
629
+ detailRows.value = detailRowsOf(b);
630
+ callUrl.value = b.callUrl || '';
631
+ icsHref.value = t ? icsUrl(b.id, t) : '';
632
+ manageHref.value = res.manage || '';
633
+ show('done');
634
+ }
635
+
636
+ // ---- Manage ----
637
+
638
+ async function startManage() {
639
+ st.quiet = true;
640
+ say('Loading…');
641
+ try {
642
+ [st.info, st.booking] = await Promise.all([
643
+ request(`${API}/services`).catch(() => ({})),
644
+ request(`${API}/booking/${encodeURIComponent(cfg.id)}?t=${encodeURIComponent(cfg.t)}`)
645
+ ]);
646
+ } catch (err) {
647
+ say(err.status === 404 ? 'We cannot find that booking. Check the link in your email.' : err.message, 'error');
648
+ return;
649
+ }
650
+ say('');
651
+ zone.value = zoneNote(st.info?.timeZone, VIEWER_ZONE);
652
+ manage();
653
+ }
654
+
655
+ function manage(note = '') {
656
+ st.mode = '';
657
+ moving.value = false;
658
+ const b = st.booking;
659
+ const cancelled = b.status === 'cancelled';
660
+ const past = Date.parse(b.endAt) < Date.now();
661
+ const join = !cancelled && !past && b.callUrl ? b.callUrl : '';
662
+ mg.value = {
663
+ cancelled, join, ics: icsUrl(b.id, cfg.t), canMove: Boolean(b.canMove), canCancel: Boolean(b.canCancel),
664
+ hasActions: Boolean(join || !cancelled || b.canMove || b.canCancel),
665
+ why: cancelled ? (note ? '' : 'cancelled') : past ? 'past' : (!b.canMove && !b.canCancel) ? 'locked' : ''
666
+ };
667
+ manageNote.value = note;
668
+ detailRows.value = detailRowsOf(b);
669
+ show('manage');
670
+ }
671
+
672
+ async function startMove() {
673
+ const b = st.booking;
674
+ st.mode = 'move';
675
+ moving.value = true;
676
+ st.ignore = b.id;
677
+ st.service = (st.info.services || []).find(s => s.id === b.serviceId)
678
+ || {id: b.serviceId, name: b.serviceName, duration: Math.round((Date.parse(b.endAt) - Date.parse(b.startAt)) / 60000), bookables: [], chooseBookable: false};
679
+ st.bookableId = b.bookableId;
680
+ st.days.clear();
681
+ st.range = null;
682
+ st.date = '';
683
+ st.week = '';
684
+ await when();
685
+ }
686
+
687
+ function confirmMove() {
688
+ const b = st.booking;
689
+ moveFrom.value = `${dayLabel(b.date)}, ${b.time}`;
690
+ moveTo.value = `${dayLabel(st.date)}, ${st.slot.time}`;
691
+ show('confirm-move');
692
+ }
693
+
694
+ async function doMove() {
695
+ busy.value = true;
696
+ say('Moving your booking…');
697
+ try {
698
+ st.booking = await request(`${API}/booking/${encodeURIComponent(st.booking.id)}/move`, {method: 'POST', body: {t: cfg.t, startAt: st.slot.start}});
699
+ say('');
700
+ manage('Done: your booking has moved. We have emailed you the new time.');
701
+ } catch (err) {
702
+ if (err.status === 409) { st.days.clear(); return when({keepWeek: true, note: 'Sorry, that time has just been taken. Please choose another.'}); }
703
+ busy.value = false;
704
+ say(err.message, 'error');
705
+ }
706
+ }
707
+
708
+ function askCancel() {
709
+ reason.value = '';
710
+ detailRows.value = detailRowsOf(st.booking);
711
+ show('cancel');
712
+ }
713
+
714
+ async function doCancel() {
715
+ busy.value = true;
716
+ say('Cancelling…');
717
+ try {
718
+ st.booking = await request(`${API}/booking/${encodeURIComponent(st.booking.id)}/cancel`, {method: 'POST', body: {t: cfg.t, reason: String(reason.peek() || '').trim()}});
719
+ say('');
720
+ manage('Your booking is cancelled. We have emailed you to confirm.');
721
+ } catch (err) {
722
+ busy.value = false;
723
+ say(err.message, 'error');
724
+ }
725
+ }
726
+
727
+ // ---- Bind ----
728
+
729
+ const model = {
730
+ base: cfg.base, live, liveTone, step, summary, zone, direct, moving, busy, services, hasServices, whos, whoTitle, whenBack,
731
+ days, chosen, dateLabel, times, prevWeek, nextWeek, waiting, f, askPhone, phoneRequired, termsUrl, errors, questions,
732
+ hasQuestions, waitLead, doneLead, callUrl, icsHref, manageHref, detailRows, manageNote, mg, moveFrom, moveTo, reason,
733
+
734
+ toServices,
735
+ pickService(row) {
736
+ const svc = (st.info?.services || []).find(s => s.id === row.id);
737
+ if (svc) pickService(svc);
738
+ },
739
+ pickWho,
740
+ whenBackGo() {
741
+ if (st.mode === 'move') return manage();
742
+ if (whenBack.peek() === 'Who') return who();
743
+ toServices();
744
+ },
745
+ weekPrev() { moveWeek(prevWeek.peek()); },
746
+ weekNext() { moveWeek(nextWeek.peek()); },
747
+ pickDay,
748
+ pickTime,
749
+ toTimes() { when({keepWeek: true}); },
750
+ submitForm() {
751
+ if (!busy.peek()) (waiting.peek() ? joinWaitlist : book)();
752
+ return false; // synchronously: the page never submits itself
753
+ },
754
+ another() {
755
+ f.notes.value = '';
756
+ if (cfg.service) start(); else toServices();
757
+ },
758
+ startMove() { startMove(); },
759
+ askCancel,
760
+ doMove() { doMove(); },
761
+ doCancel() { doCancel(); },
762
+ keepIt() { manage(); }
763
+ };
764
+
765
+ root.classList.add('is-live');
766
+ if (cfg.embedded) root.classList.add('is-embedded');
767
+ const tpl = document.createElement('template');
768
+ tpl.innerHTML = TEMPLATE.split('%U%').join(uid); // our own markup; no server text in it
769
+ root.replaceChildren(tpl.content);
770
+ M.applyBindings(model, root);
771
+
772
+ if (cfg.mode === 'manage') startManage();
773
+ else start();
774
+ }
775
+
776
+ // ---- Mount ------------------------------------------------------------------------
777
+
778
+ /**
779
+ * The stylesheet, added once and only where there is something to style.
780
+ * The booking page links it itself; on any other page this script arrives
781
+ * through the plugin's body inject (the page sanitiser strips a shortcode's
782
+ * own <link> and <script>), so it brings its CSS along.
783
+ */
784
+ function ensureStyles() {
785
+ if (document.querySelector('link[href*="/plugins/booking/public/booking.css"]')) return;
786
+ const v = new URL(import.meta.url).searchParams.get('v') || '';
787
+ const link = document.createElement('link');
788
+ link.rel = 'stylesheet';
789
+ link.href = `/plugins/booking/public/booking.css${v ? `?v=${encodeURIComponent(v)}` : ''}`;
790
+ document.head.append(link);
791
+ }
792
+
793
+ function mountAll() {
794
+ const found = document.querySelectorAll('[data-booking]');
795
+ if (!found.length) return;
796
+ if (!M?.applyBindings) { console.warn('[booking] Domma is not on this page; the booking page stays as the server sent it.'); return; }
797
+ ensureStyles();
798
+ for (const el of found) {
799
+ if (el.dataset.bookingMounted) continue;
800
+ el.dataset.bookingMounted = '1';
801
+ let cfg = {};
802
+ try { cfg = JSON.parse(el.getAttribute('data-booking') || '{}'); } catch { /* defaults */ }
803
+ mount(el, {base: '/booking', ...cfg});
804
+ }
805
+ }
806
+
807
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll, {once: true});
808
+ else mountAll();