domma-cms 0.102.1 → 0.104.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 +249 -0
- package/plugins/_lib/call/routes.js +89 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +477 -0
- package/plugins/booking/CLAUDE.md +137 -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 +207 -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 +70 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +72 -0
- package/plugins/booking/lib/extensions.js +169 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +390 -0
- package/plugins/booking/plugin.js +806 -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 +445 -0
- package/plugins/booking/tests/public.test.js +72 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +44 -1
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- package/server/services/hooks.js +35 -8
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call page - a video call in the browser, on the site itself.
|
|
3
|
+
*
|
|
4
|
+
* Mounts on every `[data-dm-call]` element. Its JSON config:
|
|
5
|
+
*
|
|
6
|
+
* {api, room, cred: {t|key|h: '…'}, title, name?, askName?}
|
|
7
|
+
*
|
|
8
|
+
* api the Tool's call API (e.g. /api/plugins/booking-pro/call)
|
|
9
|
+
* room the room id the Tool gave the link
|
|
10
|
+
* cred what the link carried; posted with the join, the Tool decides who you are
|
|
11
|
+
*
|
|
12
|
+
* The screen is domma-reactive (M) - stages, controls, people waiting, chat -
|
|
13
|
+
* and plain code only where bindings cannot reach: the <video> elements'
|
|
14
|
+
* streams, the recording canvas, and the RTCPeerConnections themselves.
|
|
15
|
+
* Browsers connect to each other directly (a mesh - up to 6 people); the
|
|
16
|
+
* site only passes the messages that set that up (see _lib/call/routes.js).
|
|
17
|
+
* Every piece of text goes in through bindings (textContent), never as HTML.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const M = window.M;
|
|
21
|
+
|
|
22
|
+
/** An icon (Domma's I.scan turns it into an SVG after binding - so no binding ever sits on it). */
|
|
23
|
+
const ico = (name) => `<span class="dmc-ico" data-icon="${name}" data-icon-size="18" aria-hidden="true"></span>`;
|
|
24
|
+
/** Two icons, one shown by a condition: the bindings are on the wrappers, never on the icons. */
|
|
25
|
+
const pair = (cond, on, off) => `<span class="dmc-i" data-bind-hidden="!(${cond})">${ico(on)}</span><span class="dmc-i" data-bind-hidden="${cond}">${ico(off)}</span>`;
|
|
26
|
+
|
|
27
|
+
const TEMPLATE = `
|
|
28
|
+
<div class="dmc">
|
|
29
|
+
<header class="dmc-head">
|
|
30
|
+
<h1 class="dmc-title" data-bind-text="title.value"></h1>
|
|
31
|
+
<span class="dmc-rec" data-bind-hidden="!recording.value" role="status">${ico('circle')} Recording</span>
|
|
32
|
+
<span class="dmc-count" data-bind-hidden="!countText.value">${ico('users')} <span data-bind-text="countText.value"></span></span>
|
|
33
|
+
</header>
|
|
34
|
+
|
|
35
|
+
<section class="dmc-panel" data-bind-hidden="stage.value !== 'check'">
|
|
36
|
+
<div class="dmc-preview"><video class="dmc-self-preview" muted playsinline autoplay></video>
|
|
37
|
+
<p class="dmc-nocam" data-bind-hidden="camOn.value">Camera off</p></div>
|
|
38
|
+
<div class="dmc-check">
|
|
39
|
+
<h2>Ready to join?</h2>
|
|
40
|
+
<p class="dmc-note" data-bind-text="deviceNote.value"></p>
|
|
41
|
+
<label class="dmc-label" data-bind-hidden="!askName">Your name
|
|
42
|
+
<input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name"></label>
|
|
43
|
+
<div class="dmc-row">
|
|
44
|
+
<button type="button" class="dmc-btn" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value">${pair('micOn.value', 'mic', 'mic-off')}<span data-bind-text="micOn.value ? 'Mic on' : 'Mic off'"></span></button>
|
|
45
|
+
<button type="button" class="dmc-btn" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value">${pair('camOn.value', 'video', 'video-off')}<span data-bind-text="camOn.value ? 'Camera on' : 'Camera off'"></span></button>
|
|
46
|
+
</div>
|
|
47
|
+
<p class="dmc-note" data-bind-hidden="!recordingAllowed.value">The host may record this call. You will see a red Recording notice while they do.</p>
|
|
48
|
+
<p class="dmc-error" data-bind-hidden="!error.value" data-bind-text="error.value" role="alert"></p>
|
|
49
|
+
<button type="button" class="dmc-btn dmc-btn--go" data-on-click="join" data-bind-disabled="busy.value">${ico('phone')}<span>Join the call</span></button>
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<section class="dmc-panel dmc-panel--msg" data-bind-hidden="stage.value !== 'waiting'">
|
|
54
|
+
<h2>Waiting for the host</h2><p>They know you are here and will let you in shortly.</p>
|
|
55
|
+
<button type="button" class="dmc-btn" data-on-click="leave">${ico('log-out')}<span>Leave</span></button>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<section class="dmc-panel dmc-panel--msg" data-bind-hidden="!(stage.value === 'ended' || stage.value === 'away' || stage.value === 'error')">
|
|
59
|
+
<h2 data-bind-text="endTitle.value"></h2><p data-bind-text="endText.value"></p>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<section class="dmc-call" data-bind-hidden="stage.value !== 'in'">
|
|
63
|
+
<div class="dmc-main" data-bind-data-people="people.value">
|
|
64
|
+
<div class="dmc-grid">
|
|
65
|
+
<figure class="dmc-tile dmc-tile--me"><video class="dmc-self" muted playsinline autoplay></video>
|
|
66
|
+
<figcaption data-bind-text="meCaption.value"></figcaption></figure>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="dmc-remote" data-each="peers key=id">
|
|
69
|
+
<figure class="dmc-tile" data-bind-data-peer="id"><video class="dmc-video" playsinline autoplay></video>
|
|
70
|
+
<figcaption data-bind-text="caption.value"></figcaption></figure>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<aside class="dmc-side" data-bind-hidden="!(sideOpen.value || waiting.value.length)">
|
|
75
|
+
<div class="dmc-waiting" data-bind-hidden="!waiting.value.length">
|
|
76
|
+
<h3>Waiting to join</h3>
|
|
77
|
+
<ul data-each="waiting key=id">
|
|
78
|
+
<li><span data-bind-text="name"></span>
|
|
79
|
+
<button type="button" class="dmc-btn dmc-btn--small dmc-btn--go" data-on-click="$parent.admit($data)">${ico('check')}<span>Let in</span></button>
|
|
80
|
+
<button type="button" class="dmc-btn dmc-btn--small" data-on-click="$parent.turnAway($data)">${ico('x')}<span>Turn away</span></button></li>
|
|
81
|
+
</ul>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="dmc-chat" data-bind-hidden="!sideOpen.value">
|
|
84
|
+
<h3>Chat</h3>
|
|
85
|
+
<ol class="dmc-messages" data-each="messages key=id">
|
|
86
|
+
<li data-bind-class="mine && 'is-mine'"><strong data-bind-text="name"></strong> <span data-bind-text="text"></span></li>
|
|
87
|
+
</ol>
|
|
88
|
+
<form class="dmc-send" data-on-submit="sendChat">
|
|
89
|
+
<input class="dmc-input" data-model="draft.value" maxlength="1000" placeholder="Message everyone" aria-label="Message everyone">
|
|
90
|
+
<button type="submit" class="dmc-btn dmc-btn--small" aria-label="Send">${ico('message-circle')}<span>Send</span></button>
|
|
91
|
+
</form>
|
|
92
|
+
</div>
|
|
93
|
+
</aside>
|
|
94
|
+
|
|
95
|
+
<nav class="dmc-bar" aria-label="Call controls">
|
|
96
|
+
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value" data-bind-title="micOn.value ? 'Mute' : 'Unmute'">${pair('micOn.value', 'mic', 'mic-off')}<span class="dmc-lbl" data-bind-text="micOn.value ? 'Mute' : 'Unmute'"></span></button>
|
|
97
|
+
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value" data-bind-title="camOn.value ? 'Stop video' : 'Start video'">${pair('camOn.value', 'video', 'video-off')}<span class="dmc-lbl" data-bind-text="camOn.value ? 'Stop video' : 'Start video'"></span></button>
|
|
98
|
+
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-class="sharing.value && 'is-on'" data-bind-aria-pressed="sharing.value" data-bind-title="sharing.value ? 'Stop sharing' : 'Share your screen'">${ico('monitor')}<span class="dmc-lbl" data-bind-text="sharing.value ? 'Stop sharing' : 'Share screen'"></span></button>
|
|
99
|
+
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleSide" data-bind-class="sideOpen.value && 'is-on'" data-bind-aria-pressed="sideOpen.value" data-bind-title="chatLabel.value">${ico('message-circle')}<span class="dmc-lbl" data-bind-text="chatLabel.value"></span><span class="dmc-badge" data-bind-hidden="!unread.value || sideOpen.value" data-bind-text="unread.value"></span></button>
|
|
100
|
+
<button type="button" class="dmc-btn dmc-tool dmc-btn--rec" data-on-click="toggleRecord" data-bind-hidden="!(host.value && recordingAllowed.value && canRecord)" data-bind-class="iRecord.value && 'is-on'" data-bind-aria-pressed="iRecord.value" data-bind-title="iRecord.value ? 'Stop recording' : 'Record the call'">${ico('circle')}<span class="dmc-lbl" data-bind-text="iRecord.value ? 'Stop recording' : 'Record'"></span></button>
|
|
101
|
+
<button type="button" class="dmc-btn dmc-tool dmc-btn--leave" data-on-click="leave" title="Leave the call">${ico('phone-off')}<span class="dmc-lbl">Leave</span></button>
|
|
102
|
+
</nav>
|
|
103
|
+
</section>
|
|
104
|
+
</div>`;
|
|
105
|
+
|
|
106
|
+
function mount(el) {
|
|
107
|
+
let cfg;
|
|
108
|
+
try { cfg = JSON.parse(el.getAttribute('data-dm-call') || '{}'); } catch { cfg = {}; }
|
|
109
|
+
if (!M || !cfg.api || !cfg.room) { el.textContent = 'This call page could not start.'; return; }
|
|
110
|
+
|
|
111
|
+
// ---- State the screen shows -------------------------------------------------------
|
|
112
|
+
const stage = M.observable('check'); // check | waiting | in | ended | away | error
|
|
113
|
+
const name = M.observable(cfg.name || '');
|
|
114
|
+
const micOn = M.observable(true);
|
|
115
|
+
const camOn = M.observable(true);
|
|
116
|
+
const sharing = M.observable(false);
|
|
117
|
+
const recording = M.observable(false); // someone (the host) is recording
|
|
118
|
+
const iRecord = M.observable(false); // this browser is recording
|
|
119
|
+
const recordingAllowed = M.observable(cfg.recordingAllowed === true);
|
|
120
|
+
const host = M.observable(false);
|
|
121
|
+
const busy = M.observable(false);
|
|
122
|
+
const error = M.observable('');
|
|
123
|
+
const deviceNote = M.observable('Checking your camera and microphone…');
|
|
124
|
+
const sideOpen = M.observable(false);
|
|
125
|
+
const unread = M.observable(0);
|
|
126
|
+
const draft = M.observable('');
|
|
127
|
+
const peers = M.observableArray([]); // {id, name, host, caption: observable}
|
|
128
|
+
const waiting = M.observableArray([]); // {id, name}
|
|
129
|
+
const messages = M.observableArray([]); // {id, name, text, mine}
|
|
130
|
+
const ended = M.observable({title: 'You have left the call', text: 'You can close this page.'});
|
|
131
|
+
const max = M.observable(6);
|
|
132
|
+
|
|
133
|
+
const endTitle = M.computed(() => ended.value.title);
|
|
134
|
+
const endText = M.computed(() => ended.value.text);
|
|
135
|
+
const meCaption = M.computed(() => `${name.value || 'You'} (you)${micOn.value ? '' : ' · muted'}`);
|
|
136
|
+
const countText = M.computed(() => (stage.value === 'in' ? `${peers.value.length + 1} of ${max.value}` : ''));
|
|
137
|
+
const people = M.computed(() => String(Math.min(6, peers.value.length + 1)));
|
|
138
|
+
const chatLabel = M.computed(() => (sideOpen.value ? 'Hide chat' : 'Chat'));
|
|
139
|
+
const title = M.observable(cfg.title || 'Call');
|
|
140
|
+
|
|
141
|
+
// ---- The connection to the site ---------------------------------------------------
|
|
142
|
+
let me = null; // {id, secret}
|
|
143
|
+
let since = 0;
|
|
144
|
+
let polling = false;
|
|
145
|
+
const headers = () => ({'Content-Type': 'application/json', 'X-Call-Id': me?.id || '', 'X-Call-Secret': me?.secret || ''});
|
|
146
|
+
const url = (p) => `${cfg.api}/${encodeURIComponent(cfg.room)}/${p}`;
|
|
147
|
+
const post = (p, body = {}, extra = {}) => fetch(url(p), {method: 'POST', headers: headers(), body: JSON.stringify(body), ...extra});
|
|
148
|
+
|
|
149
|
+
// ---- Media -------------------------------------------------------------------------
|
|
150
|
+
let local = null; // the camera + mic stream
|
|
151
|
+
let screen = null;
|
|
152
|
+
let iceServers = [];
|
|
153
|
+
const pcs = new Map(); // peer id -> {pc, polite, makingOffer, ignore, stream}
|
|
154
|
+
|
|
155
|
+
const selfVideos = () => el.querySelectorAll('.dmc-self, .dmc-self-preview');
|
|
156
|
+
const showSelf = () => selfVideos().forEach(v => { v.srcObject = screen || local; });
|
|
157
|
+
|
|
158
|
+
async function getDevices() {
|
|
159
|
+
try {
|
|
160
|
+
local = await navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}, audio: true});
|
|
161
|
+
deviceNote.value = 'Your camera and microphone are working.';
|
|
162
|
+
} catch {
|
|
163
|
+
try {
|
|
164
|
+
local = await navigator.mediaDevices.getUserMedia({audio: true});
|
|
165
|
+
camOn.value = false;
|
|
166
|
+
deviceNote.value = 'No camera found, or it is blocked - you can still join with your microphone.';
|
|
167
|
+
} catch {
|
|
168
|
+
local = new MediaStream();
|
|
169
|
+
camOn.value = false;
|
|
170
|
+
micOn.value = false;
|
|
171
|
+
deviceNote.value = 'Your camera and microphone are blocked or missing. You can join and use the chat; allow them in your browser to be seen and heard.';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
showSelf();
|
|
175
|
+
}
|
|
176
|
+
const setTracks = () => {
|
|
177
|
+
local?.getAudioTracks().forEach(t => { t.enabled = micOn.value; });
|
|
178
|
+
local?.getVideoTracks().forEach(t => { t.enabled = camOn.value; });
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// ---- Peer connections (perfect negotiation) ----------------------------------------
|
|
182
|
+
function connect(peer) {
|
|
183
|
+
if (pcs.has(peer.id)) return pcs.get(peer.id);
|
|
184
|
+
const pc = new RTCPeerConnection({iceServers});
|
|
185
|
+
const entry = {pc, polite: me.id > peer.id, makingOffer: false, ignore: false, stream: new MediaStream(), chain: Promise.resolve()};
|
|
186
|
+
pcs.set(peer.id, entry);
|
|
187
|
+
const out = screen ? [...(local?.getAudioTracks() || []), ...screen.getVideoTracks()] : (local?.getTracks() || []);
|
|
188
|
+
out.forEach(track => pc.addTrack(track, local));
|
|
189
|
+
pc.ontrack = (e) => { entry.stream.addTrack(e.track); attach(); if (e.track.kind === 'audio') addToMix(e.track); };
|
|
190
|
+
pc.onicecandidate = (e) => { if (e.candidate) post('signal', {to: peer.id, data: {candidate: e.candidate}}); };
|
|
191
|
+
pc.onnegotiationneeded = async () => {
|
|
192
|
+
try {
|
|
193
|
+
entry.makingOffer = true;
|
|
194
|
+
await pc.setLocalDescription();
|
|
195
|
+
post('signal', {to: peer.id, data: {description: pc.localDescription}});
|
|
196
|
+
} catch (err) { console.warn('[call] offer:', err?.message || err); } finally { entry.makingOffer = false; }
|
|
197
|
+
};
|
|
198
|
+
pc.onconnectionstatechange = () => {
|
|
199
|
+
const row = peers.value.find(p => p.id === peer.id);
|
|
200
|
+
if (row) row.caption.value = captionFor(row, pc.connectionState);
|
|
201
|
+
if (pc.connectionState === 'failed') pc.restartIce?.();
|
|
202
|
+
};
|
|
203
|
+
return entry;
|
|
204
|
+
}
|
|
205
|
+
const captionFor = (p, state) => `${p.name}${p.host ? ' (host)' : ''}${state === 'failed' ? ' · can’t connect' : state === 'connecting' || state === 'new' ? ' · connecting…' : ''}`;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One peer's messages are applied strictly in order: a candidate that
|
|
209
|
+
* arrives while the offer before it is still being applied would otherwise
|
|
210
|
+
* be refused and lost, and the call would never connect.
|
|
211
|
+
*/
|
|
212
|
+
function onSignal(from, data) {
|
|
213
|
+
const peer = peers.value.find(p => p.id === from);
|
|
214
|
+
if (!peer) return;
|
|
215
|
+
const entry = connect(peer);
|
|
216
|
+
entry.chain = entry.chain.then(() => applySignal(entry, from, data));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function applySignal(entry, from, data) {
|
|
220
|
+
const {pc} = entry;
|
|
221
|
+
try {
|
|
222
|
+
if (data.description) {
|
|
223
|
+
const collision = data.description.type === 'offer' && (entry.makingOffer || pc.signalingState !== 'stable');
|
|
224
|
+
entry.ignore = !entry.polite && collision;
|
|
225
|
+
if (entry.ignore) return;
|
|
226
|
+
await pc.setRemoteDescription(data.description);
|
|
227
|
+
if (data.description.type === 'offer') {
|
|
228
|
+
await pc.setLocalDescription();
|
|
229
|
+
post('signal', {to: from, data: {description: pc.localDescription}});
|
|
230
|
+
}
|
|
231
|
+
} else if (data.candidate) {
|
|
232
|
+
try { await pc.addIceCandidate(data.candidate); } catch (err) { if (!entry.ignore) throw err; }
|
|
233
|
+
}
|
|
234
|
+
} catch (err) { console.warn('[call] negotiation:', err?.message || err); /* the tile shows "can't connect" */ }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function dropPeer(id) {
|
|
238
|
+
pcs.get(id)?.pc.close();
|
|
239
|
+
pcs.delete(id);
|
|
240
|
+
const row = peers.value.find(p => p.id === id);
|
|
241
|
+
if (row) peers.remove(row);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Give each remote tile its stream (bindings cannot set srcObject). */
|
|
245
|
+
function attach() {
|
|
246
|
+
requestAnimationFrame(() => {
|
|
247
|
+
el.querySelectorAll('.dmc-remote [data-peer]').forEach(fig => {
|
|
248
|
+
const entry = pcs.get(fig.getAttribute('data-peer'));
|
|
249
|
+
const video = fig.querySelector('video');
|
|
250
|
+
if (entry && video && video.srcObject !== entry.stream) video.srcObject = entry.stream;
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const stopAttach = M.effect(() => { peers.value; attach(); });
|
|
255
|
+
|
|
256
|
+
// ---- Events from the site ----------------------------------------------------------
|
|
257
|
+
function addPeer(p) {
|
|
258
|
+
if (p.id === me.id || peers.value.some(x => x.id === p.id)) return;
|
|
259
|
+
const row = {id: p.id, name: p.name, host: p.host, caption: M.observable('')};
|
|
260
|
+
row.caption.value = captionFor(row, 'new');
|
|
261
|
+
peers.push(row);
|
|
262
|
+
connect(row);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function handle(e) {
|
|
266
|
+
if (!me) return; // the call ended part-way through a batch of events
|
|
267
|
+
switch (e.type) {
|
|
268
|
+
case 'knocking': stage.value = 'waiting'; break;
|
|
269
|
+
case 'admitted':
|
|
270
|
+
stage.value = 'in';
|
|
271
|
+
recording.value = e.recording === true;
|
|
272
|
+
(e.peers || []).forEach(addPeer);
|
|
273
|
+
showSelf();
|
|
274
|
+
break;
|
|
275
|
+
case 'joined': addPeer(e.peer); break;
|
|
276
|
+
case 'left': dropPeer(e.id); break;
|
|
277
|
+
case 'signal': onSignal(e.from, e.data || {}); break;
|
|
278
|
+
case 'waiting': waiting.value = (e.waiting || []).map(w => ({id: w.id, name: w.name})); break;
|
|
279
|
+
case 'chat':
|
|
280
|
+
messages.push({id: `${e.seq}`, name: e.name, text: e.text, mine: e.from === me.id});
|
|
281
|
+
if (!sideOpen.value && e.from !== me.id) unread.value += 1;
|
|
282
|
+
requestAnimationFrame(() => { const list = el.querySelector('.dmc-messages'); if (list) list.scrollTop = list.scrollHeight; });
|
|
283
|
+
break;
|
|
284
|
+
case 'recording': recording.value = e.on === true; break;
|
|
285
|
+
case 'turned-away': finish('away', 'The host did not let you in', 'You can close this page.'); break;
|
|
286
|
+
case 'ended': finish('ended', 'The call has ended', 'You can close this page.'); break;
|
|
287
|
+
default: break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function pollLoop() {
|
|
292
|
+
if (polling) return;
|
|
293
|
+
polling = true;
|
|
294
|
+
while (me && !['ended', 'away', 'error'].includes(stage.value)) {
|
|
295
|
+
try {
|
|
296
|
+
const r = await fetch(`${url('poll')}?since=${since}`, {headers: headers(), cache: 'no-store'});
|
|
297
|
+
if (r.status === 410) { finish('ended', 'You are no longer in the call', 'Your connection dropped for too long. Reload the page to join again.'); break; }
|
|
298
|
+
if (!r.ok) { await pause(2000); continue; }
|
|
299
|
+
const {events = []} = await r.json();
|
|
300
|
+
for (const e of events) { since = Math.max(since, e.seq); handle(e); }
|
|
301
|
+
} catch {
|
|
302
|
+
await pause(2000);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
polling = false;
|
|
306
|
+
}
|
|
307
|
+
const pause = (ms) => new Promise(r => setTimeout(r, ms));
|
|
308
|
+
|
|
309
|
+
function finish(kind, title, text) {
|
|
310
|
+
ended.value = {title, text};
|
|
311
|
+
stage.value = kind;
|
|
312
|
+
stopRecording();
|
|
313
|
+
for (const id of [...pcs.keys()]) dropPeer(id);
|
|
314
|
+
[local, screen].forEach(s => s?.getTracks().forEach(t => t.stop()));
|
|
315
|
+
me = null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- Recording (host, in this browser; the file is downloaded here) ----------------
|
|
319
|
+
let recorder = null;
|
|
320
|
+
let recordTimer = null;
|
|
321
|
+
let audioCtx = null;
|
|
322
|
+
let mix = null;
|
|
323
|
+
/** Someone's audio reaching us while we record (a late joiner) goes into the recording too. */
|
|
324
|
+
function addToMix(track) {
|
|
325
|
+
if (!audioCtx || !mix) return;
|
|
326
|
+
try { audioCtx.createMediaStreamSource(new MediaStream([track])).connect(mix); } catch { /* ended already */ }
|
|
327
|
+
}
|
|
328
|
+
const canRecord = typeof MediaRecorder !== 'undefined' && typeof HTMLCanvasElement.prototype.captureStream === 'function';
|
|
329
|
+
|
|
330
|
+
function startRecording() {
|
|
331
|
+
const canvas = document.createElement('canvas');
|
|
332
|
+
canvas.width = 1280; canvas.height = 720;
|
|
333
|
+
const g = canvas.getContext('2d');
|
|
334
|
+
recordTimer = setInterval(() => {
|
|
335
|
+
const videos = [...el.querySelectorAll('.dmc-call video')].filter(v => v.srcObject && v.videoWidth);
|
|
336
|
+
g.fillStyle = '#111'; g.fillRect(0, 0, 1280, 720);
|
|
337
|
+
const n = Math.max(1, videos.length);
|
|
338
|
+
const cols = Math.ceil(Math.sqrt(n));
|
|
339
|
+
const rows = Math.ceil(n / cols);
|
|
340
|
+
const w = 1280 / cols; const h = 720 / rows;
|
|
341
|
+
videos.forEach((v, i) => {
|
|
342
|
+
const scale = Math.min(w / v.videoWidth, h / v.videoHeight);
|
|
343
|
+
const vw = v.videoWidth * scale; const vh = v.videoHeight * scale;
|
|
344
|
+
g.drawImage(v, (i % cols) * w + (w - vw) / 2, Math.floor(i / cols) * h + (h - vh) / 2, vw, vh);
|
|
345
|
+
});
|
|
346
|
+
}, 1000 / 15);
|
|
347
|
+
audioCtx = new AudioContext();
|
|
348
|
+
mix = audioCtx.createMediaStreamDestination();
|
|
349
|
+
[local, ...[...pcs.values()].map(e => e.stream)].forEach(s => s?.getAudioTracks().forEach(addToMix));
|
|
350
|
+
const stream = new MediaStream([...canvas.captureStream(15).getVideoTracks(), ...mix.stream.getAudioTracks()]);
|
|
351
|
+
const chunks = [];
|
|
352
|
+
const type = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'].find(t => MediaRecorder.isTypeSupported?.(t)) || '';
|
|
353
|
+
recorder = new MediaRecorder(stream, type ? {mimeType: type} : undefined);
|
|
354
|
+
recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
|
355
|
+
recorder.onstop = () => {
|
|
356
|
+
const blob = new Blob(chunks, {type: 'video/webm'});
|
|
357
|
+
const a = document.createElement('a');
|
|
358
|
+
a.href = URL.createObjectURL(blob);
|
|
359
|
+
a.download = `call-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}.webm`;
|
|
360
|
+
a.click();
|
|
361
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 60000);
|
|
362
|
+
};
|
|
363
|
+
recorder.start(1000);
|
|
364
|
+
iRecord.value = true;
|
|
365
|
+
}
|
|
366
|
+
function stopRecording() {
|
|
367
|
+
clearInterval(recordTimer); recordTimer = null;
|
|
368
|
+
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
|
369
|
+
recorder = null;
|
|
370
|
+
audioCtx?.close(); audioCtx = null; mix = null;
|
|
371
|
+
iRecord.value = false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---- What the buttons do -------------------------------------------------------------
|
|
375
|
+
const model = {
|
|
376
|
+
title, people, chatLabel, askName: cfg.askName !== false, canShare: Boolean(navigator.mediaDevices?.getDisplayMedia), canRecord,
|
|
377
|
+
stage, name, micOn, camOn, sharing, recording, iRecord, recordingAllowed, host, busy, error, deviceNote, sideOpen, unread,
|
|
378
|
+
draft, peers, waiting, messages, endTitle, endText, meCaption, countText,
|
|
379
|
+
|
|
380
|
+
toggleMic() { micOn.value = !micOn.value; setTracks(); },
|
|
381
|
+
toggleCam() {
|
|
382
|
+
if (!local?.getVideoTracks().length) { error.value = 'No camera is available.'; return; }
|
|
383
|
+
camOn.value = !camOn.value; setTracks();
|
|
384
|
+
},
|
|
385
|
+
toggleSide() { sideOpen.value = !sideOpen.value; if (sideOpen.value) unread.value = 0; },
|
|
386
|
+
|
|
387
|
+
async join() {
|
|
388
|
+
error.value = '';
|
|
389
|
+
if (model.askName && !name.value.trim()) { error.value = 'Your name, please - the others will see it.'; return; }
|
|
390
|
+
busy.value = true;
|
|
391
|
+
try {
|
|
392
|
+
const r = await fetch(url('join'), {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: name.value.trim(), ...(cfg.cred || {})})});
|
|
393
|
+
const body = await r.json().catch(() => ({}));
|
|
394
|
+
if (r.status === 425 && body.opensAt) { error.value = `This call opens at ${new Date(body.opensAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}. Come back then.`; return; }
|
|
395
|
+
if (!r.ok) { error.value = body.error || 'Could not join the call.'; return; }
|
|
396
|
+
me = {id: body.id, secret: body.secret};
|
|
397
|
+
iceServers = body.iceServers || [];
|
|
398
|
+
host.value = body.host === true;
|
|
399
|
+
if (body.name) name.value = body.name;
|
|
400
|
+
max.value = body.max || 6;
|
|
401
|
+
recordingAllowed.value = body.recordingAllowed === true;
|
|
402
|
+
if (body.title) title.value = body.title;
|
|
403
|
+
stage.value = body.state === 'in' ? 'in' : 'waiting';
|
|
404
|
+
since = 0;
|
|
405
|
+
pollLoop();
|
|
406
|
+
} catch {
|
|
407
|
+
error.value = 'Could not reach the site. Check your connection and try again.';
|
|
408
|
+
} finally { busy.value = false; }
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async leave() {
|
|
412
|
+
if (me) post('leave', {}, {keepalive: true});
|
|
413
|
+
finish('ended', 'You have left the call', 'You can close this page.');
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
admit(w) { post('answer', {who: w.id, yes: true}); },
|
|
417
|
+
turnAway(w) { post('answer', {who: w.id, yes: false}); },
|
|
418
|
+
|
|
419
|
+
sendChat() {
|
|
420
|
+
const text = draft.value.trim();
|
|
421
|
+
if (text && me) post('chat', {text});
|
|
422
|
+
draft.value = '';
|
|
423
|
+
return false;
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
async toggleShare() {
|
|
427
|
+
// Each connection's video sender: the camera's, or one added for a screen when there was no camera
|
|
428
|
+
// (remembered, so sharing again reuses it rather than adding another).
|
|
429
|
+
const videoSender = (entry) => entry.pc.getSenders().find(s => s.track?.kind === 'video') || entry.videoSender || null;
|
|
430
|
+
if (sharing.value) {
|
|
431
|
+
screen?.getTracks().forEach(t => t.stop());
|
|
432
|
+
screen = null;
|
|
433
|
+
sharing.value = false;
|
|
434
|
+
const cam = local?.getVideoTracks()[0] || null;
|
|
435
|
+
pcs.forEach(entry => videoSender(entry)?.replaceTrack(cam));
|
|
436
|
+
showSelf();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
screen = await navigator.mediaDevices.getDisplayMedia({video: true});
|
|
441
|
+
const track = screen.getVideoTracks()[0];
|
|
442
|
+
track.onended = () => { if (sharing.value) model.toggleShare(); };
|
|
443
|
+
sharing.value = true;
|
|
444
|
+
pcs.forEach(entry => {
|
|
445
|
+
const sender = videoSender(entry);
|
|
446
|
+
if (sender) sender.replaceTrack(track);
|
|
447
|
+
else entry.videoSender = entry.pc.addTrack(track, screen);
|
|
448
|
+
});
|
|
449
|
+
showSelf();
|
|
450
|
+
} catch { /* cancelled the picker */ }
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
async toggleRecord() {
|
|
454
|
+
if (!host.value) return;
|
|
455
|
+
if (iRecord.value) {
|
|
456
|
+
stopRecording();
|
|
457
|
+
post('recording', {on: false});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const r = await post('recording', {on: true});
|
|
461
|
+
if (!r.ok) { error.value = (await r.json().catch(() => ({}))).error || 'Recording is not allowed here.'; return; }
|
|
462
|
+
startRecording();
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
el.innerHTML = TEMPLATE;
|
|
467
|
+
const handleBindings = M.applyBindings(model, el.firstElementChild);
|
|
468
|
+
// Icons after binding (I.scan swaps each span for an SVG); new waiting rows get theirs as they arrive.
|
|
469
|
+
const scan = () => { try { window.I?.scan?.(el); } catch { /* no icons: the labels still read */ } };
|
|
470
|
+
scan();
|
|
471
|
+
const stopScan = M.effect(() => { waiting.value; requestAnimationFrame(scan); });
|
|
472
|
+
window.addEventListener('pagehide', () => { if (me) post('leave', {}, {keepalive: true}); });
|
|
473
|
+
getDevices().then(setTracks);
|
|
474
|
+
return () => { stopAttach?.(); stopScan?.(); handleBindings?.dispose?.(); };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
document.querySelectorAll('[data-dm-call]').forEach(mount);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# booking - AI Assistant Guide
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Appointments and resources booked from the site. 1.0.0 (2026-09-30): people (appointments with you or your team)
|
|
6
|
+
and resources (rooms, equipment) with weekly hours, days off and business-wide closed days; services with a length,
|
|
7
|
+
buffers before and after, a start step, notice and how far ahead; a public booking page at a configurable address
|
|
8
|
+
(`/booking`) and the `[booking /]` shortcode; confirmation, reminder and owner emails with an .ics; a private link for
|
|
9
|
+
the customer to move or cancel within the notice; a Diary (day / week), a Bookings list, Services and People & rooms
|
|
10
|
+
in the admin; a sidebar badge for bookings still to come today.
|
|
11
|
+
|
|
12
|
+
Links to other Tools, each absent when that Tool is: customers become contacts (the booked person's address book,
|
|
13
|
+
else the settings' owner, else whoever booked); "Book {name}…" on a contact's menu and a Bookings panel on the
|
|
14
|
+
contact card (core `registerRelatedItems('contact')`); "Book {name}…" on an email's menu in Email / Mail Reader
|
|
15
|
+
(core 0.103 `registerRelatedItems('message')`; an older core ignores it); a Bookings calendar source; a person's own
|
|
16
|
+
Calendar events block their times (read from `calendar-occurrences` / `calendar-events` through the collections
|
|
17
|
+
service - no import of the Calendar, which is a separate paid Tool).
|
|
18
|
+
|
|
19
|
+
`tier: free`, ships with the engine, disabled on arrival. Plan and phases: `dcms-marketplace/docs/booking-plan.md`.
|
|
20
|
+
Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
|
|
21
|
+
pattern - it does not supersede it. Payment is Pro only.
|
|
22
|
+
|
|
23
|
+
1.4.0 (2026-09-30): "Call now" hooks for Booking Pro. Registry `version: 4`: `actions: [{id, icon, title, module}]`
|
|
24
|
+
(module under `/plugins/<owner>/`; ids `[a-z0-9-]`, never a screen's or tab's key, first owner keeps an id) ->
|
|
25
|
+
`registry.actions()` -> bootstrap `actions` -> a banner button each (`booking-x-<id>`) that imports the module and calls
|
|
26
|
+
`run(ctx, prefill)` (ctx = the screen's ctx + `owner`, `api` (sectionApi), `bookables`, `services`, `helpPopovers`).
|
|
27
|
+
`#/plugins/booking/<action-id>?contact=|email=&name=` is the Diary + that action with the prefill resolved like `new`
|
|
28
|
+
(the address goes back to `diary`). `callOpenedAt` on an online booking (`changeBooking` patch, online only, history
|
|
29
|
+
`call-opened`; a move clears it): `callWindow` opens at min(start - 10 min, callOpenedAt) and closes at max(end + 60
|
|
30
|
+
min, callOpenedAt + 2 h), so the customer's EXISTING link works at once - Booking (free) honours it after a lapse.
|
|
31
|
+
`registry.services` adds `openCall(id, {by})` (every online HOLDING booking of the session; throws `code` none |
|
|
32
|
+
not-online | cancelled), `callLinks(b, request?)` (absolute join/host URLs from a RAW booking), `adminBooking`,
|
|
33
|
+
`ownContact(id, userId)`, `siteBase(request?)`; `book()` takes `contactId` + `userId` (checked with `ownContact`) and
|
|
34
|
+
`request` (for the email links). An older Pro registers no actions; an older Booking ignores them.
|
|
35
|
+
|
|
36
|
+
1.3.0 (2026-09-30): online bookings (plan: `dcms-marketplace/docs/online-meetings-plan.md`). An extension's
|
|
37
|
+
`rules()` may say `online` (+ `recordingAllowed`); `placeBooking` then stores `online: true`, `hostKey`,
|
|
38
|
+
`recordingAllowed` ON THE BOOKING, so Booking (free) serves the call for it even after Pro lapses. `lib/call.js`:
|
|
39
|
+
a room is a SESSION (`sessionOf` = service + start + person, so a class shares one call), `whoIsCalling` (customer
|
|
40
|
+
token -> guest, host key -> host, only while HOLDING, window 10 min before to 60 after). The engine is core's
|
|
41
|
+
`_lib/call` (dynamic import - an older core has no calls); routes at `/call/...`, page at `<bookingPath>/call/<session>
|
|
42
|
+
?b=&t=|h=` (plugin.public.js). Emails: customer's main button becomes "Join the call" (change/cancel moves into the
|
|
43
|
+
text), the owner's copy gets "Start the call"; .ics LOCATION/DESCRIPTION carry the join link. `adminBooking` swaps
|
|
44
|
+
`hostKey` for `hostUrl`; `publicBooking` adds `online`, `callUrl`. Staff-made bookings still do not email the owner.
|
|
45
|
+
|
|
46
|
+
1.2.0 (2026-09-30): an email's sender who is already one of the caller's contacts is booked AS that contact:
|
|
47
|
+
`GET /contact?email=` (`contactByEmail`, main address or Contacts Pro `emails[]`) fills the slideover (the
|
|
48
|
+
sender's own address kept). Without a `contactId`, `POST /bookings` leaves it to `linkContact`, which already finds
|
|
49
|
+
the booked person's (else the caller's) contact by email - never the caller's for someone else's booking.
|
|
50
|
+
|
|
51
|
+
1.1.0 (2026-09-30): extension points for Booking Pro - see "Extensions" below. The slot engine now knows places (a
|
|
52
|
+
class), companions (a booking that also holds a room), and a daily limit, all OFF unless an extension's `rules`
|
|
53
|
+
turn them on; bookings carry `holds` (every bookable they block, first = `bookableId`) and `alsoNames`.
|
|
54
|
+
|
|
55
|
+
## The one rule about time
|
|
56
|
+
|
|
57
|
+
Bookings are UTC instants (`startAt`, `endAt`, and the span they BLOCK, `blockStart` / `blockEnd`, buffers
|
|
58
|
+
included - stored, so changing a service's buffers never moves what existing bookings hold). Opening hours, days
|
|
59
|
+
off and everything a person reads are the business's wall clock in `settings.timeZone`. `public/lib/time.js` is the
|
|
60
|
+
only place one becomes the other (`wallClock`, `atWallClock`; Intl, no library; DST-tested: a skipped time is never
|
|
61
|
+
offered, a repeated one is the first). Never build a Date from a date string and a time string anywhere else.
|
|
62
|
+
|
|
63
|
+
## The one rule about clashes
|
|
64
|
+
|
|
65
|
+
`public/lib/slots.js` `slotsFor` decides what is free - the page, the admin and the server all ask it, and
|
|
66
|
+
`lib/store.js` `placeBooking` checks AGAIN inside `withLock` before writing. The public `POST /public/book` also
|
|
67
|
+
requires the time to be one the page would offer (in range, on the service's steps, in hours). Staff may book over a
|
|
68
|
+
clash with `force: true` after a 409 (`code: 'slot-taken'`). Statuses that hold time: pending, confirmed,
|
|
69
|
+
completed, no-show (`HOLDING`); cancelled frees it.
|
|
70
|
+
|
|
71
|
+
## Files
|
|
72
|
+
|
|
73
|
+
- `plugin.json`, `config.js` (every setting, documented), `plugin.js` (API, shortcode, emails, reminders, links)
|
|
74
|
+
- `plugin.public.js` - the booking pages from ONE onRequest hook on the LIVE `bookingPath` (moving it needs no
|
|
75
|
+
restart): `<base>`, `<base>/<service-slug>`, `<base>/manage/<id>?t=<token>`.
|
|
76
|
+
- `public/lib/time.js`, `slots.js`, `clean.js` - PURE, shared by server, admin and page (public/ is served; lib/ is not).
|
|
77
|
+
- `lib/store.js` - collections I/O, live settings, `withLock`, `placeBooking` / `changeBooking`, Calendar busy time,
|
|
78
|
+
contact linking. `shelfNow` / `settingsNow` for the synchronous shortcode.
|
|
79
|
+
- `lib/mail.js` - one way out (copied from Waypoint Pro): never throws, not sent without SMTP (`mailReady()`),
|
|
80
|
+
`setMailer()` for tests; `icsFor` (stable UID per booking, SEQUENCE = history length, CANCEL on cancel).
|
|
81
|
+
- `lib/extensions.js` - the registry (`globalThis[Symbol.for('domma.booking')]`): `on` {made, moved, cancelled,
|
|
82
|
+
status}, `tabs`, and `registry.services` (Booking's data functions, `sendReminders`).
|
|
83
|
+
- `admin/` - the Tool (tab host `admin/views/booking.js`), `templates/` + `public/booking.js|css` - the page.
|
|
84
|
+
- `public/booking.js` - the page in the browser: domma-reactive (`window.M`, Domma is on every public page) - one
|
|
85
|
+
template holding every step (shown by `data-bind-hidden` on `step`), one `M.applyBindings`; plain code only fetches,
|
|
86
|
+
writes observables, and moves focus / scroll. Without `window.M` the server's page is left as it is.
|
|
87
|
+
- `docs/guide.md` - the user guide in the admin Documentation folder.
|
|
88
|
+
- `tests/pure.test.js` (time, slots, cleaning), `tests/api.test.js` (the whole API against the real collection
|
|
89
|
+
service; restores config/plugins.json and contacts-contacts; registry actions, callOpenedAt), `tests/public.test.js`
|
|
90
|
+
(the page's paths; `call-now/<id>` is left to Booking Pro).
|
|
91
|
+
|
|
92
|
+
## Data
|
|
93
|
+
|
|
94
|
+
Collections `booking-bookables`, `booking-services`, `booking-bookings` (made at every boot if missing; API access
|
|
95
|
+
all off - bookings hold customers' details). Settings: `config/plugins.json` `booking.settings`.
|
|
96
|
+
|
|
97
|
+
A booking: `serviceId`, `serviceName`, `duration`, `bookableId`, `bookableName`, `startAt`, `endAt`, `blockStart`,
|
|
98
|
+
`blockEnd`, `status`, `name`, `email`, `phone`, `notes`, `staffNote`, `contactId`, `source` (public | admin),
|
|
99
|
+
`createdBy`, `token` (the customer's link; never sent to the admin), `reminderSentAt`, `history` [{at, what, by,
|
|
100
|
+
from?, reason?}], `cancelledAt`, `ext` (Pro's); online ones also `online`, `hostKey`, `recordingAllowed`, `callOpenedAt`. Services and bookables carry `ext` too (Pro's fields, kept by the
|
|
101
|
+
free Tool untouched).
|
|
102
|
+
|
|
103
|
+
## Extensions (Booking Pro)
|
|
104
|
+
|
|
105
|
+
`bookingRegistry()` (`lib/extensions.js`, `version: 4`): `register(owner, {rules, form, answers, check, admin, waitlistUrl,
|
|
106
|
+
on, tabs, actions})` - every hook optional and guarded (one that throws counts as absent). `rules(service)` ->
|
|
107
|
+
{capacity, companions: [[ids]], maxPerDay, waitlist}, resolved to bookable objects by `registry.rules(service,
|
|
108
|
+
bookables)` and applied by `slotsFor` / `whoCanTake` / `placeBooking` (same session = same service + start +
|
|
109
|
+
primary bookable: shares places, never clashes with itself; a day's limit counts sessions). `form` / `answers`: extra
|
|
110
|
+
questions on the page, cleaned into `ext.answers` (errors come back as `errors['answers.<id>']`). `check(ctx)`: a
|
|
111
|
+
reason refuses a public booking (409 `code: 'refused'`). `admin`: section modules mounted in Booking's own
|
|
112
|
+
slideovers (service editor, new booking, booking panel) - the contract is in `admin/lib/extension-sections.js`.
|
|
113
|
+
`registry.services`: Booking's data functions plus `book()` (the whole flow: contacts, emails, notices, events; throws
|
|
114
|
+
SlotTaken with `code: 'slot-taken'`), `cancel()`, `mail` {send, compose, mailReady}, `time` helpers and
|
|
115
|
+
`timeZone()` - so an extension never imports Booking's files. Without an extension every service is plain; existing
|
|
116
|
+
bookings keep blocking what they hold.
|
|
117
|
+
|
|
118
|
+
## Emails
|
|
119
|
+
|
|
120
|
+
Customer: confirmation (setting), moved, cancelled, reminder (`reminderHours` before; not for a booking made inside
|
|
121
|
+
that window - its confirmation was the reminder), with "Change or cancel" while `customerMayChange`. Owner copy: the
|
|
122
|
+
booked person's email, else `notifyAddress`, else the site's. Staff changes email the customer only when the
|
|
123
|
+
admin's "email the customer" box is ticked (`notify`). Reminders: a 5-minute `setInterval` (unref'd, cleared on
|
|
124
|
+
close) - no engine scheduler exists. Without SMTP nothing is sent; the settings slideover says so.
|
|
125
|
+
|
|
126
|
+
## Permissions
|
|
127
|
+
|
|
128
|
+
`booking` (read = see, manage = change), granted to admin; admins give it to other roles in Users & Roles. The
|
|
129
|
+
contact and email "Book…" actions carry `permission: {resource: 'booking', action: 'manage'}`, so core shows them
|
|
130
|
+
only to roles that hold it.
|
|
131
|
+
|
|
132
|
+
## Gotchas
|
|
133
|
+
|
|
134
|
+
- Add `return reply.send(...)` / return a value in every async route (compressed responses come back empty).
|
|
135
|
+
- `siteBase()` uses `site.baseUrl` when set, else the last request's origin (emails from reminders have no request).
|
|
136
|
+
- The API tests create contacts in domma-cms's `content/collections/contacts-contacts/data.json` (tracked) - they
|
|
137
|
+
snapshot and restore it; a crashed run can leave it changed: `git checkout` it in domma-cms.
|