domma-cms 0.102.1 → 0.103.0

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