domma-cms 0.102.1 → 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 (50) 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/booking/CLAUDE.md +123 -0
  10. package/plugins/booking/admin/css/index.css +192 -0
  11. package/plugins/booking/admin/lib/details.js +58 -0
  12. package/plugins/booking/admin/lib/diary-layout.js +140 -0
  13. package/plugins/booking/admin/lib/extension-sections.js +160 -0
  14. package/plugins/booking/admin/lib/kit.js +249 -0
  15. package/plugins/booking/admin/templates/booking.html +1 -0
  16. package/plugins/booking/admin/views/booking.js +177 -0
  17. package/plugins/booking/admin/views/bookings.js +127 -0
  18. package/plugins/booking/admin/views/diary.js +374 -0
  19. package/plugins/booking/admin/views/panels.js +516 -0
  20. package/plugins/booking/admin/views/people.js +281 -0
  21. package/plugins/booking/admin/views/services.js +222 -0
  22. package/plugins/booking/admin/views/settings.js +203 -0
  23. package/plugins/booking/collections/booking-bookables.json +17 -0
  24. package/plugins/booking/collections/booking-bookings.json +18 -0
  25. package/plugins/booking/collections/booking-services.json +17 -0
  26. package/plugins/booking/config.js +36 -0
  27. package/plugins/booking/docs/guide.md +69 -0
  28. package/plugins/booking/inject/body.html +1 -0
  29. package/plugins/booking/lib/call.js +58 -0
  30. package/plugins/booking/lib/extensions.js +150 -0
  31. package/plugins/booking/lib/mail.js +113 -0
  32. package/plugins/booking/lib/store.js +384 -0
  33. package/plugins/booking/plugin.js +760 -0
  34. package/plugins/booking/plugin.json +80 -0
  35. package/plugins/booking/plugin.public.js +157 -0
  36. package/plugins/booking/public/booking.css +159 -0
  37. package/plugins/booking/public/booking.js +808 -0
  38. package/plugins/booking/public/lib/clean.js +141 -0
  39. package/plugins/booking/public/lib/page-format.js +121 -0
  40. package/plugins/booking/public/lib/slots.js +247 -0
  41. package/plugins/booking/public/lib/time.js +131 -0
  42. package/plugins/booking/templates/booking.html +5 -0
  43. package/plugins/booking/tests/admin-diary.test.js +73 -0
  44. package/plugins/booking/tests/api.test.js +356 -0
  45. package/plugins/booking/tests/public.test.js +71 -0
  46. package/plugins/booking/tests/pure.test.js +189 -0
  47. package/plugins/free-tier.lock.json +44 -1
  48. package/server/routes/api/related.js +20 -5
  49. package/server/server.js +3 -3
  50. package/server/services/hooks.js +35 -8
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Booking › People & rooms - who and what can be booked.
3
+ *
4
+ * A person is booked for appointments (link them to a user so their own
5
+ * Calendar events block their times and bookings join their Contacts); a room
6
+ * or thing (a meeting room, a van, a court) is booked for time. Each has
7
+ * weekly opening hours (several windows a day) and days off.
8
+ */
9
+
10
+ import {COLOUR_LABEL, KIND_LABEL, api, errText, esc, openPanel, wireRows} from '../lib/kit.js';
11
+ import {cleanBookable} from '/plugins/booking/public/lib/clean.js';
12
+ import {DAY_KEYS, isDate, toMinutes} from '/plugins/booking/public/lib/time.js';
13
+
14
+ const DAY_NAMES = {mon: 'Monday', tue: 'Tuesday', wed: 'Wednesday', thu: 'Thursday', fri: 'Friday', sat: 'Saturday', sun: 'Sunday'};
15
+ const WEEK = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
16
+
17
+ const ROW = `
18
+ <div class="bk-row" data-id="{{id}}" tabindex="0">
19
+ <span class="bk-dot" data-bind-data-colour="colour"></span>
20
+ <span class="bk-main"><strong data-bind-text="name"></strong><small data-bind-text="meta"></small></span>
21
+ <span class="bk-chip" data-bind-class="tone" data-bind-text="state"></span>
22
+ <button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
23
+ </div>`;
24
+
25
+ /** "Mon-Fri 09:00-17:00" style summary of weekly hours. */
26
+ export function hoursSummary(hours) {
27
+ const txt = (d) => (hours?.[d] || []).map(w => `${w.from}-${w.to}`).join(', ');
28
+ const groups = [];
29
+ for (const d of WEEK) {
30
+ const t = txt(d);
31
+ if (!t) continue;
32
+ const last = groups[groups.length - 1];
33
+ if (last && last.t === t && WEEK.indexOf(d) === WEEK.indexOf(last.to) + 1) last.to = d;
34
+ else groups.push({from: d, to: d, t});
35
+ }
36
+ if (!groups.length) return 'no opening hours';
37
+ const cap = (d) => DAY_NAMES[d].slice(0, 3);
38
+ return groups.map(g => `${cap(g.from)}${g.to !== g.from ? `-${cap(g.to)}` : ''} ${g.t}`).join('; ');
39
+ }
40
+
41
+ export async function mount(root, ctx) {
42
+ const {kit, scope, here, boot} = ctx;
43
+ const people = M.observable(boot.bookables);
44
+ const rows = M.observable([]);
45
+ const userName = (id) => boot.users.find(u => u.id === id)?.name || '';
46
+ scope(M.effect(() => {
47
+ rows.value = people.value.map(b => ({
48
+ id: b.id, name: b.name, colour: b.colour || 'blue',
49
+ meta: [KIND_LABEL[b.kind] || b.kind, b.userId && userName(b.userId) ? `user ${userName(b.userId)}` : '', hoursSummary(b.hours),
50
+ b.daysOff?.length ? `${b.daysOff.length} day${b.daysOff.length === 1 ? '' : 's'} off` : ''].filter(Boolean).join(' · '),
51
+ state: b.active === false ? 'Off' : 'Bookable',
52
+ tone: b.active === false ? 'is-muted' : 'is-success'
53
+ }));
54
+ }));
55
+
56
+ root.innerHTML = '';
57
+ const view = document.createElement('div');
58
+ view.className = 'bk-card bk-people';
59
+ view.innerHTML = `
60
+ <div class="bk-bar">
61
+ <button type="button" class="btn btn-sm btn-primary" data-part="add"><span data-icon="plus" data-icon-size="13"></span> Add a person or room</button>
62
+ <span class="bk-spacer"></span>
63
+ <span data-help-title="People &amp; rooms" data-help="Everyone and everything that can be booked. A person is for appointments; a room or thing is booked for time. Each has its own opening hours and days off - the booking page only offers times inside them."></span>
64
+ </div>
65
+ <div class="bk-empty" data-bind-hidden="rows.value.length">
66
+ <span data-icon="users" data-icon-size="28"></span>
67
+ <p><strong>Add who or what can be booked.</strong><br>You, someone on your team, a meeting room, a piece of equipment - each with the hours it can be booked.</p>
68
+ </div>
69
+ <div class="bk-list" data-each="rows.value key=id"></div>`;
70
+ root.appendChild(view);
71
+ view.querySelector('.bk-list').innerHTML = ROW;
72
+ scope(kit.helpPopovers(view, {position: 'bottom'}));
73
+ const bindings = M.applyBindings({rows}, view);
74
+ scope(() => bindings.dispose());
75
+ I.scan(view);
76
+
77
+ const byId = (id) => people.peek().find(b => b.id === id);
78
+ const replace = (b) => {
79
+ const list = people.peek();
80
+ people.value = list.some(x => x.id === b.id) ? list.map(x => (x.id === b.id ? b : x)) : [...list, b];
81
+ boot.bookables = people.peek();
82
+ };
83
+ view.querySelector('[data-part="add"]').onclick = () => edit(null);
84
+
85
+ wireRows(view.querySelector('.bk-list'), {
86
+ openMenuAt: kit.openMenuAt,
87
+ scope,
88
+ main: (id) => edit(byId(id)),
89
+ items: (id) => {
90
+ const b = byId(id);
91
+ if (!b) return [];
92
+ return [
93
+ {label: 'Edit', icon: 'edit', action: () => edit(b)},
94
+ {label: 'New booking…', icon: 'plus-circle', disabled: () => !boot.services.some(s => s.bookables.includes(b.id)),
95
+ action: () => ctx.newBooking({bookableId: b.id})},
96
+ {type: 'divider'},
97
+ {label: b.active === false ? 'Switch on' : 'Switch off', icon: b.active === false ? 'eye' : 'eye-off', action: () => toggle(b)},
98
+ {label: 'Delete…', icon: 'trash', action: () => remove(b)}
99
+ ];
100
+ }
101
+ });
102
+
103
+ async function toggle(b) {
104
+ try {
105
+ replace(await api.bookables.update(b.id, {active: b.active === false}));
106
+ E.toast(`${b.name} is ${b.active === false ? 'bookable again' : 'switched off'}.`, {type: 'success'});
107
+ } catch (err) { E.toast(`Not changed: ${errText(err)}`, {type: 'error'}); }
108
+ }
109
+
110
+ async function remove(b) {
111
+ if (!await E.confirm(`Delete <strong>${esc(b.name)}</strong>? They come off every service. Past bookings keep the name. To stop bookings but keep them, switch them off instead.`)) return;
112
+ try {
113
+ await api.bookables.remove(b.id);
114
+ people.value = people.peek().filter(x => x.id !== b.id);
115
+ boot.bookables = people.peek();
116
+ for (const s of boot.services) s.bookables = s.bookables.filter(x => x !== b.id);
117
+ E.toast(`${b.name} deleted.`, {type: 'success'});
118
+ } catch (err) { E.toast(errText(err), {type: 'error', duration: 8000}); }
119
+ }
120
+
121
+ async function edit(b) {
122
+ const start = b || cleanBookable({name: ''}).data;
123
+ const f = {};
124
+ for (const k of ['name', 'kind', 'description', 'userId', 'email', 'colour']) f[k] = M.observable(start[k] ?? '');
125
+ const bools = {calendarBusy: M.observable(start.calendarBusy !== false), active: M.observable(start.active !== false)};
126
+ const hours = Object.fromEntries(DAY_KEYS.map(d => [d, (start.hours?.[d] || []).map(w => ({...w}))]));
127
+ let daysOff = (start.daysOff || []).map(o => ({...o}));
128
+ const isPerson = M.computed(() => f.kind.value === 'person');
129
+ const saving = M.observable(false);
130
+ const snapshot = () => JSON.stringify([Object.values(f).map(o => o.peek()), Object.values(bools).map(o => o.peek()), hours, daysOff]);
131
+ const opened = snapshot();
132
+ let panel = null;
133
+
134
+ const drawHours = (el) => {
135
+ const box = el.querySelector('[data-part="hours"]');
136
+ box.innerHTML = WEEK.map(d => `<div class="bk-day" data-day="${d}">
137
+ <span class="bk-day-name">${DAY_NAMES[d]}</span>
138
+ <div class="bk-windows">${hours[d].length ? hours[d].map((w, i) => `<span class="bk-window">
139
+ <input type="time" class="form-input" step="300" value="${esc(w.from)}" data-i="${i}" data-k="from" aria-label="${DAY_NAMES[d]} from">
140
+ <span>-</span>
141
+ <input type="time" class="form-input" step="300" value="${esc(w.to)}" data-i="${i}" data-k="to" aria-label="${DAY_NAMES[d]} to">
142
+ <button type="button" class="btn btn-xs btn-ghost bk-x" data-drop="${i}" title="Remove these hours" aria-label="Remove these hours"><span data-icon="x" data-icon-size="12"></span></button></span>`).join('') : '<span class="bk-muted">Closed</span>'}</div>
143
+ <button type="button" class="btn btn-xs btn-ghost" data-add title="Add hours on ${DAY_NAMES[d]}"><span data-icon="plus" data-icon-size="12"></span> Hours</button>
144
+ </div>`).join('');
145
+ I.scan(box);
146
+ };
147
+ const drawDaysOff = (el) => {
148
+ const box = el.querySelector('[data-part="daysoff"]');
149
+ box.innerHTML = daysOff.length ? daysOff.map((o, i) => `<div class="bk-off" data-i="${i}">
150
+ <input type="date" class="form-input" value="${esc(o.from)}" data-k="from" aria-label="From">
151
+ <span>to</span>
152
+ <input type="date" class="form-input" value="${esc(o.to)}" data-k="to" aria-label="To">
153
+ <input class="form-input" value="${esc(o.label)}" data-k="label" placeholder="Holiday, training…" maxlength="80" aria-label="Why">
154
+ <button type="button" class="btn btn-xs btn-ghost bk-x" data-drop title="Remove" aria-label="Remove"><span data-icon="x" data-icon-size="12"></span></button></div>`).join('')
155
+ : '<p class="bk-note">None. Add holidays and other days they cannot be booked.</p>';
156
+ I.scan(box);
157
+ };
158
+
159
+ panel = await openPanel({
160
+ title: b ? `Edit ${b.name}` : 'Add a person or room',
161
+ size: 'lg',
162
+ model: {...f, saving, isPerson},
163
+ dirty: () => snapshot() !== opened,
164
+ html: `<div class="bk-form">
165
+ <div class="bk-grid">
166
+ <div><label class="form-label">Name <span data-help-title="Name" data-help="As customers see it: 'Dr Patel', 'Room 1', 'The van'."></span></label>
167
+ <input class="form-input" data-model="name.value" maxlength="80"><small class="bk-err" data-err="name" hidden></small></div>
168
+ <div><label class="form-label">Kind <span data-help-title="Kind" data-help="A person is booked for appointments. A room or thing is booked for time. Both work the same way; the words and icons differ."></span></label>
169
+ <select class="form-select" data-model="kind.value"><option value="person">Person</option><option value="resource">Room or thing</option></select></div>
170
+ <div class="bk-wide"><label class="form-label">Description <span class="bk-muted">(optional, shown on the booking page)</span></label>
171
+ <textarea class="form-input" rows="2" data-model="description.value" maxlength="500"></textarea></div>
172
+ <div data-bind-hidden="!isPerson.value"><label class="form-label">Their user account <span data-help-title="User account" data-help="Link a person to their sign-in here. Their customers join THEIR Contacts, and (with Calendar busy time on) their own Calendar events block their booking times."></span></label>
173
+ <select class="form-select" data-model="userId.value"><option value="">Not linked</option>${boot.users.map(u => `<option value="${esc(u.id)}">${esc(u.name)}</option>`).join('')}</select></div>
174
+ <div><label class="form-label">Email for their copy <span data-help-title="Email for their copy" data-help="New, moved and cancelled bookings with them are emailed here. Empty: the address in Booking settings, else the site's."></span></label>
175
+ <input class="form-input" type="email" data-model="email.value" maxlength="200"><small class="bk-err" data-err="email" hidden></small></div>
176
+ <div><label class="form-label">Colour <span data-help-title="Colour" data-help="Their bookings in the Diary."></span></label>
177
+ <select class="form-select" data-model="colour.value">${Object.entries(COLOUR_LABEL).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select></div>
178
+ <div class="bk-wide bk-checks">
179
+ <label class="bk-check"><input type="checkbox" data-bool="active"> Can be booked <span data-help-title="Can be booked" data-help="Off: no new bookings with them. Their existing bookings stay in the Diary."></span></label>
180
+ <label class="bk-check" data-bind-hidden="!isPerson.value"><input type="checkbox" data-bool="calendarBusy"> Their Calendar events block their times
181
+ <span data-help-title="Calendar busy time" data-help="Only for a person linked to a user account, on a site with the Calendar Tool: events they organise make those times unavailable. Also needs Calendar busy time on in Booking settings."></span></label>
182
+ </div>
183
+ </div>
184
+ <h4 class="bk-sub">Opening hours <span data-help-title="Opening hours" data-help="When they can be booked, in ${esc(ctx.tz)}. Add several windows for a day with a break (09:00-12:30, 13:30-17:00). A day with no hours is closed."></span></h4>
185
+ <div class="bk-hours" data-part="hours"></div>
186
+ <div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="copyMonday"><span data-icon="copy" data-icon-size="12"></span> Copy Monday to Tuesday-Friday</button></div>
187
+ <h4 class="bk-sub">Days off <span data-help-title="Days off" data-help="Holidays and other days they cannot be booked. For days the whole business is closed, use Closed days in Booking settings."></span></h4>
188
+ <div data-part="daysoff"></div>
189
+ <div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="addOff"><span data-icon="plus" data-icon-size="12"></span> Add days off</button></div>
190
+ <div class="bk-actions"><span class="bk-spacer"></span>
191
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
192
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="saving.value"><span data-icon="check" data-icon-size="13"></span> Save</button></div>
193
+ </div>`,
194
+ before: (el) => {
195
+ for (const box of el.querySelectorAll('[data-bool]')) {
196
+ box.checked = bools[box.dataset.bool].peek();
197
+ box.addEventListener('change', () => { bools[box.dataset.bool].value = box.checked; });
198
+ }
199
+ drawHours(el);
200
+ drawDaysOff(el);
201
+ const hoursBox = el.querySelector('[data-part="hours"]');
202
+ hoursBox.addEventListener('input', (e) => {
203
+ const day = e.target.closest('[data-day]')?.dataset.day;
204
+ if (!day || e.target.dataset.k === undefined) return;
205
+ hours[day][Number(e.target.dataset.i)][e.target.dataset.k] = e.target.value;
206
+ });
207
+ hoursBox.addEventListener('click', (e) => {
208
+ const day = e.target.closest('[data-day]')?.dataset.day;
209
+ if (!day) return;
210
+ const drop = e.target.closest('[data-drop]');
211
+ if (drop) { hours[day].splice(Number(drop.dataset.drop), 1); drawHours(el); return; }
212
+ if (e.target.closest('[data-add]')) {
213
+ const last = hours[day][hours[day].length - 1];
214
+ hours[day].push(last ? {from: last.to, to: last.to < '17:00' ? '17:00' : last.to} : {from: '09:00', to: '17:00'});
215
+ drawHours(el);
216
+ }
217
+ });
218
+ const offBox = el.querySelector('[data-part="daysoff"]');
219
+ offBox.addEventListener('input', (e) => {
220
+ const row = e.target.closest('[data-i]');
221
+ if (!row) return;
222
+ const o = daysOff[Number(row.dataset.i)];
223
+ o[e.target.dataset.k] = e.target.value;
224
+ if (e.target.dataset.k === 'from' && (!o.to || o.to < o.from)) { o.to = o.from; row.querySelector('[data-k="to"]').value = o.from; }
225
+ });
226
+ offBox.addEventListener('click', (e) => {
227
+ const row = e.target.closest('[data-i]');
228
+ if (row && e.target.closest('[data-drop]')) { daysOff.splice(Number(row.dataset.i), 1); drawDaysOff(el); }
229
+ });
230
+ },
231
+ after: (el) => setTimeout(() => el.querySelector('input')?.focus(), 60),
232
+ methods: {
233
+ cancel: () => panel?.close(),
234
+ copyMonday() {
235
+ for (const d of ['tue', 'wed', 'thu', 'fri']) hours[d] = hours.mon.map(w => ({...w}));
236
+ drawHours(panel.panel);
237
+ },
238
+ addOff() {
239
+ daysOff = [...daysOff, {from: boot.today, to: boot.today, label: ''}];
240
+ drawDaysOff(panel.panel);
241
+ },
242
+ async save() {
243
+ if (saving.peek()) return;
244
+ // Say which hours are wrong rather than let the server drop them quietly.
245
+ for (const d of DAY_KEYS) {
246
+ for (const w of hours[d]) {
247
+ if (!(toMinutes(w.to) > toMinutes(w.from))) { E.toast(`${DAY_NAMES[d]}: ${w.from || '?'}-${w.to || '?'} - the end must be after the start.`, {type: 'warning'}); return; }
248
+ }
249
+ }
250
+ for (const o of daysOff) {
251
+ if (!isDate(o.from) || !isDate(o.to) || o.to < o.from) { E.toast('Days off: each needs a start date, and an end on or after it.', {type: 'warning'}); return; }
252
+ }
253
+ const body = {
254
+ name: f.name.peek(), kind: f.kind.peek(), description: f.description.peek(), userId: f.kind.peek() === 'person' ? f.userId.peek() : '',
255
+ email: f.email.peek(), colour: f.colour.peek(), calendarBusy: bools.calendarBusy.peek(), active: bools.active.peek(), hours, daysOff
256
+ };
257
+ saving.value = true;
258
+ try {
259
+ const saved = b ? await api.bookables.update(b.id, body) : await api.bookables.create(body);
260
+ replace(saved);
261
+ E.toast(`${saved.name} saved.`, {type: 'success'});
262
+ panel.close(true);
263
+ if (!b && !boot.services.length) E.toast('Next: add a service they provide (Services).', {type: 'info', duration: 6000});
264
+ } catch (err) {
265
+ const errors = err?.body?.errors || {};
266
+ for (const box of panel.panel.querySelectorAll('[data-err]')) { box.textContent = errors[box.dataset.err] || ''; box.hidden = !errors[box.dataset.err]; }
267
+ E.toast(`Not saved: ${errText(err)}`, {type: 'error'});
268
+ } finally {
269
+ saving.value = false;
270
+ }
271
+ }
272
+ }
273
+ });
274
+ }
275
+
276
+ if (ctx.id && here()) {
277
+ if (ctx.id === 'new') edit(null);
278
+ else if (byId(ctx.id)) edit(byId(ctx.id));
279
+ ctx.replaceHash('#/plugins/booking/people');
280
+ }
281
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Booking › Services - what can be booked, for how long, and with whom.
3
+ *
4
+ * Rows with a right-click / ⋮ / Shift+F10 menu (Edit, Copy shortcode, Open on
5
+ * the booking page, Switch off / on, Delete). Click edits in a slideover.
6
+ * `#/plugins/booking/services/new` opens a new one; `/services/<id>` one.
7
+ */
8
+
9
+ import {COLOUR_LABEL, api, copy, errText, esc, minutesLabel, openPanel, wireRows} from '../lib/kit.js';
10
+ import {cleanService} from '/plugins/booking/public/lib/clean.js';
11
+ import {mountSections} from '../lib/extension-sections.js';
12
+
13
+ const ROW = `
14
+ <div class="bk-row" data-id="{{id}}" tabindex="0">
15
+ <span class="bk-dot" data-bind-data-colour="colour"></span>
16
+ <span class="bk-main"><strong data-bind-text="name"></strong><small data-bind-text="meta"></small></span>
17
+ <span class="bk-chip" data-bind-class="tone" data-bind-text="state"></span>
18
+ <button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
19
+ </div>`;
20
+
21
+ export async function mount(root, ctx) {
22
+ const {kit, scope, here, boot} = ctx;
23
+ const services = M.observable(boot.services);
24
+ const rows = M.observable([]);
25
+ const bookableName = (id) => boot.bookables.find(b => b.id === id)?.name || '(removed)';
26
+ scope(M.effect(() => {
27
+ rows.value = services.value.map(s => ({
28
+ id: s.id, name: s.name, colour: s.colour || 'blue',
29
+ meta: [minutesLabel(s.duration), s.bookables.length ? `with ${s.bookables.map(bookableName).join(', ')}` : 'nobody provides it yet',
30
+ s.bufferBefore || s.bufferAfter ? `buffers ${s.bufferBefore || 0}/${s.bufferAfter || 0} min` : '',
31
+ Number(s.ext?.['booking-pro']?.capacity) > 1 ? `${s.ext['booking-pro'].capacity} places` : ''].filter(Boolean).join(' · '),
32
+ state: s.active === false ? 'Off' : s.public === false ? 'Staff only' : 'On the booking page',
33
+ tone: s.active === false ? 'is-muted' : s.public === false ? 'is-warning' : 'is-success'
34
+ }));
35
+ }));
36
+
37
+ root.innerHTML = '';
38
+ const view = document.createElement('div');
39
+ view.className = 'bk-card bk-services';
40
+ view.innerHTML = `
41
+ <div class="bk-bar">
42
+ <button type="button" class="btn btn-sm btn-primary" data-part="add"><span data-icon="plus" data-icon-size="13"></span> New service</button>
43
+ <span class="bk-spacer"></span>
44
+ <span data-help-title="Services" data-help="What people can book: a haircut, a consultation, a meeting room for an hour. Each has a length, optional buffers either side (setting up, cleaning), and the people or rooms who provide it. Staff only services are hidden from the booking page but can be booked from the admin."></span>
45
+ </div>
46
+ <div class="bk-empty" data-bind-hidden="rows.value.length">
47
+ <span data-icon="briefcase" data-icon-size="28"></span>
48
+ <p><strong>No services yet.</strong><br>${boot.bookables.length ? 'Add what can be booked, for how long, and with whom.' : 'First add who or what can be booked (People &amp; rooms), then the services they provide.'}</p>
49
+ </div>
50
+ <div class="bk-list" data-each="rows.value key=id"></div>`;
51
+ root.appendChild(view);
52
+ view.querySelector('.bk-list').innerHTML = ROW;
53
+ scope(kit.helpPopovers(view, {position: 'bottom'}));
54
+ const bindings = M.applyBindings({rows}, view);
55
+ scope(() => bindings.dispose());
56
+ I.scan(view);
57
+
58
+ const byId = (id) => services.peek().find(s => s.id === id);
59
+ const replace = (s) => {
60
+ const list = services.peek();
61
+ services.value = list.some(x => x.id === s.id) ? list.map(x => (x.id === s.id ? s : x)) : [...list, s];
62
+ boot.services = services.peek();
63
+ };
64
+
65
+ view.querySelector('[data-part="add"]').onclick = () => edit(null);
66
+
67
+ const pageUrl = (s) => `${ctx.settings.bookingPath}/${s.slug}`;
68
+ wireRows(view.querySelector('.bk-list'), {
69
+ openMenuAt: kit.openMenuAt,
70
+ scope,
71
+ main: (id) => edit(byId(id)),
72
+ items: (id) => {
73
+ const s = byId(id);
74
+ if (!s) return [];
75
+ return [
76
+ {label: 'Edit', icon: 'edit', action: () => edit(s)},
77
+ {label: 'Copy shortcode', icon: 'copy', action: () => copy(`[booking service="${s.slug}" /]`, 'Shortcode copied')},
78
+ {label: 'Open on the booking page', icon: 'external-link', disabled: () => s.active === false || s.public === false,
79
+ action: () => window.open(pageUrl(s), '_blank', 'noopener')},
80
+ {label: 'New booking…', icon: 'plus-circle', action: () => ctx.newBooking({serviceId: s.id})},
81
+ {type: 'divider'},
82
+ {label: s.active === false ? 'Switch on' : 'Switch off', icon: s.active === false ? 'eye' : 'eye-off', action: () => toggle(s)},
83
+ {label: 'Delete…', icon: 'trash', action: () => remove(s)}
84
+ ];
85
+ }
86
+ });
87
+
88
+ async function toggle(s) {
89
+ try {
90
+ replace(await api.services.update(s.id, {active: s.active === false}));
91
+ E.toast(`${s.name} is ${s.active === false ? 'on' : 'off'}.`, {type: 'success'});
92
+ } catch (err) { E.toast(`Not changed: ${errText(err)}`, {type: 'error'}); }
93
+ }
94
+
95
+ async function remove(s) {
96
+ if (!await E.confirm(`Delete the service <strong>${esc(s.name)}</strong>? Past bookings keep its name. To stop taking bookings but keep it, switch it off instead.`)) return;
97
+ try {
98
+ await api.services.remove(s.id);
99
+ services.value = services.peek().filter(x => x.id !== s.id);
100
+ boot.services = services.peek();
101
+ E.toast(`${s.name} deleted.`, {type: 'success'});
102
+ } catch (err) { E.toast(errText(err), {type: 'error', duration: 8000}); }
103
+ }
104
+
105
+ async function edit(s) {
106
+ if (!s && !boot.bookables.length) {
107
+ if (await E.confirm('A service needs someone or something to provide it. Add a person or room first?')) ctx.go('people', 'new');
108
+ return;
109
+ }
110
+ const start = s || cleanService({name: '', bookables: boot.bookables.filter(b => b.active !== false).length === 1 ? [boot.bookables.find(b => b.active !== false).id] : []}).data;
111
+ const f = {};
112
+ for (const k of ['name', 'slug', 'description', 'duration', 'bufferBefore', 'bufferAfter', 'step', 'maxAdvanceDays', 'colour']) f[k] = M.observable(start[k] ?? '');
113
+ const notice = Number(start.minNoticeMinutes ?? 120);
114
+ f.noticeAmount = M.observable(notice % 60 === 0 ? notice / 60 : notice);
115
+ f.noticeUnit = M.observable(notice % 60 === 0 ? 'hours' : 'minutes');
116
+ const bools = {chooseBookable: M.observable(start.chooseBookable !== false), public: M.observable(start.public !== false), active: M.observable(start.active !== false)};
117
+ const picked = new Set(start.bookables || []);
118
+ const saving = M.observable(false);
119
+ const snapshot = () => JSON.stringify([Object.values(f).map(o => o.peek()), Object.values(bools).map(o => o.peek()), [...picked].sort()]);
120
+ const opened = snapshot();
121
+ let panel = null;
122
+ let sections = null;
123
+
124
+ const shortcode = M.computed(() => `[booking service="${f.slug.value || '…'}" /]`);
125
+ panel = await openPanel({
126
+ title: s ? `Edit ${s.name}` : 'New service',
127
+ size: 'md',
128
+ model: {...f, saving, shortcode},
129
+ dirty: () => snapshot() !== opened || Boolean(sections?.dirty()),
130
+ cleanup: () => sections?.destroy(),
131
+ html: `<div class="bk-form">
132
+ <div class="bk-grid">
133
+ <div class="bk-wide"><label class="form-label">Name <span data-help-title="Name" data-help="What the customer sees on the booking page and in their emails."></span></label>
134
+ <input class="form-input" data-model="name.value" maxlength="80" placeholder="Consultation"><small class="bk-err" data-err="name" hidden></small></div>
135
+ <div class="bk-wide"><label class="form-label">Description <span class="bk-muted">(optional)</span></label>
136
+ <textarea class="form-input" rows="2" data-model="description.value" maxlength="1000"></textarea></div>
137
+ <div><label class="form-label">Length (minutes) <span data-help-title="Length" data-help="How long the booking itself lasts."></span></label>
138
+ <input class="form-input" type="number" min="5" max="1440" step="5" data-model="duration.value"></div>
139
+ <div><label class="form-label">Start every (minutes) <span data-help-title="Start every" data-help="How far apart the times offered are. 30 with a 60-minute service offers 09:00, 09:30, 10:00… Use the length itself for back-to-back slots."></span></label>
140
+ <input class="form-input" type="number" min="5" max="1440" step="5" data-model="step.value"></div>
141
+ <div><label class="form-label">Buffer before (minutes) <span data-help-title="Buffer before" data-help="Time kept free before each booking - to set up, or travel. Customers do not see it."></span></label>
142
+ <input class="form-input" type="number" min="0" max="480" step="5" data-model="bufferBefore.value"></div>
143
+ <div><label class="form-label">Buffer after (minutes) <span data-help-title="Buffer after" data-help="Time kept free after each booking - to clean a room, or write notes."></span></label>
144
+ <input class="form-input" type="number" min="0" max="480" step="5" data-model="bufferAfter.value"></div>
145
+ <div><label class="form-label">Notice needed <span data-help-title="Notice" data-help="The soonest a customer can book on the page. Staff can always book at short notice from the admin."></span></label>
146
+ <div class="bk-inline"><input class="form-input" type="number" min="0" data-model="noticeAmount.value">
147
+ <select class="form-select" data-model="noticeUnit.value"><option value="minutes">minutes</option><option value="hours">hours</option></select></div></div>
148
+ <div><label class="form-label">Bookable up to (days ahead) <span data-help-title="How far ahead" data-help="How far into the future the booking page offers times."></span></label>
149
+ <input class="form-input" type="number" min="1" max="365" data-model="maxAdvanceDays.value"></div>
150
+ <div class="bk-wide"><label class="form-label">Provided by <span data-help-title="Provided by" data-help="The people or rooms that can take this booking. A time is offered while any of them is free."></span></label>
151
+ <div class="bk-picks" data-part="picks">${boot.bookables.map(b => `<label class="bk-check"><input type="checkbox" value="${esc(b.id)}" ${picked.has(b.id) ? 'checked' : ''}> ${esc(b.name)}${b.active === false ? ' <span class="bk-muted">(off)</span>' : ''}</label>`).join('')}</div>
152
+ <small class="bk-err" data-err="bookables" hidden></small></div>
153
+ <div><label class="form-label">Colour</label>
154
+ <select class="form-select" data-model="colour.value">${Object.entries(COLOUR_LABEL).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select></div>
155
+ <div><label class="form-label">Web address <span data-help-title="Web address" data-help="The last part of this service's own booking page (${esc(ctx.settings.bookingPath)}/…) and of its shortcode. Made from the name if left empty."></span></label>
156
+ <input class="form-input" data-model="slug.value" maxlength="60"></div>
157
+ <div class="bk-wide bk-checks">
158
+ <label class="bk-check"><input type="checkbox" data-bool="active"> Taking bookings <span data-help-title="Taking bookings" data-help="Off: hidden everywhere and nothing new can be booked. Existing bookings stay."></span></label>
159
+ <label class="bk-check"><input type="checkbox" data-bool="public"> On the booking page <span data-help-title="On the booking page" data-help="Off: staff only - it can be booked from the admin but customers do not see it."></span></label>
160
+ <label class="bk-check"><input type="checkbox" data-bool="chooseBookable"> Customers choose who <span data-help-title="Customers choose who" data-help="With more than one provider, the page lets the customer pick one, or take anyone free. Off: whoever is free takes it."></span></label>
161
+ </div>
162
+ <div class="bk-wide"><label class="form-label">Put it on any page <span data-help-title="Shortcode" data-help="Paste this into a page to show this service's booking straight away. [booking /] on its own shows every service."></span></label>
163
+ <div class="bk-copy"><code data-bind-text="shortcode.value"></code><button type="button" class="btn btn-sm btn-ghost" data-on-click="copyCode" title="Copy"><span data-icon="copy" data-icon-size="13"></span></button></div></div>
164
+ </div>
165
+ <div class="bk-ext-host" data-part="ext"></div>
166
+ <div class="bk-actions"><span class="bk-spacer"></span>
167
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
168
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="saving.value"><span data-icon="check" data-icon-size="13"></span> Save</button></div>
169
+ </div>`,
170
+ before: (el) => {
171
+ for (const box of el.querySelectorAll('[data-bool]')) {
172
+ box.checked = bools[box.dataset.bool].peek();
173
+ box.addEventListener('change', () => { bools[box.dataset.bool].value = box.checked; });
174
+ }
175
+ el.querySelector('[data-part="picks"]').addEventListener('change', (e) => {
176
+ if (e.target.checked) picked.add(e.target.value); else picked.delete(e.target.value);
177
+ });
178
+ sections = mountSections(el.querySelector('[data-part="ext"]'), 'serviceEditor', {boot, service: s || null});
179
+ },
180
+ after: (el) => setTimeout(() => el.querySelector('input')?.focus(), 60),
181
+ methods: {
182
+ cancel: () => panel?.close(),
183
+ copyCode: () => copy(shortcode.peek(), 'Shortcode copied'),
184
+ async save() {
185
+ if (saving.peek()) return;
186
+ await sections?.ready;
187
+ const why = sections?.validate();
188
+ if (why) { E.toast(why, {type: 'warning'}); return; }
189
+ const n = Number(f.noticeAmount.peek()) || 0;
190
+ const body = {
191
+ name: f.name.peek(), slug: f.slug.peek(), description: f.description.peek(), colour: f.colour.peek(),
192
+ duration: Number(f.duration.peek()), step: Number(f.step.peek()), bufferBefore: Number(f.bufferBefore.peek()) || 0,
193
+ bufferAfter: Number(f.bufferAfter.peek()) || 0, maxAdvanceDays: Number(f.maxAdvanceDays.peek()),
194
+ minNoticeMinutes: f.noticeUnit.peek() === 'hours' ? n * 60 : n,
195
+ bookables: [...picked], chooseBookable: bools.chooseBookable.peek(), public: bools.public.peek(), active: bools.active.peek()
196
+ };
197
+ const ext = sections?.values() || {};
198
+ if (Object.keys(ext).length) body.ext = ext;
199
+ saving.value = true;
200
+ try {
201
+ const saved = s ? await api.services.update(s.id, body) : await api.services.create(body);
202
+ replace(saved);
203
+ E.toast(`${saved.name} saved.`, {type: 'success'});
204
+ panel.close(true);
205
+ } catch (err) {
206
+ const errors = err?.body?.errors || {};
207
+ for (const box of panel.panel.querySelectorAll('[data-err]')) { box.textContent = errors[box.dataset.err] || ''; box.hidden = !errors[box.dataset.err]; }
208
+ E.toast(`Not saved: ${errText(err)}`, {type: 'error'});
209
+ } finally {
210
+ saving.value = false;
211
+ }
212
+ }
213
+ }
214
+ });
215
+ }
216
+
217
+ if (ctx.id && here()) {
218
+ if (ctx.id === 'new') edit(null);
219
+ else if (byId(ctx.id)) edit(byId(ctx.id));
220
+ ctx.replaceHash('#/plugins/booking/services');
221
+ }
222
+ }