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.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/message-actions.js +56 -0
- package/plugins/_lib/admin/mail/reader-view.js +30 -1
- package/plugins/_lib/call/ice.js +58 -0
- package/plugins/_lib/call/rooms.js +232 -0
- package/plugins/_lib/call/routes.js +88 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +468 -0
- package/plugins/blog/lib/layouts.js +4 -2
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/tests/layouts.test.js +6 -0
- package/plugins/booking/CLAUDE.md +123 -0
- package/plugins/booking/admin/css/index.css +192 -0
- package/plugins/booking/admin/lib/details.js +58 -0
- package/plugins/booking/admin/lib/diary-layout.js +140 -0
- package/plugins/booking/admin/lib/extension-sections.js +160 -0
- package/plugins/booking/admin/lib/kit.js +249 -0
- package/plugins/booking/admin/templates/booking.html +1 -0
- package/plugins/booking/admin/views/booking.js +177 -0
- package/plugins/booking/admin/views/bookings.js +127 -0
- package/plugins/booking/admin/views/diary.js +374 -0
- package/plugins/booking/admin/views/panels.js +516 -0
- package/plugins/booking/admin/views/people.js +281 -0
- package/plugins/booking/admin/views/services.js +222 -0
- package/plugins/booking/admin/views/settings.js +203 -0
- package/plugins/booking/collections/booking-bookables.json +17 -0
- package/plugins/booking/collections/booking-bookings.json +18 -0
- package/plugins/booking/collections/booking-services.json +17 -0
- package/plugins/booking/config.js +36 -0
- package/plugins/booking/docs/guide.md +69 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +58 -0
- package/plugins/booking/lib/extensions.js +150 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +384 -0
- package/plugins/booking/plugin.js +760 -0
- package/plugins/booking/plugin.json +80 -0
- package/plugins/booking/plugin.public.js +157 -0
- package/plugins/booking/public/booking.css +159 -0
- package/plugins/booking/public/booking.js +808 -0
- package/plugins/booking/public/lib/clean.js +141 -0
- package/plugins/booking/public/lib/page-format.js +121 -0
- package/plugins/booking/public/lib/slots.js +247 -0
- package/plugins/booking/public/lib/time.js +131 -0
- package/plugins/booking/templates/booking.html +5 -0
- package/plugins/booking/tests/admin-diary.test.js +73 -0
- package/plugins/booking/tests/api.test.js +356 -0
- package/plugins/booking/tests/public.test.js +71 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +48 -5
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- package/server/services/hooks.js +35 -8
- package/server/services/markdown.js +10 -12
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking settings - the banner cog's slideover: the booking page, what
|
|
3
|
+
* customers may change themselves, emails, links to other Tools, and the days
|
|
4
|
+
* the whole business is closed. Each field explained in place; the server's
|
|
5
|
+
* per-field messages shown beside the field.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {api, copy, errText, esc, openPanel, store} from '../lib/kit.js';
|
|
9
|
+
import {isDate} from '/plugins/booking/public/lib/time.js';
|
|
10
|
+
|
|
11
|
+
const SECTIONS = [
|
|
12
|
+
{key: 'page', label: 'Booking page', icon: 'globe'},
|
|
13
|
+
{key: 'changes', label: 'Changes', icon: 'refresh-cw'},
|
|
14
|
+
{key: 'emails', label: 'Emails', icon: 'mail'},
|
|
15
|
+
{key: 'tools', label: 'Other Tools', icon: 'plug'},
|
|
16
|
+
{key: 'closed', label: 'Closed days', icon: 'ban'}
|
|
17
|
+
];
|
|
18
|
+
const BOOL_KEYS = ['askPhone', 'phoneRequired', 'allowCancel', 'allowMove', 'sendConfirmation', 'addToContacts', 'calendarBusy'];
|
|
19
|
+
const FIELD_SECTION = {pageTitle: 'page', bookingPath: 'page', timeZone: 'page', termsUrl: 'page', notifyAddress: 'emails'};
|
|
20
|
+
|
|
21
|
+
/** Zones the browser knows, the business's own first. */
|
|
22
|
+
function zones(current) {
|
|
23
|
+
let list = [];
|
|
24
|
+
try { list = Intl.supportedValuesOf('timeZone'); } catch { list = ['Europe/London', 'Europe/Dublin', 'Europe/Paris', 'America/New_York', 'UTC']; }
|
|
25
|
+
return [...new Set([current, ...list])];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function openSettings(ctx, section = 'page') {
|
|
29
|
+
store.set('booking.settings-seen', true);
|
|
30
|
+
let info;
|
|
31
|
+
try { info = await api.settings.get(); } catch (err) { E.toast(`Settings did not load: ${errText(err)}`, {type: 'error'}); return; }
|
|
32
|
+
const s = info.settings;
|
|
33
|
+
const f = {};
|
|
34
|
+
for (const [k, v] of Object.entries(s)) if (k !== 'closures') f[k] = M.observable(v);
|
|
35
|
+
let closures = (s.closures || []).map(c => ({...c}));
|
|
36
|
+
const tab = M.observable(SECTIONS.some(x => x.key === section) ? section : 'page');
|
|
37
|
+
const saving = M.observable(false);
|
|
38
|
+
const snapshot = () => JSON.stringify([Object.entries(f).map(([k, o]) => [k, o.peek()]), closures]);
|
|
39
|
+
const opened = snapshot();
|
|
40
|
+
const model = {
|
|
41
|
+
...f, tab, saving,
|
|
42
|
+
pageUrl: M.computed(() => `${location.origin}${f.bookingPath.value || '/booking'}`),
|
|
43
|
+
noPhone: M.computed(() => !f.askPhone.value),
|
|
44
|
+
remindText: M.computed(() => (Number(f.reminderHours.value) > 0 ? `${f.reminderHours.value} hours before` : 'off'))
|
|
45
|
+
};
|
|
46
|
+
for (const x of SECTIONS) model[`is_${x.key}`] = M.computed(() => tab.value === x.key);
|
|
47
|
+
|
|
48
|
+
const check = (k, label, help = '') => `<label class="bk-check"><input type="checkbox" data-bool="${k}"> ${label}${help ? ` <span data-help-title="${esc(label)}" data-help="${esc(help)}"></span>` : ''}</label>`;
|
|
49
|
+
const err = (k) => `<small class="bk-err" data-err="${k}" hidden></small>`;
|
|
50
|
+
const users = ctx.boot.users || [];
|
|
51
|
+
const html = `<div class="bk-form bk-settings">
|
|
52
|
+
<div class="bk-seg bk-settings-nav" role="tablist">
|
|
53
|
+
${SECTIONS.map(x => `<button type="button" role="tab" data-tab="${x.key}" data-bind-class="tab.value === '${x.key}' && 'is-on'"><span data-icon="${x.icon}" data-icon-size="13"></span> ${x.label}</button>`).join('')}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<section data-bind-hidden="!is_page.value" class="bk-grid">
|
|
57
|
+
<div><label class="form-label">Page heading <span data-help-title="Page heading" data-help="The heading on the booking page."></span></label>
|
|
58
|
+
<input class="form-input" data-model="pageTitle.value" maxlength="80">${err('pageTitle')}</div>
|
|
59
|
+
<div><label class="form-label">Address <span data-help-title="Address" data-help="Where the booking page lives on your site. /booking gives /booking, a page per service at /booking/service-name, and the customer's own page at /booking/manage/…. Links in emails already sent keep working only at the old address."></span></label>
|
|
60
|
+
<input class="form-input" data-model="bookingPath.value" maxlength="60">${err('bookingPath')}</div>
|
|
61
|
+
<div class="bk-wide"><div class="bk-copy"><code data-bind-text="pageUrl.value"></code><button type="button" class="btn btn-sm btn-ghost" data-on-click="copyUrl" title="Copy the address"><span data-icon="copy" data-icon-size="13"></span></button></div></div>
|
|
62
|
+
<div class="bk-wide"><label class="form-label">Introduction <span data-help-title="Introduction" data-help="A line or two under the heading: what to expect, where you are, how to prepare."></span></label>
|
|
63
|
+
<textarea class="form-input" rows="2" data-model="intro.value" maxlength="2000"></textarea></div>
|
|
64
|
+
<div><label class="form-label">Time zone <span data-help-title="Time zone" data-help="Opening hours, the Diary and every time in emails are in this zone - where the business is, not where the customer is."></span></label>
|
|
65
|
+
<select class="form-select" data-model="timeZone.value">${zones(s.timeZone).map(z => `<option value="${esc(z)}">${esc(z.replace(/_/g, ' '))}</option>`).join('')}</select>${err('timeZone')}</div>
|
|
66
|
+
<div><label class="form-label">Terms page <span data-help-title="Terms page" data-help="If set, customers tick 'I agree to the terms' (linked here) before booking. /terms or a full https:// address."></span></label>
|
|
67
|
+
<input class="form-input" data-model="termsUrl.value" placeholder="/terms">${err('termsUrl')}</div>
|
|
68
|
+
<div class="bk-wide bk-checks">
|
|
69
|
+
${check('askPhone', 'Ask for a phone number')}
|
|
70
|
+
<span data-bind-hidden="noPhone.value">${check('phoneRequired', 'A phone number is required')}</span>
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
|
|
74
|
+
<section data-bind-hidden="!is_changes.value" class="bk-grid">
|
|
75
|
+
<p class="bk-wide bk-note">Every confirmation email has a private link where the customer can see their booking - and, if you allow it here, move or cancel it themselves.</p>
|
|
76
|
+
<div class="bk-wide bk-checks">
|
|
77
|
+
${check('allowCancel', 'Customers can cancel', 'From their private link. The time becomes free at once and you are told.')}
|
|
78
|
+
${check('allowMove', 'Customers can move to another time', 'To another free time with the same person or room.')}
|
|
79
|
+
</div>
|
|
80
|
+
<div><label class="form-label">Up to (hours before) <span data-help-title="Notice for changes" data-help="Changes close this many hours before the booking; after that the page asks them to contact you. 0: right up to the start."></span></label>
|
|
81
|
+
<input class="form-input" type="number" min="0" max="720" data-model="changeNoticeHours.value"></div>
|
|
82
|
+
</section>
|
|
83
|
+
|
|
84
|
+
<section data-bind-hidden="!is_emails.value" class="bk-grid">
|
|
85
|
+
${info.mailReady ? '' : `<div class="bk-wide bk-alert"><span data-icon="alert-triangle" data-icon-size="15"></span>
|
|
86
|
+
<span><strong>No emails can be sent.</strong> This site has no outgoing mail server, so confirmations and reminders are not sent. Set one up in Settings › Email (SMTP).</span></div>`}
|
|
87
|
+
<div class="bk-wide bk-checks">
|
|
88
|
+
${check('sendConfirmation', 'Email customers a confirmation', 'Sent when they book on the page. The details, a calendar file, and their private link.')}
|
|
89
|
+
</div>
|
|
90
|
+
<div><label class="form-label">Reminder (hours before) <span data-help-title="Reminder" data-help="A reminder email this many hours before. 0 turns reminders off. Not sent for a booking made after that point - its confirmation is recent enough."></span></label>
|
|
91
|
+
<input class="form-input" type="number" min="0" max="336" data-model="reminderHours.value"><small class="bk-muted" data-bind-text="remindText.value"></small></div>
|
|
92
|
+
<div><label class="form-label">Copies to <span data-help-title="Copies to" data-help="New, moved and cancelled bookings are emailed to the person booked (their email under People & rooms), else here, else the site's own address."></span></label>
|
|
93
|
+
<input class="form-input" type="email" data-model="notifyAddress.value" placeholder="bookings@example.com">${err('notifyAddress')}</div>
|
|
94
|
+
<div class="bk-wide"><label class="form-label">Note in every confirmation <span data-help-title="Note" data-help="Directions, parking, what to bring - added at the end of confirmation and move emails."></span></label>
|
|
95
|
+
<textarea class="form-input" rows="3" data-model="confirmationNote.value" maxlength="2000"></textarea></div>
|
|
96
|
+
</section>
|
|
97
|
+
|
|
98
|
+
<section data-bind-hidden="!is_tools.value" class="bk-grid">
|
|
99
|
+
<div class="bk-wide bk-checks">
|
|
100
|
+
${check('addToContacts', 'Add customers to Contacts', 'Each new customer becomes a contact (once - matched by email), in the address book of the person booked if they are linked to a user, else the owner below.')}
|
|
101
|
+
</div>
|
|
102
|
+
<div class="bk-wide"><label class="form-label">Contacts go to <span data-help-title="Contacts owner" data-help="Contacts are per user. Customers of a room, or of a person with no user account, go into this user's Contacts."></span></label>
|
|
103
|
+
<select class="form-select" data-model="contactsOwnerId.value"><option value="">Nobody (not added)</option>${users.map(u => `<option value="${esc(u.id)}">${esc(u.name)}</option>`).join('')}</select>
|
|
104
|
+
${ctx.boot.contacts ? '' : '<small class="bk-muted">Contacts is switched off on this site.</small>'}</div>
|
|
105
|
+
<div class="bk-wide bk-checks">
|
|
106
|
+
${check('calendarBusy', 'Calendar events block booking times', 'A person linked to a user is not offered at times of Calendar events they organise. Needs the Calendar Tool.')}
|
|
107
|
+
${ctx.boot.calendar ? '' : '<small class="bk-muted">The Calendar Tool is not on this site, so this has no effect yet.</small>'}
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section data-bind-hidden="!is_closed.value">
|
|
112
|
+
<p class="bk-note">Days the whole business is closed - bank holidays, a closure. Nobody can be booked. (For one person's holiday, use their days off.)</p>
|
|
113
|
+
<div data-part="closures"></div>
|
|
114
|
+
<div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="addClosure"><span data-icon="plus" data-icon-size="12"></span> Add closed days</button></div>
|
|
115
|
+
</section>
|
|
116
|
+
|
|
117
|
+
<div class="bk-actions">
|
|
118
|
+
<span class="bk-spacer"></span>
|
|
119
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
|
|
120
|
+
<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>
|
|
121
|
+
</div>
|
|
122
|
+
</div>`;
|
|
123
|
+
|
|
124
|
+
const drawClosures = (el) => {
|
|
125
|
+
const box = el.querySelector('[data-part="closures"]');
|
|
126
|
+
box.innerHTML = closures.length ? closures.map((o, i) => `<div class="bk-off" data-i="${i}">
|
|
127
|
+
<input type="date" class="form-input" value="${esc(o.from)}" data-k="from" aria-label="From"><span>to</span>
|
|
128
|
+
<input type="date" class="form-input" value="${esc(o.to)}" data-k="to" aria-label="To">
|
|
129
|
+
<input class="form-input" value="${esc(o.label)}" data-k="label" placeholder="Bank holiday…" maxlength="80" aria-label="Why">
|
|
130
|
+
<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('')
|
|
131
|
+
: '<p class="bk-note">None yet.</p>';
|
|
132
|
+
I.scan(box);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let panel = null;
|
|
136
|
+
panel = await openPanel({
|
|
137
|
+
title: 'Booking settings',
|
|
138
|
+
size: 'md',
|
|
139
|
+
html,
|
|
140
|
+
model,
|
|
141
|
+
dirty: () => snapshot() !== opened,
|
|
142
|
+
before: (el) => {
|
|
143
|
+
for (const box of el.querySelectorAll('[data-bool]')) {
|
|
144
|
+
box.checked = Boolean(f[box.dataset.bool].peek());
|
|
145
|
+
box.addEventListener('change', () => { f[box.dataset.bool].value = box.checked; });
|
|
146
|
+
}
|
|
147
|
+
el.querySelector('.bk-settings-nav').addEventListener('click', (e) => {
|
|
148
|
+
const b = e.target.closest('[data-tab]');
|
|
149
|
+
if (b) tab.value = b.dataset.tab;
|
|
150
|
+
});
|
|
151
|
+
drawClosures(el);
|
|
152
|
+
const box = el.querySelector('[data-part="closures"]');
|
|
153
|
+
box.addEventListener('input', (e) => {
|
|
154
|
+
const row = e.target.closest('[data-i]');
|
|
155
|
+
if (!row) return;
|
|
156
|
+
const o = closures[Number(row.dataset.i)];
|
|
157
|
+
o[e.target.dataset.k] = e.target.value;
|
|
158
|
+
if (e.target.dataset.k === 'from' && (!o.to || o.to < o.from)) { o.to = o.from; row.querySelector('[data-k="to"]').value = o.from; }
|
|
159
|
+
});
|
|
160
|
+
box.addEventListener('click', (e) => {
|
|
161
|
+
const row = e.target.closest('[data-i]');
|
|
162
|
+
if (row && e.target.closest('[data-drop]')) { closures.splice(Number(row.dataset.i), 1); drawClosures(el); }
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
methods: {
|
|
166
|
+
cancel: () => panel?.close(),
|
|
167
|
+
copyUrl: () => copy(model.pageUrl.peek(), 'Booking page address copied'),
|
|
168
|
+
addClosure() { closures = [...closures, {from: ctx.boot.today, to: ctx.boot.today, label: ''}]; drawClosures(panel.panel); },
|
|
169
|
+
async save() {
|
|
170
|
+
if (saving.peek()) return;
|
|
171
|
+
for (const o of closures) {
|
|
172
|
+
if (!isDate(o.from) || !isDate(o.to) || o.to < o.from) { tab.value = 'closed'; E.toast('Closed days: each needs a start date, and an end on or after it.', {type: 'warning'}); return; }
|
|
173
|
+
}
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const [k, o] of Object.entries(f)) out[k] = o.peek();
|
|
176
|
+
for (const k of BOOL_KEYS) out[k] = Boolean(out[k]);
|
|
177
|
+
out.changeNoticeHours = Number(out.changeNoticeHours) || 0;
|
|
178
|
+
out.reminderHours = Number(out.reminderHours) || 0;
|
|
179
|
+
out.closures = closures;
|
|
180
|
+
saving.value = true;
|
|
181
|
+
const el = panel.panel;
|
|
182
|
+
try {
|
|
183
|
+
const r = await api.settings.save(out);
|
|
184
|
+
Object.assign(ctx.settings, r.settings);
|
|
185
|
+
ctx.tz = r.settings.timeZone;
|
|
186
|
+
ctx.boot.settings = ctx.settings;
|
|
187
|
+
E.toast('Booking settings saved.', {type: 'success'});
|
|
188
|
+
const moved = r.settings.timeZone !== s.timeZone;
|
|
189
|
+
panel.close(true);
|
|
190
|
+
if (moved) location.reload();
|
|
191
|
+
} catch (e) {
|
|
192
|
+
const errors = e?.body?.errors || {};
|
|
193
|
+
for (const box of el.querySelectorAll('[data-err]')) { box.textContent = errors[box.dataset.err] || ''; box.hidden = !errors[box.dataset.err]; }
|
|
194
|
+
const first = Object.keys(errors)[0];
|
|
195
|
+
if (first && FIELD_SECTION[first]) tab.value = FIELD_SECTION[first];
|
|
196
|
+
E.toast(`Not saved: ${errText(e)}`, {type: 'error', duration: 8000});
|
|
197
|
+
} finally {
|
|
198
|
+
saving.value = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "booking-bookables",
|
|
3
|
+
"title": "Booking - People & rooms",
|
|
4
|
+
"description": "Who and what can be booked: people (appointments) and resources (rooms, equipment), each with weekly hours and days off. Edit them in Booking, not here.",
|
|
5
|
+
"plugin": "booking",
|
|
6
|
+
"fields": [
|
|
7
|
+
{"name": "name", "label": "Name", "type": "text"},
|
|
8
|
+
{"name": "kind", "label": "Kind", "type": "text"},
|
|
9
|
+
{"name": "active", "label": "Active", "type": "boolean"}
|
|
10
|
+
],
|
|
11
|
+
"api": {
|
|
12
|
+
"create": {"enabled": false, "access": "admin"},
|
|
13
|
+
"read": {"enabled": false, "access": "admin"},
|
|
14
|
+
"update": {"enabled": false, "access": "admin"},
|
|
15
|
+
"delete": {"enabled": false, "access": "admin"}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "booking-bookings",
|
|
3
|
+
"title": "Booking - Bookings",
|
|
4
|
+
"description": "Bookings, times in UTC. They hold customers' details: change them in Booking, never here, so the diary and the emails stay right.",
|
|
5
|
+
"plugin": "booking",
|
|
6
|
+
"fields": [
|
|
7
|
+
{"name": "startAt", "label": "Starts (UTC)", "type": "text"},
|
|
8
|
+
{"name": "status", "label": "Status", "type": "text"},
|
|
9
|
+
{"name": "serviceName", "label": "Service", "type": "text"},
|
|
10
|
+
{"name": "email", "label": "Email", "type": "text"}
|
|
11
|
+
],
|
|
12
|
+
"api": {
|
|
13
|
+
"create": {"enabled": false, "access": "admin"},
|
|
14
|
+
"read": {"enabled": false, "access": "admin"},
|
|
15
|
+
"update": {"enabled": false, "access": "admin"},
|
|
16
|
+
"delete": {"enabled": false, "access": "admin"}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "booking-services",
|
|
3
|
+
"title": "Booking - Services",
|
|
4
|
+
"description": "What can be booked, for how long, and with whom. Edit them in Booking, not here.",
|
|
5
|
+
"plugin": "booking",
|
|
6
|
+
"fields": [
|
|
7
|
+
{"name": "name", "label": "Name", "type": "text"},
|
|
8
|
+
{"name": "duration", "label": "Length (minutes)", "type": "number"},
|
|
9
|
+
{"name": "active", "label": "Active", "type": "boolean"}
|
|
10
|
+
],
|
|
11
|
+
"api": {
|
|
12
|
+
"create": {"enabled": false, "access": "admin"},
|
|
13
|
+
"read": {"enabled": false, "access": "admin"},
|
|
14
|
+
"update": {"enabled": false, "access": "admin"},
|
|
15
|
+
"delete": {"enabled": false, "access": "admin"}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking - default settings.
|
|
3
|
+
*
|
|
4
|
+
* Every one is editable from the cog in the Booking banner, which writes to
|
|
5
|
+
* config/plugins.json under `booking.settings`. Times are the business's
|
|
6
|
+
* wall clock in `timeZone`.
|
|
7
|
+
*/
|
|
8
|
+
export default {
|
|
9
|
+
// The booking page
|
|
10
|
+
pageTitle: 'Book',
|
|
11
|
+
bookingPath: '/booking',
|
|
12
|
+
intro: '',
|
|
13
|
+
timeZone: 'Europe/London',
|
|
14
|
+
askPhone: true,
|
|
15
|
+
phoneRequired: false,
|
|
16
|
+
termsUrl: '',
|
|
17
|
+
confirmationNote: '',
|
|
18
|
+
|
|
19
|
+
// Changes by the customer
|
|
20
|
+
allowCancel: true,
|
|
21
|
+
allowMove: true,
|
|
22
|
+
changeNoticeHours: 24,
|
|
23
|
+
|
|
24
|
+
// Emails
|
|
25
|
+
sendConfirmation: true,
|
|
26
|
+
reminderHours: 24,
|
|
27
|
+
notifyAddress: '',
|
|
28
|
+
|
|
29
|
+
// Links to other Tools
|
|
30
|
+
addToContacts: true,
|
|
31
|
+
contactsOwnerId: '',
|
|
32
|
+
calendarBusy: true,
|
|
33
|
+
|
|
34
|
+
// Closed days for the whole business: [{from, to, label}]
|
|
35
|
+
closures: []
|
|
36
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Guide
|
|
3
|
+
order: 1
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Let people book time with you, your team, or a room or piece of equipment, straight from your website. Booking is free.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
- A booking page at `/booking` (you can change the address). Customers pick a service, a day and a free time, then enter their details.
|
|
11
|
+
- **People and rooms.** A person is someone customers book an appointment with. A room or resource is something they book, such as a meeting room, a court or a van. Each has its own weekly hours and days off.
|
|
12
|
+
- **Services.** Each service has a length and optional buffer time before and after, for setting up, cleaning or travel. You also choose how often a start time is offered, how much notice you need, how far ahead people can book, and who provides it.
|
|
13
|
+
- **Emails.** Customers get a confirmation with a calendar file and a reminder before the booking, and you get a copy of each new booking. Each email has a private link that lets the customer move or cancel within the notice you set.
|
|
14
|
+
- **The Diary** in the admin, by day or by week, plus a list of every booking. The sidebar badge shows how many bookings are still to come today.
|
|
15
|
+
- **Nobody is double-booked.** A time is only offered while it is free, and it is checked again at the moment of booking.
|
|
16
|
+
|
|
17
|
+
## Getting started
|
|
18
|
+
|
|
19
|
+
1. Switch Booking on in **Tools**.
|
|
20
|
+
2. Open **Booking > People & rooms** and add who or what can be booked. Set the weekly hours and any days off.
|
|
21
|
+
3. Open **Services** and add what can be booked, how long it takes, and who provides it.
|
|
22
|
+
4. Open the settings (the cog in the Booking banner) and check the booking page's address, your time zone and the emails.
|
|
23
|
+
5. Share the booking page link, or put the booking form on any page with the `[booking /]` shortcode.
|
|
24
|
+
|
|
25
|
+
Booking emails need the site's outgoing email (SMTP) to be set up in **Settings**. Without it, bookings still work, but no emails are sent, and the Booking settings will warn you.
|
|
26
|
+
|
|
27
|
+
## Booking from Contacts and Email
|
|
28
|
+
|
|
29
|
+
- **From a contact:** open the contact's menu and choose **Book (name)...** to start a new booking with their details filled in. The contact card also shows their bookings.
|
|
30
|
+
- **From an email:** in Email or Mail Reader, right-click a message (or use its menu) and choose **Book (name)...** to book the sender. If they are already one of your contacts, the booking starts from that contact (phone number included) and is linked to it - no second contact is made.
|
|
31
|
+
- **New customers become contacts.** When someone books, they are added to your Contacts. If they are already there, the booking is linked to their existing contact. You can switch this off in the settings.
|
|
32
|
+
|
|
33
|
+
The **Book...** options only appear for people who are allowed to manage bookings. The contact option also needs the Contacts Tool switched on.
|
|
34
|
+
|
|
35
|
+
## Working with the Calendar
|
|
36
|
+
|
|
37
|
+
If you also use the Calendar Tool, bookings appear there under **Bookings**. A person's own Calendar events block their booking times, so nobody can book them while they are busy. To use this, link the person to their user account in **People & rooms**. You can switch it off per person or for the whole of Booking.
|
|
38
|
+
|
|
39
|
+
## Putting the booking form on a page
|
|
40
|
+
|
|
41
|
+
| Shortcode | What it shows |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `[booking /]` | The whole booking form, starting with the list of services |
|
|
44
|
+
| `[booking service="consultation" /]` | Goes straight to one service (use the service's address name, shown in its editor) |
|
|
45
|
+
| `[booking service="consultation" with="..." /]` | One service with one person or room already chosen |
|
|
46
|
+
|
|
47
|
+
## Changes and cancellations
|
|
48
|
+
|
|
49
|
+
- **Customers** can move or cancel their own booking from the link in their emails, up to the notice you set (24 hours by default). Closer to the time, the page asks them to contact you instead. You can switch moving and cancelling off in the settings.
|
|
50
|
+
- **You** can move, cancel, or mark a booking as completed or a no-show from the Diary or the booking itself. When you move or cancel a booking, tick **Email the customer** if you want them to be told.
|
|
51
|
+
- If you pick a time that clashes with another booking, Booking asks before double-booking. Only staff can double-book; customers never can.
|
|
52
|
+
|
|
53
|
+
## Who can use it
|
|
54
|
+
|
|
55
|
+
Booking has its own permission, **Booking**, which admins have by default. **See** lets someone view the Diary and bookings. **Manage** lets them make, move and cancel bookings, and edit services, people and rooms, and the settings. To give it to other roles, open **Users & Roles**.
|
|
56
|
+
|
|
57
|
+
## Booking Pro
|
|
58
|
+
|
|
59
|
+
Booking Pro adds the following to the free Tool:
|
|
60
|
+
- Online appointments, held as a video call on your site.
|
|
61
|
+
- Services that need more than one person or room at once.
|
|
62
|
+
- Classes and group sessions with places and a waiting list.
|
|
63
|
+
- Recurring bookings.
|
|
64
|
+
- Questions to ask customers when they book.
|
|
65
|
+
- A daily limit per person or room, and one booking per customer every so many days.
|
|
66
|
+
|
|
67
|
+
Taking payment or a deposit online, and booking reports, are planned for Booking Pro.
|
|
68
|
+
|
|
69
|
+
Your bookings stay the same with or without Pro.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<script type="module" src="/plugins/booking/public/booking.js?v=1.3.1"></script>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Online bookings (made online by Booking Pro): who may join which call, and
|
|
3
|
+
* the addresses in the emails, the .ics, the customer's page and the admin.
|
|
4
|
+
*
|
|
5
|
+
* The call itself is the engine's (`_lib/call`). A room is a SESSION - the
|
|
6
|
+
* same service, start and person - so everyone booked into an online class
|
|
7
|
+
* shares one call, and a one-to-one appointment is a session of one. Links
|
|
8
|
+
* name the session and the booking: `?b=<booking>&t=<customer token>` for the
|
|
9
|
+
* customer, `&h=<host key>` for the person they booked. Booking (free) serves
|
|
10
|
+
* these for any booking that is already online, so a lapsed Pro licence
|
|
11
|
+
* strands nobody; only Pro can make a service online in the first place.
|
|
12
|
+
*
|
|
13
|
+
* @module booking/lib/call
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {createHash, timingSafeEqual} from 'crypto';
|
|
17
|
+
|
|
18
|
+
export const OPENS_BEFORE_MIN = 10;
|
|
19
|
+
export const CLOSES_AFTER_MIN = 60;
|
|
20
|
+
const HOLDING = new Set(['pending', 'confirmed', 'completed', 'no-show']);
|
|
21
|
+
|
|
22
|
+
/** The room a booking's call happens in. */
|
|
23
|
+
export const sessionOf = (b) => createHash('sha256').update(`${b.serviceId}|${b.startAt}|${b.bookableId}`).digest('base64url').slice(0, 22);
|
|
24
|
+
|
|
25
|
+
const same = (a, b) => {
|
|
26
|
+
const x = Buffer.from(String(a || ''));
|
|
27
|
+
const y = Buffer.from(String(b || ''));
|
|
28
|
+
return x.length === y.length && x.length > 0 && timingSafeEqual(x, y);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The customer's join address (relative to the site). */
|
|
32
|
+
export const joinPath = (settings, b) => `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&t=${encodeURIComponent(b.token)}`;
|
|
33
|
+
/** The host's (the person booked) - never shown to the customer. */
|
|
34
|
+
export const hostPath = (settings, b) => `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&h=${encodeURIComponent(b.hostKey)}`;
|
|
35
|
+
|
|
36
|
+
/** When the call opens and closes, for a booking. */
|
|
37
|
+
export function callWindow(b) {
|
|
38
|
+
return {
|
|
39
|
+
opensAt: new Date(Date.parse(b.startAt) - OPENS_BEFORE_MIN * 60000).toISOString(),
|
|
40
|
+
closesAt: new Date(Date.parse(b.endAt) + CLOSES_AFTER_MIN * 60000).toISOString()
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Who the caller is in a room, or null. `cred` is what the join link carried.
|
|
46
|
+
*
|
|
47
|
+
* @param {object|null} b - the booking named by `cred.b`
|
|
48
|
+
* @param {string} room - the room in the address
|
|
49
|
+
* @param {{t?: string, h?: string}} cred
|
|
50
|
+
*/
|
|
51
|
+
export function whoIsCalling(b, room, cred = {}) {
|
|
52
|
+
if (!b || b.online !== true || !HOLDING.has(b.status) || sessionOf(b) !== room) return null;
|
|
53
|
+
const base = {title: b.serviceName, recordingAllowed: b.recordingAllowed === true, max: 6, ...callWindow(b)};
|
|
54
|
+
// identity: a link the host turns away cannot knock again (the engine remembers it per room).
|
|
55
|
+
if (cred.h && same(cred.h, b.hostKey)) return {...base, host: true, name: b.bookableName || 'Host', identity: `${b.id}:host`};
|
|
56
|
+
if (cred.t && same(cred.t, b.token)) return {...base, host: false, name: b.name || 'Guest', identity: `${b.id}:guest`};
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Booking Pro (and anything else) plugs in, without Booking importing
|
|
3
|
+
* it or it importing Booking: a registry on globalThis, the Shopping Cart's
|
|
4
|
+
* pattern. Booking owns the data, the screens and the slot engine; an
|
|
5
|
+
* extension registers from its `onReady` with any of:
|
|
6
|
+
*
|
|
7
|
+
* rules(service) -> {capacity?, companions?: string[][], maxPerDay?, waitlist?, online?, recordingAllowed?}
|
|
8
|
+
* applied by the free engine (public/lib/slots.js)
|
|
9
|
+
* form(service) -> [{id, label, kind, required?, options?, help?}] extra booking-form questions
|
|
10
|
+
* answers(service, raw) -> {data, errors} cleans what the form sent (stored as ext.answers)
|
|
11
|
+
* check(ctx) -> '' or a reason refuse a booking (ctx: {service, customer, startAt, source, bookings})
|
|
12
|
+
* admin: {serviceEditor?, newBooking?, bookingPanel?} module paths under /plugins/<owner>/ - sections in
|
|
13
|
+
* Booking's own slideovers (see admin/lib/extension-sections.js)
|
|
14
|
+
* waitlistUrl - where the booking page posts "join the waiting list"
|
|
15
|
+
* on: {made, moved, cancelled, status} - told after the fact, never waited on
|
|
16
|
+
* tabs: [{key, label, icon, module}] - admin screens beside Booking's own
|
|
17
|
+
*
|
|
18
|
+
* Every hook is optional and guarded: one that throws counts as absent, so an
|
|
19
|
+
* extension can never stop a booking being made. A lapsed Pro licence means
|
|
20
|
+
* Pro never registers and every service is plain again (existing bookings keep
|
|
21
|
+
* what they hold).
|
|
22
|
+
*
|
|
23
|
+
* @module booking/lib/extensions
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {PLAIN} from '../public/lib/slots.js';
|
|
27
|
+
|
|
28
|
+
const KEY = Symbol.for('domma.booking');
|
|
29
|
+
const safe = (fn, fallback) => { try { const v = fn(); return v === undefined ? fallback : v; } catch { return fallback; } };
|
|
30
|
+
const underOwner = (owner, p) => typeof p === 'string' && p.startsWith(`/plugins/${owner}/`);
|
|
31
|
+
|
|
32
|
+
export function bookingRegistry() {
|
|
33
|
+
if (!globalThis[KEY]) {
|
|
34
|
+
const owners = new Map();
|
|
35
|
+
globalThis[KEY] = {
|
|
36
|
+
// 2: rules, form, answers, check, admin sections, waitlistUrl, services.book / cancel (Booking 1.1).
|
|
37
|
+
// 3: rules may say online (+ recordingAllowed): held as a video call (Booking 1.3).
|
|
38
|
+
version: 3,
|
|
39
|
+
services: {},
|
|
40
|
+
register(owner, ext = {}) {
|
|
41
|
+
if (typeof owner !== 'string' || !owner) return false;
|
|
42
|
+
owners.set(owner, ext);
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
unregister(owner) { owners.delete(owner); },
|
|
46
|
+
owners: () => [...owners.keys()],
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The rules for a service, merged over every extension. Companion
|
|
50
|
+
* groups come back as bookable objects (active ones only).
|
|
51
|
+
*/
|
|
52
|
+
rules(service, bookables = []) {
|
|
53
|
+
const out = {...PLAIN, companions: [], waitlist: false, online: false, recordingAllowed: false};
|
|
54
|
+
for (const ext of owners.values()) {
|
|
55
|
+
if (typeof ext.rules !== 'function') continue;
|
|
56
|
+
const r = safe(() => ext.rules(service), {}) || {};
|
|
57
|
+
if (Number(r.capacity) > 1) out.capacity = Math.min(1000, Math.round(Number(r.capacity)));
|
|
58
|
+
if (Number(r.maxPerDay) > 0) out.maxPerDay = Math.round(Number(r.maxPerDay));
|
|
59
|
+
if (r.waitlist === true) out.waitlist = true;
|
|
60
|
+
if (r.online === true) out.online = true;
|
|
61
|
+
if (r.recordingAllowed === true) out.recordingAllowed = true;
|
|
62
|
+
for (const group of Array.isArray(r.companions) ? r.companions : []) {
|
|
63
|
+
const ids = new Set((Array.isArray(group) ? group : []).map(String));
|
|
64
|
+
const members = bookables.filter(b => ids.has(b.id) && b.active !== false);
|
|
65
|
+
if (members.length) out.companions.push(members);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/** Extra questions for the booking form. */
|
|
72
|
+
form(service) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const ext of owners.values()) {
|
|
75
|
+
if (typeof ext.form !== 'function') continue;
|
|
76
|
+
for (const f of safe(() => ext.form(service), []) || []) {
|
|
77
|
+
if (f && typeof f.id === 'string' && typeof f.label === 'string') out.push(f);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/** Clean the form's answers: {data, errors}. */
|
|
84
|
+
answers(service, raw) {
|
|
85
|
+
const data = {};
|
|
86
|
+
const errors = {};
|
|
87
|
+
for (const ext of owners.values()) {
|
|
88
|
+
if (typeof ext.answers !== 'function') continue;
|
|
89
|
+
const r = safe(() => ext.answers(service, raw || {}), null);
|
|
90
|
+
if (r?.data) Object.assign(data, r.data);
|
|
91
|
+
if (r?.errors) Object.assign(errors, r.errors);
|
|
92
|
+
}
|
|
93
|
+
return {data, errors};
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/** '' when every extension allows the booking, else the first reason given. */
|
|
97
|
+
async check(ctx) {
|
|
98
|
+
for (const ext of owners.values()) {
|
|
99
|
+
if (typeof ext.check !== 'function') continue;
|
|
100
|
+
try {
|
|
101
|
+
const why = await ext.check(ctx);
|
|
102
|
+
if (typeof why === 'string' && why) return why;
|
|
103
|
+
} catch { /* an extension never breaks a booking */ }
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/** Where the booking page sends "join the waiting list", or ''. */
|
|
109
|
+
waitlistUrl() {
|
|
110
|
+
for (const [owner, ext] of owners) {
|
|
111
|
+
if (typeof ext.waitlistUrl === 'string' && ext.waitlistUrl.startsWith(`/api/plugins/${owner}/`)) return ext.waitlistUrl;
|
|
112
|
+
}
|
|
113
|
+
return '';
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/** Admin sections extensions add to Booking's slideovers. */
|
|
117
|
+
admin() {
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const [owner, ext] of owners) {
|
|
120
|
+
const a = ext.admin || {};
|
|
121
|
+
const entry = {owner};
|
|
122
|
+
for (const k of ['serviceEditor', 'newBooking', 'bookingPanel']) if (underOwner(owner, a[k])) entry[k] = a[k];
|
|
123
|
+
if (Object.keys(entry).length > 1) out.push(entry);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
tabs() {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const [owner, ext] of owners) {
|
|
131
|
+
for (const t of Array.isArray(ext.tabs) ? ext.tabs : []) {
|
|
132
|
+
if (typeof t?.key !== 'string' || !underOwner(owner, t.module)) continue;
|
|
133
|
+
out.push({key: t.key, label: String(t.label || t.key), icon: String(t.icon || 'puzzle'), module: t.module, owner});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/** Tell every extension; never throws, never waits. */
|
|
140
|
+
emit(event, ...args) {
|
|
141
|
+
for (const ext of owners.values()) {
|
|
142
|
+
const fn = ext?.on?.[event];
|
|
143
|
+
if (typeof fn !== 'function') continue;
|
|
144
|
+
try { Promise.resolve(fn(...args)).catch(() => {}); } catch { /* an extension never breaks a booking */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return globalThis[KEY];
|
|
150
|
+
}
|