domma-cms 0.103.0 → 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/call/rooms.js +17 -0
- package/plugins/_lib/call/routes.js +2 -1
- package/plugins/_lib/public/call/call.css +1 -1
- package/plugins/_lib/public/call/call.js +26 -17
- package/plugins/booking/CLAUDE.md +18 -4
- package/plugins/booking/admin/lib/kit.js +1 -1
- package/plugins/booking/admin/views/booking.js +34 -4
- package/plugins/booking/admin/views/panels.js +2 -2
- package/plugins/booking/docs/guide.md +1 -0
- package/plugins/booking/lib/call.js +22 -8
- package/plugins/booking/lib/extensions.js +20 -1
- package/plugins/booking/lib/store.js +7 -1
- package/plugins/booking/plugin.js +52 -6
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/api.test.js +89 -0
- package/plugins/booking/tests/public.test.js +1 -0
- package/plugins/free-tier.lock.json +14 -14
package/package.json
CHANGED
|
@@ -218,6 +218,23 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
|
|
|
218
218
|
return room ? {in: admitted(room).length, waiting: waitingList(room).length} : {in: 0, waiting: 0};
|
|
219
219
|
},
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Take one person out of a call - everyone joined with `identity` (someone who left the conversation
|
|
223
|
+
* the call belongs to). They are told the call ended for them; the others see them leave.
|
|
224
|
+
*/
|
|
225
|
+
kick(key, identity) {
|
|
226
|
+
const room = rooms.get(key);
|
|
227
|
+
if (!room || !identity) return 0;
|
|
228
|
+
let n = 0;
|
|
229
|
+
for (const p of [...room.people.values()]) {
|
|
230
|
+
if (p.identity !== identity) continue;
|
|
231
|
+
push(p, {type: 'ended'});
|
|
232
|
+
remove(room, p, 'removed');
|
|
233
|
+
n += 1;
|
|
234
|
+
}
|
|
235
|
+
return n;
|
|
236
|
+
},
|
|
237
|
+
|
|
221
238
|
/** End a call for everyone (a booking cancelled, an event deleted). */
|
|
222
239
|
close(key) {
|
|
223
240
|
const room = rooms.get(key);
|
|
@@ -84,5 +84,6 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
|
|
|
84
84
|
action('recording', (k, id, s, b) => rooms.recording(k, id, s, b.on === true));
|
|
85
85
|
action('leave', (k, id, s) => rooms.leave(k, id, s));
|
|
86
86
|
|
|
87
|
-
return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room))
|
|
87
|
+
return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room)),
|
|
88
|
+
kickFromRoom: (room, identity) => rooms.kick(keyOf(room), identity)};
|
|
88
89
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.dmc{--dmc-gap: .75rem;display:flex;flex-direction:column;gap:1rem;min-height:70vh;color:var(--dm-text, inherit)}.dmc [hidden]{display:none!important}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc-title{font-size:1.35rem;margin:0;flex:1 1 auto}.dmc-rec{color:#fff;background:var(--dm-danger, #c62828);border-radius:999px;padding:.2rem .7rem;font-size:.85rem;font-weight:600}.dmc-count{color:var(--dm-text-muted, #666);font-size:.9rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:1.5rem;align-items:start}.dmc-panel--msg{display:block;text-align:center;padding:3rem 1rem}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:grid;place-items:center;color:#ddd;margin:0}.dmc-check h2{margin-top:0}.dmc-label{display:grid;gap:.35rem;margin:1rem 0;font-weight:600}.dmc-input{font:inherit;padding:.55rem .7rem;border:1px solid var(--dm-border, #ccc);border-radius:.5rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.5rem;border:1px solid var(--dm-border, #ccc);background:var(--dm-surface, #fff);color:inherit}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:disabled{opacity:.6;cursor:default}.dmc-btn.is-off{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff}.dmc-btn--leave{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--rec{border-color:var(--dm-danger, #c62828)}.dmc-btn--small{padding:.3rem .65rem;font-size:.9rem}.dmc-call{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:1fr auto;gap:var(--dmc-gap)}.dmc-main{display:grid;gap:var(--dmc-gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));align-content:start}.dmc-main[data-people="1"]{grid-template-columns:minmax(0,1fr)}.dmc-main[data-people="2"]{grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))}.dmc-grid,.dmc-remote{display:contents}.dmc-tile{position:relative;margin:0;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-tile figcaption{position:absolute;left:.5rem;bottom:.5rem;padding:.15rem .5rem;border-radius:.35rem;background:#0009;color:#fff;font-size:.85rem}.dmc-side{width:300px;display:flex;flex-direction:column;gap:1rem;grid-row:1 / span 1;grid-column:2}.dmc-side h3{margin:0 0 .5rem;font-size:1rem}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.dmc-waiting li{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dmc-waiting li span{flex:1 1 100%;font-weight:600}.dmc-chat{display:flex;flex-direction:column;min-height:0;flex:1}.dmc-messages{list-style:none;margin:0 0 .5rem;padding:0;overflow-y:auto;max-height:50vh;display:grid;gap:.4rem}.dmc-messages li.is-mine strong{color:var(--dm-primary, #1f6feb)}.dmc-send{display:flex;gap:.4rem}.dmc-send .dmc-input{flex:1}.dmc-bar{grid-column:1 / -1;display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}@media(max-width:760px){.dmc-panel,.dmc-call{grid-template-columns:1fr}.dmc-side{width:auto;grid-column:1;grid-row:auto}.dmc-bar{position:sticky;bottom:0;background:var(--dm-background, #fff);padding:.5rem 0}.dmc-bar .dmc-btn{flex:1 1
|
|
1
|
+
.dmc{--dmc-gap: .75rem;display:flex;flex-direction:column;gap:1rem;min-height:70vh;color:var(--dm-text, inherit)}.dmc [hidden]{display:none!important}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc-title{font-size:1.35rem;margin:0;flex:1 1 auto}.dmc-rec{color:#fff;background:var(--dm-danger, #c62828);border-radius:999px;padding:.2rem .7rem;font-size:.85rem;font-weight:600}.dmc-count{color:var(--dm-text-muted, #666);font-size:.9rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:1.5rem;align-items:start}.dmc-panel--msg{display:block;text-align:center;padding:3rem 1rem}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:grid;place-items:center;color:#ddd;margin:0}.dmc-check h2{margin-top:0}.dmc-label{display:grid;gap:.35rem;margin:1rem 0;font-weight:600}.dmc-input{font:inherit;padding:.55rem .7rem;border:1px solid var(--dm-border, #ccc);border-radius:.5rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.5rem;border:1px solid var(--dm-border, #ccc);background:var(--dm-surface, #fff);color:inherit;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;position:relative}.dmc-i{display:inline-flex}.dmc-btn svg,.dmc-rec svg,.dmc-count svg{width:18px;height:18px;flex:none}.dmc-rec svg{width:10px;height:10px;fill:currentColor}.dmc-count{display:inline-flex;align-items:center;gap:.35rem}.dmc-tool{flex-direction:column;gap:.2rem;min-width:5.5rem;padding:.5rem .75rem;font-size:.85rem}.dmc-btn.is-on{border-color:var(--dm-primary, #1f6feb);color:var(--dm-primary, #1f6feb)}.dmc-btn--rec.is-on{border-color:var(--dm-danger, #c62828);color:var(--dm-danger, #c62828)}.dmc-btn--rec.is-on svg{fill:currentColor}.dmc-badge{position:absolute;top:.2rem;right:.35rem;min-width:1.1rem;height:1.1rem;padding:0 .25rem;border-radius:999px;background:var(--dm-danger, #c62828);color:#fff;font-size:.7rem;line-height:1.1rem;text-align:center}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:disabled{opacity:.6;cursor:default}.dmc-btn.is-off{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff}.dmc-btn--leave{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--rec{border-color:var(--dm-danger, #c62828)}.dmc-btn--small{padding:.3rem .65rem;font-size:.9rem}.dmc-call{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:1fr auto;gap:var(--dmc-gap)}.dmc-main{display:grid;gap:var(--dmc-gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));align-content:start}.dmc-main[data-people="1"]{grid-template-columns:minmax(0,1fr)}.dmc-main[data-people="2"]{grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))}.dmc-grid,.dmc-remote{display:contents}.dmc-tile{position:relative;margin:0;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-tile figcaption{position:absolute;left:.5rem;bottom:.5rem;padding:.15rem .5rem;border-radius:.35rem;background:#0009;color:#fff;font-size:.85rem}.dmc-side{width:300px;display:flex;flex-direction:column;gap:1rem;grid-row:1 / span 1;grid-column:2}.dmc-side h3{margin:0 0 .5rem;font-size:1rem}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.dmc-waiting li{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dmc-waiting li span{flex:1 1 100%;font-weight:600}.dmc-chat{display:flex;flex-direction:column;min-height:0;flex:1}.dmc-messages{list-style:none;margin:0 0 .5rem;padding:0;overflow-y:auto;max-height:50vh;display:grid;gap:.4rem}.dmc-messages li.is-mine strong{color:var(--dm-primary, #1f6feb)}.dmc-send{display:flex;gap:.4rem}.dmc-send .dmc-input{flex:1}.dmc-bar{grid-column:1 / -1;display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}@media(max-width:760px){.dmc-panel,.dmc-call{grid-template-columns:1fr}.dmc-side{width:auto;grid-column:1;grid-row:auto}.dmc-bar{position:sticky;bottom:0;background:var(--dm-background, #fff);padding:.5rem 0}.dmc-bar .dmc-btn{flex:1 1 auto;min-width:3rem}.dmc-lbl{display:none}.dmc-tool{padding:.6rem}}
|
|
@@ -19,12 +19,17 @@
|
|
|
19
19
|
|
|
20
20
|
const M = window.M;
|
|
21
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
|
+
|
|
22
27
|
const TEMPLATE = `
|
|
23
28
|
<div class="dmc">
|
|
24
29
|
<header class="dmc-head">
|
|
25
30
|
<h1 class="dmc-title" data-bind-text="title.value"></h1>
|
|
26
|
-
<span class="dmc-rec" data-bind-hidden="!recording.value" role="status"
|
|
27
|
-
<span class="dmc-count" data-bind-text="countText.value"></span>
|
|
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>
|
|
28
33
|
</header>
|
|
29
34
|
|
|
30
35
|
<section class="dmc-panel" data-bind-hidden="stage.value !== 'check'">
|
|
@@ -36,18 +41,18 @@ const TEMPLATE = `
|
|
|
36
41
|
<label class="dmc-label" data-bind-hidden="!askName">Your name
|
|
37
42
|
<input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name"></label>
|
|
38
43
|
<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>
|
|
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>
|
|
41
46
|
</div>
|
|
42
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>
|
|
43
48
|
<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>
|
|
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>
|
|
45
50
|
</div>
|
|
46
51
|
</section>
|
|
47
52
|
|
|
48
53
|
<section class="dmc-panel dmc-panel--msg" data-bind-hidden="stage.value !== 'waiting'">
|
|
49
54
|
<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>
|
|
55
|
+
<button type="button" class="dmc-btn" data-on-click="leave">${ico('log-out')}<span>Leave</span></button>
|
|
51
56
|
</section>
|
|
52
57
|
|
|
53
58
|
<section class="dmc-panel dmc-panel--msg" data-bind-hidden="!(stage.value === 'ended' || stage.value === 'away' || stage.value === 'error')">
|
|
@@ -71,8 +76,8 @@ const TEMPLATE = `
|
|
|
71
76
|
<h3>Waiting to join</h3>
|
|
72
77
|
<ul data-each="waiting key=id">
|
|
73
78
|
<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>
|
|
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>
|
|
76
81
|
</ul>
|
|
77
82
|
</div>
|
|
78
83
|
<div class="dmc-chat" data-bind-hidden="!sideOpen.value">
|
|
@@ -82,18 +87,18 @@ const TEMPLATE = `
|
|
|
82
87
|
</ol>
|
|
83
88
|
<form class="dmc-send" data-on-submit="sendChat">
|
|
84
89
|
<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>
|
|
90
|
+
<button type="submit" class="dmc-btn dmc-btn--small" aria-label="Send">${ico('message-circle')}<span>Send</span></button>
|
|
86
91
|
</form>
|
|
87
92
|
</div>
|
|
88
93
|
</aside>
|
|
89
94
|
|
|
90
95
|
<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>
|
|
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>
|
|
97
102
|
</nav>
|
|
98
103
|
</section>
|
|
99
104
|
</div>`;
|
|
@@ -130,7 +135,7 @@ function mount(el) {
|
|
|
130
135
|
const meCaption = M.computed(() => `${name.value || 'You'} (you)${micOn.value ? '' : ' · muted'}`);
|
|
131
136
|
const countText = M.computed(() => (stage.value === 'in' ? `${peers.value.length + 1} of ${max.value}` : ''));
|
|
132
137
|
const people = M.computed(() => String(Math.min(6, peers.value.length + 1)));
|
|
133
|
-
const chatLabel = M.computed(() => (sideOpen.value ? 'Hide chat' :
|
|
138
|
+
const chatLabel = M.computed(() => (sideOpen.value ? 'Hide chat' : 'Chat'));
|
|
134
139
|
const title = M.observable(cfg.title || 'Call');
|
|
135
140
|
|
|
136
141
|
// ---- The connection to the site ---------------------------------------------------
|
|
@@ -460,9 +465,13 @@ function mount(el) {
|
|
|
460
465
|
|
|
461
466
|
el.innerHTML = TEMPLATE;
|
|
462
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); });
|
|
463
472
|
window.addEventListener('pagehide', () => { if (me) post('leave', {}, {keepalive: true}); });
|
|
464
473
|
getDevices().then(setTracks);
|
|
465
|
-
return () => { stopAttach?.(); handleBindings?.dispose?.(); };
|
|
474
|
+
return () => { stopAttach?.(); stopScan?.(); handleBindings?.dispose?.(); };
|
|
466
475
|
}
|
|
467
476
|
|
|
468
477
|
document.querySelectorAll('[data-dm-call]').forEach(mount);
|
|
@@ -20,6 +20,19 @@ 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.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
|
+
|
|
23
36
|
1.3.0 (2026-09-30): online bookings (plan: `dcms-marketplace/docs/online-meetings-plan.md`). An extension's
|
|
24
37
|
`rules()` may say `online` (+ `recordingAllowed`); `placeBooking` then stores `online: true`, `hostKey`,
|
|
25
38
|
`recordingAllowed` ON THE BOOKING, so Booking (free) serves the call for it even after Pro lapses. `lib/call.js`:
|
|
@@ -73,7 +86,8 @@ completed, no-show (`HOLDING`); cancelled frees it.
|
|
|
73
86
|
writes observables, and moves focus / scroll. Without `window.M` the server's page is left as it is.
|
|
74
87
|
- `docs/guide.md` - the user guide in the admin Documentation folder.
|
|
75
88
|
- `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`
|
|
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).
|
|
77
91
|
|
|
78
92
|
## Data
|
|
79
93
|
|
|
@@ -83,13 +97,13 @@ all off - bookings hold customers' details). Settings: `config/plugins.json` `bo
|
|
|
83
97
|
A booking: `serviceId`, `serviceName`, `duration`, `bookableId`, `bookableName`, `startAt`, `endAt`, `blockStart`,
|
|
84
98
|
`blockEnd`, `status`, `name`, `email`, `phone`, `notes`, `staffNote`, `contactId`, `source` (public | admin),
|
|
85
99
|
`createdBy`, `token` (the customer's link; never sent to the admin), `reminderSentAt`, `history` [{at, what, by,
|
|
86
|
-
from?, reason?}], `cancelledAt`, `ext` (Pro's)
|
|
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
|
|
87
101
|
free Tool untouched).
|
|
88
102
|
|
|
89
103
|
## Extensions (Booking Pro)
|
|
90
104
|
|
|
91
|
-
`bookingRegistry()` (`lib/extensions.js`, `version:
|
|
92
|
-
on, tabs})` - every hook optional and guarded (one that throws counts as absent). `rules(service)` ->
|
|
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)` ->
|
|
93
107
|
{capacity, companions: [[ids]], maxPerDay, waitlist}, resolved to bookable objects by `registry.rules(service,
|
|
94
108
|
bookables)` and applied by `slotsFor` / `whoCanTake` / `placeBooking` (same session = same service + start +
|
|
95
109
|
primary bookable: shares places, never clashes with itself; a day's limit counts sessions). `form` / `answers`: extra
|
|
@@ -11,11 +11,15 @@
|
|
|
11
11
|
* #/plugins/booking/new?contact=<id> the Diary with New booking open, filled from a contact
|
|
12
12
|
* #/plugins/booking/new?email=&name= ... or from an email's sender (Email's "Book Sam…")
|
|
13
13
|
* #/plugins/booking/<ext-key> an extension's screen
|
|
14
|
+
* #/plugins/booking/<action-id>?contact=<id> | ?email=&name=
|
|
15
|
+
* the Diary, then an extension's banner action run with that prefill
|
|
16
|
+
* (Booking Pro's "Call now" from a contact's or an email's menu)
|
|
14
17
|
*
|
|
15
18
|
* Settings are behind the banner's cog, in a slideover.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
import {V, api, errText, loadKit, store} from '../lib/kit.js';
|
|
22
|
+
import {sectionApi} from '../lib/extension-sections.js';
|
|
19
23
|
|
|
20
24
|
const ROUTE = /^#\/plugins\/booking(\/|\?|$)/;
|
|
21
25
|
const TABS = [
|
|
@@ -65,8 +69,10 @@ export const bookingView = {
|
|
|
65
69
|
if (!here()) return;
|
|
66
70
|
|
|
67
71
|
const tabs = [...TABS, ...(boot.tabs || [])];
|
|
72
|
+
const actions = (boot.actions || []).filter(a => a && typeof a.module === 'string' && a.module.startsWith(`/plugins/${a.owner}/`));
|
|
68
73
|
const wantNew = tab === 'new';
|
|
69
|
-
const
|
|
74
|
+
const wantAction = actions.find(a => a.id === tab) || null;
|
|
75
|
+
const current = tabs.find(t => t.key === (wantNew || wantAction ? 'diary' : tab)) || TABS[0];
|
|
70
76
|
|
|
71
77
|
// "Booking › Diary" in the banner.
|
|
72
78
|
const name = container.querySelector('.dm-plugin-banner-name');
|
|
@@ -138,6 +144,28 @@ export const bookingView = {
|
|
|
138
144
|
kit.addBannerAction($container, {id: 'booking-new', icon: 'plus', title: 'New booking',
|
|
139
145
|
onClick: () => ctx.newBooking({})});
|
|
140
146
|
|
|
147
|
+
/**
|
|
148
|
+
* An extension's banner action (Booking 1.4): its module's `run(ctx, prefill)`, imported on the click.
|
|
149
|
+
* ctx is the screen's (boot, openBooking, refresh, go...) plus what sections get (api, owner, helpPopovers).
|
|
150
|
+
* Anything that must open a new tab does so on a click of its own, never after this import.
|
|
151
|
+
*/
|
|
152
|
+
const runAction = async (a, prefill = {}) => {
|
|
153
|
+
try {
|
|
154
|
+
const mod = await import(a.module);
|
|
155
|
+
const run = mod.run || mod.default?.run;
|
|
156
|
+
if (typeof run !== 'function') throw new Error('no run()');
|
|
157
|
+
return await run({...ctx, owner: a.owner, api: sectionApi, bookables: boot.bookables, services: boot.services,
|
|
158
|
+
helpPopovers: (el) => kit.helpPopovers(el)}, prefill);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.warn(`[booking] ${a.owner} action ${a.id}:`, err);
|
|
161
|
+
E.toast(`${a.title} could not start: ${errText(err)}`, {type: 'error'});
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
for (const a of actions) {
|
|
166
|
+
kit.addBannerAction($container, {id: `booking-x-${a.id}`, icon: a.icon, title: a.title, onClick: () => runAction(a)});
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
try {
|
|
142
170
|
const mod = await import(current.module);
|
|
143
171
|
if (!here()) return;
|
|
@@ -154,9 +182,9 @@ export const bookingView = {
|
|
|
154
182
|
}
|
|
155
183
|
store.set('booking.last-tab', current.key);
|
|
156
184
|
|
|
157
|
-
// From Contacts or Email: the New booking slideover, filled in. The
|
|
185
|
+
// From Contacts or Email: the New booking slideover (or an extension's action), filled in. The
|
|
158
186
|
// address goes back to the Diary so a reload does not open it again.
|
|
159
|
-
if (wantNew && here()) {
|
|
187
|
+
if ((wantNew || wantAction) && here()) {
|
|
160
188
|
ctx.replaceHash('#/plugins/booking/diary');
|
|
161
189
|
const prefill = {email: query.email || '', name: query.name || ''};
|
|
162
190
|
if (query.contact) {
|
|
@@ -171,7 +199,9 @@ export const bookingView = {
|
|
|
171
199
|
const c = await api.contactByEmail(query.email).catch(() => null);
|
|
172
200
|
if (c?.id) Object.assign(prefill, {contactId: c.id, name: c.name || prefill.name, phone: c.phone});
|
|
173
201
|
}
|
|
174
|
-
if (ROUTE.test(location.hash))
|
|
202
|
+
if (!ROUTE.test(location.hash)) return;
|
|
203
|
+
if (wantAction) runAction(wantAction, prefill);
|
|
204
|
+
else ctx.newBooking(prefill);
|
|
175
205
|
}
|
|
176
206
|
}
|
|
177
207
|
};
|
|
@@ -436,7 +436,7 @@ export async function openBooking(ctx, id) {
|
|
|
436
436
|
const saved = M.observable(b.staffNote || '');
|
|
437
437
|
const tone = `is-${STATUS_TONE[b.status] || 'muted'}`;
|
|
438
438
|
const history = (b.history || []).slice().reverse().map(h => {
|
|
439
|
-
const what = {made: 'Booked', moved: 'Moved', cancelled: 'Cancelled', completed: 'Marked done', 'no-show': 'Marked no-show', confirmed: 'Back to booked', pending: 'Pending'}[h.what] || h.what;
|
|
439
|
+
const what = {made: 'Booked', moved: 'Moved', cancelled: 'Cancelled', completed: 'Marked done', 'no-show': 'Marked no-show', confirmed: 'Back to booked', pending: 'Pending', 'call-opened': 'Call opened early'}[h.what] || h.what;
|
|
440
440
|
const was = h.from ? wallClock(h.from, ctx.tz) : null;
|
|
441
441
|
const extra = h.what === 'moved' && was ? ` (was ${dateLabel(was.date)}, ${was.time})` : h.reason ? `: ${h.reason}` : '';
|
|
442
442
|
return `<li><strong>${esc(what)}</strong>${esc(extra)}<small>${esc(fmtDateTime(h.at))}${h.by ? ` · ${esc(h.by)}` : ''}</small></li>`;
|
|
@@ -477,7 +477,7 @@ export async function openBooking(ctx, id) {
|
|
|
477
477
|
<div class="bk-ext-host" data-part="ext"></div>
|
|
478
478
|
${b.hostUrl && live ? `<div class="bk-actions bk-actions-start bk-call-row">
|
|
479
479
|
<a class="btn btn-sm btn-primary" href="${esc(b.hostUrl)}" target="_blank" rel="noopener"><span data-icon="video" data-icon-size="13"></span> Start the call</a>
|
|
480
|
-
<span class="bk-muted">You are the host: let ${esc(b.name || 'them')} in when they arrive. It opens 10 minutes before the start
|
|
480
|
+
<span class="bk-muted">You are the host: let ${esc(b.name || 'them')} in when they arrive. ${Date.parse(b.callOpenedAt || '') > Date.now() - 2 * 3600000 ? 'It was opened early, so it is open now.' : 'It opens 10 minutes before the start.'}</span></div>` : ''}
|
|
481
481
|
<div class="bk-actions bk-actions-start">
|
|
482
482
|
<button type="button" class="btn btn-sm btn-outline" data-on-click="move" ${live ? '' : 'disabled'}><span data-icon="move" data-icon-size="13"></span> Move</button>
|
|
483
483
|
<button type="button" class="btn btn-sm btn-outline" data-on-click="done" ${live && b.status !== 'completed' ? '' : 'disabled'}><span data-icon="check" data-icon-size="13"></span> Done</button>
|
|
@@ -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.
|
|
61
|
+
- **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.
|
|
61
62
|
- Services that need more than one person or room at once.
|
|
62
63
|
- Classes and group sessions with places and a waiting list.
|
|
63
64
|
- Recurring bookings.
|
|
@@ -33,12 +33,26 @@ export const joinPath = (settings, b) => `${settings.bookingPath}/call/${session
|
|
|
33
33
|
/** The host's (the person booked) - never shown to the customer. */
|
|
34
34
|
export const hostPath = (settings, b) => `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&h=${encodeURIComponent(b.hostKey)}`;
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
/** How long a call opened early ("Call now", Booking 1.4) stays open at least. */
|
|
37
|
+
export const OPENED_EARLY_HOURS = 2;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* When the call opens and closes, for a booking. Staff may open it early
|
|
41
|
+
* (`callOpenedAt`, "Call now"): it opens then, and stays open at least
|
|
42
|
+
* OPENED_EARLY_HOURS after, even when the booking itself is later.
|
|
43
|
+
*/
|
|
44
|
+
export function callWindow(b, now = Date.now()) {
|
|
45
|
+
const opens = Date.parse(b.startAt) - OPENS_BEFORE_MIN * 60000;
|
|
46
|
+
const closes = Date.parse(b.endAt) + CLOSES_AFTER_MIN * 60000;
|
|
47
|
+
const early = Date.parse(b.callOpenedAt || '');
|
|
48
|
+
const iso = (a, z) => ({opensAt: new Date(a).toISOString(), closesAt: new Date(z).toISOString()});
|
|
49
|
+
// Opened early ("Call now"): open for OPENED_EARLY_HOURS from then - not all the way to the booking,
|
|
50
|
+
// which may be days off - joined up with the booking's own window when the two meet.
|
|
51
|
+
if (Number.isFinite(early) && now >= early && now <= early + OPENED_EARLY_HOURS * 3600000) {
|
|
52
|
+
const earlyEnd = early + OPENED_EARLY_HOURS * 3600000;
|
|
53
|
+
return iso(Math.min(early, opens), earlyEnd >= opens ? Math.max(earlyEnd, closes) : earlyEnd);
|
|
54
|
+
}
|
|
55
|
+
return iso(opens, closes);
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
/**
|
|
@@ -48,9 +62,9 @@ export function callWindow(b) {
|
|
|
48
62
|
* @param {string} room - the room in the address
|
|
49
63
|
* @param {{t?: string, h?: string}} cred
|
|
50
64
|
*/
|
|
51
|
-
export function whoIsCalling(b, room, cred = {}) {
|
|
65
|
+
export function whoIsCalling(b, room, cred = {}, now = Date.now()) {
|
|
52
66
|
if (!b || b.online !== true || !HOLDING.has(b.status) || sessionOf(b) !== room) return null;
|
|
53
|
-
const base = {title: b.serviceName, recordingAllowed: b.recordingAllowed === true, max: 6, ...callWindow(b)};
|
|
67
|
+
const base = {title: b.serviceName, recordingAllowed: b.recordingAllowed === true, max: 6, ...callWindow(b, now)};
|
|
54
68
|
// identity: a link the host turns away cannot knock again (the engine remembers it per room).
|
|
55
69
|
if (cred.h && same(cred.h, b.hostKey)) return {...base, host: true, name: b.bookableName || 'Host', identity: `${b.id}:host`};
|
|
56
70
|
if (cred.t && same(cred.t, b.token)) return {...base, host: false, name: b.name || 'Guest', identity: `${b.id}:guest`};
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* waitlistUrl - where the booking page posts "join the waiting list"
|
|
15
15
|
* on: {made, moved, cancelled, status} - told after the fact, never waited on
|
|
16
16
|
* tabs: [{key, label, icon, module}] - admin screens beside Booking's own
|
|
17
|
+
* actions: [{id, icon, title, module}] - banner buttons on Booking's screens (Booking 1.4); the module's
|
|
18
|
+
* `run(ctx, prefill)` is called on the click - and for `#/plugins/booking/<id>?...`
|
|
19
|
+
* (a deep link from another Tool), with the prefill from the address
|
|
17
20
|
*
|
|
18
21
|
* Every hook is optional and guarded: one that throws counts as absent, so an
|
|
19
22
|
* extension can never stop a booking being made. A lapsed Pro licence means
|
|
@@ -35,7 +38,8 @@ export function bookingRegistry() {
|
|
|
35
38
|
globalThis[KEY] = {
|
|
36
39
|
// 2: rules, form, answers, check, admin sections, waitlistUrl, services.book / cancel (Booking 1.1).
|
|
37
40
|
// 3: rules may say online (+ recordingAllowed): held as a video call (Booking 1.3).
|
|
38
|
-
|
|
41
|
+
// 4: actions (banner buttons), services.openCall / callLinks, book({contactId, request}) (Booking 1.4).
|
|
42
|
+
version: 4,
|
|
39
43
|
services: {},
|
|
40
44
|
register(owner, ext = {}) {
|
|
41
45
|
if (typeof owner !== 'string' || !owner) return false;
|
|
@@ -136,6 +140,21 @@ export function bookingRegistry() {
|
|
|
136
140
|
return out;
|
|
137
141
|
},
|
|
138
142
|
|
|
143
|
+
/** Banner buttons extensions add to Booking's screens. Ids are unique; the first to claim one keeps it. */
|
|
144
|
+
actions() {
|
|
145
|
+
const out = [];
|
|
146
|
+
// A deep link is #/plugins/booking/<id>: never a screen's own address.
|
|
147
|
+
const taken = new Set(['diary', 'bookings', 'services', 'people', 'new', 'settings', ...this.tabs().map(t => t.key)]);
|
|
148
|
+
for (const [owner, ext] of owners) {
|
|
149
|
+
for (const a of Array.isArray(ext.actions) ? ext.actions : []) {
|
|
150
|
+
if (typeof a?.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(a.id) || taken.has(a.id) || !underOwner(owner, a.module)) continue;
|
|
151
|
+
taken.add(a.id);
|
|
152
|
+
out.push({id: a.id, title: String(a.title || a.id).slice(0, 80), icon: String(a.icon || 'puzzle'), module: a.module, owner});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
},
|
|
157
|
+
|
|
139
158
|
/** Tell every extension; never throws, never waits. */
|
|
140
159
|
emit(event, ...args) {
|
|
141
160
|
for (const ext of owners.values()) {
|
|
@@ -260,7 +260,8 @@ export function placeBooking({service, candidates, startAt, fields, moveId = '',
|
|
|
260
260
|
const current = await getEntry(BOOKINGS, moveId);
|
|
261
261
|
if (!current) throw new Error('That booking no longer exists.');
|
|
262
262
|
const history = [...(current.data.history || []), {at: new Date().toISOString(), what: 'moved', from: current.data.startAt, by: fields.by || ''}].slice(-50);
|
|
263
|
-
|
|
263
|
+
// A call opened early ("Call now") was for the old time; the new one opens as usual.
|
|
264
|
+
return flat(await updateEntry(BOOKINGS, moveId, {...current.data, ...times, callOpenedAt: '', history}));
|
|
264
265
|
}
|
|
265
266
|
const {by, ...rest} = fields;
|
|
266
267
|
return flat(await createEntry(BOOKINGS, {
|
|
@@ -307,6 +308,11 @@ export function changeBooking(id, patch, by = '') {
|
|
|
307
308
|
}
|
|
308
309
|
if (patch.reminderSentAt !== undefined) data.reminderSentAt = patch.reminderSentAt;
|
|
309
310
|
if (patch.contactId !== undefined) data.contactId = patch.contactId;
|
|
311
|
+
// "Call now" (1.4): an online booking's call opened before its time.
|
|
312
|
+
if (patch.callOpenedAt !== undefined && data.online === true) {
|
|
313
|
+
data.callOpenedAt = String(patch.callOpenedAt || '');
|
|
314
|
+
if (data.callOpenedAt) data.history = [...(data.history || []), {at: data.callOpenedAt, what: 'call-opened', by}].slice(-50);
|
|
315
|
+
}
|
|
310
316
|
if (patch.ext && typeof patch.ext === 'object') data.ext = {...(data.ext || {}), ...patch.ext};
|
|
311
317
|
return {booking: flat(await updateEntry(BOOKINGS, id, data)), from};
|
|
312
318
|
});
|
|
@@ -119,7 +119,7 @@ function registerShortcodes(hooks) {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
export const ASSET_V = '1.
|
|
122
|
+
export const ASSET_V = '1.4.0';
|
|
123
123
|
|
|
124
124
|
export default async function booking(fastify, options) {
|
|
125
125
|
const {authenticate, requirePermission} = options.auth;
|
|
@@ -271,7 +271,13 @@ export default async function booking(fastify, options) {
|
|
|
271
271
|
|
|
272
272
|
registry.services = {
|
|
273
273
|
name: NAME, liveSettings, allBookings, allServices, allBookables, getBooking, getService, getBookable,
|
|
274
|
-
changeBooking, sendReminders,
|
|
274
|
+
changeBooking, sendReminders,
|
|
275
|
+
/** The site's address: `site.baseUrl`, else the request's origin (a signed-in one), else the last admin's. */
|
|
276
|
+
siteBase: (request = null) => siteBase(request),
|
|
277
|
+
/** A booking as the admin may see it (no customer token; an online one's host key as `hostUrl`). */
|
|
278
|
+
adminBooking,
|
|
279
|
+
/** The contact `id` when it is `userId`'s own (Contacts on), else null. */
|
|
280
|
+
ownContact: async (id, userId) => (contactsOn() && id && userId ? ownContact(String(id), String(userId)).catch(() => null) : null),
|
|
275
281
|
manageUrl: (b) => liveSettings().then(s => manageUrl(s, b, siteBase(null))),
|
|
276
282
|
/** Make a booking the whole way (contacts, emails, notices, events) - a waiting list's place, a series. Throws SlotTaken. */
|
|
277
283
|
book: (p) => bookForExtension(p),
|
|
@@ -286,6 +292,30 @@ export default async function booking(fastify, options) {
|
|
|
286
292
|
}
|
|
287
293
|
return res.booking;
|
|
288
294
|
},
|
|
295
|
+
/**
|
|
296
|
+
* "Call now" (1.4): open an online booking's call now, even when it is booked for later - for everyone
|
|
297
|
+
* booked into that session (a class shares one call). The customer's own link then works at once.
|
|
298
|
+
* Throws with `code`: 'none' | 'not-online' | 'cancelled'.
|
|
299
|
+
*/
|
|
300
|
+
openCall: async (id, {by = ''} = {}) => {
|
|
301
|
+
const b = await getBooking(String(id || ''));
|
|
302
|
+
const refuse = (code, message) => Object.assign(new Error(message), {code});
|
|
303
|
+
if (!b) throw refuse('none', 'No such booking.');
|
|
304
|
+
if (b.online !== true || !b.hostKey) throw refuse('not-online', 'That booking is not an online one, so it has no call.');
|
|
305
|
+
if (!HOLDING.has(b.status) || b.status === 'cancelled') throw refuse('cancelled', 'That booking is cancelled, so it has no call.');
|
|
306
|
+
const now = new Date().toISOString();
|
|
307
|
+
const room = sessionOf(b);
|
|
308
|
+
const session = (await allBookings()).filter(x => x.online === true && HOLDING.has(x.status) && sessionOf(x) === room);
|
|
309
|
+
for (const x of session) await changeBooking(x.id, {callOpenedAt: now}, by);
|
|
310
|
+
return getBooking(b.id);
|
|
311
|
+
},
|
|
312
|
+
/** An online booking's call addresses, whole: the customer's (`joinUrl`) and the host's (`hostUrl`). */
|
|
313
|
+
callLinks: (b, request = null) => {
|
|
314
|
+
if (b?.online !== true) return {joinUrl: '', hostUrl: ''};
|
|
315
|
+
const s = settingsNow();
|
|
316
|
+
const base = siteBase(request);
|
|
317
|
+
return {joinUrl: b.token ? `${base}${joinPath(s, b)}` : '', hostUrl: b.hostKey ? `${base}${hostPath(s, b)}` : ''};
|
|
318
|
+
},
|
|
289
319
|
SlotTaken,
|
|
290
320
|
// Booking's own helpers, so an extension never imports Booking's files.
|
|
291
321
|
mail: {send: (msg) => send(msg, {log}), compose, mailReady},
|
|
@@ -293,20 +323,27 @@ export default async function booking(fastify, options) {
|
|
|
293
323
|
timeZone: () => settingsNow().timeZone
|
|
294
324
|
};
|
|
295
325
|
|
|
296
|
-
|
|
326
|
+
/**
|
|
327
|
+
* `contactId` (1.4): the caller's own contact (`userId` = the caller) to book as - checked, dropped when it is
|
|
328
|
+
* not theirs. `request`: the admin request it came from, for the links in the emails.
|
|
329
|
+
*/
|
|
330
|
+
async function bookForExtension({serviceId, bookableId = '', startAt, customer: raw = {}, source = 'extension', ext = {}, force = false, notify = true, by = '',
|
|
331
|
+
contactId: askedContact = '', userId = '', request = null}) {
|
|
297
332
|
const s = await liveSettings();
|
|
298
333
|
const {service, candidates, rules} = await candidatesFor(String(serviceId || ''), String(bookableId || ''), {includeInactive: true});
|
|
299
334
|
if (!service || !candidates.length) throw new Error('That service cannot be booked.');
|
|
300
335
|
const {data: customer, errors} = cleanCustomer(raw, {askPhone: true});
|
|
301
336
|
if (Object.keys(errors).length) throw new Error(Object.values(errors)[0]);
|
|
337
|
+
const contactId = askedContact && userId && await ownContact(String(askedContact), String(userId)).catch(() => null) ? String(askedContact) : '';
|
|
302
338
|
const b = await placeBooking({service, candidates, rules, startAt: new Date(startAt).toISOString(), tz: s.timeZone, force,
|
|
303
|
-
fields: {...customer, source, by: by || customer.name, ext: extOf(ext)}});
|
|
304
|
-
if (s.addToContacts) {
|
|
339
|
+
fields: {...customer, source, by: by || customer.name, ext: extOf(ext), ...(contactId ? {contactId} : {}), ...(userId ? {createdBy: String(userId)} : {})}});
|
|
340
|
+
if (!contactId && s.addToContacts) {
|
|
305
341
|
const bk = candidates.find(c => c.id === b.bookableId);
|
|
306
342
|
const id = await linkContact(customer, bk?.userId || s.contactsOwnerId, contactsOn()).catch(() => '');
|
|
307
343
|
if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
|
|
308
344
|
}
|
|
309
|
-
if (
|
|
345
|
+
if (request) siteBase(request);
|
|
346
|
+
if (notify) tell(b, 'made', {request, toOwner: source !== 'admin'});
|
|
310
347
|
registry.emit('made', b);
|
|
311
348
|
return b;
|
|
312
349
|
}
|
|
@@ -358,6 +395,7 @@ export default async function booking(fastify, options) {
|
|
|
358
395
|
calendar: Boolean(getPluginStates().calendar?.enabled),
|
|
359
396
|
tabs: registry.tabs(),
|
|
360
397
|
sections: registry.admin(),
|
|
398
|
+
actions: typeof registry.actions === 'function' ? registry.actions() : [],
|
|
361
399
|
now: new Date().toISOString(),
|
|
362
400
|
today: wallClock(Date.now(), s.timeZone).date
|
|
363
401
|
};
|
|
@@ -752,6 +790,14 @@ export default async function booking(fastify, options) {
|
|
|
752
790
|
const {mountCallRoutes} = await import('../_lib/call/routes.js');
|
|
753
791
|
calls = mountCallRoutes(fastify, {prefix: '/call', ns: 'booking', resolve: async (request, room, body) => {
|
|
754
792
|
const b = await getBooking(String(body?.b || '')).catch(() => null);
|
|
793
|
+
// A class opened early ("Call now") is open for everyone in the session - someone who booked in
|
|
794
|
+
// after the click too: take the session's latest opening when this booking has none of its own.
|
|
795
|
+
if (b && b.online === true && !b.callOpenedAt) {
|
|
796
|
+
const room0 = sessionOf(b);
|
|
797
|
+
const opened = (await allBookings()).filter(x => x.id !== b.id && x.online === true && x.callOpenedAt && sessionOf(x) === room0)
|
|
798
|
+
.map(x => x.callOpenedAt).sort().at(-1);
|
|
799
|
+
if (opened) b.callOpenedAt = opened;
|
|
800
|
+
}
|
|
755
801
|
return whoIsCalling(b, room, {t: body?.t, h: body?.h});
|
|
756
802
|
}});
|
|
757
803
|
} catch (err) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "booking",
|
|
3
3
|
"displayName": "Booking",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.4.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
|
|
7
7
|
"author": "Domma CMS",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
],
|
|
73
73
|
"views": {
|
|
74
74
|
"plugin-booking": {
|
|
75
|
-
"entry": "booking/admin/views/booking.js?v=1.
|
|
75
|
+
"entry": "booking/admin/views/booking.js?v=1.4.0",
|
|
76
76
|
"exportName": "bookingView"
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -354,3 +354,92 @@ test('online: a service an extension makes online gives a call - the customer an
|
|
|
354
354
|
reg.unregister('test-online');
|
|
355
355
|
}
|
|
356
356
|
});
|
|
357
|
+
|
|
358
|
+
test('extensions: banner actions reach the bootstrap, only under their owner, one per id', async () => {
|
|
359
|
+
const reg = bookingRegistry();
|
|
360
|
+
assert.ok(reg.version >= 4);
|
|
361
|
+
reg.register('test-actions', {
|
|
362
|
+
tabs: [{key: 'extra', label: 'Extra', module: '/plugins/test-actions/admin/extra.js'}],
|
|
363
|
+
actions: [
|
|
364
|
+
{id: 'ring', icon: 'video', title: 'Ring now', module: '/plugins/test-actions/admin/ring.js?v=1'},
|
|
365
|
+
{id: 'steal', title: 'Not mine', module: '/plugins/someone-else/admin/x.js'},
|
|
366
|
+
{id: 'Bad Id', title: 'Bad', module: '/plugins/test-actions/admin/x.js'},
|
|
367
|
+
{id: 'bookings', title: 'A screen of Booking\'s', module: '/plugins/test-actions/admin/x.js'},
|
|
368
|
+
{id: 'extra', title: 'A tab\'s address', module: '/plugins/test-actions/admin/x.js'},
|
|
369
|
+
{id: 'ring', title: 'Twice', module: '/plugins/test-actions/admin/ring2.js'},
|
|
370
|
+
null
|
|
371
|
+
]
|
|
372
|
+
});
|
|
373
|
+
reg.register('test-actions-2', {actions: [{id: 'ring', title: 'Taken already', module: '/plugins/test-actions-2/admin/ring.js'}], tabs: 'nope'});
|
|
374
|
+
try {
|
|
375
|
+
const r = await call('GET', '/bootstrap');
|
|
376
|
+
assert.equal(r.status, 200);
|
|
377
|
+
assert.deepEqual(r.body.actions, [{id: 'ring', title: 'Ring now', icon: 'video', module: '/plugins/test-actions/admin/ring.js?v=1', owner: 'test-actions'}]);
|
|
378
|
+
} finally {
|
|
379
|
+
reg.unregister('test-actions');
|
|
380
|
+
reg.unregister('test-actions-2');
|
|
381
|
+
}
|
|
382
|
+
assert.deepEqual((await call('GET', '/bootstrap')).body.actions, []);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('Call now on a booking: its call opens early for the customer\'s own link; moving it closes it again', async () => {
|
|
386
|
+
const reg = bookingRegistry();
|
|
387
|
+
reg.register('test-online', {rules: (s) => (s?.slug === 'early-call' ? {online: true} : {})});
|
|
388
|
+
try {
|
|
389
|
+
const vic = (await call('GET', '/bookables')).body.find(b => b.name === 'Vic')
|
|
390
|
+
|| (await call('POST', '/bookables', {name: 'Vic', kind: 'person'})).body;
|
|
391
|
+
const svc = (await call('POST', '/services', {name: 'Early call', duration: 30, bookables: [vic.id]})).body;
|
|
392
|
+
const plain = (await call('GET', '/services')).body.find(s => s.slug === 'consultation');
|
|
393
|
+
const day = aheadWeekday();
|
|
394
|
+
let r = await call('POST', '/bookings', {serviceId: svc.id, startAt: atWallClock(day, '10:00', TZ).toISOString(), customer: {name: 'Early Bird', email: 'early@example.com'}, force: true, notify: false});
|
|
395
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
396
|
+
const b = r.body;
|
|
397
|
+
const raw = await reg.services.getBooking(b.id);
|
|
398
|
+
const links = reg.services.callLinks(raw);
|
|
399
|
+
assert.match(links.joinUrl, new RegExp(`/booking/call/[\\w-]+\\?b=${b.id}&t=${raw.token}$`));
|
|
400
|
+
assert.match(links.hostUrl, /&h=/);
|
|
401
|
+
const room = b.hostUrl.split('/call/')[1].split('?')[0];
|
|
402
|
+
const join = () => call('POST', `/call/${room}/join`, {b: b.id, t: raw.token});
|
|
403
|
+
assert.equal((await join()).status, 425, 'a fortnight ahead: not open yet');
|
|
404
|
+
|
|
405
|
+
const opened = await reg.services.openCall(b.id, {by: 'Sam'});
|
|
406
|
+
assert.ok(opened.callOpenedAt);
|
|
407
|
+
assert.equal(opened.history.at(-1).what, 'call-opened');
|
|
408
|
+
r = await join();
|
|
409
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
410
|
+
assert.equal(r.body.state, 'waiting', 'the customer knocks at once');
|
|
411
|
+
const host = await call('POST', `/call/${room}/join`, {b: b.id, h: raw.hostKey});
|
|
412
|
+
assert.equal(host.status, 200);
|
|
413
|
+
assert.equal(host.body.host, true);
|
|
414
|
+
assert.equal(reg.services.adminBooking(opened).token, undefined);
|
|
415
|
+
assert.equal(reg.services.adminBooking(opened).hostKey, undefined);
|
|
416
|
+
|
|
417
|
+
// Moved: the new time opens as usual again.
|
|
418
|
+
r = await call('PUT', `/bookings/${b.id}/move`, {startAt: atWallClock(day, '11:00', TZ).toISOString(), force: true, notify: false});
|
|
419
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
420
|
+
assert.equal(r.body.callOpenedAt, '');
|
|
421
|
+
const moved = await reg.services.getBooking(b.id);
|
|
422
|
+
const room2 = r.body.hostUrl.split('/call/')[1].split('?')[0];
|
|
423
|
+
assert.equal((await call('POST', `/call/${room2}/join`, {b: b.id, t: moved.token})).status, 425);
|
|
424
|
+
|
|
425
|
+
// Refused: not online, cancelled, missing.
|
|
426
|
+
const other = (await call('POST', '/bookings', {serviceId: plain.id, startAt: atWallClock(day, '15:00', TZ).toISOString(), customer: {name: 'In Person', email: 'ip@example.com'}, force: true, notify: false})).body;
|
|
427
|
+
await assert.rejects(reg.services.openCall(other.id), {code: 'not-online'});
|
|
428
|
+
await call('PUT', `/bookings/${b.id}`, {status: 'cancelled', notify: false});
|
|
429
|
+
await assert.rejects(reg.services.openCall(b.id), {code: 'cancelled'});
|
|
430
|
+
await assert.rejects(reg.services.openCall('nope'), {code: 'none'});
|
|
431
|
+
for (const id of [b.id, other.id]) await call('DELETE', `/bookings/${id}`);
|
|
432
|
+
} finally {
|
|
433
|
+
reg.unregister('test-online');
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('an early-opened call lasts two hours from the click, not all the way to a booking days off', async () => {
|
|
438
|
+
const {callWindow} = await import('../lib/call.js');
|
|
439
|
+
const b = {startAt: '2031-01-10T10:00:00.000Z', endAt: '2031-01-10T11:00:00.000Z', callOpenedAt: '2031-01-03T09:00:00.000Z'};
|
|
440
|
+
const at = (iso) => Date.parse(iso);
|
|
441
|
+
assert.deepEqual(callWindow(b, at('2031-01-03T09:30:00.000Z')), {opensAt: '2031-01-03T09:00:00.000Z', closesAt: '2031-01-03T11:00:00.000Z'});
|
|
442
|
+
assert.deepEqual(callWindow(b, at('2031-01-05T09:00:00.000Z')), {opensAt: '2031-01-10T09:50:00.000Z', closesAt: '2031-01-10T12:00:00.000Z'}, "days later: back to the booking's own window");
|
|
443
|
+
const soon = {...b, callOpenedAt: '2031-01-10T09:00:00.000Z'};
|
|
444
|
+
assert.deepEqual(callWindow(soon, at('2031-01-10T09:05:00.000Z')), {opensAt: '2031-01-10T09:00:00.000Z', closesAt: '2031-01-10T12:00:00.000Z'}, 'joined up when they meet');
|
|
445
|
+
});
|
|
@@ -16,6 +16,7 @@ test('addresses under the booking page', () => {
|
|
|
16
16
|
assert.equal(matchBookingPath('/booking/manage', b), null, '"manage" alone is not a service');
|
|
17
17
|
assert.equal(matchBookingPath('/booking/manage/short', b), null);
|
|
18
18
|
assert.equal(matchBookingPath('/booking/a/b', b), null);
|
|
19
|
+
assert.equal(matchBookingPath('/booking/call-now/AbCdEf0123456789', b), null, 'an instant call is Booking Pro\'s page');
|
|
19
20
|
assert.equal(matchBookingPath('/booking/Hair_Cut', b), null);
|
|
20
21
|
assert.equal(matchBookingPath('/bookings', b), null, 'a neighbour address is not ours');
|
|
21
22
|
assert.equal(matchBookingPath('/booking/%E0%A4%A', b), null, 'bad encoding');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@4cdfbe1",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.4",
|
|
@@ -73,19 +73,19 @@
|
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
75
|
"booking": {
|
|
76
|
-
"version": "1.
|
|
76
|
+
"version": "1.4.0",
|
|
77
77
|
"files": {
|
|
78
|
-
"CLAUDE.md": "
|
|
78
|
+
"CLAUDE.md": "8e96d41dcbba3adcebf8456a6638e266f559d6fbfb4ba1406645a82cb40ce146",
|
|
79
79
|
"admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
|
|
80
80
|
"admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
|
|
81
81
|
"admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
|
|
82
82
|
"admin/lib/extension-sections.js": "0588ad47660e511b0dd8077bb9c3999c775d97597333a75e87ee12d915bbf00b",
|
|
83
|
-
"admin/lib/kit.js": "
|
|
83
|
+
"admin/lib/kit.js": "661e9c407bf398694dfa08105590065a6352594d526f3c09c31b180c840045bf",
|
|
84
84
|
"admin/templates/booking.html": "20b9a3bfe3e5d5bbee613f29be99551b12790fb38f80d119585360cc38e9ec57",
|
|
85
|
-
"admin/views/booking.js": "
|
|
85
|
+
"admin/views/booking.js": "4eddf6c0b60dbbba4c6e35352df247a3d34b66ee1d43c51fe2d53f894707348f",
|
|
86
86
|
"admin/views/bookings.js": "bf17db82ed4d2f5c29ac924dd60dc0b84c8bd9c8ce32da6cbb011527699a56a6",
|
|
87
87
|
"admin/views/diary.js": "9e8976e67c2232d353b3d9a1ef0d0503cc4d06b710d54d0cba84fb5b796fdfe9",
|
|
88
|
-
"admin/views/panels.js": "
|
|
88
|
+
"admin/views/panels.js": "9d1c45e56d2a7a8caa12e9af29545c808ffb88da3ea48397d55aae19e802ec69",
|
|
89
89
|
"admin/views/people.js": "58cb9cc550b48c959a5572a4d25650ea2b32b658c9d0bf4c244e4abe0277c211",
|
|
90
90
|
"admin/views/services.js": "122326d222ed16dc8ec903fef6f62f6a7638c31d73c4612b7a8c2ced38358068",
|
|
91
91
|
"admin/views/settings.js": "299297b47a6509986276a859807a33ea225681d28e0e9cd06cdf36b0f6ec0c11",
|
|
@@ -93,14 +93,14 @@
|
|
|
93
93
|
"collections/booking-bookings.json": "fa0b15cc81b4a2114734a0bc8adc4b4d25049d16cf62549b83634cdbeb543b52",
|
|
94
94
|
"collections/booking-services.json": "cecb500a9215a0eba894e6b4c9ae78f5fbeb209830d07d47050c56c75143f66a",
|
|
95
95
|
"config.js": "5565fd627481cf809322257cc300a7cbb7ccbf6cae076b4cf8a7453fcf554bd3",
|
|
96
|
-
"docs/guide.md": "
|
|
96
|
+
"docs/guide.md": "545b050500a09ffdf7bc620e01f0b63bf59e92dfe855ba3201419b0bcc92a1fc",
|
|
97
97
|
"inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
|
|
98
|
-
"lib/call.js": "
|
|
99
|
-
"lib/extensions.js": "
|
|
98
|
+
"lib/call.js": "7dfa0ae60be87d8f7f26b4360d5c1144322abe66eb61b233ed812e35bb8e59bd",
|
|
99
|
+
"lib/extensions.js": "8cec28ff8685daad8083b23f034f1c3bf82fd2322943dd19d46570ccdc311105",
|
|
100
100
|
"lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
|
|
101
|
-
"lib/store.js": "
|
|
102
|
-
"plugin.js": "
|
|
103
|
-
"plugin.json": "
|
|
101
|
+
"lib/store.js": "270161f907c68de39d32758931e86c290582524af4650e722c2c3be209acc875",
|
|
102
|
+
"plugin.js": "963e8190dbcaec71915d73402648899bb38135b25ec869a659d0938d03868682",
|
|
103
|
+
"plugin.json": "0ef595cd1925b41194821f97d0e0e0fca5edb20a98a0f97a8a0efd52e8c50c16",
|
|
104
104
|
"plugin.public.js": "cd607de84ba7cffe384aa40a1a5976277896bf3973baa6415a01a1a4c9e3aca1",
|
|
105
105
|
"public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
|
|
106
106
|
"public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
|
|
@@ -110,8 +110,8 @@
|
|
|
110
110
|
"public/lib/time.js": "48e13a23e3d7a4916e2097e8f87c03a87d37b71c4ef037a5a16143c6a4185c41",
|
|
111
111
|
"templates/booking.html": "a4995de46dfdab7e692b56f21347f29c76320cf3b0477889dc3c5d14e0b6dcd7",
|
|
112
112
|
"tests/admin-diary.test.js": "4e34b447d62b41272ceec49bad9d491ae00dd04f4760d8a7a3046be1912f4abe",
|
|
113
|
-
"tests/api.test.js": "
|
|
114
|
-
"tests/public.test.js": "
|
|
113
|
+
"tests/api.test.js": "57ce73f48776e15454794405bae5b9674baeda46a96e4a11d0e253c6a977a764",
|
|
114
|
+
"tests/public.test.js": "0b7b626d60df5a3d73d7c0077b73f59b62a9fded98ce84a8d93cd32cd0333159",
|
|
115
115
|
"tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
|
|
116
116
|
}
|
|
117
117
|
},
|