domma-cms 1.6.55 → 1.6.57
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/admin/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/public/js/site.js +1 -1
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
- package/server/templates/page.html +1 -1
|
@@ -55,10 +55,17 @@ export const bookingView = {
|
|
|
55
55
|
const here = () => location.hash === mountedFor && root.isConnected;
|
|
56
56
|
const {tab, id, query} = where();
|
|
57
57
|
|
|
58
|
+
// The first load: a skeleton (core's loading.js) in a slot that goes when the screen draws.
|
|
59
|
+
root.innerHTML = '';
|
|
60
|
+
const slot = document.createElement('div');
|
|
61
|
+
slot.className = 'bk-note bk-loading-slot dm-loading-slot';
|
|
62
|
+
slot.textContent = 'Loading…';
|
|
63
|
+
root.appendChild(slot);
|
|
58
64
|
let boot;
|
|
59
65
|
try {
|
|
60
|
-
boot = await api.bootstrap();
|
|
66
|
+
boot = await kit.whileLoading(slot, api.bootstrap(), kit.SHAPES.rows);
|
|
61
67
|
} catch (err) {
|
|
68
|
+
if (!here()) return;
|
|
62
69
|
root.innerHTML = '';
|
|
63
70
|
const p = document.createElement('p');
|
|
64
71
|
p.className = 'bk-error';
|
|
@@ -110,6 +117,8 @@ export const bookingView = {
|
|
|
110
117
|
|
|
111
118
|
const ctx = {
|
|
112
119
|
kit, scope, here, root: body, container, boot, settings: boot.settings, id, query,
|
|
120
|
+
/** Like scope, but answers the remover: a panel lets go of the screen when it closes first. */
|
|
121
|
+
hold: (fn) => lifetime.add(fn),
|
|
113
122
|
/** Tidy the address (a deep link done with) without leaving the screen. */
|
|
114
123
|
replaceHash(h) { history.replaceState(null, '', h); mountedFor = location.hash; },
|
|
115
124
|
tz: boot.settings.timeZone,
|
|
@@ -199,7 +208,7 @@ export const bookingView = {
|
|
|
199
208
|
const c = await api.contactByEmail(query.email).catch(() => null);
|
|
200
209
|
if (c?.id) Object.assign(prefill, {contactId: c.id, name: c.name || prefill.name, phone: c.phone});
|
|
201
210
|
}
|
|
202
|
-
if (!
|
|
211
|
+
if (!here()) return;
|
|
203
212
|
if (wantAction) runAction(wantAction, prefill);
|
|
204
213
|
else ctx.newBooking(prefill);
|
|
205
214
|
}
|
|
@@ -85,7 +85,7 @@ export async function mount(root, ctx) {
|
|
|
85
85
|
<input type="search" class="form-input bk-search" placeholder="Search name, email, service…" aria-label="Search bookings" data-model="query.value">
|
|
86
86
|
<span data-help-title="Bookings" data-help="Every booking, newest changes included. Click one to open it; right-click (or ⋮, or Shift+F10) to move it, mark it done or a no-show, or cancel it. Cancelled bookings free their time and can then be deleted."></span>
|
|
87
87
|
</div>
|
|
88
|
-
<
|
|
88
|
+
<div class="bk-note bk-loading-slot dm-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
|
|
89
89
|
<p class="bk-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
90
90
|
<p class="bk-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
|
|
91
91
|
<div class="bk-list" data-each="rows.value key=id"></div>`;
|
|
@@ -99,13 +99,18 @@ export async function mount(root, ctx) {
|
|
|
99
99
|
if (b) filter.value = b.dataset.filter;
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
// The first load shows a skeleton in the slot; a reload keeps the list on screen.
|
|
103
|
+
let first = true;
|
|
102
104
|
async function load() {
|
|
103
105
|
try {
|
|
104
|
-
const
|
|
106
|
+
const slot = first ? view.querySelector('.bk-loading-slot') : null;
|
|
107
|
+
first = false;
|
|
108
|
+
const list = await (slot ? kit.whileLoading(slot, api.bookings.list(), kit.SHAPES.rows) : api.bookings.list());
|
|
105
109
|
if (!here()) return;
|
|
106
110
|
all.value = Array.isArray(list) ? list : [];
|
|
107
111
|
error.value = '';
|
|
108
112
|
} catch (err) {
|
|
113
|
+
if (!here()) return;
|
|
109
114
|
error.value = `The bookings could not load: ${errText(err)}`;
|
|
110
115
|
} finally {
|
|
111
116
|
loading.value = false;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* that works the geometry out in JS (diary-layout.js); the markup only binds.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {STATUS_LABEL, V, api, copy, errText, esc, openPanel, store, wireRows} from '../lib/kit.js';
|
|
15
|
+
import {STATUS_LABEL, V, api, claim, copy, errText, esc, openPanel, store, wireRows} from '../lib/kit.js';
|
|
16
16
|
import {columnItems, dayLabel, datesFor, gridHours, layoutColumn, mergeWindows, minuteAt, placeOf} from '../lib/diary-layout.js';
|
|
17
17
|
import {withNames} from '../lib/details.js';
|
|
18
18
|
import {addDays, atWallClock, fromMinutes, isDate, wallClock} from '/plugins/booking/public/lib/time.js';
|
|
@@ -66,6 +66,8 @@ export async function mount(root, ctx) {
|
|
|
66
66
|
/** Class sessions drawn as one block: key -> their bookings. */
|
|
67
67
|
const sessions = new Map();
|
|
68
68
|
const loading = M.observable(true);
|
|
69
|
+
// Only the first load hides the grid behind a skeleton; moving a day or a week keeps it on screen.
|
|
70
|
+
const firstLoad = M.observable(true);
|
|
69
71
|
const error = M.observable('');
|
|
70
72
|
const label = M.observable('');
|
|
71
73
|
// What the grid shows, as rows for the keyed lists.
|
|
@@ -107,7 +109,8 @@ export async function mount(root, ctx) {
|
|
|
107
109
|
<span data-help-title="The Diary" data-help="Bookings in your business's time zone (${esc(ctx.tz)}). Hatched times are outside opening hours. Click a booking to open it; right-click it (or ⋮, or Shift+F10) for Move, Done, No-show and Cancel. Click an empty time to book it. Keys: ← → earlier and later, T today, D day, W week."></span>
|
|
108
110
|
</div>
|
|
109
111
|
<p class="bk-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
110
|
-
<div class="bk-
|
|
112
|
+
<div class="bk-note bk-loading-slot dm-loading-slot" data-bind-hidden="!firstLoad.value || !hasBookables.value">Loading…</div>
|
|
113
|
+
<div class="bk-diary-wrap" data-part="grid" tabindex="-1" data-bind-hidden="firstLoad.value && hasBookables.value">
|
|
111
114
|
<div class="bk-empty" data-bind-hidden="hasBookables.value"><span data-icon="users" data-icon-size="28"></span>
|
|
112
115
|
<p><strong>Add who or what can be booked.</strong><br>A person (for appointments) or a room or thing - each with their opening hours.</p>
|
|
113
116
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="addPerson"><span data-icon="plus" data-icon-size="13"></span> Add a person or room</button></div>
|
|
@@ -127,7 +130,7 @@ export async function mount(root, ctx) {
|
|
|
127
130
|
const cancelledBox = card.querySelector('[data-part="cancelled"]');
|
|
128
131
|
scope(kit.helpPopovers(card, {position: 'bottom'}));
|
|
129
132
|
const model = {
|
|
130
|
-
view, label, error, hasBookables, hasServices, heads, hours, cols, gridCols, gridH, steps, checklistOn,
|
|
133
|
+
view, label, error, firstLoad, hasBookables, hasServices, heads, hours, cols, gridCols, gridH, steps, checklistOn,
|
|
131
134
|
hideChecklist() { store.set(CHECKLIST_KEY, true); checklistOn.value = false; },
|
|
132
135
|
step(s) { s.go(); },
|
|
133
136
|
addPerson: () => ctx.go('people', 'new'),
|
|
@@ -202,14 +205,15 @@ export async function mount(root, ctx) {
|
|
|
202
205
|
try {
|
|
203
206
|
const from = atWallClock(ds[0], 0, ctx.tz).toISOString();
|
|
204
207
|
const to = atWallClock(addDays(ds[ds.length - 1], 1), 0, ctx.tz).toISOString();
|
|
205
|
-
const
|
|
208
|
+
const slot = firstLoad.peek() && hasBookables.peek() ? card.querySelector('.bk-loading-slot') : null;
|
|
209
|
+
const list = await (slot ? kit.whileLoading(slot, api.bookings.list({from, to}), kit.SHAPES.table) : api.bookings.list({from, to}));
|
|
206
210
|
if (my !== seq || !here()) return;
|
|
207
211
|
bookings.value = Array.isArray(list) ? list : [];
|
|
208
212
|
error.value = '';
|
|
209
213
|
} catch (err) {
|
|
210
|
-
if (my === seq) error.value = `The diary could not load: ${errText(err)}`;
|
|
214
|
+
if (my === seq && here()) error.value = `The diary could not load: ${errText(err)}`;
|
|
211
215
|
} finally {
|
|
212
|
-
if (my === seq) loading.value = false;
|
|
216
|
+
if (my === seq) { loading.value = false; firstLoad.value = false; }
|
|
213
217
|
}
|
|
214
218
|
}
|
|
215
219
|
ctx.onBookingsChanged = () => { if (here()) load(); };
|
|
@@ -330,6 +334,8 @@ export async function mount(root, ctx) {
|
|
|
330
334
|
/** A class: who is in it, each opening their booking. */
|
|
331
335
|
async function openSession(list) {
|
|
332
336
|
if (!list.length) return;
|
|
337
|
+
const release = claim('session');
|
|
338
|
+
if (!release) return;
|
|
333
339
|
const first = list[0];
|
|
334
340
|
const s = wallClock(first.startAt, ctx.tz);
|
|
335
341
|
const e = wallClock(first.endAt, ctx.tz);
|
|
@@ -337,6 +343,7 @@ export async function mount(root, ctx) {
|
|
|
337
343
|
panel = await openPanel({
|
|
338
344
|
title: `${first.serviceName}, ${s.time}-${e.time}`,
|
|
339
345
|
size: 'sm',
|
|
346
|
+
ctx, release,
|
|
340
347
|
html: `<div class="bk-form">
|
|
341
348
|
<p class="bk-muted">${esc(dayLabel(s.date))}${first.bookableName ? ` · with ${esc(withNames(first))}` : ''} · ${list.length} booked</p>
|
|
342
349
|
<div class="bk-list bk-session-list">${list.map(b => `<button type="button" class="bk-row" data-open="${esc(b.id)}">
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
-
STATUS_LABEL, STATUS_TONE, api, dateLabel, errText, esc, fmtDateTime, isClash, minutesLabel, openPanel, refreshBadge
|
|
12
|
+
STATUS_LABEL, STATUS_TONE, api, claim, dateLabel, errText, esc, fmtDateTime, isClash, minutesLabel, openPanel, refreshBadge
|
|
13
13
|
} from '../lib/kit.js';
|
|
14
14
|
import {atWallClock, isDate, toMinutes, wallClock} from '/plugins/booking/public/lib/time.js';
|
|
15
15
|
import {answerRows, placesLabel, sourceLabel, withNames} from '../lib/details.js';
|
|
@@ -70,6 +70,8 @@ export async function setStatus(ctx, b, status) {
|
|
|
70
70
|
|
|
71
71
|
/** Cancel, with a reason and whether to email the customer. Resolves the booking, or null. */
|
|
72
72
|
export function cancelBooking(ctx, b) {
|
|
73
|
+
const release = claim(`cancel:${b.id}`);
|
|
74
|
+
if (!release) return Promise.resolve(null);
|
|
73
75
|
return new Promise((resolve) => {
|
|
74
76
|
const reason = M.observable('');
|
|
75
77
|
const notify = M.observable(Boolean(b.email) && ctx.boot.mailReady);
|
|
@@ -78,6 +80,7 @@ export function cancelBooking(ctx, b) {
|
|
|
78
80
|
openPanel({
|
|
79
81
|
title: 'Cancel booking',
|
|
80
82
|
size: 'sm',
|
|
83
|
+
ctx, release,
|
|
81
84
|
model: {reason, notify},
|
|
82
85
|
html: `<div class="bk-form">
|
|
83
86
|
<p><strong>${esc(b.serviceName)}</strong> for <strong>${esc(b.name)}</strong><br><span class="bk-muted">${esc(timeRange(b, ctx.tz))}</span></p>
|
|
@@ -110,7 +113,11 @@ export function cancelBooking(ctx, b) {
|
|
|
110
113
|
}
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
|
-
}).then(p => { panel = p; })
|
|
116
|
+
}).then(p => { panel = p; if (!p) resolve(null); }, (err) => {
|
|
117
|
+
// The panel could not be built (openPanel has released its claim and tidied up).
|
|
118
|
+
E.toast(`That could not open: ${errText(err)}`, {type: 'error'});
|
|
119
|
+
resolve(null);
|
|
120
|
+
});
|
|
114
121
|
});
|
|
115
122
|
}
|
|
116
123
|
|
|
@@ -207,6 +214,12 @@ function timePicker(ctx, {date, time = '', ignore = ''}) {
|
|
|
207
214
|
return {
|
|
208
215
|
el, load,
|
|
209
216
|
value: () => ({date: dateIn.value, time: timeIn.value}),
|
|
217
|
+
/** Put a date and time back (a draft), and look up that day's free times. */
|
|
218
|
+
set({date: d, time: t} = {}) {
|
|
219
|
+
if (isDate(d)) dateIn.value = d;
|
|
220
|
+
timeIn.value = typeof t === 'string' ? t : '';
|
|
221
|
+
load();
|
|
222
|
+
},
|
|
210
223
|
startAt() {
|
|
211
224
|
if (!isDate(dateIn.value) || !Number.isFinite(toMinutes(timeIn.value))) return '';
|
|
212
225
|
return atWallClock(dateIn.value, timeIn.value, ctx.tz).toISOString();
|
|
@@ -238,9 +251,12 @@ export async function newBooking(ctx, prefill = {}) {
|
|
|
238
251
|
const ok = await E.confirm(!boot.bookables.length
|
|
239
252
|
? 'Add who or what can be booked first - a person or a room - then a service they provide.<br><br>Go to People & rooms now?'
|
|
240
253
|
: 'Add a service first - what can be booked, for how long, and with whom.<br><br>Go to Services now?');
|
|
241
|
-
if (ok) ctx.go(!boot.bookables.length ? 'people' : 'services', 'new');
|
|
254
|
+
if (ok && ctx.here()) ctx.go(!boot.bookables.length ? 'people' : 'services', 'new');
|
|
242
255
|
return null;
|
|
243
256
|
}
|
|
257
|
+
// One New booking at a time: a second click (or a click in the Diary) while it is open does nothing.
|
|
258
|
+
const release = claim('new-booking');
|
|
259
|
+
if (!release) return null;
|
|
244
260
|
const fromBookable = prefill.bookableId ? services.filter(s => s.bookables.includes(prefill.bookableId)) : [];
|
|
245
261
|
const f = {
|
|
246
262
|
serviceId: M.observable(prefill.serviceId || fromBookable[0]?.id || (services.length === 1 ? services[0].id : '')),
|
|
@@ -253,7 +269,9 @@ export async function newBooking(ctx, prefill = {}) {
|
|
|
253
269
|
notify: M.observable(boot.mailReady && boot.settings.sendConfirmation !== false),
|
|
254
270
|
saving: M.observable(false)
|
|
255
271
|
};
|
|
256
|
-
|
|
272
|
+
// The contact it was opened from; a restored draft brings back its own.
|
|
273
|
+
let contactId = prefill.contactId || '';
|
|
274
|
+
const contactName = M.observable(contactId ? (prefill.name || prefill.email || '') : '');
|
|
257
275
|
const providers = M.observable([]);
|
|
258
276
|
const updateProviders = () => {
|
|
259
277
|
const svc = boot.services.find(s => s.id === f.serviceId.peek());
|
|
@@ -268,15 +286,37 @@ export async function newBooking(ctx, prefill = {}) {
|
|
|
268
286
|
let panel = null;
|
|
269
287
|
let made = null;
|
|
270
288
|
let sections = null;
|
|
289
|
+
// Set in before(): put a service and a person back as if picked (the selects are drawn by hand).
|
|
290
|
+
let pickService = () => {};
|
|
291
|
+
const prefilled = Boolean(prefill.contactId || prefill.email || prefill.name || prefill.date || prefill.time || prefill.serviceId || prefill.bookableId);
|
|
271
292
|
|
|
272
293
|
return new Promise((resolve) => {
|
|
273
294
|
openPanel({
|
|
274
295
|
title: 'New booking',
|
|
275
296
|
size: 'md',
|
|
276
|
-
|
|
297
|
+
ctx, release,
|
|
298
|
+
model: {...f, providers, contactName},
|
|
277
299
|
dirty: () => !made && (typed() !== opened || Boolean(sections?.dirty())),
|
|
278
|
-
|
|
279
|
-
|
|
300
|
+
// Extension sections (Booking Pro's questions) are not in the draft: the section contract has
|
|
301
|
+
// values() but no way to put values back. What Booking's own form holds is.
|
|
302
|
+
draft: {
|
|
303
|
+
key: 'new-booking', kind: 'a new booking', label: () => f.name.peek(), prefilled,
|
|
304
|
+
values: () => ({serviceId: f.serviceId.peek(), bookableId: f.bookableId.peek(), ...picker.value(),
|
|
305
|
+
name: f.name.peek(), email: f.email.peek(), phone: f.phone.peek(), notes: f.notes.peek(),
|
|
306
|
+
staffNote: f.staffNote.peek(), notify: f.notify.peek(), contactId, contactName: contactName.peek()}),
|
|
307
|
+
apply(v, el) {
|
|
308
|
+
for (const k of ['name', 'email', 'phone', 'notes', 'staffNote']) if (typeof v[k] === 'string') f[k].value = v[k];
|
|
309
|
+
if (typeof v.notify === 'boolean') f.notify.value = v.notify;
|
|
310
|
+
contactId = typeof v.contactId === 'string' ? v.contactId : '';
|
|
311
|
+
contactName.value = contactId ? String(v.contactName || '') : '';
|
|
312
|
+
pickService(String(v.serviceId || ''), String(v.bookableId || ''));
|
|
313
|
+
picker.set({date: v.date, time: v.time});
|
|
314
|
+
const box = el.querySelector('[data-bool="notify"]');
|
|
315
|
+
if (box) box.checked = f.notify.peek();
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
html: `<div class="bk-form bk-new-booking">
|
|
319
|
+
<p class="bk-note" data-bind-hidden="!contactName.value"><span data-icon="user" data-icon-size="13"></span> From your contact <strong data-bind-text="contactName.value"></strong>.</p>
|
|
280
320
|
<div class="bk-grid">
|
|
281
321
|
<div><label class="form-label">Service <span data-help-title="Service" data-help="What is being booked. Its length and buffers decide which times are free."></span></label>
|
|
282
322
|
<select class="form-select" data-part="service"><option value="">Choose…</option>${services.map(s => `<option value="${esc(s.id)}">${esc(s.name)} (${esc(minutesLabel(s.duration))})${s.active === false ? ' - off' : ''}</option>`).join('')}</select></div>
|
|
@@ -319,6 +359,16 @@ export async function newBooking(ctx, prefill = {}) {
|
|
|
319
359
|
f.serviceId.value = svcSel.value; updateProviders(); drawWith(); picker.load({service: svcSel.value, with: withSel.value});
|
|
320
360
|
sections.serviceChanged(serviceNow());
|
|
321
361
|
});
|
|
362
|
+
pickService = (serviceId, bookableId) => {
|
|
363
|
+
// A service that has gone (or been switched off) since is left unpicked.
|
|
364
|
+
svcSel.value = serviceId;
|
|
365
|
+
f.serviceId.value = svcSel.value;
|
|
366
|
+
updateProviders();
|
|
367
|
+
f.bookableId.value = providers.peek().some(p => p.id === bookableId) ? bookableId : '';
|
|
368
|
+
drawWith();
|
|
369
|
+
picker.load({service: svcSel.value, with: withSel.value});
|
|
370
|
+
sections.serviceChanged(serviceNow());
|
|
371
|
+
};
|
|
322
372
|
withSel.addEventListener('change', () => { f.bookableId.value = withSel.value; picker.load({service: svcSel.value, with: withSel.value}); });
|
|
323
373
|
const box = el.querySelector('[data-bool="notify"]');
|
|
324
374
|
box.checked = f.notify.peek();
|
|
@@ -364,13 +414,19 @@ export async function newBooking(ctx, prefill = {}) {
|
|
|
364
414
|
}
|
|
365
415
|
}
|
|
366
416
|
}
|
|
367
|
-
}).then(p => { panel = p; })
|
|
417
|
+
}).then(p => { panel = p; if (!p) resolve(null); }, (err) => {
|
|
418
|
+
// The panel could not be built (openPanel has released its claim and tidied up).
|
|
419
|
+
E.toast(`That could not open: ${errText(err)}`, {type: 'error'});
|
|
420
|
+
resolve(null);
|
|
421
|
+
});
|
|
368
422
|
});
|
|
369
423
|
}
|
|
370
424
|
|
|
371
425
|
// ---- Move ------------------------------------------------------------------------
|
|
372
426
|
|
|
373
427
|
export function moveBooking(ctx, b) {
|
|
428
|
+
const release = claim(`move:${b.id}`);
|
|
429
|
+
if (!release) return Promise.resolve(null);
|
|
374
430
|
const svc = ctx.boot.services.find(s => s.id === b.serviceId);
|
|
375
431
|
const list = svc ? ctx.boot.bookables.filter(x => svc.bookables.includes(x.id)) : ctx.boot.bookables.filter(x => x.id === b.bookableId);
|
|
376
432
|
const at = wallClock(b.startAt, ctx.tz);
|
|
@@ -383,6 +439,7 @@ export function moveBooking(ctx, b) {
|
|
|
383
439
|
openPanel({
|
|
384
440
|
title: 'Move booking',
|
|
385
441
|
size: 'md',
|
|
442
|
+
ctx, release,
|
|
386
443
|
model: {withId, notify},
|
|
387
444
|
dirty: () => !moved && (picker.value().date !== at.date || picker.value().time !== at.time || withId.peek() !== b.bookableId),
|
|
388
445
|
html: `<div class="bk-form">
|
|
@@ -423,15 +480,22 @@ export function moveBooking(ctx, b) {
|
|
|
423
480
|
}
|
|
424
481
|
}
|
|
425
482
|
}
|
|
426
|
-
}).then(p => { panel = p; })
|
|
483
|
+
}).then(p => { panel = p; if (!p) resolve(null); }, (err) => {
|
|
484
|
+
// The panel could not be built (openPanel has released its claim and tidied up).
|
|
485
|
+
E.toast(`That could not open: ${errText(err)}`, {type: 'error'});
|
|
486
|
+
resolve(null);
|
|
487
|
+
});
|
|
427
488
|
});
|
|
428
489
|
}
|
|
429
490
|
|
|
430
491
|
// ---- One booking ------------------------------------------------------------------
|
|
431
492
|
|
|
432
493
|
export async function openBooking(ctx, id) {
|
|
494
|
+
const release = claim(`booking:${id}`);
|
|
495
|
+
if (!release) return null;
|
|
433
496
|
let b;
|
|
434
|
-
try { b = await api.bookings.get(id); } catch (err) { E.toast(`That booking could not be opened: ${errText(err)}`, {type: 'error'}); return; }
|
|
497
|
+
try { b = await api.bookings.get(id); } catch (err) { release(); if (ctx.here()) E.toast(`That booking could not be opened: ${errText(err)}`, {type: 'error'}); return null; }
|
|
498
|
+
if (!ctx.here()) { release(); return null; }
|
|
435
499
|
const staffNote = M.observable(b.staffNote || '');
|
|
436
500
|
const saved = M.observable(b.staffNote || '');
|
|
437
501
|
const tone = `is-${STATUS_TONE[b.status] || 'muted'}`;
|
|
@@ -450,8 +514,14 @@ export async function openBooking(ctx, id) {
|
|
|
450
514
|
panel = await openPanel({
|
|
451
515
|
title: `${b.serviceName}: ${b.name}`,
|
|
452
516
|
size: 'md',
|
|
517
|
+
ctx, release,
|
|
453
518
|
model: {staffNote, saved},
|
|
454
519
|
dirty: () => staffNote.peek() !== saved.peek(),
|
|
520
|
+
draft: {
|
|
521
|
+
key: `note:${b.id}`, label: `staff note for ${b.name}`, was: b.updatedAt || '',
|
|
522
|
+
values: () => ({staffNote: staffNote.peek()}),
|
|
523
|
+
apply: (v) => { if (typeof v.staffNote === 'string') staffNote.value = v.staffNote; }
|
|
524
|
+
},
|
|
455
525
|
cleanup: () => sections?.destroy(),
|
|
456
526
|
before: (el) => {
|
|
457
527
|
sections = mountSections(el.querySelector('[data-part="ext"]'), 'bookingPanel', {boot: ctx.boot, booking: b,
|
|
@@ -499,9 +569,12 @@ export async function openBooking(ctx, id) {
|
|
|
499
569
|
</div>`,
|
|
500
570
|
methods: {
|
|
501
571
|
async save() {
|
|
572
|
+
// What was sent is what is saved (the form is locked meanwhile, but say so plainly).
|
|
573
|
+
const sent = staffNote.peek();
|
|
502
574
|
try {
|
|
503
|
-
await api.bookings.update(b.id, {staffNote:
|
|
504
|
-
saved.value =
|
|
575
|
+
const next = await api.bookings.update(b.id, {staffNote: sent});
|
|
576
|
+
saved.value = sent;
|
|
577
|
+
panel?.rebase(next?.updatedAt);
|
|
505
578
|
E.toast('Note saved.', {type: 'success'});
|
|
506
579
|
changed(ctx);
|
|
507
580
|
} catch (err) {
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* weekly opening hours (several windows a day) and days off.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {COLOUR_LABEL, KIND_LABEL, api, errText, esc, openPanel, wireRows} from '../lib/kit.js';
|
|
10
|
+
import {COLOUR_LABEL, KIND_LABEL, allDrafts, api, ask, claim, dropDraft, errText, esc, openPanel, wireRows} from '../lib/kit.js';
|
|
11
|
+
import {orphanDrafts, restoreText, usableDraft} from '../lib/drafts.js';
|
|
11
12
|
import {cleanBookable} from '/plugins/booking/public/lib/clean.js';
|
|
12
13
|
import {DAY_KEYS, isDate, toMinutes} from '/plugins/booking/public/lib/time.js';
|
|
13
14
|
|
|
@@ -143,7 +144,10 @@ export async function mount(root, ctx) {
|
|
|
143
144
|
} catch (err) { E.toast(errText(err), {type: 'error', duration: 8000}); }
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
/** `initial`: a draft to start from (one of a person or room deleted since). */
|
|
148
|
+
async function edit(b, initial = null) {
|
|
149
|
+
const release = claim(`bookable:${b?.id || 'new'}`);
|
|
150
|
+
if (!release) return;
|
|
147
151
|
const start = b || cleanBookable({name: ''}).data;
|
|
148
152
|
const f = {};
|
|
149
153
|
for (const k of ['name', 'kind', 'description', 'userId', 'email', 'colour']) f[k] = M.observable(start[k] ?? '');
|
|
@@ -169,6 +173,21 @@ export async function mount(root, ctx) {
|
|
|
169
173
|
panel = await openPanel({
|
|
170
174
|
title: b ? `Edit ${b.name}` : 'Add a person or room',
|
|
171
175
|
size: 'lg',
|
|
176
|
+
ctx, release,
|
|
177
|
+
draft: {
|
|
178
|
+
key: `bookable:${b?.id || 'new'}`, label: () => f.name.peek() || b?.name || '', was: b?.updatedAt || '', initial,
|
|
179
|
+
values: () => ({f: Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])),
|
|
180
|
+
bools: Object.fromEntries(Object.entries(bools).map(([k, o]) => [k, o.peek()])), hours: hoursOut(), daysOff: daysOffOut()}),
|
|
181
|
+
apply(v, el) {
|
|
182
|
+
for (const [k, val] of Object.entries(v.f || {})) if (f[k] && typeof val === 'string') f[k].value = val;
|
|
183
|
+
for (const [k, val] of Object.entries(v.bools || {})) if (bools[k] && typeof val === 'boolean') bools[k].value = val;
|
|
184
|
+
for (const box of el.querySelectorAll('[data-bool]')) box.checked = bools[box.dataset.bool].peek();
|
|
185
|
+
const hours = v.hours && typeof v.hours === 'object' ? v.hours : {};
|
|
186
|
+
for (const d of days.peek()) d.windows.value = (Array.isArray(hours[d.id]) ? hours[d.id] : []).map(windowRow);
|
|
187
|
+
daysOff.value = (Array.isArray(v.daysOff) ? v.daysOff : []).map(offRow);
|
|
188
|
+
rescan();
|
|
189
|
+
}
|
|
190
|
+
},
|
|
172
191
|
model: {
|
|
173
192
|
...f, saving, isPerson, days, daysOff,
|
|
174
193
|
addWindow(day) {
|
|
@@ -269,11 +288,31 @@ export async function mount(root, ctx) {
|
|
|
269
288
|
}
|
|
270
289
|
}
|
|
271
290
|
});
|
|
291
|
+
if (panel && initial?.from) dropDraft(initial.from);
|
|
272
292
|
}
|
|
273
293
|
|
|
274
294
|
if (ctx.id && here()) {
|
|
275
295
|
if (ctx.id === 'new') edit(null);
|
|
276
296
|
else if (byId(ctx.id)) edit(byId(ctx.id));
|
|
277
297
|
ctx.replaceHash('#/plugins/booking/people');
|
|
298
|
+
} else {
|
|
299
|
+
offerOrphan();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Changes left on a person or room deleted since: offered back as a new one, once asked. */
|
|
303
|
+
async function offerOrphan() {
|
|
304
|
+
const map = allDrafts();
|
|
305
|
+
// A new one's unsaved draft is offered first (when New is opened): starting the orphan as new
|
|
306
|
+
// now would write over it. The orphan waits until that one is saved or discarded.
|
|
307
|
+
if (usableDraft(map['bookable:new'])) return;
|
|
308
|
+
const [o] = orphanDrafts(map, 'bookable:', people.peek().map(x => x.id));
|
|
309
|
+
if (!o) return;
|
|
310
|
+
// Discard is the confirm button: Escape (Cancel) - and leaving - keep the work.
|
|
311
|
+
const discard = await ask(esc(restoreText('deleted', {label: o.draft.label, at: o.draft.at})),
|
|
312
|
+
{title: 'Unsaved changes', confirmText: 'Discard them', cancelText: 'Add them as new'}, ctx);
|
|
313
|
+
if (!here()) return; // left while asked: the draft stays
|
|
314
|
+
if (discard) { dropDraft(o.key); return; }
|
|
315
|
+
// Its old key goes once the new panel holds it (edit drops `from` after opening).
|
|
316
|
+
edit(null, {...o.draft, from: o.key});
|
|
278
317
|
}
|
|
279
318
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* `#/plugins/booking/services/new` opens a new one; `/services/<id>` one.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {COLOUR_LABEL, api, copy, errText, esc, minutesLabel, openPanel, wireRows} from '../lib/kit.js';
|
|
9
|
+
import {COLOUR_LABEL, allDrafts, api, ask, claim, copy, dropDraft, errText, esc, minutesLabel, openPanel, wireRows} from '../lib/kit.js';
|
|
10
|
+
import {orphanDrafts, restoreText, usableDraft} from '../lib/drafts.js';
|
|
10
11
|
import {cleanService} from '/plugins/booking/public/lib/clean.js';
|
|
11
12
|
import {mountSections} from '../lib/extension-sections.js';
|
|
12
13
|
|
|
@@ -102,11 +103,14 @@ export async function mount(root, ctx) {
|
|
|
102
103
|
} catch (err) { E.toast(errText(err), {type: 'error', duration: 8000}); }
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
/** `initial`: a draft to start from (one of a service deleted since). */
|
|
107
|
+
async function edit(s, initial = null) {
|
|
106
108
|
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');
|
|
109
|
+
if (await E.confirm('A service needs someone or something to provide it. Add a person or room first?') && here()) ctx.go('people', 'new');
|
|
108
110
|
return;
|
|
109
111
|
}
|
|
112
|
+
const release = claim(`service:${s?.id || 'new'}`);
|
|
113
|
+
if (!release) return;
|
|
110
114
|
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
115
|
const f = {};
|
|
112
116
|
for (const k of ['name', 'slug', 'description', 'duration', 'bufferBefore', 'bufferAfter', 'step', 'maxAdvanceDays', 'colour']) f[k] = M.observable(start[k] ?? '');
|
|
@@ -125,8 +129,26 @@ export async function mount(root, ctx) {
|
|
|
125
129
|
panel = await openPanel({
|
|
126
130
|
title: s ? `Edit ${s.name}` : 'New service',
|
|
127
131
|
size: 'md',
|
|
132
|
+
ctx, release,
|
|
128
133
|
model: {...f, saving, shortcode},
|
|
129
134
|
dirty: () => snapshot() !== opened || Boolean(sections?.dirty()),
|
|
135
|
+
// Booking Pro's section (places, questions…) is not in the draft: the section contract has
|
|
136
|
+
// values() but no way to put them back. Booking's own fields, ticks and providers are.
|
|
137
|
+
draft: {
|
|
138
|
+
key: `service:${s?.id || 'new'}`, label: () => f.name.peek() || s?.name || '', was: s?.updatedAt || '', initial,
|
|
139
|
+
values: () => ({f: Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])),
|
|
140
|
+
bools: Object.fromEntries(Object.entries(bools).map(([k, o]) => [k, o.peek()])), picked: [...picked]}),
|
|
141
|
+
apply(v, el) {
|
|
142
|
+
for (const [k, val] of Object.entries(v.f || {})) if (f[k] && (typeof val === 'string' || typeof val === 'number')) f[k].value = val;
|
|
143
|
+
for (const [k, val] of Object.entries(v.bools || {})) if (bools[k] && typeof val === 'boolean') bools[k].value = val;
|
|
144
|
+
for (const box of el.querySelectorAll('[data-bool]')) box.checked = bools[box.dataset.bool].peek();
|
|
145
|
+
// Providers deleted since are left out.
|
|
146
|
+
picked.clear();
|
|
147
|
+
const live = new Set(boot.bookables.map(b => b.id));
|
|
148
|
+
for (const id of Array.isArray(v.picked) ? v.picked : []) if (live.has(id)) picked.add(id);
|
|
149
|
+
for (const box of el.querySelectorAll('[data-part="picks"] input')) box.checked = picked.has(box.value);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
130
152
|
cleanup: () => sections?.destroy(),
|
|
131
153
|
html: `<div class="bk-form">
|
|
132
154
|
<div class="bk-grid">
|
|
@@ -212,11 +234,31 @@ export async function mount(root, ctx) {
|
|
|
212
234
|
}
|
|
213
235
|
}
|
|
214
236
|
});
|
|
237
|
+
if (panel && initial?.from) dropDraft(initial.from);
|
|
215
238
|
}
|
|
216
239
|
|
|
217
240
|
if (ctx.id && here()) {
|
|
218
241
|
if (ctx.id === 'new') edit(null);
|
|
219
242
|
else if (byId(ctx.id)) edit(byId(ctx.id));
|
|
220
243
|
ctx.replaceHash('#/plugins/booking/services');
|
|
244
|
+
} else {
|
|
245
|
+
offerOrphan();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Changes left on a service that has been deleted since: offered back as a new one, once asked. */
|
|
249
|
+
async function offerOrphan() {
|
|
250
|
+
const map = allDrafts();
|
|
251
|
+
// A new one's unsaved draft is offered first (when New is opened): starting the orphan as new
|
|
252
|
+
// now would write over it. The orphan waits until that one is saved or discarded.
|
|
253
|
+
if (usableDraft(map['service:new'])) return;
|
|
254
|
+
const [o] = orphanDrafts(map, 'service:', services.peek().map(x => x.id));
|
|
255
|
+
if (!o) return;
|
|
256
|
+
// Discard is the confirm button: Escape (Cancel) - and leaving - keep the work.
|
|
257
|
+
const discard = await ask(esc(restoreText('deleted', {label: o.draft.label, at: o.draft.at})),
|
|
258
|
+
{title: 'Unsaved changes', confirmText: 'Discard them', cancelText: 'Start a new service'}, ctx);
|
|
259
|
+
if (!here()) return; // left while asked: the draft stays
|
|
260
|
+
if (discard) { dropDraft(o.key); return; }
|
|
261
|
+
// Its old key goes once the new panel holds it (edit drops `from` after opening).
|
|
262
|
+
edit(null, {...o.draft, from: o.key});
|
|
221
263
|
}
|
|
222
264
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* per-field messages shown beside the field.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {api, copy, errText, esc, openPanel, store} from '../lib/kit.js';
|
|
8
|
+
import {api, claim, copy, errText, esc, openPanel, store} from '../lib/kit.js';
|
|
9
|
+
import {fingerprint} from '../lib/drafts.js';
|
|
9
10
|
import {isDate} from '/plugins/booking/public/lib/time.js';
|
|
10
11
|
|
|
11
12
|
const SECTIONS = [
|
|
@@ -36,8 +37,12 @@ function zones(current) {
|
|
|
36
37
|
|
|
37
38
|
export async function openSettings(ctx, section = 'page') {
|
|
38
39
|
store.set('booking.settings-seen', true);
|
|
40
|
+
// One settings panel: the cog clicked twice while they load opens it once.
|
|
41
|
+
const release = claim('settings');
|
|
42
|
+
if (!release) return;
|
|
39
43
|
let info;
|
|
40
|
-
try { info = await api.settings.get(); } catch (err) { E.toast(`Settings did not load: ${errText(err)}`, {type: 'error'}); return; }
|
|
44
|
+
try { info = await api.settings.get(); } catch (err) { release(); if (ctx.here()) E.toast(`Settings did not load: ${errText(err)}`, {type: 'error'}); return; }
|
|
45
|
+
if (!ctx.here()) { release(); return; }
|
|
41
46
|
const s = info.settings;
|
|
42
47
|
const f = {};
|
|
43
48
|
for (const [k, v] of Object.entries(s)) if (k !== 'closures') f[k] = M.observable(v);
|
|
@@ -149,7 +154,22 @@ export async function openSettings(ctx, section = 'page') {
|
|
|
149
154
|
size: 'md',
|
|
150
155
|
html,
|
|
151
156
|
model,
|
|
157
|
+
ctx, release,
|
|
152
158
|
dirty: () => snapshot() !== opened,
|
|
159
|
+
// Settings carry no updatedAt: `was` is a fingerprint of them as loaded, so a save made
|
|
160
|
+
// elsewhere since shows up as a change.
|
|
161
|
+
draft: {
|
|
162
|
+
key: 'settings', label: 'Booking settings', was: fingerprint(s),
|
|
163
|
+
values: () => ({f: Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])), closures: closuresOut()}),
|
|
164
|
+
apply(v, el) {
|
|
165
|
+
for (const [k, val] of Object.entries(v.f || {})) {
|
|
166
|
+
if (f[k] && ['string', 'number', 'boolean'].includes(typeof val) && typeof val === typeof f[k].peek()) f[k].value = val;
|
|
167
|
+
else if (f[k] && typeof f[k].peek() === 'number' && typeof val === 'string') f[k].value = val; // a number typed is text
|
|
168
|
+
}
|
|
169
|
+
for (const box of el.querySelectorAll('[data-bool]')) box.checked = Boolean(f[box.dataset.bool].peek());
|
|
170
|
+
closures.value = (Array.isArray(v.closures) ? v.closures : []).map(closureRow);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
153
173
|
before: (el) => {
|
|
154
174
|
for (const box of el.querySelectorAll('[data-bool]')) {
|
|
155
175
|
box.checked = Boolean(f[box.dataset.bool].peek());
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "booking",
|
|
3
3
|
"displayName": "Booking",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.7.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
8
|
+
"date": "2026-10-15",
|
|
9
9
|
"icon": "clipboard-check",
|
|
10
10
|
"uses": {
|
|
11
11
|
"contacts": "Customers become contacts; Book… on a contact and a Bookings panel on the contact card",
|