domma-cms 0.102.1 → 0.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/message-actions.js +56 -0
- package/plugins/_lib/admin/mail/reader-view.js +30 -1
- package/plugins/_lib/call/ice.js +58 -0
- package/plugins/_lib/call/rooms.js +249 -0
- package/plugins/_lib/call/routes.js +89 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +477 -0
- package/plugins/booking/CLAUDE.md +137 -0
- package/plugins/booking/admin/css/index.css +192 -0
- package/plugins/booking/admin/lib/details.js +58 -0
- package/plugins/booking/admin/lib/diary-layout.js +140 -0
- package/plugins/booking/admin/lib/extension-sections.js +160 -0
- package/plugins/booking/admin/lib/kit.js +249 -0
- package/plugins/booking/admin/templates/booking.html +1 -0
- package/plugins/booking/admin/views/booking.js +207 -0
- package/plugins/booking/admin/views/bookings.js +127 -0
- package/plugins/booking/admin/views/diary.js +374 -0
- package/plugins/booking/admin/views/panels.js +516 -0
- package/plugins/booking/admin/views/people.js +281 -0
- package/plugins/booking/admin/views/services.js +222 -0
- package/plugins/booking/admin/views/settings.js +203 -0
- package/plugins/booking/collections/booking-bookables.json +17 -0
- package/plugins/booking/collections/booking-bookings.json +18 -0
- package/plugins/booking/collections/booking-services.json +17 -0
- package/plugins/booking/config.js +36 -0
- package/plugins/booking/docs/guide.md +70 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +72 -0
- package/plugins/booking/lib/extensions.js +169 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +390 -0
- package/plugins/booking/plugin.js +806 -0
- package/plugins/booking/plugin.json +80 -0
- package/plugins/booking/plugin.public.js +157 -0
- package/plugins/booking/public/booking.css +159 -0
- package/plugins/booking/public/booking.js +808 -0
- package/plugins/booking/public/lib/clean.js +141 -0
- package/plugins/booking/public/lib/page-format.js +121 -0
- package/plugins/booking/public/lib/slots.js +247 -0
- package/plugins/booking/public/lib/time.js +131 -0
- package/plugins/booking/templates/booking.html +5 -0
- package/plugins/booking/tests/admin-diary.test.js +73 -0
- package/plugins/booking/tests/api.test.js +445 -0
- package/plugins/booking/tests/public.test.js +72 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +44 -1
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- package/server/services/hooks.js +35 -8
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/* Booking admin - only --dm-* tokens, so it follows the admin theme (light, dark, custom). */
|
|
2
|
+
.bk, .bk-form { --bk-soft: color-mix(in srgb, var(--dm-text) 5%, transparent); --bk-line: var(--dm-border, rgba(128, 128, 128, .3)); }
|
|
3
|
+
.bk [hidden], .bk-form [hidden] { display: none !important; }
|
|
4
|
+
.bk { display: flex; flex-direction: column; gap: 1rem; }
|
|
5
|
+
.bk-body { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
6
|
+
.bk-error { color: var(--dm-danger); }
|
|
7
|
+
.bk-muted { color: var(--dm-text-muted); }
|
|
8
|
+
.bk-note { margin: 0; font-size: var(--dm-text-sm); color: var(--dm-text-muted); }
|
|
9
|
+
.bk-note.is-bad { color: var(--dm-danger); }
|
|
10
|
+
.bk-note.is-warn { color: color-mix(in srgb, var(--dm-warning) 60%, var(--dm-text)); }
|
|
11
|
+
.bk-spacer { flex: 1; }
|
|
12
|
+
.bk-link { border: 0; background: none; padding: 0; color: var(--dm-primary); cursor: pointer; font: inherit; text-align: left; }
|
|
13
|
+
.bk-link:hover { text-decoration: underline; }
|
|
14
|
+
.bk-danger-text { color: var(--dm-danger) !important; }
|
|
15
|
+
|
|
16
|
+
/* Tabs under the banner */
|
|
17
|
+
.bk-nav { display: flex; flex-wrap: wrap; gap: .15rem; border-bottom: 1px solid var(--bk-line); }
|
|
18
|
+
.bk-nav a { display: inline-flex; align-items: center; gap: .4rem; padding: .5rem .85rem; color: var(--dm-text-muted); text-decoration: none;
|
|
19
|
+
font-size: var(--dm-text-sm); font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; border-radius: var(--dm-radius-sm) var(--dm-radius-sm) 0 0; }
|
|
20
|
+
.bk-nav a:hover { color: var(--dm-text); background: var(--bk-soft); }
|
|
21
|
+
.bk-nav a.is-on { color: var(--dm-primary); border-bottom-color: var(--dm-primary); font-weight: 600; }
|
|
22
|
+
.bk-nav a.is-ext::after { content: 'Pro'; font-size: .62rem; font-weight: 700; letter-spacing: .04em; padding: .05rem .35rem; border-radius: 999px;
|
|
23
|
+
background: color-mix(in srgb, var(--dm-primary) 15%, transparent); color: var(--dm-primary); }
|
|
24
|
+
|
|
25
|
+
/* Cards, bars, chips */
|
|
26
|
+
.bk-card { border: 1px solid var(--bk-line); border-radius: var(--dm-radius-md); padding: .9rem 1rem; display: flex; flex-direction: column; gap: .7rem;
|
|
27
|
+
background: var(--dm-surface, transparent); min-width: 0; }
|
|
28
|
+
.bk-bar { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
|
|
29
|
+
.bk-search { flex: 1; min-width: 10rem; max-width: 22rem; }
|
|
30
|
+
.bk-date { width: auto; max-width: 11rem; }
|
|
31
|
+
.bk-when { font-size: var(--dm-text-sm); }
|
|
32
|
+
.bk-seg { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--bk-line); border-radius: var(--dm-radius-md); overflow: hidden; }
|
|
33
|
+
.bk-seg button { border: 0; background: none; color: var(--dm-text); padding: .32rem .7rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm);
|
|
34
|
+
display: inline-flex; align-items: center; gap: .3rem; }
|
|
35
|
+
.bk-seg button + button { border-left: 1px solid var(--bk-line); }
|
|
36
|
+
.bk-seg button b { font-weight: 500; opacity: .6; }
|
|
37
|
+
.bk-seg button:hover { background: var(--bk-soft); }
|
|
38
|
+
.bk-seg button.is-on { background: var(--dm-primary); color: var(--dm-primary-text, #fff); }
|
|
39
|
+
.bk-chip { display: inline-flex; align-items: center; gap: .25rem; font-size: var(--dm-text-xs); font-weight: 600; padding: .12rem .55rem; border-radius: 999px;
|
|
40
|
+
white-space: nowrap; background: color-mix(in srgb, var(--dm-text) 9%, transparent); color: var(--dm-text-muted); }
|
|
41
|
+
.bk-chip.is-primary { background: color-mix(in srgb, var(--dm-primary) 14%, transparent); color: var(--dm-primary); }
|
|
42
|
+
.bk-chip.is-success { background: color-mix(in srgb, var(--dm-success) 15%, transparent); color: var(--dm-success); }
|
|
43
|
+
.bk-chip.is-warning { background: color-mix(in srgb, var(--dm-warning) 16%, transparent); color: color-mix(in srgb, var(--dm-warning) 55%, var(--dm-text)); }
|
|
44
|
+
.bk-chip.is-danger { background: color-mix(in srgb, var(--dm-danger) 14%, transparent); color: var(--dm-danger); }
|
|
45
|
+
.bk-empty { display: flex; flex-direction: column; align-items: center; gap: .5rem; text-align: center; padding: 2rem 1rem; color: var(--dm-text-muted); }
|
|
46
|
+
.bk-empty p { margin: 0; }
|
|
47
|
+
.bk-empty strong { color: var(--dm-text); }
|
|
48
|
+
|
|
49
|
+
/* Colours - one hue per name, mixed with the theme so they read on light and dark */
|
|
50
|
+
[data-colour] { --bk-c: var(--dm-primary); }
|
|
51
|
+
[data-colour="blue"] { --bk-c: var(--dm-blue-500, #3b82f6); }
|
|
52
|
+
[data-colour="green"] { --bk-c: var(--dm-green-500, #16a34a); }
|
|
53
|
+
[data-colour="teal"] { --bk-c: #0d9488; }
|
|
54
|
+
[data-colour="purple"] { --bk-c: #8b5cf6; }
|
|
55
|
+
[data-colour="pink"] { --bk-c: #db2777; }
|
|
56
|
+
[data-colour="orange"] { --bk-c: #ea580c; }
|
|
57
|
+
[data-colour="red"] { --bk-c: var(--dm-red-500, #dc2626); }
|
|
58
|
+
[data-colour="grey"] { --bk-c: var(--dm-gray-500, #6b7280); }
|
|
59
|
+
.bk-dot { width: .7rem; height: .7rem; border-radius: 50%; flex: none; background: var(--bk-c, var(--dm-text-muted)); }
|
|
60
|
+
|
|
61
|
+
/* Rows */
|
|
62
|
+
.bk-list { display: flex; flex-direction: column; }
|
|
63
|
+
.bk-row { display: flex; align-items: center; gap: .85rem; padding: .6rem .4rem; border-bottom: 1px solid var(--bk-line); cursor: pointer; border-radius: var(--dm-radius-sm); }
|
|
64
|
+
.bk-row:last-child { border-bottom: 0; }
|
|
65
|
+
.bk-row:hover, .bk-row:focus-visible { background: var(--bk-soft); outline: none; }
|
|
66
|
+
.bk-when-col { display: flex; flex-direction: column; min-width: 8.5rem; font-variant-numeric: tabular-nums; }
|
|
67
|
+
.bk-when-col small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
|
|
68
|
+
.bk-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
69
|
+
.bk-main strong, .bk-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
70
|
+
.bk-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
|
|
71
|
+
.bk-kebab { border: 0; background: none; cursor: pointer; font-size: 1.15rem; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .35rem; line-height: 1.6; }
|
|
72
|
+
.bk-kebab:hover { color: var(--dm-primary); background: var(--bk-soft); }
|
|
73
|
+
@media (max-width: 720px) { .bk-when-col { min-width: 6rem; } }
|
|
74
|
+
|
|
75
|
+
/* Getting started */
|
|
76
|
+
.bk-checklist { border: 1px solid color-mix(in srgb, var(--dm-primary) 40%, var(--bk-line)); border-radius: var(--dm-radius-md); padding: .75rem 1rem;
|
|
77
|
+
background: color-mix(in srgb, var(--dm-primary) 5%, transparent); }
|
|
78
|
+
.bk-checklist header { display: flex; align-items: center; justify-content: space-between; }
|
|
79
|
+
.bk-checklist h3 { margin: 0; font-size: var(--dm-text-base, 1rem); display: flex; align-items: center; gap: .35rem; }
|
|
80
|
+
.bk-checklist ol { list-style: none; margin: .5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .35rem; }
|
|
81
|
+
.bk-checklist li { display: flex; align-items: center; gap: .5rem; font-size: var(--dm-text-sm); }
|
|
82
|
+
.bk-checklist li.is-done { color: var(--dm-text-muted); }
|
|
83
|
+
.bk-checklist li:not(.is-done) .bk-link { color: var(--dm-text); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--dm-text) 35%, transparent); text-underline-offset: 3px; }
|
|
84
|
+
.bk-checklist li.is-done .bk-link { color: var(--dm-text-muted); text-decoration: line-through; }
|
|
85
|
+
.bk-checklist li.is-done svg { color: var(--dm-success); }
|
|
86
|
+
|
|
87
|
+
/* The Diary */
|
|
88
|
+
.bk-diary-wrap { overflow: auto; max-height: calc(100vh - 16rem); min-height: 20rem; border: 1px solid var(--bk-line); border-radius: var(--dm-radius-sm); }
|
|
89
|
+
.bk-diary-wrap > .bk-note { padding: .5rem .75rem; }
|
|
90
|
+
.bk-diary-grid { --bk-gutter: 3.4rem; min-width: calc(var(--bk-gutter) + var(--bk-cols) * 9rem); }
|
|
91
|
+
.bk-diary-grid.is-week { min-width: calc(var(--bk-gutter) + 7 * 7.5rem); }
|
|
92
|
+
.bk-diary-head, .bk-diary-body { display: grid; grid-template-columns: var(--bk-gutter) repeat(var(--bk-cols), minmax(0, 1fr)); }
|
|
93
|
+
.bk-diary-head { position: sticky; top: 0; z-index: 3; background: var(--dm-surface, var(--dm-bg, #fff)); border-bottom: 1px solid var(--bk-line); }
|
|
94
|
+
.bk-col-head { padding: .45rem .5rem; font-size: var(--dm-text-sm); font-weight: 600; display: flex; align-items: center; gap: .3rem; border-left: 1px solid var(--bk-line);
|
|
95
|
+
overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
96
|
+
.bk-col-head[data-colour] { box-shadow: inset 0 -3px 0 var(--bk-c); }
|
|
97
|
+
.bk-col-head.is-today { color: var(--dm-primary); }
|
|
98
|
+
.bk-link-head { cursor: pointer; }
|
|
99
|
+
.bk-link-head:hover { background: var(--bk-soft); }
|
|
100
|
+
.bk-gutter { position: relative; height: var(--bk-h); }
|
|
101
|
+
.bk-gutter span { position: absolute; right: .4rem; transform: translateY(-.55em); font-size: var(--dm-text-xs); color: var(--dm-text-muted); font-variant-numeric: tabular-nums; }
|
|
102
|
+
.bk-gutter span:first-child { transform: none; }
|
|
103
|
+
.bk-col { position: relative; height: var(--bk-h); border-left: 1px solid var(--bk-line); cursor: copy;
|
|
104
|
+
background-image:
|
|
105
|
+
repeating-linear-gradient(to bottom, transparent 0, transparent calc(var(--bk-hour) - 1px), var(--bk-line) calc(var(--bk-hour) - 1px), var(--bk-line) var(--bk-hour)),
|
|
106
|
+
repeating-linear-gradient(135deg, transparent 0, transparent 7px, color-mix(in srgb, var(--dm-text) 7%, transparent) 7px, color-mix(in srgb, var(--dm-text) 7%, transparent) 8px); }
|
|
107
|
+
/* Opening hours: plain; outside them, hatched. */
|
|
108
|
+
.bk-open { position: absolute; left: 0; right: 0; background-color: var(--dm-surface, var(--dm-bg, #fff)); pointer-events: none;
|
|
109
|
+
background-image: repeating-linear-gradient(to bottom, transparent 0, transparent calc(var(--bk-hour) - 1px), var(--bk-line) calc(var(--bk-hour) - 1px), var(--bk-line) var(--bk-hour)); }
|
|
110
|
+
.bk-now { position: absolute; left: 0; right: 0; height: 2px; background: var(--dm-danger); z-index: 2; pointer-events: none; }
|
|
111
|
+
.bk-block { position: absolute; z-index: 1; border-radius: var(--dm-radius-sm); padding: .2rem .4rem; overflow: hidden; cursor: pointer; font-size: var(--dm-text-xs);
|
|
112
|
+
line-height: 1.25; background: color-mix(in srgb, var(--bk-c) 18%, var(--dm-surface, var(--dm-bg, #fff))); border-left: 3px solid var(--bk-c);
|
|
113
|
+
color: var(--dm-text); box-shadow: 0 1px 2px rgba(0, 0, 0, .12); }
|
|
114
|
+
.bk-block:hover, .bk-block:focus-visible { background: color-mix(in srgb, var(--bk-c) 28%, var(--dm-surface, var(--dm-bg, #fff))); outline: none; }
|
|
115
|
+
.bk-block:focus-visible { box-shadow: 0 0 0 2px var(--dm-primary); }
|
|
116
|
+
.bk-block strong, .bk-block small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
117
|
+
.bk-block small { color: var(--dm-text-muted); }
|
|
118
|
+
.bk-block .bk-kebab { float: right; font-size: .95rem; line-height: 1; padding: 0 .15rem; margin: -.05rem -.2rem 0 .1rem; }
|
|
119
|
+
.bk-block.is-cancelled { opacity: .55; text-decoration: line-through; border-left-style: dashed; }
|
|
120
|
+
.bk-block.is-completed { border-left-color: var(--dm-success); }
|
|
121
|
+
.bk-block.is-no-show { border-left-color: var(--dm-danger); }
|
|
122
|
+
|
|
123
|
+
/* Forms (slideovers) */
|
|
124
|
+
.bk-form { display: flex; flex-direction: column; gap: .75rem; padding: .25rem .1rem 1rem; }
|
|
125
|
+
.bk-form p { margin: 0; }
|
|
126
|
+
.bk-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem 1rem; align-items: start; }
|
|
127
|
+
.bk-grid > div { display: flex; flex-direction: column; gap: .25rem; min-width: 0; }
|
|
128
|
+
.bk-wide { grid-column: 1 / -1; }
|
|
129
|
+
@media (max-width: 560px) { .bk-grid { grid-template-columns: 1fr; } }
|
|
130
|
+
.bk-form .form-label { margin: 0; display: inline-flex; align-items: center; gap: .3rem; }
|
|
131
|
+
.bk-sub { margin: .5rem 0 0; font-size: var(--dm-text-sm); font-weight: 600; display: flex; align-items: center; gap: .3rem; }
|
|
132
|
+
.bk-check { display: inline-flex; align-items: center; gap: .45rem; font-size: var(--dm-text-sm); cursor: pointer; }
|
|
133
|
+
.bk-checks { display: flex; flex-direction: column; gap: .4rem; }
|
|
134
|
+
.bk-picks { display: flex; flex-wrap: wrap; gap: .4rem 1rem; }
|
|
135
|
+
.bk-inline { display: flex; gap: .4rem; }
|
|
136
|
+
.bk-inline .form-input { max-width: 6rem; }
|
|
137
|
+
.bk-err { color: var(--dm-danger); font-size: var(--dm-text-xs); }
|
|
138
|
+
.bk-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
|
139
|
+
.bk-actions-start { justify-content: flex-start; }
|
|
140
|
+
.bk-copy { display: flex; align-items: center; gap: .4rem; }
|
|
141
|
+
.bk-copy code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: .3rem .5rem; border-radius: var(--dm-radius-sm);
|
|
142
|
+
background: var(--bk-soft); font-size: var(--dm-text-sm); }
|
|
143
|
+
.bk-alert { display: flex; gap: .5rem; align-items: flex-start; padding: .6rem .75rem; border-radius: var(--dm-radius-sm); font-size: var(--dm-text-sm);
|
|
144
|
+
background: color-mix(in srgb, var(--dm-warning) 14%, transparent); border: 1px solid color-mix(in srgb, var(--dm-warning) 45%, transparent); }
|
|
145
|
+
.bk-alert svg { color: color-mix(in srgb, var(--dm-warning) 60%, var(--dm-text)); flex: none; margin-top: .1rem; }
|
|
146
|
+
.bk-settings-nav { align-self: flex-start; }
|
|
147
|
+
|
|
148
|
+
/* Free times */
|
|
149
|
+
.bk-picker { display: flex; flex-direction: column; gap: .5rem; }
|
|
150
|
+
.bk-slots { display: flex; flex-wrap: wrap; gap: .35rem; }
|
|
151
|
+
.bk-slot { border: 1px solid var(--bk-line); background: none; color: var(--dm-text); border-radius: var(--dm-radius-sm); padding: .25rem .55rem; cursor: pointer;
|
|
152
|
+
font: inherit; font-size: var(--dm-text-sm); font-variant-numeric: tabular-nums; }
|
|
153
|
+
.bk-slot:hover { border-color: var(--dm-primary); color: var(--dm-primary); }
|
|
154
|
+
.bk-slot.is-on { background: var(--dm-primary); border-color: var(--dm-primary); color: var(--dm-primary-text, #fff); }
|
|
155
|
+
|
|
156
|
+
/* One booking */
|
|
157
|
+
.bk-booking-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
|
158
|
+
.bk-dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .35rem 1rem; margin: 0; font-size: var(--dm-text-sm); }
|
|
159
|
+
.bk-dl dt { color: var(--dm-text-muted); }
|
|
160
|
+
.bk-dl dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; }
|
|
161
|
+
.bk-history { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .35rem; font-size: var(--dm-text-sm); }
|
|
162
|
+
.bk-history small { display: block; color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
|
|
163
|
+
|
|
164
|
+
/* Hours and days off */
|
|
165
|
+
.bk-hours { display: flex; flex-direction: column; border: 1px solid var(--bk-line); border-radius: var(--dm-radius-sm); }
|
|
166
|
+
.bk-day { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr) auto; gap: .5rem; align-items: center; padding: .35rem .6rem; }
|
|
167
|
+
.bk-day + .bk-day { border-top: 1px solid var(--bk-line); }
|
|
168
|
+
.bk-day-name { font-size: var(--dm-text-sm); font-weight: 500; }
|
|
169
|
+
.bk-windows { display: flex; flex-wrap: wrap; gap: .35rem .75rem; align-items: center; font-size: var(--dm-text-sm); }
|
|
170
|
+
.bk-window { display: inline-flex; align-items: center; gap: .25rem; }
|
|
171
|
+
.bk-window .form-input { width: 7.2rem; padding-block: .2rem; }
|
|
172
|
+
.bk-x { opacity: .6; }
|
|
173
|
+
.bk-x:hover { opacity: 1; }
|
|
174
|
+
.bk-off { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; margin-bottom: .35rem; font-size: var(--dm-text-sm); }
|
|
175
|
+
.bk-off .form-input[type="date"] { width: 10rem; }
|
|
176
|
+
.bk-off .form-input:not([type="date"]) { flex: 1; min-width: 8rem; }
|
|
177
|
+
@media (max-width: 560px) { .bk-day { grid-template-columns: 1fr; } }
|
|
178
|
+
/* Week columns are narrow: the menu is right-click / Shift+F10 there, so the time and name get the room. */
|
|
179
|
+
.bk-diary-grid.is-week .bk-block .bk-kebab { display: none; }
|
|
180
|
+
.bk-col-head.is-today::after { content: ''; width: .4rem; height: .4rem; border-radius: 50%; background: var(--dm-primary); flex: none; }
|
|
181
|
+
|
|
182
|
+
/* ---- 1.1: companions, classes, extension sections, places ---- */
|
|
183
|
+
.bk-block.is-companion { border-left-style: dashed; background: color-mix(in srgb, var(--bk-c) 8%, var(--dm-surface, var(--dm-bg, #fff))); }
|
|
184
|
+
.bk-block.is-companion strong { font-weight: 500; }
|
|
185
|
+
.bk-block.is-session { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bk-c) 45%, transparent); }
|
|
186
|
+
.bk-session-list .bk-row { width: 100%; text-align: left; background: none; color: inherit; font: inherit; }
|
|
187
|
+
.bk-ext-host:empty { display: none; }
|
|
188
|
+
.bk-ext { border: 1px solid var(--bk-line); border-radius: var(--dm-radius-md, 8px); padding: .75rem .9rem; margin: .9rem 0 0; }
|
|
189
|
+
.bk-ext > .bk-note { margin: 0; }
|
|
190
|
+
.bk-slot small { display: block; font-size: var(--dm-text-xs); color: var(--dm-text-muted); line-height: 1.2; }
|
|
191
|
+
.bk-slot.is-on small { color: inherit; opacity: .85; }
|
|
192
|
+
.bk-answers { margin-bottom: .5rem; }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a booking reads in the admin - PURE, shared by the booking slideover,
|
|
3
|
+
* the Bookings list and the Diary, and tested without a browser.
|
|
4
|
+
*
|
|
5
|
+
* @module booking/admin/lib/details
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** "Alex", "Alex and Court 1", "Alex, Court 1 and Projector". */
|
|
9
|
+
export function withNames(b) {
|
|
10
|
+
const names = [b?.bookableName, ...(Array.isArray(b?.alsoNames) ? b.alsoNames : [])].filter(Boolean);
|
|
11
|
+
if (names.length < 2) return names[0] || '';
|
|
12
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Where a booking came from, in words. */
|
|
16
|
+
export function sourceLabel(source) {
|
|
17
|
+
return {
|
|
18
|
+
public: 'Online, on the booking page',
|
|
19
|
+
admin: 'By staff',
|
|
20
|
+
waitlist: 'From the waiting list',
|
|
21
|
+
extension: 'Added by an extension'
|
|
22
|
+
}[source] || (source ? `From ${source}` : 'By staff');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The short form for a list row's meta. */
|
|
26
|
+
export function sourceShort(source) {
|
|
27
|
+
return {public: 'booked online', admin: 'booked by staff', waitlist: 'from the waiting list', extension: 'added automatically'}[source] || 'booked by staff';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The answers to a service's questions as [label, text] rows, in the
|
|
32
|
+
* questions' order, then any answer whose question has gone (by its key).
|
|
33
|
+
*
|
|
34
|
+
* @param {Object<string, *>} answers
|
|
35
|
+
* @param {object} [service] - its ext['booking-pro'].questions give the labels
|
|
36
|
+
* @returns {[string, string][]}
|
|
37
|
+
*/
|
|
38
|
+
export function answerRows(answers, service) {
|
|
39
|
+
if (!answers || typeof answers !== 'object') return [];
|
|
40
|
+
const questions = Array.isArray(service?.ext?.['booking-pro']?.questions) ? service.ext['booking-pro'].questions : [];
|
|
41
|
+
const text = (v) => (v === true ? 'Yes' : v === false ? 'No' : Array.isArray(v) ? v.join(', ') : String(v ?? ''));
|
|
42
|
+
const rows = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const q of questions) {
|
|
45
|
+
if (!q || !(q.id in answers)) continue;
|
|
46
|
+
seen.add(q.id);
|
|
47
|
+
rows.push([String(q.label || q.id), text(answers[q.id])]);
|
|
48
|
+
}
|
|
49
|
+
for (const [k, v] of Object.entries(answers)) if (!seen.has(k)) rows.push([k, text(v)]);
|
|
50
|
+
return rows.filter(([, v]) => v !== '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** "2 places left" / "1 place left" - for a service that takes more than one. */
|
|
54
|
+
export function placesLabel(left) {
|
|
55
|
+
const n = Number(left);
|
|
56
|
+
if (!Number.isFinite(n)) return '';
|
|
57
|
+
return `${n} place${n === 1 ? '' : 's'} left`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Diary's geometry - PURE, so it can be tested without a browser.
|
|
3
|
+
*
|
|
4
|
+
* A day column is a strip of minutes. Bookings are placed by their wall-clock
|
|
5
|
+
* time in the business zone; bookings that overlap (a clash booked anyway, or
|
|
6
|
+
* several people's in the week view) sit side by side in lanes.
|
|
7
|
+
*
|
|
8
|
+
* Imported relatively (../../public/lib/...) so the same file works in the
|
|
9
|
+
* browser (/plugins/booking/admin/lib/) and under node --test.
|
|
10
|
+
*
|
|
11
|
+
* @module booking/admin/lib/diary-layout
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {addDays, weekdayOf, wallClock} from '../../public/lib/time.js';
|
|
15
|
+
import {windowsOn} from '../../public/lib/slots.js';
|
|
16
|
+
|
|
17
|
+
/** Monday of the week a date is in. */
|
|
18
|
+
export function weekStart(date) {
|
|
19
|
+
const wd = weekdayOf(date);
|
|
20
|
+
return addDays(date, wd === 0 ? -6 : 1 - wd);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The dates a view shows: one for 'day', Monday to Sunday for 'week'. */
|
|
24
|
+
export function datesFor(view, date) {
|
|
25
|
+
if (view !== 'week') return [date];
|
|
26
|
+
const first = weekStart(date);
|
|
27
|
+
return Array.from({length: 7}, (_, i) => addDays(first, i));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The hours the grid shows: from the earliest opening to the latest closing of
|
|
32
|
+
* any bookable on those dates, widened to take in any booking outside them,
|
|
33
|
+
* whole hours, never less than 8 hours. 08:00-18:00 when nothing is open.
|
|
34
|
+
*
|
|
35
|
+
* @param {object[]} bookables
|
|
36
|
+
* @param {string[]} dates
|
|
37
|
+
* @param {{startMin: number, endMin: number}[]} placed - bookings' minutes on those dates
|
|
38
|
+
* @returns {{from: number, to: number}} minutes since midnight, whole hours
|
|
39
|
+
*/
|
|
40
|
+
export function gridHours(bookables, dates, placed = []) {
|
|
41
|
+
let from = Infinity;
|
|
42
|
+
let to = -Infinity;
|
|
43
|
+
for (const date of dates) {
|
|
44
|
+
for (const bk of bookables) {
|
|
45
|
+
for (const w of windowsOn(bk, date)) { from = Math.min(from, w.from); to = Math.max(to, w.to); }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const p of placed) { from = Math.min(from, p.startMin); to = Math.max(to, p.endMin); }
|
|
49
|
+
if (!Number.isFinite(from)) { from = 480; to = 1080; }
|
|
50
|
+
from = Math.floor(from / 60) * 60;
|
|
51
|
+
to = Math.ceil(to / 60) * 60;
|
|
52
|
+
if (to - from < 480) to = Math.min(1440, from + 480);
|
|
53
|
+
if (to - from < 480) from = Math.max(0, to - 480);
|
|
54
|
+
return {from, to};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where a booking falls: its date and minutes in the zone, clipped to its own
|
|
59
|
+
* day (a booking past midnight shows to the end of the day it starts on).
|
|
60
|
+
*
|
|
61
|
+
* @param {{startAt: string, endAt: string}} b
|
|
62
|
+
* @param {string} tz
|
|
63
|
+
* @returns {{date: string, startMin: number, endMin: number}}
|
|
64
|
+
*/
|
|
65
|
+
export function placeOf(b, tz) {
|
|
66
|
+
const s = wallClock(b.startAt, tz);
|
|
67
|
+
const e = wallClock(b.endAt, tz);
|
|
68
|
+
const endMin = e.date === s.date ? e.minutes : 1440;
|
|
69
|
+
return {date: s.date, startMin: s.minutes, endMin: Math.max(endMin, s.minutes + 10)};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lanes for items in one column: each gets `lane` and `lanes` (how many sit
|
|
74
|
+
* side by side in its cluster of overlapping items).
|
|
75
|
+
*
|
|
76
|
+
* @template T
|
|
77
|
+
* @param {(T & {startMin: number, endMin: number})[]} items
|
|
78
|
+
* @returns {(T & {lane: number, lanes: number})[]}
|
|
79
|
+
*/
|
|
80
|
+
export function layoutColumn(items) {
|
|
81
|
+
const sorted = [...items].sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin);
|
|
82
|
+
const out = [];
|
|
83
|
+
let cluster = [];
|
|
84
|
+
let clusterEnd = -1;
|
|
85
|
+
const flush = () => {
|
|
86
|
+
const n = cluster.reduce((m, x) => Math.max(m, x.lane + 1), 0);
|
|
87
|
+
for (const x of cluster) out.push({...x, lanes: n});
|
|
88
|
+
cluster = [];
|
|
89
|
+
};
|
|
90
|
+
for (const it of sorted) {
|
|
91
|
+
if (cluster.length && it.startMin >= clusterEnd) { flush(); clusterEnd = -1; }
|
|
92
|
+
const busy = new Set(cluster.filter(x => x.endMin > it.startMin).map(x => x.lane));
|
|
93
|
+
let lane = 0;
|
|
94
|
+
while (busy.has(lane)) lane++;
|
|
95
|
+
cluster.push({...it, lane});
|
|
96
|
+
clusterEnd = Math.max(clusterEnd, it.endMin);
|
|
97
|
+
}
|
|
98
|
+
if (cluster.length) flush();
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The minute a click at `y` px lands on, rounded down to `snap` minutes. */
|
|
103
|
+
export function minuteAt(y, {from, pxPerMin, snap = 15}) {
|
|
104
|
+
const raw = from + Math.max(0, y) / pxPerMin;
|
|
105
|
+
return Math.floor(raw / snap) * snap;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Column heading: "Mon 06/10". */
|
|
109
|
+
export function dayLabel(date) {
|
|
110
|
+
const [, m, d] = date.split('-');
|
|
111
|
+
return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][weekdayOf(date)]} ${d}/${m}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The bookables a booking holds: its own, and any it took along. */
|
|
115
|
+
const holdsOf = (b) => (Array.isArray(b?.holds) && b.holds.length ? b.holds : [b?.bookableId]).filter(Boolean);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What one Diary column shows. In the day view (a column per person or room)
|
|
119
|
+
* a booking appears in every column it holds - the companion copies (a court
|
|
120
|
+
* booked with a trainer) marked `companion`. Several live bookings of one
|
|
121
|
+
* session (same service, start and person - a class) become ONE item with all
|
|
122
|
+
* of them in `bookings`. Cancelled ones are never grouped.
|
|
123
|
+
*
|
|
124
|
+
* @param {object[]} bookings
|
|
125
|
+
* @param {{bookableId?: string}} [column] - none: every booking (the week view)
|
|
126
|
+
* @returns {{key: string, b: object, bookings: object[], companion: boolean, session: boolean}[]}
|
|
127
|
+
*/
|
|
128
|
+
export function columnItems(bookings, {bookableId = ''} = {}) {
|
|
129
|
+
const groups = new Map();
|
|
130
|
+
for (const b of bookings || []) {
|
|
131
|
+
if (bookableId && !holdsOf(b).includes(bookableId)) continue;
|
|
132
|
+
const companion = Boolean(bookableId) && b.bookableId !== bookableId;
|
|
133
|
+
const key = b.status === 'cancelled'
|
|
134
|
+
? `x|${b.id}`
|
|
135
|
+
: `s|${b.serviceId}|${b.startAt}|${b.bookableId}${companion ? '|c' : ''}`;
|
|
136
|
+
if (!groups.has(key)) groups.set(key, {key, b, bookings: [], companion});
|
|
137
|
+
groups.get(key).bookings.push(b);
|
|
138
|
+
}
|
|
139
|
+
return [...groups.values()].map(g => ({...g, session: g.bookings.length > 1}));
|
|
140
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sections other Tools add to Booking's own slideovers - the host.
|
|
3
|
+
*
|
|
4
|
+
* THE CONTRACT (Booking 1.1; Booking Pro is the first to use it)
|
|
5
|
+
*
|
|
6
|
+
* GET /api/plugins/booking/bootstrap returns
|
|
7
|
+
* `sections: [{owner, serviceEditor?, newBooking?, bookingPanel?}]`
|
|
8
|
+
* - module paths under /plugins/<owner>/, from what an extension registered
|
|
9
|
+
* (Booking's lib/extensions.js `admin`).
|
|
10
|
+
*
|
|
11
|
+
* Each module exports `async mount(el, ctx)` and returns an object with any of:
|
|
12
|
+
* values() serviceEditor: the owner's settings for the service - saved as
|
|
13
|
+
* `service.ext[owner]`. newBooking: `{answers?, ext?}` - the host sends
|
|
14
|
+
* `body.answers` (merged over every section) and `body.ext[owner]`.
|
|
15
|
+
* validate() '' when all is well, else a message; the host shows it and does not save.
|
|
16
|
+
* after(booking) newBooking only: awaited after the booking is made (a failure is
|
|
17
|
+
* toasted; the booking stays made).
|
|
18
|
+
* dirty() true when the section holds changes (the slideover asks before closing).
|
|
19
|
+
* destroy() called when the slideover closes.
|
|
20
|
+
*
|
|
21
|
+
* ctx = {
|
|
22
|
+
* kind: 'serviceEditor' | 'newBooking' | 'bookingPanel',
|
|
23
|
+
* owner,
|
|
24
|
+
* service, serviceEditor: the service being edited (null when new);
|
|
25
|
+
* newBooking: the service picked so far (null when none)
|
|
26
|
+
* onServiceChange(fn) newBooking: fn(service|null) whenever the pick changes
|
|
27
|
+
* booking, bookingPanel: the booking shown
|
|
28
|
+
* bookables, services, settings,
|
|
29
|
+
* api: {request(method, path, body), get(path), post(path, body), put(path, body), delete(path)}
|
|
30
|
+
* the admin's request helper; paths are under /api - e.g. '/plugins/booking-pro/series'.
|
|
31
|
+
* A failure throws with `status` and `body` (the server's answer).
|
|
32
|
+
* kit, the admin's tool-kit (openMenuAt, onceClosed, helpPopovers, ...)
|
|
33
|
+
* helpPopovers(el), wire the help popovers (data-help="...") inside el (returns the teardown)
|
|
34
|
+
* onChange(), mark the slideover as changed
|
|
35
|
+
* refresh() bookingPanel: reopen the booking with fresh data
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* The host puts one bordered `<section class="bk-ext" data-owner="...">` per
|
|
39
|
+
* extension after Booking's own fields. A module that fails to load or mount
|
|
40
|
+
* leaves a short muted note - never a broken slideover.
|
|
41
|
+
*
|
|
42
|
+
* @module booking/admin/lib/extension-sections
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import {loadKit, request} from './kit.js';
|
|
46
|
+
|
|
47
|
+
const say = (el, text) => {
|
|
48
|
+
el.innerHTML = '';
|
|
49
|
+
const p = document.createElement('p');
|
|
50
|
+
p.className = 'bk-note';
|
|
51
|
+
p.textContent = text;
|
|
52
|
+
el.appendChild(p);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const sectionApi = {
|
|
56
|
+
request,
|
|
57
|
+
get: (p) => request('GET', p),
|
|
58
|
+
post: (p, b) => request('POST', p, b ?? {}),
|
|
59
|
+
put: (p, b) => request('PUT', p, b ?? {}),
|
|
60
|
+
delete: (p) => request('DELETE', p)
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Mount every extension's section of one kind into `box`.
|
|
65
|
+
*
|
|
66
|
+
* @param {HTMLElement} box
|
|
67
|
+
* @param {'serviceEditor'|'newBooking'|'bookingPanel'} kind
|
|
68
|
+
* @param {object} opts - {boot, service?, booking?, onChange?, refresh?}
|
|
69
|
+
* @returns {Promise<{ready: Promise<void>, validate(): string, values(): Object<string, object>, answers(): object, extFor(): object,
|
|
70
|
+
* after(booking): Promise<void>, dirty(): boolean, serviceChanged(service): void, destroy(): void}>}
|
|
71
|
+
*/
|
|
72
|
+
export function mountSections(box, kind, {boot, service = null, booking = null, onChange = () => {}, refresh = () => {}}) {
|
|
73
|
+
const mounted = []; // {owner, api}
|
|
74
|
+
const listeners = [];
|
|
75
|
+
let gone = false;
|
|
76
|
+
const entries = (boot?.sections || []).filter(s => s && typeof s[kind] === 'string' && s[kind].startsWith(`/plugins/${s.owner}/`));
|
|
77
|
+
|
|
78
|
+
const ready = (async () => {
|
|
79
|
+
if (!entries.length) return;
|
|
80
|
+
const kit = await loadKit();
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (gone) return;
|
|
83
|
+
const el = document.createElement('section');
|
|
84
|
+
el.className = 'bk-ext';
|
|
85
|
+
el.dataset.owner = entry.owner;
|
|
86
|
+
box.appendChild(el);
|
|
87
|
+
const ctx = {
|
|
88
|
+
kind, owner: entry.owner, service, booking,
|
|
89
|
+
onServiceChange: (fn) => { if (typeof fn === 'function') listeners.push(fn); },
|
|
90
|
+
bookables: boot.bookables, services: boot.services, settings: boot.settings,
|
|
91
|
+
api: sectionApi, kit, helpPopovers: (el2) => kit.helpPopovers(el2), onChange, refresh
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
const mod = await import(entry[kind]);
|
|
95
|
+
if (gone) return;
|
|
96
|
+
const mount = mod.mount || mod.default?.mount;
|
|
97
|
+
if (typeof mount !== 'function') throw new Error('no mount()');
|
|
98
|
+
const api = (await mount(el, ctx)) || {};
|
|
99
|
+
if (gone) { try { api.destroy?.(); } catch { /* never */ } return; }
|
|
100
|
+
mounted.push({owner: entry.owner, api});
|
|
101
|
+
window.I?.scan?.(el);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.warn(`[booking] ${entry.owner} section (${kind}) could not load:`, err);
|
|
104
|
+
say(el, `This part (from ${entry.owner}) could not be loaded just now.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})();
|
|
108
|
+
|
|
109
|
+
const call = (m, name, ...args) => { try { return m.api[name]?.(...args); } catch (err) { console.warn(`[booking] ${m.owner}.${name}:`, err); return undefined; } };
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
ready,
|
|
113
|
+
/** The first problem any section reports, or ''. */
|
|
114
|
+
validate() {
|
|
115
|
+
for (const m of mounted) {
|
|
116
|
+
const why = call(m, 'validate');
|
|
117
|
+
if (typeof why === 'string' && why) return why;
|
|
118
|
+
}
|
|
119
|
+
return '';
|
|
120
|
+
},
|
|
121
|
+
/** {owner: values()} for every section that gives any. */
|
|
122
|
+
values() {
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const m of mounted) {
|
|
125
|
+
const v = call(m, 'values');
|
|
126
|
+
if (v && typeof v === 'object') out[m.owner] = v;
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
},
|
|
130
|
+
/** newBooking: every section's answers, merged. */
|
|
131
|
+
answers() {
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const v of Object.values(this.values())) if (v.answers && typeof v.answers === 'object') Object.assign(out, v.answers);
|
|
134
|
+
return out;
|
|
135
|
+
},
|
|
136
|
+
/** newBooking: {owner: ext} for body.ext. */
|
|
137
|
+
extFor() {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const [owner, v] of Object.entries(this.values())) if (v.ext && typeof v.ext === 'object') out[owner] = v.ext;
|
|
140
|
+
return out;
|
|
141
|
+
},
|
|
142
|
+
async after(b) {
|
|
143
|
+
for (const m of mounted) {
|
|
144
|
+
if (typeof m.api.after !== 'function') continue;
|
|
145
|
+
try { await m.api.after(b); } catch (err) {
|
|
146
|
+
E.toast(`Booked, but ${m.owner} could not finish: ${err?.message || err}`, {type: 'warning', duration: 8000});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
dirty: () => mounted.some(m => call(m, 'dirty') === true),
|
|
151
|
+
serviceChanged(svc) {
|
|
152
|
+
for (const fn of listeners) { try { fn(svc || null); } catch (err) { console.warn('[booking] onServiceChange:', err); } }
|
|
153
|
+
},
|
|
154
|
+
destroy() {
|
|
155
|
+
gone = true;
|
|
156
|
+
for (const m of mounted) call(m, 'destroy');
|
|
157
|
+
mounted.length = 0;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|