domma-cms 1.6.55 → 1.6.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/admin/css/calculator.css +1 -1
  2. package/admin/js/lib/analytics-shape.js +1 -1
  3. package/admin/js/lib/calculator-engine.js +1 -1
  4. package/admin/js/lib/calculator.js +22 -10
  5. package/admin/js/lib/form-editor-arrange.js +1 -1
  6. package/admin/js/lib/forms-arrange.js +1 -1
  7. package/admin/js/lib/notes-arrange.js +1 -1
  8. package/admin/js/lib/search-settings-arrange.js +1 -1
  9. package/admin/js/lib/seo-arrange.js +1 -0
  10. package/admin/js/lib/todo-arrange.js +1 -1
  11. package/admin/js/lib/tool-kit.js +1 -1
  12. package/admin/js/lib/tool-recovery.js +4 -4
  13. package/admin/js/templates/analytics.html +26 -14
  14. package/admin/js/templates/contacts.html +26 -5
  15. package/admin/js/templates/form-editor.html +2 -0
  16. package/admin/js/templates/notes.html +11 -8
  17. package/admin/js/templates/search.html +9 -3
  18. package/admin/js/templates/seo.html +23 -17
  19. package/admin/js/templates/todo.html +18 -14
  20. package/admin/js/views/analytics.js +10 -10
  21. package/admin/js/views/contacts.js +47 -47
  22. package/admin/js/views/form-editor.js +7 -7
  23. package/admin/js/views/form-submissions.js +2 -2
  24. package/admin/js/views/forms.js +5 -5
  25. package/admin/js/views/notes.js +6 -6
  26. package/admin/js/views/search.js +2 -2
  27. package/admin/js/views/seo.js +43 -43
  28. package/admin/js/views/todo.js +8 -8
  29. package/package.json +1 -1
  30. package/plugins/_lib/admin/mail/compose-window.js +4 -1
  31. package/plugins/_lib/admin/mail/mail.css +1 -1
  32. package/plugins/_lib/admin/mail/reader-kit.js +364 -0
  33. package/plugins/_lib/admin/mail/reader-view.js +1009 -161
  34. package/plugins/_lib/admin/mail/when-closed.js +33 -3
  35. package/plugins/_lib/mail/readRoutes.js +48 -10
  36. package/plugins/_lib/mail/unreadBadge.js +4 -1
  37. package/plugins/blog/CLAUDE.md +14 -0
  38. package/plugins/blog/admin/css/index.css +5 -0
  39. package/plugins/blog/admin/lib/drafts.js +95 -0
  40. package/plugins/blog/admin/templates/blog.html +1 -1
  41. package/plugins/blog/admin/templates/categories.html +1 -1
  42. package/plugins/blog/admin/templates/comments.html +1 -1
  43. package/plugins/blog/admin/templates/post-editor.html +5 -1
  44. package/plugins/blog/admin/views/blog.js +44 -19
  45. package/plugins/blog/admin/views/categories.js +138 -20
  46. package/plugins/blog/admin/views/comments.js +32 -23
  47. package/plugins/blog/admin/views/kit.js +232 -22
  48. package/plugins/blog/admin/views/post-editor.js +348 -178
  49. package/plugins/blog/admin/views/settings.js +17 -3
  50. package/plugins/blog/plugin.js +1 -1
  51. package/plugins/blog/plugin.json +7 -7
  52. package/plugins/blog/plugin.public.js +1 -1
  53. package/plugins/blog/tests/drafts.test.js +50 -0
  54. package/plugins/booking/CLAUDE.md +13 -0
  55. package/plugins/booking/admin/lib/drafts.js +139 -0
  56. package/plugins/booking/admin/lib/kit.js +246 -25
  57. package/plugins/booking/admin/views/booking.js +11 -2
  58. package/plugins/booking/admin/views/bookings.js +7 -2
  59. package/plugins/booking/admin/views/diary.js +13 -6
  60. package/plugins/booking/admin/views/panels.js +85 -12
  61. package/plugins/booking/admin/views/people.js +41 -2
  62. package/plugins/booking/admin/views/services.js +45 -3
  63. package/plugins/booking/admin/views/settings.js +22 -2
  64. package/plugins/booking/plugin.json +2 -2
  65. package/plugins/booking/tests/admin-drafts.test.js +67 -0
  66. package/plugins/feedback/CLAUDE.md +12 -0
  67. package/plugins/feedback/admin/css/index.css +5 -0
  68. package/plugins/feedback/admin/lib/drafts.js +145 -0
  69. package/plugins/feedback/admin/lib/kit.js +161 -5
  70. package/plugins/feedback/admin/lib/settings.js +46 -28
  71. package/plugins/feedback/admin/views/feedback.js +1 -1
  72. package/plugins/feedback/admin/views/inbox.js +110 -46
  73. package/plugins/feedback/admin/views/send.js +174 -83
  74. package/plugins/feedback/plugin.json +3 -3
  75. package/plugins/feedback/tests/drafts.test.js +93 -0
  76. package/plugins/free-tier.lock.json +75 -63
  77. package/plugins/security/CLAUDE.md +9 -0
  78. package/plugins/security/admin/lib/screen-state.js +120 -0
  79. package/plugins/security/admin/templates/security.html +15 -10
  80. package/plugins/security/admin/views/security.js +270 -132
  81. package/plugins/security/plugin.json +2 -2
  82. package/plugins/security/tests/screen-state.test.js +69 -0
  83. package/plugins/shopping-cart/CLAUDE.md +13 -0
  84. package/plugins/shopping-cart/admin/css/index.css +21 -5
  85. package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
  86. package/plugins/shopping-cart/admin/lib/kit.js +136 -28
  87. package/plugins/shopping-cart/admin/views/orders.js +117 -36
  88. package/plugins/shopping-cart/admin/views/overview.js +38 -8
  89. package/plugins/shopping-cart/admin/views/products.js +317 -68
  90. package/plugins/shopping-cart/admin/views/sales.js +21 -3
  91. package/plugins/shopping-cart/admin/views/settings.js +62 -3
  92. package/plugins/shopping-cart/admin/views/shop.js +18 -3
  93. package/plugins/shopping-cart/plugin.json +3 -3
  94. package/plugins/shopping-cart/tests/accounts.test.js +11 -0
  95. package/plugins/shopping-cart/tests/drafts.test.js +118 -0
  96. package/plugins/support/CLAUDE.md +14 -0
  97. package/plugins/support/admin/css/index.css +10 -0
  98. package/plugins/support/admin/lib/drafts.js +162 -0
  99. package/plugins/support/admin/lib/kit.js +156 -16
  100. package/plugins/support/admin/lib/settings.js +52 -29
  101. package/plugins/support/admin/views/desk.js +114 -33
  102. package/plugins/support/admin/views/inbox.js +85 -26
  103. package/plugins/support/admin/views/support.js +1 -1
  104. package/plugins/support/plugin.json +3 -3
  105. package/plugins/support/tests/drafts.test.js +92 -0
  106. package/public/js/site.js +1 -1
  107. package/server/routes/api/analytics.js +5 -1
  108. package/server/routes/api/contacts.js +42 -0
  109. package/server/routes/api/forms.js +8 -0
  110. package/server/routes/api/seo.js +4 -1
  111. package/server/routes/api/todo.js +50 -10
  112. package/server/server.js +1 -1
  113. package/server/templates/page.html +1 -1
@@ -55,10 +55,17 @@ export const bookingView = {
55
55
  const here = () => location.hash === mountedFor && root.isConnected;
56
56
  const {tab, id, query} = where();
57
57
 
58
+ // The first load: a skeleton (core's loading.js) in a slot that goes when the screen draws.
59
+ root.innerHTML = '';
60
+ const slot = document.createElement('div');
61
+ slot.className = 'bk-note bk-loading-slot dm-loading-slot';
62
+ slot.textContent = 'Loading…';
63
+ root.appendChild(slot);
58
64
  let boot;
59
65
  try {
60
- boot = await api.bootstrap();
66
+ boot = await kit.whileLoading(slot, api.bootstrap(), kit.SHAPES.rows);
61
67
  } catch (err) {
68
+ if (!here()) return;
62
69
  root.innerHTML = '';
63
70
  const p = document.createElement('p');
64
71
  p.className = 'bk-error';
@@ -110,6 +117,8 @@ export const bookingView = {
110
117
 
111
118
  const ctx = {
112
119
  kit, scope, here, root: body, container, boot, settings: boot.settings, id, query,
120
+ /** Like scope, but answers the remover: a panel lets go of the screen when it closes first. */
121
+ hold: (fn) => lifetime.add(fn),
113
122
  /** Tidy the address (a deep link done with) without leaving the screen. */
114
123
  replaceHash(h) { history.replaceState(null, '', h); mountedFor = location.hash; },
115
124
  tz: boot.settings.timeZone,
@@ -199,7 +208,7 @@ export const bookingView = {
199
208
  const c = await api.contactByEmail(query.email).catch(() => null);
200
209
  if (c?.id) Object.assign(prefill, {contactId: c.id, name: c.name || prefill.name, phone: c.phone});
201
210
  }
202
- if (!ROUTE.test(location.hash)) return;
211
+ if (!here()) return;
203
212
  if (wantAction) runAction(wantAction, prefill);
204
213
  else ctx.newBooking(prefill);
205
214
  }
@@ -85,7 +85,7 @@ export async function mount(root, ctx) {
85
85
  <input type="search" class="form-input bk-search" placeholder="Search name, email, service…" aria-label="Search bookings" data-model="query.value">
86
86
  <span data-help-title="Bookings" data-help="Every booking, newest changes included. Click one to open it; right-click (or ⋮, or Shift+F10) to move it, mark it done or a no-show, or cancel it. Cancelled bookings free their time and can then be deleted."></span>
87
87
  </div>
88
- <p class="bk-note" data-bind-hidden="!loading.value">Loading…</p>
88
+ <div class="bk-note bk-loading-slot dm-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
89
89
  <p class="bk-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
90
90
  <p class="bk-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
91
91
  <div class="bk-list" data-each="rows.value key=id"></div>`;
@@ -99,13 +99,18 @@ export async function mount(root, ctx) {
99
99
  if (b) filter.value = b.dataset.filter;
100
100
  });
101
101
 
102
+ // The first load shows a skeleton in the slot; a reload keeps the list on screen.
103
+ let first = true;
102
104
  async function load() {
103
105
  try {
104
- const list = await api.bookings.list();
106
+ const slot = first ? view.querySelector('.bk-loading-slot') : null;
107
+ first = false;
108
+ const list = await (slot ? kit.whileLoading(slot, api.bookings.list(), kit.SHAPES.rows) : api.bookings.list());
105
109
  if (!here()) return;
106
110
  all.value = Array.isArray(list) ? list : [];
107
111
  error.value = '';
108
112
  } catch (err) {
113
+ if (!here()) return;
109
114
  error.value = `The bookings could not load: ${errText(err)}`;
110
115
  } finally {
111
116
  loading.value = false;
@@ -12,7 +12,7 @@
12
12
  * that works the geometry out in JS (diary-layout.js); the markup only binds.
13
13
  */
14
14
 
15
- import {STATUS_LABEL, V, api, copy, errText, esc, openPanel, store, wireRows} from '../lib/kit.js';
15
+ import {STATUS_LABEL, V, api, claim, copy, errText, esc, openPanel, store, wireRows} from '../lib/kit.js';
16
16
  import {columnItems, dayLabel, datesFor, gridHours, layoutColumn, mergeWindows, minuteAt, placeOf} from '../lib/diary-layout.js';
17
17
  import {withNames} from '../lib/details.js';
18
18
  import {addDays, atWallClock, fromMinutes, isDate, wallClock} from '/plugins/booking/public/lib/time.js';
@@ -66,6 +66,8 @@ export async function mount(root, ctx) {
66
66
  /** Class sessions drawn as one block: key -> their bookings. */
67
67
  const sessions = new Map();
68
68
  const loading = M.observable(true);
69
+ // Only the first load hides the grid behind a skeleton; moving a day or a week keeps it on screen.
70
+ const firstLoad = M.observable(true);
69
71
  const error = M.observable('');
70
72
  const label = M.observable('');
71
73
  // What the grid shows, as rows for the keyed lists.
@@ -107,7 +109,8 @@ export async function mount(root, ctx) {
107
109
  <span data-help-title="The Diary" data-help="Bookings in your business's time zone (${esc(ctx.tz)}). Hatched times are outside opening hours. Click a booking to open it; right-click it (or ⋮, or Shift+F10) for Move, Done, No-show and Cancel. Click an empty time to book it. Keys: ← → earlier and later, T today, D day, W week."></span>
108
110
  </div>
109
111
  <p class="bk-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
110
- <div class="bk-diary-wrap" data-part="grid" tabindex="-1">
112
+ <div class="bk-note bk-loading-slot dm-loading-slot" data-bind-hidden="!firstLoad.value || !hasBookables.value">Loading…</div>
113
+ <div class="bk-diary-wrap" data-part="grid" tabindex="-1" data-bind-hidden="firstLoad.value && hasBookables.value">
111
114
  <div class="bk-empty" data-bind-hidden="hasBookables.value"><span data-icon="users" data-icon-size="28"></span>
112
115
  <p><strong>Add who or what can be booked.</strong><br>A person (for appointments) or a room or thing - each with their opening hours.</p>
113
116
  <button type="button" class="btn btn-sm btn-primary" data-on-click="addPerson"><span data-icon="plus" data-icon-size="13"></span> Add a person or room</button></div>
@@ -127,7 +130,7 @@ export async function mount(root, ctx) {
127
130
  const cancelledBox = card.querySelector('[data-part="cancelled"]');
128
131
  scope(kit.helpPopovers(card, {position: 'bottom'}));
129
132
  const model = {
130
- view, label, error, hasBookables, hasServices, heads, hours, cols, gridCols, gridH, steps, checklistOn,
133
+ view, label, error, firstLoad, hasBookables, hasServices, heads, hours, cols, gridCols, gridH, steps, checklistOn,
131
134
  hideChecklist() { store.set(CHECKLIST_KEY, true); checklistOn.value = false; },
132
135
  step(s) { s.go(); },
133
136
  addPerson: () => ctx.go('people', 'new'),
@@ -202,14 +205,15 @@ export async function mount(root, ctx) {
202
205
  try {
203
206
  const from = atWallClock(ds[0], 0, ctx.tz).toISOString();
204
207
  const to = atWallClock(addDays(ds[ds.length - 1], 1), 0, ctx.tz).toISOString();
205
- const list = await api.bookings.list({from, to});
208
+ const slot = firstLoad.peek() && hasBookables.peek() ? card.querySelector('.bk-loading-slot') : null;
209
+ const list = await (slot ? kit.whileLoading(slot, api.bookings.list({from, to}), kit.SHAPES.table) : api.bookings.list({from, to}));
206
210
  if (my !== seq || !here()) return;
207
211
  bookings.value = Array.isArray(list) ? list : [];
208
212
  error.value = '';
209
213
  } catch (err) {
210
- if (my === seq) error.value = `The diary could not load: ${errText(err)}`;
214
+ if (my === seq && here()) error.value = `The diary could not load: ${errText(err)}`;
211
215
  } finally {
212
- if (my === seq) loading.value = false;
216
+ if (my === seq) { loading.value = false; firstLoad.value = false; }
213
217
  }
214
218
  }
215
219
  ctx.onBookingsChanged = () => { if (here()) load(); };
@@ -330,6 +334,8 @@ export async function mount(root, ctx) {
330
334
  /** A class: who is in it, each opening their booking. */
331
335
  async function openSession(list) {
332
336
  if (!list.length) return;
337
+ const release = claim('session');
338
+ if (!release) return;
333
339
  const first = list[0];
334
340
  const s = wallClock(first.startAt, ctx.tz);
335
341
  const e = wallClock(first.endAt, ctx.tz);
@@ -337,6 +343,7 @@ export async function mount(root, ctx) {
337
343
  panel = await openPanel({
338
344
  title: `${first.serviceName}, ${s.time}-${e.time}`,
339
345
  size: 'sm',
346
+ ctx, release,
340
347
  html: `<div class="bk-form">
341
348
  <p class="bk-muted">${esc(dayLabel(s.date))}${first.bookableName ? ` · with ${esc(withNames(first))}` : ''} · ${list.length} booked</p>
342
349
  <div class="bk-list bk-session-list">${list.map(b => `<button type="button" class="bk-row" data-open="${esc(b.id)}">
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import {
12
- STATUS_LABEL, STATUS_TONE, api, dateLabel, errText, esc, fmtDateTime, isClash, minutesLabel, openPanel, refreshBadge
12
+ STATUS_LABEL, STATUS_TONE, api, claim, dateLabel, errText, esc, fmtDateTime, isClash, minutesLabel, openPanel, refreshBadge
13
13
  } from '../lib/kit.js';
14
14
  import {atWallClock, isDate, toMinutes, wallClock} from '/plugins/booking/public/lib/time.js';
15
15
  import {answerRows, placesLabel, sourceLabel, withNames} from '../lib/details.js';
@@ -70,6 +70,8 @@ export async function setStatus(ctx, b, status) {
70
70
 
71
71
  /** Cancel, with a reason and whether to email the customer. Resolves the booking, or null. */
72
72
  export function cancelBooking(ctx, b) {
73
+ const release = claim(`cancel:${b.id}`);
74
+ if (!release) return Promise.resolve(null);
73
75
  return new Promise((resolve) => {
74
76
  const reason = M.observable('');
75
77
  const notify = M.observable(Boolean(b.email) && ctx.boot.mailReady);
@@ -78,6 +80,7 @@ export function cancelBooking(ctx, b) {
78
80
  openPanel({
79
81
  title: 'Cancel booking',
80
82
  size: 'sm',
83
+ ctx, release,
81
84
  model: {reason, notify},
82
85
  html: `<div class="bk-form">
83
86
  <p><strong>${esc(b.serviceName)}</strong> for <strong>${esc(b.name)}</strong><br><span class="bk-muted">${esc(timeRange(b, ctx.tz))}</span></p>
@@ -110,7 +113,11 @@ export function cancelBooking(ctx, b) {
110
113
  }
111
114
  }
112
115
  }
113
- }).then(p => { panel = p; });
116
+ }).then(p => { panel = p; if (!p) resolve(null); }, (err) => {
117
+ // The panel could not be built (openPanel has released its claim and tidied up).
118
+ E.toast(`That could not open: ${errText(err)}`, {type: 'error'});
119
+ resolve(null);
120
+ });
114
121
  });
115
122
  }
116
123
 
@@ -207,6 +214,12 @@ function timePicker(ctx, {date, time = '', ignore = ''}) {
207
214
  return {
208
215
  el, load,
209
216
  value: () => ({date: dateIn.value, time: timeIn.value}),
217
+ /** Put a date and time back (a draft), and look up that day's free times. */
218
+ set({date: d, time: t} = {}) {
219
+ if (isDate(d)) dateIn.value = d;
220
+ timeIn.value = typeof t === 'string' ? t : '';
221
+ load();
222
+ },
210
223
  startAt() {
211
224
  if (!isDate(dateIn.value) || !Number.isFinite(toMinutes(timeIn.value))) return '';
212
225
  return atWallClock(dateIn.value, timeIn.value, ctx.tz).toISOString();
@@ -238,9 +251,12 @@ export async function newBooking(ctx, prefill = {}) {
238
251
  const ok = await E.confirm(!boot.bookables.length
239
252
  ? 'Add who or what can be booked first - a person or a room - then a service they provide.<br><br>Go to People &amp; rooms now?'
240
253
  : 'Add a service first - what can be booked, for how long, and with whom.<br><br>Go to Services now?');
241
- if (ok) ctx.go(!boot.bookables.length ? 'people' : 'services', 'new');
254
+ if (ok && ctx.here()) ctx.go(!boot.bookables.length ? 'people' : 'services', 'new');
242
255
  return null;
243
256
  }
257
+ // One New booking at a time: a second click (or a click in the Diary) while it is open does nothing.
258
+ const release = claim('new-booking');
259
+ if (!release) return null;
244
260
  const fromBookable = prefill.bookableId ? services.filter(s => s.bookables.includes(prefill.bookableId)) : [];
245
261
  const f = {
246
262
  serviceId: M.observable(prefill.serviceId || fromBookable[0]?.id || (services.length === 1 ? services[0].id : '')),
@@ -253,7 +269,9 @@ export async function newBooking(ctx, prefill = {}) {
253
269
  notify: M.observable(boot.mailReady && boot.settings.sendConfirmation !== false),
254
270
  saving: M.observable(false)
255
271
  };
256
- const contactId = prefill.contactId || '';
272
+ // The contact it was opened from; a restored draft brings back its own.
273
+ let contactId = prefill.contactId || '';
274
+ const contactName = M.observable(contactId ? (prefill.name || prefill.email || '') : '');
257
275
  const providers = M.observable([]);
258
276
  const updateProviders = () => {
259
277
  const svc = boot.services.find(s => s.id === f.serviceId.peek());
@@ -268,15 +286,37 @@ export async function newBooking(ctx, prefill = {}) {
268
286
  let panel = null;
269
287
  let made = null;
270
288
  let sections = null;
289
+ // Set in before(): put a service and a person back as if picked (the selects are drawn by hand).
290
+ let pickService = () => {};
291
+ const prefilled = Boolean(prefill.contactId || prefill.email || prefill.name || prefill.date || prefill.time || prefill.serviceId || prefill.bookableId);
271
292
 
272
293
  return new Promise((resolve) => {
273
294
  openPanel({
274
295
  title: 'New booking',
275
296
  size: 'md',
276
- model: {...f, providers},
297
+ ctx, release,
298
+ model: {...f, providers, contactName},
277
299
  dirty: () => !made && (typed() !== opened || Boolean(sections?.dirty())),
278
- html: `<div class="bk-form">
279
- ${contactId ? `<p class="bk-note"><span data-icon="user" data-icon-size="13"></span> From your contact <strong>${esc(prefill.name || prefill.email)}</strong>.</p>` : ''}
300
+ // Extension sections (Booking Pro's questions) are not in the draft: the section contract has
301
+ // values() but no way to put values back. What Booking's own form holds is.
302
+ draft: {
303
+ key: 'new-booking', kind: 'a new booking', label: () => f.name.peek(), prefilled,
304
+ values: () => ({serviceId: f.serviceId.peek(), bookableId: f.bookableId.peek(), ...picker.value(),
305
+ name: f.name.peek(), email: f.email.peek(), phone: f.phone.peek(), notes: f.notes.peek(),
306
+ staffNote: f.staffNote.peek(), notify: f.notify.peek(), contactId, contactName: contactName.peek()}),
307
+ apply(v, el) {
308
+ for (const k of ['name', 'email', 'phone', 'notes', 'staffNote']) if (typeof v[k] === 'string') f[k].value = v[k];
309
+ if (typeof v.notify === 'boolean') f.notify.value = v.notify;
310
+ contactId = typeof v.contactId === 'string' ? v.contactId : '';
311
+ contactName.value = contactId ? String(v.contactName || '') : '';
312
+ pickService(String(v.serviceId || ''), String(v.bookableId || ''));
313
+ picker.set({date: v.date, time: v.time});
314
+ const box = el.querySelector('[data-bool="notify"]');
315
+ if (box) box.checked = f.notify.peek();
316
+ }
317
+ },
318
+ html: `<div class="bk-form bk-new-booking">
319
+ <p class="bk-note" data-bind-hidden="!contactName.value"><span data-icon="user" data-icon-size="13"></span> From your contact <strong data-bind-text="contactName.value"></strong>.</p>
280
320
  <div class="bk-grid">
281
321
  <div><label class="form-label">Service <span data-help-title="Service" data-help="What is being booked. Its length and buffers decide which times are free."></span></label>
282
322
  <select class="form-select" data-part="service"><option value="">Choose…</option>${services.map(s => `<option value="${esc(s.id)}">${esc(s.name)} (${esc(minutesLabel(s.duration))})${s.active === false ? ' - off' : ''}</option>`).join('')}</select></div>
@@ -319,6 +359,16 @@ export async function newBooking(ctx, prefill = {}) {
319
359
  f.serviceId.value = svcSel.value; updateProviders(); drawWith(); picker.load({service: svcSel.value, with: withSel.value});
320
360
  sections.serviceChanged(serviceNow());
321
361
  });
362
+ pickService = (serviceId, bookableId) => {
363
+ // A service that has gone (or been switched off) since is left unpicked.
364
+ svcSel.value = serviceId;
365
+ f.serviceId.value = svcSel.value;
366
+ updateProviders();
367
+ f.bookableId.value = providers.peek().some(p => p.id === bookableId) ? bookableId : '';
368
+ drawWith();
369
+ picker.load({service: svcSel.value, with: withSel.value});
370
+ sections.serviceChanged(serviceNow());
371
+ };
322
372
  withSel.addEventListener('change', () => { f.bookableId.value = withSel.value; picker.load({service: svcSel.value, with: withSel.value}); });
323
373
  const box = el.querySelector('[data-bool="notify"]');
324
374
  box.checked = f.notify.peek();
@@ -364,13 +414,19 @@ export async function newBooking(ctx, prefill = {}) {
364
414
  }
365
415
  }
366
416
  }
367
- }).then(p => { panel = p; });
417
+ }).then(p => { panel = p; if (!p) resolve(null); }, (err) => {
418
+ // The panel could not be built (openPanel has released its claim and tidied up).
419
+ E.toast(`That could not open: ${errText(err)}`, {type: 'error'});
420
+ resolve(null);
421
+ });
368
422
  });
369
423
  }
370
424
 
371
425
  // ---- Move ------------------------------------------------------------------------
372
426
 
373
427
  export function moveBooking(ctx, b) {
428
+ const release = claim(`move:${b.id}`);
429
+ if (!release) return Promise.resolve(null);
374
430
  const svc = ctx.boot.services.find(s => s.id === b.serviceId);
375
431
  const list = svc ? ctx.boot.bookables.filter(x => svc.bookables.includes(x.id)) : ctx.boot.bookables.filter(x => x.id === b.bookableId);
376
432
  const at = wallClock(b.startAt, ctx.tz);
@@ -383,6 +439,7 @@ export function moveBooking(ctx, b) {
383
439
  openPanel({
384
440
  title: 'Move booking',
385
441
  size: 'md',
442
+ ctx, release,
386
443
  model: {withId, notify},
387
444
  dirty: () => !moved && (picker.value().date !== at.date || picker.value().time !== at.time || withId.peek() !== b.bookableId),
388
445
  html: `<div class="bk-form">
@@ -423,15 +480,22 @@ export function moveBooking(ctx, b) {
423
480
  }
424
481
  }
425
482
  }
426
- }).then(p => { panel = p; });
483
+ }).then(p => { panel = p; if (!p) resolve(null); }, (err) => {
484
+ // The panel could not be built (openPanel has released its claim and tidied up).
485
+ E.toast(`That could not open: ${errText(err)}`, {type: 'error'});
486
+ resolve(null);
487
+ });
427
488
  });
428
489
  }
429
490
 
430
491
  // ---- One booking ------------------------------------------------------------------
431
492
 
432
493
  export async function openBooking(ctx, id) {
494
+ const release = claim(`booking:${id}`);
495
+ if (!release) return null;
433
496
  let b;
434
- try { b = await api.bookings.get(id); } catch (err) { E.toast(`That booking could not be opened: ${errText(err)}`, {type: 'error'}); return; }
497
+ try { b = await api.bookings.get(id); } catch (err) { release(); if (ctx.here()) E.toast(`That booking could not be opened: ${errText(err)}`, {type: 'error'}); return null; }
498
+ if (!ctx.here()) { release(); return null; }
435
499
  const staffNote = M.observable(b.staffNote || '');
436
500
  const saved = M.observable(b.staffNote || '');
437
501
  const tone = `is-${STATUS_TONE[b.status] || 'muted'}`;
@@ -450,8 +514,14 @@ export async function openBooking(ctx, id) {
450
514
  panel = await openPanel({
451
515
  title: `${b.serviceName}: ${b.name}`,
452
516
  size: 'md',
517
+ ctx, release,
453
518
  model: {staffNote, saved},
454
519
  dirty: () => staffNote.peek() !== saved.peek(),
520
+ draft: {
521
+ key: `note:${b.id}`, label: `staff note for ${b.name}`, was: b.updatedAt || '',
522
+ values: () => ({staffNote: staffNote.peek()}),
523
+ apply: (v) => { if (typeof v.staffNote === 'string') staffNote.value = v.staffNote; }
524
+ },
455
525
  cleanup: () => sections?.destroy(),
456
526
  before: (el) => {
457
527
  sections = mountSections(el.querySelector('[data-part="ext"]'), 'bookingPanel', {boot: ctx.boot, booking: b,
@@ -499,9 +569,12 @@ export async function openBooking(ctx, id) {
499
569
  </div>`,
500
570
  methods: {
501
571
  async save() {
572
+ // What was sent is what is saved (the form is locked meanwhile, but say so plainly).
573
+ const sent = staffNote.peek();
502
574
  try {
503
- await api.bookings.update(b.id, {staffNote: staffNote.peek()});
504
- saved.value = staffNote.peek();
575
+ const next = await api.bookings.update(b.id, {staffNote: sent});
576
+ saved.value = sent;
577
+ panel?.rebase(next?.updatedAt);
505
578
  E.toast('Note saved.', {type: 'success'});
506
579
  changed(ctx);
507
580
  } catch (err) {
@@ -7,7 +7,8 @@
7
7
  * weekly opening hours (several windows a day) and days off.
8
8
  */
9
9
 
10
- import {COLOUR_LABEL, KIND_LABEL, api, errText, esc, openPanel, wireRows} from '../lib/kit.js';
10
+ import {COLOUR_LABEL, KIND_LABEL, allDrafts, api, ask, claim, dropDraft, errText, esc, openPanel, wireRows} from '../lib/kit.js';
11
+ import {orphanDrafts, restoreText, usableDraft} from '../lib/drafts.js';
11
12
  import {cleanBookable} from '/plugins/booking/public/lib/clean.js';
12
13
  import {DAY_KEYS, isDate, toMinutes} from '/plugins/booking/public/lib/time.js';
13
14
 
@@ -143,7 +144,10 @@ export async function mount(root, ctx) {
143
144
  } catch (err) { E.toast(errText(err), {type: 'error', duration: 8000}); }
144
145
  }
145
146
 
146
- async function edit(b) {
147
+ /** `initial`: a draft to start from (one of a person or room deleted since). */
148
+ async function edit(b, initial = null) {
149
+ const release = claim(`bookable:${b?.id || 'new'}`);
150
+ if (!release) return;
147
151
  const start = b || cleanBookable({name: ''}).data;
148
152
  const f = {};
149
153
  for (const k of ['name', 'kind', 'description', 'userId', 'email', 'colour']) f[k] = M.observable(start[k] ?? '');
@@ -169,6 +173,21 @@ export async function mount(root, ctx) {
169
173
  panel = await openPanel({
170
174
  title: b ? `Edit ${b.name}` : 'Add a person or room',
171
175
  size: 'lg',
176
+ ctx, release,
177
+ draft: {
178
+ key: `bookable:${b?.id || 'new'}`, label: () => f.name.peek() || b?.name || '', was: b?.updatedAt || '', initial,
179
+ values: () => ({f: Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])),
180
+ bools: Object.fromEntries(Object.entries(bools).map(([k, o]) => [k, o.peek()])), hours: hoursOut(), daysOff: daysOffOut()}),
181
+ apply(v, el) {
182
+ for (const [k, val] of Object.entries(v.f || {})) if (f[k] && typeof val === 'string') f[k].value = val;
183
+ for (const [k, val] of Object.entries(v.bools || {})) if (bools[k] && typeof val === 'boolean') bools[k].value = val;
184
+ for (const box of el.querySelectorAll('[data-bool]')) box.checked = bools[box.dataset.bool].peek();
185
+ const hours = v.hours && typeof v.hours === 'object' ? v.hours : {};
186
+ for (const d of days.peek()) d.windows.value = (Array.isArray(hours[d.id]) ? hours[d.id] : []).map(windowRow);
187
+ daysOff.value = (Array.isArray(v.daysOff) ? v.daysOff : []).map(offRow);
188
+ rescan();
189
+ }
190
+ },
172
191
  model: {
173
192
  ...f, saving, isPerson, days, daysOff,
174
193
  addWindow(day) {
@@ -269,11 +288,31 @@ export async function mount(root, ctx) {
269
288
  }
270
289
  }
271
290
  });
291
+ if (panel && initial?.from) dropDraft(initial.from);
272
292
  }
273
293
 
274
294
  if (ctx.id && here()) {
275
295
  if (ctx.id === 'new') edit(null);
276
296
  else if (byId(ctx.id)) edit(byId(ctx.id));
277
297
  ctx.replaceHash('#/plugins/booking/people');
298
+ } else {
299
+ offerOrphan();
300
+ }
301
+
302
+ /** Changes left on a person or room deleted since: offered back as a new one, once asked. */
303
+ async function offerOrphan() {
304
+ const map = allDrafts();
305
+ // A new one's unsaved draft is offered first (when New is opened): starting the orphan as new
306
+ // now would write over it. The orphan waits until that one is saved or discarded.
307
+ if (usableDraft(map['bookable:new'])) return;
308
+ const [o] = orphanDrafts(map, 'bookable:', people.peek().map(x => x.id));
309
+ if (!o) return;
310
+ // Discard is the confirm button: Escape (Cancel) - and leaving - keep the work.
311
+ const discard = await ask(esc(restoreText('deleted', {label: o.draft.label, at: o.draft.at})),
312
+ {title: 'Unsaved changes', confirmText: 'Discard them', cancelText: 'Add them as new'}, ctx);
313
+ if (!here()) return; // left while asked: the draft stays
314
+ if (discard) { dropDraft(o.key); return; }
315
+ // Its old key goes once the new panel holds it (edit drops `from` after opening).
316
+ edit(null, {...o.draft, from: o.key});
278
317
  }
279
318
  }
@@ -6,7 +6,8 @@
6
6
  * `#/plugins/booking/services/new` opens a new one; `/services/<id>` one.
7
7
  */
8
8
 
9
- import {COLOUR_LABEL, api, copy, errText, esc, minutesLabel, openPanel, wireRows} from '../lib/kit.js';
9
+ import {COLOUR_LABEL, allDrafts, api, ask, claim, copy, dropDraft, errText, esc, minutesLabel, openPanel, wireRows} from '../lib/kit.js';
10
+ import {orphanDrafts, restoreText, usableDraft} from '../lib/drafts.js';
10
11
  import {cleanService} from '/plugins/booking/public/lib/clean.js';
11
12
  import {mountSections} from '../lib/extension-sections.js';
12
13
 
@@ -102,11 +103,14 @@ export async function mount(root, ctx) {
102
103
  } catch (err) { E.toast(errText(err), {type: 'error', duration: 8000}); }
103
104
  }
104
105
 
105
- async function edit(s) {
106
+ /** `initial`: a draft to start from (one of a service deleted since). */
107
+ async function edit(s, initial = null) {
106
108
  if (!s && !boot.bookables.length) {
107
- if (await E.confirm('A service needs someone or something to provide it. Add a person or room first?')) ctx.go('people', 'new');
109
+ if (await E.confirm('A service needs someone or something to provide it. Add a person or room first?') && here()) ctx.go('people', 'new');
108
110
  return;
109
111
  }
112
+ const release = claim(`service:${s?.id || 'new'}`);
113
+ if (!release) return;
110
114
  const start = s || cleanService({name: '', bookables: boot.bookables.filter(b => b.active !== false).length === 1 ? [boot.bookables.find(b => b.active !== false).id] : []}).data;
111
115
  const f = {};
112
116
  for (const k of ['name', 'slug', 'description', 'duration', 'bufferBefore', 'bufferAfter', 'step', 'maxAdvanceDays', 'colour']) f[k] = M.observable(start[k] ?? '');
@@ -125,8 +129,26 @@ export async function mount(root, ctx) {
125
129
  panel = await openPanel({
126
130
  title: s ? `Edit ${s.name}` : 'New service',
127
131
  size: 'md',
132
+ ctx, release,
128
133
  model: {...f, saving, shortcode},
129
134
  dirty: () => snapshot() !== opened || Boolean(sections?.dirty()),
135
+ // Booking Pro's section (places, questions…) is not in the draft: the section contract has
136
+ // values() but no way to put them back. Booking's own fields, ticks and providers are.
137
+ draft: {
138
+ key: `service:${s?.id || 'new'}`, label: () => f.name.peek() || s?.name || '', was: s?.updatedAt || '', initial,
139
+ values: () => ({f: Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])),
140
+ bools: Object.fromEntries(Object.entries(bools).map(([k, o]) => [k, o.peek()])), picked: [...picked]}),
141
+ apply(v, el) {
142
+ for (const [k, val] of Object.entries(v.f || {})) if (f[k] && (typeof val === 'string' || typeof val === 'number')) f[k].value = val;
143
+ for (const [k, val] of Object.entries(v.bools || {})) if (bools[k] && typeof val === 'boolean') bools[k].value = val;
144
+ for (const box of el.querySelectorAll('[data-bool]')) box.checked = bools[box.dataset.bool].peek();
145
+ // Providers deleted since are left out.
146
+ picked.clear();
147
+ const live = new Set(boot.bookables.map(b => b.id));
148
+ for (const id of Array.isArray(v.picked) ? v.picked : []) if (live.has(id)) picked.add(id);
149
+ for (const box of el.querySelectorAll('[data-part="picks"] input')) box.checked = picked.has(box.value);
150
+ }
151
+ },
130
152
  cleanup: () => sections?.destroy(),
131
153
  html: `<div class="bk-form">
132
154
  <div class="bk-grid">
@@ -212,11 +234,31 @@ export async function mount(root, ctx) {
212
234
  }
213
235
  }
214
236
  });
237
+ if (panel && initial?.from) dropDraft(initial.from);
215
238
  }
216
239
 
217
240
  if (ctx.id && here()) {
218
241
  if (ctx.id === 'new') edit(null);
219
242
  else if (byId(ctx.id)) edit(byId(ctx.id));
220
243
  ctx.replaceHash('#/plugins/booking/services');
244
+ } else {
245
+ offerOrphan();
246
+ }
247
+
248
+ /** Changes left on a service that has been deleted since: offered back as a new one, once asked. */
249
+ async function offerOrphan() {
250
+ const map = allDrafts();
251
+ // A new one's unsaved draft is offered first (when New is opened): starting the orphan as new
252
+ // now would write over it. The orphan waits until that one is saved or discarded.
253
+ if (usableDraft(map['service:new'])) return;
254
+ const [o] = orphanDrafts(map, 'service:', services.peek().map(x => x.id));
255
+ if (!o) return;
256
+ // Discard is the confirm button: Escape (Cancel) - and leaving - keep the work.
257
+ const discard = await ask(esc(restoreText('deleted', {label: o.draft.label, at: o.draft.at})),
258
+ {title: 'Unsaved changes', confirmText: 'Discard them', cancelText: 'Start a new service'}, ctx);
259
+ if (!here()) return; // left while asked: the draft stays
260
+ if (discard) { dropDraft(o.key); return; }
261
+ // Its old key goes once the new panel holds it (edit drops `from` after opening).
262
+ edit(null, {...o.draft, from: o.key});
221
263
  }
222
264
  }
@@ -5,7 +5,8 @@
5
5
  * per-field messages shown beside the field.
6
6
  */
7
7
 
8
- import {api, copy, errText, esc, openPanel, store} from '../lib/kit.js';
8
+ import {api, claim, copy, errText, esc, openPanel, store} from '../lib/kit.js';
9
+ import {fingerprint} from '../lib/drafts.js';
9
10
  import {isDate} from '/plugins/booking/public/lib/time.js';
10
11
 
11
12
  const SECTIONS = [
@@ -36,8 +37,12 @@ function zones(current) {
36
37
 
37
38
  export async function openSettings(ctx, section = 'page') {
38
39
  store.set('booking.settings-seen', true);
40
+ // One settings panel: the cog clicked twice while they load opens it once.
41
+ const release = claim('settings');
42
+ if (!release) return;
39
43
  let info;
40
- try { info = await api.settings.get(); } catch (err) { E.toast(`Settings did not load: ${errText(err)}`, {type: 'error'}); return; }
44
+ try { info = await api.settings.get(); } catch (err) { release(); if (ctx.here()) E.toast(`Settings did not load: ${errText(err)}`, {type: 'error'}); return; }
45
+ if (!ctx.here()) { release(); return; }
41
46
  const s = info.settings;
42
47
  const f = {};
43
48
  for (const [k, v] of Object.entries(s)) if (k !== 'closures') f[k] = M.observable(v);
@@ -149,7 +154,22 @@ export async function openSettings(ctx, section = 'page') {
149
154
  size: 'md',
150
155
  html,
151
156
  model,
157
+ ctx, release,
152
158
  dirty: () => snapshot() !== opened,
159
+ // Settings carry no updatedAt: `was` is a fingerprint of them as loaded, so a save made
160
+ // elsewhere since shows up as a change.
161
+ draft: {
162
+ key: 'settings', label: 'Booking settings', was: fingerprint(s),
163
+ values: () => ({f: Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])), closures: closuresOut()}),
164
+ apply(v, el) {
165
+ for (const [k, val] of Object.entries(v.f || {})) {
166
+ if (f[k] && ['string', 'number', 'boolean'].includes(typeof val) && typeof val === typeof f[k].peek()) f[k].value = val;
167
+ else if (f[k] && typeof f[k].peek() === 'number' && typeof val === 'string') f[k].value = val; // a number typed is text
168
+ }
169
+ for (const box of el.querySelectorAll('[data-bool]')) box.checked = Boolean(f[box.dataset.bool].peek());
170
+ closures.value = (Array.isArray(v.closures) ? v.closures : []).map(closureRow);
171
+ }
172
+ },
153
173
  before: (el) => {
154
174
  for (const box of el.querySelectorAll('[data-bool]')) {
155
175
  box.checked = Boolean(f[box.dataset.bool].peek());
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "booking",
3
3
  "displayName": "Booking",
4
- "version": "1.6.4",
4
+ "version": "1.7.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",
8
- "date": "2026-10-13",
8
+ "date": "2026-10-15",
9
9
  "icon": "clipboard-check",
10
10
  "uses": {
11
11
  "contacts": "Customers become contacts; Book… on a contact and a Bookings panel on the contact card",