domma-cms 0.106.0 → 0.107.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/admin/js/templates/docs/usage-tools.html +7 -0
- package/package.json +1 -1
- package/plugins/_lib/call/invite.js +172 -0
- package/plugins/_lib/call/rooms.js +19 -5
- package/plugins/_lib/call/routes.js +78 -3
- package/plugins/_lib/public/call/call.css +1 -1
- package/plugins/_lib/public/call/call.js +287 -23
- package/plugins/booking/CLAUDE.md +18 -1
- package/plugins/booking/docs/guide.md +1 -0
- package/plugins/booking/lib/call.js +18 -2
- package/plugins/booking/lib/store.js +2 -0
- package/plugins/booking/plugin.js +59 -6
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/plugin.public.js +4 -4
- package/plugins/booking/tests/api.test.js +114 -0
- package/plugins/free-tier.lock.json +10 -10
- package/server/routes/api/calls.js +25 -3
- package/server/routes/call-public.js +4 -3
- package/server/services/callInvites.js +238 -0
- package/server/services/hooks.js +55 -0
- package/server/services/instantCalls.js +30 -3
- package/server/services/plugins.js +6 -1
|
@@ -27,8 +27,9 @@ const ico = (name) => `<span class="dmc-ico" data-icon="${name}" data-icon-size=
|
|
|
27
27
|
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>`;
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* The screen.
|
|
31
|
-
*
|
|
30
|
+
* The screen. The whole of it is a full-bleed layer below the site's header (layout()). In the call: a slate
|
|
31
|
+
* stage (a slim top bar, tiles that fill it, a docked control bar - icon + label, icons only on a phone) beside a
|
|
32
|
+
* chat panel.
|
|
32
33
|
* Chat rows are plain objects built in JS (see addItem) - every string reaches the page as textContent.
|
|
33
34
|
*/
|
|
34
35
|
const TEMPLATE = `
|
|
@@ -113,12 +114,18 @@ const TEMPLATE = `
|
|
|
113
114
|
</div>
|
|
114
115
|
|
|
115
116
|
<nav class="dmc-bar" aria-label="Call controls">
|
|
117
|
+
<div class="dmc-bar-side" aria-hidden="true"></div>
|
|
118
|
+
<div class="dmc-bar-mid">
|
|
116
119
|
<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-aria-label="micOn.value ? 'Mute' : 'Unmute'">${pair('micOn.value', 'mic', 'mic-off')}<span class="dmc-lbl" data-bind-text="micOn.value ? 'Mute' : 'Unmute'"></span></button>
|
|
117
120
|
<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-aria-label="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>
|
|
118
|
-
<button type="button" class="dmc-btn dmc-tool dmc-tool--share" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-class="sharing.value && 'is-on'" data-bind-aria-pressed="sharing.value" data-bind-aria-label="sharing.value ? 'Stop sharing' : 'Share your screen'">${ico('monitor')}<span class="dmc-lbl" data-bind-text="sharing.value ? 'Stop
|
|
119
|
-
<button type="button" class="dmc-btn dmc-tool dmc-tool--chat" data-on-click="toggleSide" data-bind-class="sideOpen.value && 'is-on'" data-bind-aria-pressed="sideOpen.value" data-bind-aria-label="chatLabel.value">${ico('message-circle')}<span class="dmc-lbl"
|
|
121
|
+
<button type="button" class="dmc-btn dmc-tool dmc-tool--share" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-class="sharing.value && 'is-on'" data-bind-aria-pressed="sharing.value" data-bind-aria-label="sharing.value ? 'Stop sharing' : 'Share your screen'">${ico('monitor')}<span class="dmc-lbl" data-bind-text="sharing.value ? 'Stop share' : 'Share'"></span></button>
|
|
122
|
+
<button type="button" class="dmc-btn dmc-tool dmc-tool--chat" data-on-click="toggleSide" data-bind-class="sideOpen.value && 'is-on'" data-bind-aria-pressed="sideOpen.value" data-bind-aria-label="chatLabel.value">${ico('message-circle')}<span class="dmc-lbl">Chat</span><span class="dmc-badge" data-bind-hidden="!unread.value || sideOpen.value" data-bind-text="unreadText.value"></span></button>
|
|
123
|
+
<button type="button" class="dmc-btn dmc-tool dmc-tool--invite" data-slot="invite" data-on-click="toggleInvite" data-bind-hidden="!canInvite.value" data-bind-class="inviteOpen.value && 'is-on'" data-bind-aria-pressed="inviteOpen.value" aria-label="Add people">${ico('user-plus')}<span class="dmc-lbl">Add people</span></button>
|
|
120
124
|
<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-aria-label="iRecord.value ? 'Stop recording' : 'Record the call'">${ico('circle')}<span class="dmc-lbl" data-bind-text="iRecord.value ? 'Stop recording' : 'Record'"></span></button>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="dmc-bar-side dmc-bar-end">
|
|
121
127
|
<button type="button" class="dmc-btn dmc-tool dmc-btn--leave" data-on-click="leave" aria-label="Leave the call">${ico('phone-off')}<span class="dmc-lbl">Leave</span></button>
|
|
128
|
+
</div>
|
|
122
129
|
</nav>
|
|
123
130
|
</div>
|
|
124
131
|
|
|
@@ -133,7 +140,8 @@ const TEMPLATE = `
|
|
|
133
140
|
<li data-bind-class="cls" data-bind-data-hue="hue">
|
|
134
141
|
<div class="dmc-day" data-bind-hidden="!day"><span data-bind-text="day"></span></div>
|
|
135
142
|
<div class="dmc-sys" data-bind-hidden="!sys"><span data-bind-text="text"></span></div>
|
|
136
|
-
<div class="dmc-
|
|
143
|
+
<div class="dmc-meta" data-bind-hidden="!showMeta"><span class="dmc-from" data-bind-hidden="!showName" data-bind-text="name"></span><span class="dmc-time" data-bind-text="time"></span></div>
|
|
144
|
+
<div class="dmc-bubble" data-bind-hidden="sys" data-bind-title="time"><span class="dmc-text" data-bind-text="text"></span></div>
|
|
137
145
|
</li>
|
|
138
146
|
</ol>
|
|
139
147
|
<p class="dmc-chat-empty" data-bind-hidden="messages.value.length">No messages yet. Messages are seen by everyone in the call.</p>
|
|
@@ -144,6 +152,62 @@ const TEMPLATE = `
|
|
|
144
152
|
<button type="submit" class="dmc-btn dmc-sendbtn" aria-label="Send" data-bind-disabled="!canSend.value">${ico('send')}</button>
|
|
145
153
|
</form>
|
|
146
154
|
</aside>
|
|
155
|
+
|
|
156
|
+
<aside class="dmc-invite" aria-label="Add people" data-bind-class="inviteOpen.value && 'is-open'" data-bind-aria-hidden="!inviteOpen.value" data-bind-inert="!inviteOpen.value">
|
|
157
|
+
<header class="dmc-chat-head">
|
|
158
|
+
<button type="button" class="dmc-chat-back" data-on-click="toggleInvite" aria-label="Back to the call">${ico('arrow-left')}</button>
|
|
159
|
+
<div class="dmc-chat-titles"><h3>Add people</h3><span data-bind-text="inviteSub.value"></span></div>
|
|
160
|
+
<button type="button" class="dmc-chat-close" data-on-click="toggleInvite" aria-label="Close Add people">${ico('x')}</button>
|
|
161
|
+
</header>
|
|
162
|
+
<div class="dmc-inv-body">
|
|
163
|
+
<p class="dmc-inv-only" data-bind-hidden="inviteSearch.value || inviteTyped.value">Make a link for each person you want to add and pass it on yourself - by email, text or chat. Each link is theirs alone: they type their name and knock, and you let them in.</p>
|
|
164
|
+
<div class="dmc-inv-find" data-bind-hidden="!inviteSearch.value">
|
|
165
|
+
<label class="dmc-inv-search"><span class="dmc-inv-sico" aria-hidden="true">${ico('search')}</span>
|
|
166
|
+
<input class="dmc-inv-input dmc-inv-q" type="search" data-model="query.value" data-on-input="searchSoon" maxlength="100" autocomplete="off" data-bind-placeholder="searchHint.value" data-bind-aria-label="searchHint.value"></label>
|
|
167
|
+
<div class="dmc-inv-chips" data-bind-hidden="!picked.value.length" aria-label="Adding">
|
|
168
|
+
<ul data-each="picked key=key">
|
|
169
|
+
<li class="dmc-inv-chip"><span class="dmc-inv-chipname" data-bind-text="name"></span><span class="dmc-inv-chipkind" data-bind-text="badge"></span>
|
|
170
|
+
<button type="button" class="dmc-inv-chipx" data-on-click="$parent.unpick($data)" data-bind-aria-label="removeLabel"><span class="dmc-i">${ico('x')}</span></button></li>
|
|
171
|
+
</ul>
|
|
172
|
+
</div>
|
|
173
|
+
<p class="dmc-inv-label" data-bind-text="foundTitle.value"></p>
|
|
174
|
+
<ul class="dmc-inv-list" data-each="found key=key">
|
|
175
|
+
<li><button type="button" class="dmc-inv-row" data-on-click="$parent.pick($data)" data-bind-class="picked.value && 'is-picked'" data-bind-aria-pressed="picked.value">
|
|
176
|
+
<span class="dmc-inv-av" aria-hidden="true" data-bind-text="initial"></span>
|
|
177
|
+
<span class="dmc-inv-who"><span class="dmc-inv-name" data-bind-text="name"></span><span class="dmc-inv-mail" data-bind-hidden="!email" data-bind-text="email"></span></span>
|
|
178
|
+
<span class="dmc-inv-kind" data-bind-data-kind="kind" data-bind-text="badge"></span>
|
|
179
|
+
<span class="dmc-inv-tick" aria-hidden="true"><span class="dmc-i">${ico('check')}</span></span>
|
|
180
|
+
</button></li>
|
|
181
|
+
</ul>
|
|
182
|
+
<p class="dmc-inv-empty" data-bind-hidden="!emptyText.value" data-bind-text="emptyText.value"></p>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="dmc-inv-typed" data-bind-hidden="!inviteTyped.value">
|
|
185
|
+
<p class="dmc-inv-label">Or type a name and email</p>
|
|
186
|
+
<div class="dmc-inv-pair">
|
|
187
|
+
<input class="dmc-inv-input" data-model="typedName.value" maxlength="120" autocomplete="off" placeholder="Name" aria-label="Their name">
|
|
188
|
+
<input class="dmc-inv-input" type="email" data-model="typedEmail.value" maxlength="200" autocomplete="off" placeholder="name@example.com" aria-label="Their email" data-on-keydown="typedKey">
|
|
189
|
+
<button type="button" class="dmc-btn dmc-inv-add" data-on-click="addTyped" aria-label="Add this person">${ico('plus')}</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="dmc-inv-foot">
|
|
194
|
+
<fieldset class="dmc-inv-via" data-bind-hidden="!inviteSearch.value && !inviteTyped.value"><legend>Send them</legend>
|
|
195
|
+
<label><input type="checkbox" data-model="viaNotify.value"><span>Notification</span></label>
|
|
196
|
+
<label data-bind-hidden="!canMessage.value"><input type="checkbox" data-model="viaMessage.value"><span>Message</span></label>
|
|
197
|
+
<label><input type="checkbox" data-model="viaEmail.value"><span>Email</span></label>
|
|
198
|
+
</fieldset>
|
|
199
|
+
<p class="dmc-inv-hint" data-bind-hidden="!inviteSearch.value && !inviteTyped.value" data-bind-text="viaHint.value"></p>
|
|
200
|
+
<p class="dmc-inv-status" role="status" data-bind-hidden="!inviteNote.value" data-bind-class="inviteBad.value && 'is-err'" data-bind-text="inviteNote.value"></p>
|
|
201
|
+
<ul class="dmc-inv-results" data-each="sent key=id">
|
|
202
|
+
<li data-bind-class="cls"><span class="dmc-inv-rname" data-bind-text="name"></span><span class="dmc-inv-rtext" data-bind-text="text"></span></li>
|
|
203
|
+
</ul>
|
|
204
|
+
<input class="dmc-inv-input dmc-inv-link" readonly data-bind-hidden="!copiedLink.value" data-bind-value="copiedLink.value" aria-label="The Join link" data-on-focus="selectLink">
|
|
205
|
+
<div class="dmc-inv-btns">
|
|
206
|
+
<button type="button" class="dmc-btn dmc-btn--ghost dmc-inv-copy" data-bind-hidden="!inviteCopy.value" data-on-click="copyLink" data-bind-disabled="inviteBusy.value">${ico('copy')}<span>Copy link</span></button>
|
|
207
|
+
<button type="button" class="dmc-btn dmc-btn--go dmc-inv-send" data-bind-hidden="!inviteSearch.value && !inviteTyped.value" data-on-click="sendInvites" data-bind-disabled="!canSendInvites.value">${ico('send')}<span data-bind-text="sendLabel.value"></span></button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</aside>
|
|
147
211
|
</section>
|
|
148
212
|
</div>`;
|
|
149
213
|
|
|
@@ -155,6 +219,8 @@ const hueOf = (key) => {
|
|
|
155
219
|
};
|
|
156
220
|
const initialOf = (n) => (String(n || '').trim()[0] || '?').toUpperCase();
|
|
157
221
|
const GROUP_MS = 5 * 60e3;
|
|
222
|
+
const INVITE_MAX = 10; // people in one invite (the engine's MAX_TARGETS)
|
|
223
|
+
const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
158
224
|
|
|
159
225
|
function mount(el) {
|
|
160
226
|
let cfg;
|
|
@@ -208,6 +274,41 @@ function mount(el) {
|
|
|
208
274
|
const canSend = M.computed(() => /\S/.test(draft.value || ''));
|
|
209
275
|
const title = M.observable(cfg.title || 'Call');
|
|
210
276
|
|
|
277
|
+
// ---- Add people (the host, when the Tool allows it: join answers canInvite) ----------------
|
|
278
|
+
const canInvite = M.observable(false);
|
|
279
|
+
// What the Tool offers this host (join's inviteOptions): search the team (+ contacts when typed), contacts and
|
|
280
|
+
// "type a name and email", Copy link. Messages: search only. Booking's host with no account on the site: Copy link only.
|
|
281
|
+
const inviteSearch = M.observable(true);
|
|
282
|
+
const inviteTyped = M.observable(true);
|
|
283
|
+
const inviteCopy = M.observable(true);
|
|
284
|
+
const inviteOpen = M.observable(false);
|
|
285
|
+
const query = M.observable('');
|
|
286
|
+
const found = M.observableArray([]); // search rows {key, kind, id, name, email, badge, initial, picked: observable}
|
|
287
|
+
const picked = M.observableArray([]); // chips {key, kind, id, name, email?, badge, removeLabel}
|
|
288
|
+
const typedName = M.observable('');
|
|
289
|
+
const typedEmail = M.observable('');
|
|
290
|
+
const viaNotify = M.observable(true);
|
|
291
|
+
const viaMessage = M.observable(true);
|
|
292
|
+
const viaEmail = M.observable(true);
|
|
293
|
+
const canMessage = M.observable(false); // a search said a direct message can reach the team
|
|
294
|
+
const inviteBusy = M.observable(false);
|
|
295
|
+
const inviteNote = M.observable('');
|
|
296
|
+
const inviteBad = M.observable(false);
|
|
297
|
+
const sent = M.observableArray([]); // {id, name, text, cls}
|
|
298
|
+
const copiedLink = M.observable('');
|
|
299
|
+
const searched = M.observable(false);
|
|
300
|
+
const inviteSub = M.computed(() => (!inviteSearch.value ? 'Copy a link to pass on' : inviteTyped.value ? 'Contacts, the team or any email' : 'Ring another team member'));
|
|
301
|
+
const searchHint = M.computed(() => (inviteTyped.value ? 'Search your contacts and team' : 'Search the team'));
|
|
302
|
+
const foundTitle = M.computed(() => ((query.value || '').trim() ? 'Results' : 'Your team'));
|
|
303
|
+
const emptyText = M.computed(() => (!searched.value || found.value.length ? ''
|
|
304
|
+
: (query.value || '').trim() ? `Nobody found.${inviteTyped.value ? ' Type their name and email below.' : ''}` : 'Nobody else to add.'));
|
|
305
|
+
const viaHint = M.computed(() => (inviteTyped.value
|
|
306
|
+
? 'Notification and Message reach team members only; contacts and typed addresses get the email. Everyone knocks and you let them in.'
|
|
307
|
+
: 'They get a notification and a message in Messages with their own link; tick Email to send one too.'));
|
|
308
|
+
const canSendInvites = M.computed(() => picked.value.length > 0 && !inviteBusy.value
|
|
309
|
+
&& (viaEmail.value || viaNotify.value || (viaMessage.value && canMessage.value)));
|
|
310
|
+
const sendLabel = M.computed(() => (picked.value.length > 1 ? `Invite ${picked.value.length}` : 'Invite'));
|
|
311
|
+
|
|
211
312
|
// ---- The connection to the site ---------------------------------------------------
|
|
212
313
|
let me = null; // {id, secret, who}
|
|
213
314
|
let since = 0;
|
|
@@ -337,7 +438,7 @@ function mount(el) {
|
|
|
337
438
|
// ---- Chat -------------------------------------------------------------------------------
|
|
338
439
|
// Items come from the site as {id, seq, at, kind: 'chat'|'system', from, who, name, text, event?}: the history
|
|
339
440
|
// in `admitted.chat`, then one per `chat` event (and on joined/left/recording events as `item`). Each becomes a
|
|
340
|
-
// display row here, grouped
|
|
441
|
+
// display row here, grouped against the row before it (a name + time line opens each group); the same id never shows twice.
|
|
341
442
|
const seenIds = new Set();
|
|
342
443
|
let lastRow = null;
|
|
343
444
|
const scroller = () => el.querySelector('.dmc-messages');
|
|
@@ -367,7 +468,7 @@ function mount(el) {
|
|
|
367
468
|
const day = !lastRow || dayKey(lastRow.when) !== dayKey(when) ? dayLabel(when) : '';
|
|
368
469
|
const first = sys || Boolean(day) || !lastRow || lastRow.sys || lastRow.sender !== sender || when - lastRow.when > GROUP_MS;
|
|
369
470
|
const row = {id: raw.id, sys, mine, text: raw.text, name: raw.name || 'Someone', day, sender, when,
|
|
370
|
-
showName: first && !mine && !sys, hue: hueOf(sender),
|
|
471
|
+
showName: first && !mine && !sys, showMeta: first && !sys, hue: hueOf(sender),
|
|
371
472
|
time: when.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'}),
|
|
372
473
|
cls: ['dmc-msg', sys ? 'is-sys' : mine ? 'is-mine' : 'is-other', first ? 'is-first' : 'is-cont', day ? 'has-day' : ''].join(' ').trim()};
|
|
373
474
|
const stick = nearBottom() || mine || !live;
|
|
@@ -389,7 +490,7 @@ function mount(el) {
|
|
|
389
490
|
(e.peers || []).forEach(addPeer);
|
|
390
491
|
showSelf();
|
|
391
492
|
startClock();
|
|
392
|
-
|
|
493
|
+
layout();
|
|
393
494
|
break;
|
|
394
495
|
case 'joined': addPeer(e.peer); addItem(e.item); break;
|
|
395
496
|
case 'left': dropPeer(e.id); addItem(e.item); break;
|
|
@@ -437,23 +538,71 @@ function mount(el) {
|
|
|
437
538
|
tick();
|
|
438
539
|
clockTimer = setInterval(tick, 1000);
|
|
439
540
|
}
|
|
440
|
-
/**
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
541
|
+
/**
|
|
542
|
+
* The screen is a full-bleed layer (fixed) over the page below the site's header - measured, not guessed: the
|
|
543
|
+
* navbar's bottom edge (or its side, for a vertical one); without a recognisable header, where the mount point
|
|
544
|
+
* was. The page behind does not scroll.
|
|
545
|
+
*/
|
|
546
|
+
let flowTop = null;
|
|
547
|
+
function layoutChrome() {
|
|
548
|
+
const root = el.firstElementChild;
|
|
549
|
+
if (!root) return;
|
|
550
|
+
if (flowTop === null) {
|
|
551
|
+
try { window.scrollTo(0, 0); } catch { /* never mind */ }
|
|
552
|
+
flowTop = el.getBoundingClientRect().top;
|
|
553
|
+
root.classList.add('dmc--app');
|
|
554
|
+
document.documentElement.classList.add('dmc-page');
|
|
555
|
+
}
|
|
556
|
+
const vw = window.innerWidth; const vh = window.innerHeight;
|
|
557
|
+
let top = flowTop; let left = 0; let right = 0;
|
|
558
|
+
const nav = [document.getElementById('site-navbar'), document.querySelector('body > header, body > nav, header[role="banner"], .site-header')]
|
|
559
|
+
.find(n => n && !el.contains(n) && n.getBoundingClientRect().height > 0);
|
|
560
|
+
if (nav) {
|
|
561
|
+
const r = nav.getBoundingClientRect();
|
|
562
|
+
const vertical = r.height > vh * 0.6 && r.width < vw * 0.5;
|
|
563
|
+
if (vertical && r.left < vw / 2) { top = 0; left = r.right; } else if (vertical) { top = 0; right = vw - r.left; } else top = r.bottom;
|
|
564
|
+
}
|
|
565
|
+
const clamp = (v, most) => `${Math.round(Math.max(0, Math.min(v, most)))}px`;
|
|
566
|
+
root.style.setProperty('--dmc-offset', clamp(top, vh * 0.35));
|
|
567
|
+
root.style.setProperty('--dmc-left', clamp(left, vw * 0.4));
|
|
568
|
+
root.style.setProperty('--dmc-right', clamp(right, vw * 0.4));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Tiles fill the stage: for this stage's size, the columns x rows (and the tile size) that show the most of
|
|
572
|
+
* everyone. A tile stretches to its cell and the video covers it (no letterbox bands); a tile may be tall
|
|
573
|
+
* (the sides of the picture are cropped - faces are central) but never wider than 2.4:1, which would crop heads.
|
|
574
|
+
* Score: tile area x sqrt(share of the 16:9 picture still visible).
|
|
575
|
+
*/
|
|
576
|
+
function layoutTiles() {
|
|
577
|
+
const box = el.querySelector('.dmc-remote');
|
|
578
|
+
const n = Math.min(6, peers.value.length);
|
|
579
|
+
if (!box || !n || !box.clientWidth) return;
|
|
580
|
+
const gap = parseFloat(getComputedStyle(box).columnGap) || 8;
|
|
581
|
+
const W = box.clientWidth; const H = box.clientHeight;
|
|
582
|
+
const WIDEST = 2.4; const TALLEST = 0.5; const VIDEO = 16 / 9;
|
|
583
|
+
let best = null;
|
|
584
|
+
for (let cols = 1; cols <= n; cols++) {
|
|
585
|
+
const rows = Math.ceil(n / cols);
|
|
586
|
+
let w = (W - (cols - 1) * gap) / cols; let h = (H - (rows - 1) * gap) / rows;
|
|
587
|
+
if (w <= 0 || h <= 0) continue;
|
|
588
|
+
if (w / h > WIDEST) w = h * WIDEST;
|
|
589
|
+
if (w / h < TALLEST) h = w / TALLEST;
|
|
590
|
+
const aspect = w / h;
|
|
591
|
+
const score = w * h * Math.sqrt(Math.min(aspect / VIDEO, VIDEO / aspect));
|
|
592
|
+
if (!best || score > best.score) best = {cols, rows, w, h, score};
|
|
593
|
+
}
|
|
594
|
+
if (!best) return;
|
|
595
|
+
box.style.setProperty('--cols', String(best.cols));
|
|
596
|
+
box.style.setProperty('--rows', String(best.rows));
|
|
597
|
+
box.style.setProperty('--tile-w', `${Math.floor(best.w)}px`);
|
|
598
|
+
box.style.setProperty('--tile-h', `${Math.floor(best.h)}px`);
|
|
452
599
|
}
|
|
453
|
-
|
|
600
|
+
function layout() { requestAnimationFrame(() => { layoutChrome(); layoutTiles(); }); }
|
|
601
|
+
window.addEventListener('resize', layout);
|
|
454
602
|
|
|
455
603
|
function finish(kind, title, text) {
|
|
456
604
|
ended.value = {title, text};
|
|
605
|
+
inviteOpen.value = false;
|
|
457
606
|
clearInterval(clockTimer); clockTimer = null;
|
|
458
607
|
stage.value = kind;
|
|
459
608
|
stopRecording();
|
|
@@ -524,6 +673,80 @@ function mount(el) {
|
|
|
524
673
|
stage, name, initial, micOn, camOn, sharing, recording, iRecord, recordingAllowed, host, busy, error, deviceNote, sideOpen, unread,
|
|
525
674
|
unreadText, draft, canSend, peers, waiting, waitingTitle, messages, newBelow, endTitle, endText, meCaption, countText, countTitle,
|
|
526
675
|
clock, chatSub, aloneText,
|
|
676
|
+
canInvite, inviteSearch, inviteTyped, inviteCopy, inviteOpen, query, found, picked, typedName, typedEmail, viaNotify, viaMessage, viaEmail, canMessage,
|
|
677
|
+
inviteBusy, inviteNote, inviteBad, sent, copiedLink, inviteSub, searchHint, foundTitle, emptyText, viaHint, canSendInvites, sendLabel,
|
|
678
|
+
|
|
679
|
+
toggleInvite() {
|
|
680
|
+
inviteOpen.value = !inviteOpen.value;
|
|
681
|
+
if (!inviteOpen.value) return;
|
|
682
|
+
sideOpen.value = false;
|
|
683
|
+
if (!searched.value && inviteSearch.value) runSearch();
|
|
684
|
+
if (window.matchMedia('(min-width: 761px)').matches) setTimeout(() => el.querySelector('.dmc-inv-q')?.focus({preventScroll: true}), 250);
|
|
685
|
+
},
|
|
686
|
+
searchSoon() {
|
|
687
|
+
clearTimeout(searchTimer);
|
|
688
|
+
searchTimer = setTimeout(runSearch, 250);
|
|
689
|
+
},
|
|
690
|
+
pick(row) {
|
|
691
|
+
const at = picked.value.find(p => p.key === row.key);
|
|
692
|
+
if (at) { model.unpick(at); return; }
|
|
693
|
+
if (picked.value.length >= INVITE_MAX) { note(`Add at most ${INVITE_MAX} people at a time.`, true); return; }
|
|
694
|
+
picked.push({key: row.key, kind: row.kind, id: row.id, name: row.name, badge: row.badge, removeLabel: `Remove ${row.name}`});
|
|
695
|
+
row.picked.value = true;
|
|
696
|
+
},
|
|
697
|
+
unpick(chip) {
|
|
698
|
+
picked.remove(chip);
|
|
699
|
+
const row = found.value.find(r => r.key === chip.key);
|
|
700
|
+
if (row) row.picked.value = false;
|
|
701
|
+
},
|
|
702
|
+
addTyped() {
|
|
703
|
+
const email = String(typedEmail.value || '').trim().toLowerCase();
|
|
704
|
+
if (!EMAIL_RE.test(email)) { note('That email address does not look right.', true); return; }
|
|
705
|
+
const name = String(typedName.value || '').trim().slice(0, 120) || email.split('@')[0];
|
|
706
|
+
const key = `email:${email}`;
|
|
707
|
+
if (!picked.value.some(p => p.key === key)) {
|
|
708
|
+
if (picked.value.length >= INVITE_MAX) { note(`Add at most ${INVITE_MAX} people at a time.`, true); return; }
|
|
709
|
+
picked.push({key, kind: 'email', name, email, badge: 'Email', removeLabel: `Remove ${name}`});
|
|
710
|
+
}
|
|
711
|
+
typedName.value = ''; typedEmail.value = ''; note('');
|
|
712
|
+
},
|
|
713
|
+
typedKey(e) { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); model.addTyped(); } },
|
|
714
|
+
selectLink(e) { try { e.target.select(); } catch { /* never mind */ } },
|
|
715
|
+
|
|
716
|
+
async sendInvites() {
|
|
717
|
+
if (!canSendInvites.value || !me) return;
|
|
718
|
+
inviteBusy.value = true; note('');
|
|
719
|
+
const people = picked.value.map(p => (p.kind === 'email' ? {name: p.name, email: p.email} : {kind: p.kind, id: p.id}));
|
|
720
|
+
try {
|
|
721
|
+
const r = await post('invite', {people, via: {email: viaEmail.value, notify: viaNotify.value, message: viaMessage.value && canMessage.value}});
|
|
722
|
+
const body = await r.json().catch(() => ({}));
|
|
723
|
+
if (!r.ok) { note(body.error || 'The invitations could not be sent.', true); return; }
|
|
724
|
+
const results = Array.isArray(body.results) ? body.results : [];
|
|
725
|
+
sent.value = results.map((x, i) => ({id: `${Date.now()}-${i}`, name: x.name, text: resultText(x),
|
|
726
|
+
cls: `dmc-inv-res ${x.emailed || x.notified || x.messaged ? 'is-ok' : 'is-err'}`}));
|
|
727
|
+
for (const p of [...picked.value]) model.unpick(p);
|
|
728
|
+
const reached = results.filter(x => x.emailed || x.notified || x.messaged).length;
|
|
729
|
+
note(reached ? (inviteTyped.value ? 'Sent. They will knock - let them in from the waiting list.' : 'Sent. They join as soon as they open their link.')
|
|
730
|
+
: 'Nobody could be reached - see below.', !reached);
|
|
731
|
+
} catch {
|
|
732
|
+
note('Could not reach the site. Check your connection and try again.', true);
|
|
733
|
+
} finally { inviteBusy.value = false; }
|
|
734
|
+
},
|
|
735
|
+
async copyLink() {
|
|
736
|
+
if (!me) return;
|
|
737
|
+
inviteBusy.value = true; note('');
|
|
738
|
+
try {
|
|
739
|
+
const r = await post('invite', {people: [], via: {}});
|
|
740
|
+
const body = await r.json().catch(() => ({}));
|
|
741
|
+
if (!r.ok || !body.link) { note(body.error || 'No link could be made.', true); return; }
|
|
742
|
+
copiedLink.value = body.link;
|
|
743
|
+
let copied = false;
|
|
744
|
+
try { await navigator.clipboard.writeText(body.link); copied = true; } catch { copied = false; }
|
|
745
|
+
note(copied ? 'Link copied. Whoever opens it knocks, and you let them in.' : 'Copy the link below. Whoever opens it knocks, and you let them in.');
|
|
746
|
+
} catch {
|
|
747
|
+
note('Could not reach the site. Check your connection and try again.', true);
|
|
748
|
+
} finally { inviteBusy.value = false; }
|
|
749
|
+
},
|
|
527
750
|
|
|
528
751
|
toggleMic() { micOn.value = !micOn.value; setTracks(); },
|
|
529
752
|
toggleCam() {
|
|
@@ -533,6 +756,7 @@ function mount(el) {
|
|
|
533
756
|
toggleSide() {
|
|
534
757
|
sideOpen.value = !sideOpen.value;
|
|
535
758
|
if (!sideOpen.value) return;
|
|
759
|
+
inviteOpen.value = false;
|
|
536
760
|
unread.value = 0;
|
|
537
761
|
toBottom(false);
|
|
538
762
|
// Not on a phone: the keyboard would cover the messages the moment the sheet opens.
|
|
@@ -556,6 +780,12 @@ function mount(el) {
|
|
|
556
780
|
max.value = body.max || 6;
|
|
557
781
|
recordingAllowed.value = body.recordingAllowed === true;
|
|
558
782
|
if (body.title) title.value = body.title;
|
|
783
|
+
canInvite.value = body.canInvite === true;
|
|
784
|
+
const offer = body.inviteOptions || {};
|
|
785
|
+
inviteSearch.value = offer.search !== false;
|
|
786
|
+
inviteTyped.value = typeof offer.typed === 'boolean' ? offer.typed : offer.external !== false;
|
|
787
|
+
inviteCopy.value = typeof offer.copyLink === 'boolean' ? offer.copyLink : offer.external !== false;
|
|
788
|
+
if (!inviteTyped.value) viaEmail.value = false;
|
|
559
789
|
stage.value = body.state === 'in' ? 'in' : 'waiting';
|
|
560
790
|
since = 0;
|
|
561
791
|
pollLoop();
|
|
@@ -636,16 +866,50 @@ function mount(el) {
|
|
|
636
866
|
};
|
|
637
867
|
|
|
638
868
|
el.innerHTML = TEMPLATE;
|
|
869
|
+
let searchTimer = null;
|
|
870
|
+
let searchSeq = 0;
|
|
871
|
+
function note(text, bad = false) { inviteNote.value = text; inviteBad.value = Boolean(bad && text); }
|
|
872
|
+
/** The people the Tool offers for this search (the team for an empty one). Late answers are dropped. */
|
|
873
|
+
async function runSearch() {
|
|
874
|
+
if (!me || !canInvite.value || !inviteSearch.value) return;
|
|
875
|
+
const mine = ++searchSeq;
|
|
876
|
+
const q = String(query.value || '').trim();
|
|
877
|
+
try {
|
|
878
|
+
const r = await fetch(`${url('people')}?q=${encodeURIComponent(q)}`, {headers: headers(), cache: 'no-store'});
|
|
879
|
+
const body = await r.json().catch(() => ({}));
|
|
880
|
+
if (mine !== searchSeq) return;
|
|
881
|
+
if (!r.ok) { note(body.error || 'Could not search just now.', true); return; }
|
|
882
|
+
const chosen = new Set(picked.value.map(p => p.key));
|
|
883
|
+
const rows = (Array.isArray(body.people) ? body.people : []).map(p => ({key: `${p.kind}:${p.id}`, kind: p.kind, id: p.id, name: p.name,
|
|
884
|
+
email: p.email || '', badge: p.kind === 'user' ? 'Team' : 'Contact', initial: initialOf(p.name), canMessage: p.canMessage === true,
|
|
885
|
+
picked: M.observable(chosen.has(`${p.kind}:${p.id}`))}));
|
|
886
|
+
if (rows.some(x => x.kind === 'user' && x.canMessage)) canMessage.value = true;
|
|
887
|
+
found.value = rows;
|
|
888
|
+
searched.value = true;
|
|
889
|
+
} catch {
|
|
890
|
+
if (mine === searchSeq) note('Could not reach the site. Check your connection and try again.', true);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const resultText = (x) => {
|
|
894
|
+
const did = [x.notified && 'notified', x.messaged && 'messaged', x.emailed && 'emailed'].filter(Boolean);
|
|
895
|
+
const said = did.length ? `${did.slice(0, -1).join(', ')}${did.length > 1 ? ' and ' : ''}${did.at(-1)}` : '';
|
|
896
|
+
const text = said ? said[0].toUpperCase() + said.slice(1) : '';
|
|
897
|
+
return [text, x.error || ''].filter(Boolean).join(' - ') || 'Invited';
|
|
898
|
+
};
|
|
899
|
+
|
|
639
900
|
const handleBindings = M.applyBindings(model, el.firstElementChild);
|
|
640
901
|
// Icons after binding (I.scan swaps each span for an SVG); new waiting rows get theirs as they arrive.
|
|
641
902
|
const scan = () => { try { window.I?.scan?.(el); } catch { /* no icons: the labels still read */ } };
|
|
903
|
+
layout();
|
|
904
|
+
const stopLayout = M.effect(() => { peers.value; requestAnimationFrame(layoutTiles); });
|
|
905
|
+
if (typeof ResizeObserver === 'function') { const box = el.querySelector('.dmc-remote'); if (box) new ResizeObserver(() => layoutTiles()).observe(box); }
|
|
642
906
|
scan();
|
|
643
|
-
const stopScan = M.effect(() => { waiting.value; requestAnimationFrame(scan); });
|
|
907
|
+
const stopScan = M.effect(() => { waiting.value; found.value; picked.value; requestAnimationFrame(scan); });
|
|
644
908
|
// Reading scrolled back up: the "new messages" chip goes once they are at the bottom again.
|
|
645
909
|
el.querySelector('.dmc-messages')?.addEventListener('scroll', () => { if (newBelow.value && nearBottom()) newBelow.value = false; }, {passive: true});
|
|
646
910
|
window.addEventListener('pagehide', () => { if (me) post('leave', {}, {keepalive: true}); });
|
|
647
911
|
getDevices().then(setTracks);
|
|
648
|
-
return () => { stopAttach?.(); stopScan?.(); stopTiles?.(); clearInterval(clockTimer); handleBindings?.dispose?.(); };
|
|
912
|
+
return () => { stopLayout?.(); stopAttach?.(); stopScan?.(); stopTiles?.(); clearInterval(clockTimer); handleBindings?.dispose?.(); };
|
|
649
913
|
}
|
|
650
914
|
|
|
651
915
|
document.querySelectorAll('[data-dm-call]').forEach(mount);
|
|
@@ -20,6 +20,22 @@ service - no import of the Calendar, which is a separate paid Tool).
|
|
|
20
20
|
Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
|
|
21
21
|
pattern - it does not supersede it. Payment is Pro only.
|
|
22
22
|
|
|
23
|
+
1.6.0 (2026-09-30): "Add people" in an online booking's call (core with `server/services/callInvites.js` and
|
|
24
|
+
`_lib/call/invite.js`; both imported dynamically - lib/call.js by top-level `await import`, plugin.js beside
|
|
25
|
+
`mountCallRoutes` - so an older core simply has no button). The HOST (the person's host link) searches the contacts
|
|
26
|
+
of the person's linked ACTIVE account (`hostUserOf`) and the team, and invites contacts, team members or any name +
|
|
27
|
+
email. With NO linked account the host link may be held by an outsider (it goes to the person's email address and
|
|
28
|
+
the .ics), so resolve answers `inviteOptions: {search: false, typed: false, copyLink: true}` - Copy link only: no
|
|
29
|
+
directory, no contacts, nothing sent (the engine and `send` both refuse people). Links use core's `inviteOrigin`
|
|
30
|
+
(`site.baseUrl` first).
|
|
31
|
+
Each invitee gets their OWN pass `?b=<booking>&g=<nonce>.<hmac>` signed with the session's `callExtraKey` - made on the
|
|
32
|
+
first invite and written to EVERY booking of the session (`changeBooking` patch, online only, never replaced; the
|
|
33
|
+
stored key is re-read after writing, so two sends at once agree), never the customer's token or the host key (a
|
|
34
|
+
customer token carries their name and identity: an invitee would replace them). `whoIsCalling` answers `{host: false,
|
|
35
|
+
name: '', identity: '<session>:inv:<nonce>', invited: true}` for a pass; the page asks their name; they knock. Core
|
|
36
|
+
sends (Join email, a notification + a DM from the person's user for team members). `adminBooking` strips
|
|
37
|
+
`callExtraKey`. Tests: tests/api.test.js "1.6 Add people" (a linked host; a host with no account).
|
|
38
|
+
|
|
23
39
|
1.5.0 (2026-09-30): the call's chat is saved per SESSION (`sessionOf` - a class's shared call has one chat) in
|
|
24
40
|
`data/call-chat.json` (`options.dataDir` in tests) through core's `_lib/call/chatStore.js` (dynamic import beside
|
|
25
41
|
`mountCallRoutes`; an older core has none and nothing changes), passed as the engine's `chat` - so the call reopened
|
|
@@ -110,7 +126,8 @@ all off - bookings hold customers' details). Settings: `config/plugins.json` `bo
|
|
|
110
126
|
A booking: `serviceId`, `serviceName`, `duration`, `bookableId`, `bookableName`, `startAt`, `endAt`, `blockStart`,
|
|
111
127
|
`blockEnd`, `status`, `name`, `email`, `phone`, `notes`, `staffNote`, `contactId`, `source` (public | admin),
|
|
112
128
|
`createdBy`, `token` (the customer's link; never sent to the admin), `reminderSentAt`, `history` [{at, what, by,
|
|
113
|
-
from?, reason?}], `cancelledAt`, `ext` (Pro's); online ones also `online`, `hostKey`, `recordingAllowed`, `callOpenedAt
|
|
129
|
+
from?, reason?}], `cancelledAt`, `ext` (Pro's); online ones also `online`, `hostKey`, `recordingAllowed`, `callOpenedAt`, `callExtraKey` (1.6, the session's key for
|
|
130
|
+
Add people passes; never sent to the admin). Services and bookables carry `ext` too (Pro's fields, kept by the
|
|
114
131
|
free Tool untouched).
|
|
115
132
|
|
|
116
133
|
## Extensions (Booking Pro)
|
|
@@ -58,6 +58,7 @@ Booking has its own permission, **Booking**, which admins have by default. **See
|
|
|
58
58
|
|
|
59
59
|
Booking Pro adds the following to the free Tool:
|
|
60
60
|
- Online appointments, held as a video call on your site. What is typed in the call's chat is kept: open the booking and click **Call chat** to read it afterwards (a class shares one chat).
|
|
61
|
+
- **Add people** during an online call: the person being booked clicks **Add people** in the call, searches their contacts and the team (or types a name and email), and sends each person their own Join link - by email, and for team members also as a notification and a message in Messages. They knock and you let them in. **Copy link** gives you a link to pass on yourself. This needs the person to be linked to their account on the site (**People & rooms**); without one, their call offers **Copy link** only.
|
|
61
62
|
- **Call now**: open an online booking's call early, or start a video call with anyone straight away - from Booking, a contact or an email.
|
|
62
63
|
- Services that need more than one person or room at once.
|
|
63
64
|
- Classes and group sessions with places and a waiting list.
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* same service, start and person - so everyone booked into an online class
|
|
7
7
|
* shares one call, and a one-to-one appointment is a session of one. Links
|
|
8
8
|
* name the session and the booking: `?b=<booking>&t=<customer token>` for the
|
|
9
|
-
* customer, `&h=<host key>` for the person they booked
|
|
9
|
+
* customer, `&h=<host key>` for the person they booked, `&g=<invite pass>` for
|
|
10
|
+
* someone the host added while the call runs ("Add people", 1.6: a pass under
|
|
11
|
+
* the session's `callExtraKey` - never the customer's token or the host key -
|
|
12
|
+
* who names themselves and knocks). Booking (free) serves
|
|
10
13
|
* these for any booking that is already online, so a lapsed Pro licence
|
|
11
14
|
* strands nobody; only Pro can make a service online in the first place.
|
|
12
15
|
*
|
|
@@ -15,6 +18,12 @@
|
|
|
15
18
|
|
|
16
19
|
import {createHash, timingSafeEqual} from 'crypto';
|
|
17
20
|
|
|
21
|
+
// Add people (1.6) needs a core with _lib/call/invite.js; an older one has no invite passes.
|
|
22
|
+
let invite = null;
|
|
23
|
+
try { invite = await import('../../_lib/call/invite.js'); } catch { invite = null; }
|
|
24
|
+
/** Does this core have "Add people"? */
|
|
25
|
+
export const invitesAvailable = () => Boolean(invite);
|
|
26
|
+
|
|
18
27
|
export const OPENS_BEFORE_MIN = 10;
|
|
19
28
|
export const CLOSES_AFTER_MIN = 60;
|
|
20
29
|
const HOLDING = new Set(['pending', 'confirmed', 'completed', 'no-show']);
|
|
@@ -33,6 +42,10 @@ export const joinPath = (settings, b) => `${settings.bookingPath}/call/${session
|
|
|
33
42
|
/** The host's (the person booked) - never shown to the customer. */
|
|
34
43
|
export const hostPath = (settings, b) => `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&h=${encodeURIComponent(b.hostKey)}`;
|
|
35
44
|
|
|
45
|
+
/** A Join link for someone added to the call (1.6): a new pass under the session's extra key. */
|
|
46
|
+
export const invitePath = (settings, b, key) => (invite && key
|
|
47
|
+
? `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&g=${encodeURIComponent(invite.invitePass(key, sessionOf(b)))}` : '');
|
|
48
|
+
|
|
36
49
|
/** How long a call opened early ("Call now", Booking 1.4) stays open at least. */
|
|
37
50
|
export const OPENED_EARLY_HOURS = 2;
|
|
38
51
|
|
|
@@ -60,7 +73,7 @@ export function callWindow(b, now = Date.now()) {
|
|
|
60
73
|
*
|
|
61
74
|
* @param {object|null} b - the booking named by `cred.b`
|
|
62
75
|
* @param {string} room - the room in the address
|
|
63
|
-
* @param {{t?: string, h?: string}} cred
|
|
76
|
+
* @param {{t?: string, h?: string, g?: string}} cred
|
|
64
77
|
*/
|
|
65
78
|
export function whoIsCalling(b, room, cred = {}, now = Date.now()) {
|
|
66
79
|
if (!b || b.online !== true || !HOLDING.has(b.status) || sessionOf(b) !== room) return null;
|
|
@@ -68,5 +81,8 @@ export function whoIsCalling(b, room, cred = {}, now = Date.now()) {
|
|
|
68
81
|
// identity: a link the host turns away cannot knock again (the engine remembers it per room).
|
|
69
82
|
if (cred.h && same(cred.h, b.hostKey)) return {...base, host: true, name: b.bookableName || 'Host', identity: `${b.id}:host`};
|
|
70
83
|
if (cred.t && same(cred.t, b.token)) return {...base, host: false, name: b.name || 'Guest', identity: `${b.id}:guest`};
|
|
84
|
+
// Someone the host added (1.6): their own pass, their own name, and they knock.
|
|
85
|
+
const nonce = cred.g && b.callExtraKey && invite ? invite.checkInvitePass(b.callExtraKey, room, cred.g) : null;
|
|
86
|
+
if (nonce) return {...base, host: false, name: '', identity: `${room}:inv:${nonce}`, invited: true};
|
|
71
87
|
return null;
|
|
72
88
|
}
|
|
@@ -313,6 +313,8 @@ export function changeBooking(id, patch, by = '') {
|
|
|
313
313
|
data.callOpenedAt = String(patch.callOpenedAt || '');
|
|
314
314
|
if (data.callOpenedAt) data.history = [...(data.history || []), {at: data.callOpenedAt, what: 'call-opened', by}].slice(-50);
|
|
315
315
|
}
|
|
316
|
+
// "Add people" (1.6): the session's key for invite passes - set once, never replaced.
|
|
317
|
+
if (patch.callExtraKey !== undefined && data.online === true && !data.callExtraKey) data.callExtraKey = String(patch.callExtraKey || '').slice(0, 64);
|
|
316
318
|
if (patch.ext && typeof patch.ext === 'object') data.ext = {...(data.ext || {}), ...patch.ext};
|
|
317
319
|
return {booking: flat(await updateEntry(BOOKINGS, id, data)), from};
|
|
318
320
|
});
|