domma-cms 1.6.56 → 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 (111) 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/server/routes/api/analytics.js +5 -1
  107. package/server/routes/api/contacts.js +42 -0
  108. package/server/routes/api/forms.js +8 -0
  109. package/server/routes/api/seo.js +4 -1
  110. package/server/routes/api/todo.js +50 -10
  111. package/server/server.js +1 -1
@@ -14,9 +14,10 @@
14
14
  */
15
15
 
16
16
  import {
17
- FILE_TYPES, THREAD, TONE, api, copyText, esc, fileKeys, filePicker, fileSize, findFile, listOf, openPanel, refreshBadge, rowKeys, stamp, telHref,
18
- threadFiles, threadRows, when
17
+ FILE_TYPES, THREAD, TONE, api, copyText, esc, fileKeys, filePicker, fileSize, findFile, guardPanel, listOf, msgKeys, offerDraft, openPanel, panelSet,
18
+ refreshBadge, rowKeys, stamp, store, telHref, threadFiles, threadRows, when
19
19
  } from '../lib/kit.js';
20
+ import {DRAFT_KEYS, afterSend, draftQuestion, draftText, draftValues, newDraftQuestion, ticketPrint} from '../lib/drafts.js';
20
21
 
21
22
  const ROW = `
22
23
  <div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
@@ -62,6 +63,8 @@ export async function mount(root, ctx) {
62
63
  const connected = M.observable(Boolean(ctx.mode.connected));
63
64
  const contact = M.observable(ctx.mode.contact || {});
64
65
  const tickets = M.observable([]);
66
+ const panels = panelSet();
67
+ scope(() => panels.leave());
65
68
  const loading = M.observable(Boolean(ctx.mode.connected));
66
69
  const reachable = M.observable(true);
67
70
  const error = M.observable('');
@@ -155,21 +158,24 @@ export async function mount(root, ctx) {
155
158
  if (c.phone) tel.setAttribute('href', telHref(c.phone));
156
159
  }));
157
160
 
161
+ // The first load shows a skeleton in the slot; a reload keeps the list on screen.
162
+ let loaded = false;
158
163
  async function load() {
159
- loading.value = true;
164
+ const first = !loaded;
160
165
  try {
161
- const res = await api.tickets();
166
+ const asked = api.tickets();
167
+ const res = first ? await kit.whileLoading(view.querySelector('.sp-loading'), asked, kit.SHAPES.iconRows) : await asked;
162
168
  if (!here()) return;
163
169
  connected.value = Boolean(res.connected);
164
170
  reachable.value = Boolean(res.reachable) || !res.connected;
165
171
  if (res.contact) contact.value = res.contact;
166
172
  tickets.value = listOf(res, 'tickets');
167
173
  error.value = '';
174
+ loaded = true;
168
175
  } catch (err) {
169
- error.value = `Could not load your requests: ${err.message || err}`;
176
+ if (here()) error.value = `Could not load your requests: ${err.message || err}`;
170
177
  } finally {
171
- loading.value = false;
172
- I.scan(view);
178
+ if (here()) { loading.value = false; I.scan(view); }
173
179
  }
174
180
  }
175
181
 
@@ -190,13 +196,14 @@ export async function mount(root, ctx) {
190
196
  async resolve(t) {
191
197
  const r = await api.close(t.ref);
192
198
  E.toast(r.message || 'Marked as resolved.', {type: r.state === 'sent' ? 'success' : 'warning'});
193
- await load();
199
+ refreshBadge();
200
+ if (here()) await load();
194
201
  },
195
202
  async archive(t, on) {
196
203
  await api.shelf(t.ref, on ? 'archived' : null, t.stale ? undefined : lastDomma(t));
197
204
  patchLocal(t.ref, {archived: on, ...(on && {unread: false})});
198
205
  refreshBadge();
199
- E.toast(on ? `Archived "${t.subject}". A new reply from Domma brings it back.` : `"${t.subject}" is back in your requests.`, {type: 'success'});
206
+ E.toast(on ? `Archived “${t.subject}”. A new reply from Domma brings it back.` : `“${t.subject}” is back in your requests.`, {type: 'success'});
200
207
  },
201
208
  async remove(t) {
202
209
  const local = t.status === 'waiting' || t.status === 'refused';
@@ -205,6 +212,11 @@ export async function mount(root, ctx) {
205
212
  : `Remove "${esc(t.subject)}" from this site's list? Domma keeps the conversation, and it comes back here if Domma replies again.`;
206
213
  if (!await E.confirm(ask)) return false;
207
214
  const r = await api.shelf(t.ref, 'removed', t.stale ? undefined : lastDomma(t));
215
+ // Removed on purpose: its panel goes without asking, and so does a message half-written on it.
216
+ const showing = panels.get('detail');
217
+ if (showing?.key === t.ref) showing.close?.(true);
218
+ store.remove(DRAFT_KEYS.reply(t.ref));
219
+ if (!here()) return true;
208
220
  tickets.value = tickets.peek().filter(x => x.ref !== t.ref);
209
221
  refreshBadge();
210
222
  E.toast(r.state === 'discarded' ? 'Removed - it will not be sent.'
@@ -229,12 +241,25 @@ export async function mount(root, ctx) {
229
241
  ];
230
242
  }
231
243
 
232
- function compose() {
233
- const f = {category: M.observable('help'), priority: M.observable('normal'), subject: M.observable(''), body: M.observable(''),
234
- where: M.observable(''), busy: M.observable(false), room: M.observable(true), uploading: M.observable(false)};
235
- let panel = null;
244
+ const FORM = {category: 'help', priority: 'normal', subject: '', body: '', where: ''};
245
+
246
+ async function compose() {
247
+ if (!panels.claim('compose')) return;
248
+ let opened = false;
249
+ try {
250
+ // A request left unsent last time is offered back first; it goes only on "Throw away".
251
+ const offer = await offerDraft(DRAFT_KEYS.compose, {here, question: (d) => newDraftQuestion(d, 'the request')});
252
+ if (offer.state !== 'gone') opened = await openCompose(offer.state === 'keep' ? draftValues(offer.draft, FORM) : {...FORM});
253
+ } catch (err) { fail(err); }
254
+ if (!opened) panels.release('compose');
255
+ }
256
+
257
+ async function openCompose(start) {
258
+ const f = {category: M.observable(start.category), priority: M.observable(start.priority), subject: M.observable(start.subject),
259
+ body: M.observable(start.body), where: M.observable(start.where), busy: M.observable(false), room: M.observable(true), uploading: M.observable(false)};
260
+ let guard = null;
236
261
  let wired = null;
237
- openPanel({
262
+ const p = await openPanel({
238
263
  title: 'Ask Domma for help',
239
264
  model: f,
240
265
  cleanup: () => wired?.dispose(),
@@ -245,7 +270,7 @@ export async function mount(root, ctx) {
245
270
  </div>
246
271
  <label class="form-label" for="sp-subject">Subject</label>
247
272
  <input id="sp-subject" class="form-input" maxlength="140" data-model="subject.value" placeholder="One line - e.g. My contact form stopped sending">
248
- <label class="form-label" for="sp-body">How can we help? <span data-help="What you were doing, what you expected and what happened instead. A page address or the screen you were on helps us get there faster."></span></label>
273
+ <label class="form-label" for="sp-body">How can we help? <span data-help="What you were doing, what you expected and what happened instead. A page address or the screen you were on helps us get there faster. If you leave before sending, what you typed is kept and offered back next time."></span></label>
249
274
  <textarea id="sp-body" class="form-textarea" rows="8" maxlength="8000" data-model="body.value"></textarea>
250
275
  <label class="form-label">How urgent is it?</label>
251
276
  <div class="sp-choices is-two" role="radiogroup" aria-label="Priority">
@@ -253,7 +278,7 @@ export async function mount(root, ctx) {
253
278
  </div>
254
279
  <label class="form-label" for="sp-where">Where <span data-help="The page or admin screen it is about, if there is one - e.g. /contact, or Pages. Optional."></span></label>
255
280
  <input id="sp-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
256
- <label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps us most. Only the Domma team sees them.`)}"></span></label>
281
+ <label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps us most. Only the Domma team sees them. Files are not kept if you leave before sending - attach them again.`)}"></span></label>
257
282
  ${attachUi('Or paste a screenshot into the message.')}
258
283
  <p class="sp-hint">Sent with your name, email and this site, so we can reply here and by email.</p>
259
284
  <div class="sp-actions"><span class="sp-spacer"></span>
@@ -263,36 +288,71 @@ export async function mount(root, ctx) {
263
288
  </div>`,
264
289
  methods: {
265
290
  pick: (group, value) => { if (f[group]) f[group].value = value; },
266
- cancel: () => panel?.close(),
267
- pickFiles: () => panel?.panel.querySelector('.sp-file-input')?.click(),
291
+ cancel: () => guard?.close(),
292
+ pickFiles: () => p?.panel.querySelector('.sp-file-input')?.click(),
268
293
  async send() {
269
294
  if (f.busy.peek() || f.uploading.peek()) return;
270
295
  f.busy.value = true;
296
+ // The panel closes once sent: locked meanwhile, so nothing typed during the request is lost.
297
+ guard?.lock(true);
271
298
  try {
272
299
  const r = await api.open({category: f.category.peek(), priority: f.priority.peek(), subject: f.subject.peek(),
273
300
  body: f.body.peek(), where: f.where.peek(), attachments: wired?.picker.attachments() || []});
274
301
  E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
275
- panel?.close();
302
+ guard?.settle();
303
+ guard?.close(true);
276
304
  refreshBadge();
305
+ if (!here()) return;
277
306
  filter.value = 'active';
278
307
  await load();
279
308
  } catch (err) {
309
+ guard?.lock(false);
280
310
  fail(err);
281
311
  } finally {
282
312
  f.busy.value = false;
283
313
  }
284
314
  }
285
315
  }
286
- }).then(p => {
287
- panel = p;
288
- wired = wirePicker(p, '#sp-body', f.room, f.uploading);
289
- p.panel.querySelector('#sp-subject')?.focus();
290
316
  });
317
+ wired = wirePicker(p, '#sp-body', f.room, f.uploading);
318
+ guard = guardPanel(p, {
319
+ key: DRAFT_KEYS.compose,
320
+ values: () => ({category: f.category.value, priority: f.priority.value, subject: f.subject.value, body: f.body.value, where: f.where.value}),
321
+ baseline: {...FORM},
322
+ extraDirty: () => (wired?.picker.attachments() || []).length > 0 || f.uploading.peek(),
323
+ onClosed: () => panels.release('compose')
324
+ });
325
+ panels.set('compose', {close: (how) => guard.close(how)});
326
+ p.panel.querySelector('#sp-subject')?.focus();
327
+ return true;
291
328
  }
292
329
 
330
+ /** One request open at a time: opening another closes (and may ask about) the one showing. */
293
331
  async function open(ref) {
332
+ const showing = panels.get('detail');
333
+ if (showing?.key === ref) return;
334
+ if (showing) { await showing.close?.(); if (panels.get('detail')) return; }
335
+ if (!here() || !panels.claim('detail')) return;
336
+ let opened = false;
337
+ try { opened = await openTicket(ref); } catch (err) { fail(err); }
338
+ if (!opened) panels.release('detail');
339
+ }
340
+
341
+ async function openTicket(ref) {
342
+ const key = DRAFT_KEYS.reply(ref);
294
343
  const t0 = byRef(ref);
295
- if (!t0) return;
344
+ if (!t0) {
345
+ // Gone from Domma (or removed here) with a message half-written on it: offer the words back.
346
+ const offer = await offerDraft(key, {here, question: (d) => draftQuestion(d, null, {what: 'a request'})});
347
+ if (offer.state === 'keep' && await copyText(draftText(offer.draft), 'Your unsent message is copied')) store.remove(key);
348
+ return false;
349
+ }
350
+ const canWrite = !['waiting', 'refused'].includes(t0.status);
351
+ const offer = canWrite
352
+ ? await offerDraft(key, {here, question: (d) => draftQuestion(d, ticketPrint(t0), {what: `your reply to “${t0.subject}”`,
353
+ changed: 'Domma has written since', changedAt: lastDomma(t0)})})
354
+ : {state: 'none'};
355
+ if (offer.state === 'gone') return false;
296
356
  // The panel follows the list's copy, so a reload or a move from the row menu shows here at once.
297
357
  const cur = M.computed(() => tickets.value.find(x => x.ref === ref) || t0);
298
358
  // Every file in this site's requests was attached here, so this site's own copy shows them -
@@ -307,12 +367,12 @@ export async function mount(root, ctx) {
307
367
  const statusLabel = M.computed(() => cur.value.statusLabel || cur.value.status);
308
368
  const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
309
369
  const archived = M.computed(() => Boolean(cur.value.archived));
310
- const draft = M.observable('');
370
+ const draft = M.observable(offer.state === 'keep' ? draftValues(offer.draft, {text: ''}).text : '');
311
371
  // Bindings may not call methods (.trim()), so the test is a computed.
312
372
  const hasDraft = M.computed(() => Boolean(draft.value.trim()));
313
373
  const busy = M.observable(false);
314
- const canWrite = !['waiting', 'refused'].includes(t0.status);
315
374
  let panel = null;
375
+ let guard = null;
316
376
  let stopScroll = () => {};
317
377
  let menu = null;
318
378
  const run = async (fn) => {
@@ -327,6 +387,7 @@ export async function mount(root, ctx) {
327
387
  cleanup: () => {
328
388
  stopScroll(); stopFiles(); menu?.destroy?.(); wired?.dispose(); shown.dispose();
329
389
  [cur, thread, status, statusLabel, tone, archived, hasDraft].forEach(c => c.dispose?.());
390
+ panels.release('detail');
330
391
  },
331
392
  html: `<div class="sp-detail">
332
393
  <dl class="sp-facts">
@@ -339,7 +400,7 @@ export async function mount(root, ctx) {
339
400
  </dl>
340
401
  <div class="sp-thread-host">${THREAD}</div>
341
402
  ${canWrite ? `<div class="sp-reply">
342
- <label class="form-label" for="sp-write" data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></label>
403
+ <label class="form-label" for="sp-write"><span data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></span> <span data-help="Goes to the Domma team with your name. If you leave before sending, what you typed is kept and offered back when you open this request again. Right-click a message (or its ⋮, or Shift+F10) to copy it."></span></label>
343
404
  <textarea id="sp-write" class="form-textarea" rows="4" maxlength="8000" data-model="draft.value" placeholder="Write to Domma Support"></textarea>
344
405
  ${attachUi('Files go with a message - paste a screenshot into it, or attach one.')}
345
406
  <div class="sp-actions">
@@ -353,29 +414,48 @@ export async function mount(root, ctx) {
353
414
  </div>`,
354
415
  methods: {
355
416
  pickFiles: () => panel?.panel.querySelector('.sp-file-input')?.click(),
417
+ // The panel stays open: what is typed while it sends stays in the box, unsent and kept.
356
418
  send: () => run(async () => {
357
419
  if (!draft.peek().trim() || uploading.peek()) return;
358
- const r = await api.write(ref, draft.peek(), wired?.picker.attachments() || []);
420
+ const sent = draft.peek();
421
+ const files = wired?.picker.attachments() || [];
422
+ const r = await api.write(ref, sent, files);
359
423
  if (r.state !== 'sent') E.toast(r.message, {type: 'warning'});
360
- draft.value = '';
361
- wired?.picker.clear();
424
+ draft.value = afterSend(draft.peek(), sent);
425
+ // Only the files that went: any picked while it was sending stay for the next message.
426
+ wired?.picker.drop(files.map(a => a.id));
427
+ // What is left unsent is kept (or the draft goes) even if the panel was left meanwhile;
428
+ // our own message is not "Domma has written since", so `was` moves on.
429
+ guard?.rebase({text: ''}, ticketPrint({messages: [...(cur.peek().messages || []), {id: r.ref}]}));
430
+ refreshBadge();
431
+ if (!here()) return;
362
432
  await load();
433
+ guard?.rebase({text: ''}, ticketPrint(cur.peek()));
363
434
  }),
364
435
  resolve: () => run(() => actions.resolve(cur.peek())),
365
436
  archive: () => run(async () => {
366
437
  const on = !archived.peek();
367
438
  await actions.archive(cur.peek(), on);
368
- if (on) panel?.close();
439
+ if (on) guard?.close();
369
440
  })
370
441
  }
371
442
  });
443
+ if (canWrite) wired = wirePicker(panel, '#sp-write', room, uploading);
444
+ guard = guardPanel(panel, {
445
+ key,
446
+ values: () => ({text: draft.value}),
447
+ baseline: {text: ''},
448
+ was: offer.state === 'keep' ? offer.draft.was : ticketPrint(t0),
449
+ extraDirty: () => (wired?.picker.attachments() || []).length > 0 || uploading.peek()
450
+ });
451
+ panels.set('detail', {key: ref, close: (how) => guard.close(how)});
372
452
  const box = panel.panel.querySelector('.sp-thread-host');
373
453
  stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
374
- if (canWrite) wired = wirePicker(panel, '#sp-write', room, uploading);
375
454
  // Thumbnails for the images in the thread, as messages come; click a file to open it.
376
455
  const fileStop = M.effect(() => { shown.load(cur.value.messages || []); });
377
456
  const keysOff = fileKeys(box, (id) => shown.open(findFile(cur.peek().messages, id)));
378
- stopFiles = () => { fileStop(); keysOff(); };
457
+ const msgOff = msgKeys(box, kit.openMenuAt);
458
+ stopFiles = () => { fileStop(); keysOff(); msgOff(); };
379
459
  menu = E.contextMenu(box, {
380
460
  cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
381
461
  items: (li) => {
@@ -384,6 +464,7 @@ export async function mount(root, ctx) {
384
464
  }
385
465
  });
386
466
  await act('seen')(cur.peek());
467
+ return true;
387
468
  }
388
469
 
389
470
  const bindings = M.applyBindings({connected, contact, loading, reachable, error, filter, rows, counts, hasArchive, emptyText, waitingCount}, view, {
@@ -9,8 +9,10 @@
9
9
  */
10
10
 
11
11
  import {
12
- THREAD, TONE, api, copyText, esc, fileKeys, findFile, listOf, openPanel, refreshBadge, rowKeys, stamp, store, threadFiles, threadRows, when
12
+ THREAD, TONE, api, copyText, esc, fileKeys, findFile, guardPanel, listOf, msgKeys, offerDraft, openPanel, panelSet, refreshBadge, rowKeys, stamp, store,
13
+ threadFiles, threadRows, when
13
14
  } from '../lib/kit.js';
15
+ import {DRAFT_KEYS, afterSend, draftQuestion, draftText, draftValues, ticketPrint} from '../lib/drafts.js';
14
16
 
15
17
  const ROW = `
16
18
  <div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
@@ -40,6 +42,8 @@ export async function mount(root, ctx) {
40
42
  const inboxLabel = (s) => statuses.find(x => x.key === s)?.inboxLabel || s;
41
43
 
42
44
  const tickets = M.observable([]);
45
+ const panels = panelSet();
46
+ scope(() => panels.leave());
43
47
  const loading = M.observable(true);
44
48
  const error = M.observable('');
45
49
  const stored = store.get('support.filter');
@@ -100,21 +104,20 @@ export async function mount(root, ctx) {
100
104
  scope(M.effect(() => { rows.value; Promise.resolve().then(() => { M.flush?.(); I.scan(listEl); }); }));
101
105
 
102
106
  async function load() {
103
- loading.value = true;
104
107
  try {
105
- const res = await api.tickets();
108
+ const res = await kit.whileLoading(view.querySelector('.sp-loading'), api.tickets(), kit.SHAPES.iconRows);
106
109
  if (!here()) return;
107
110
  tickets.value = listOf(res, 'tickets');
108
111
  error.value = '';
109
112
  } catch (err) {
110
- error.value = `Could not load requests: ${err.message || err}`;
113
+ if (here()) error.value = `Could not load requests: ${err.message || err}`;
111
114
  } finally {
112
- loading.value = false;
115
+ if (here()) loading.value = false;
113
116
  }
114
117
  }
115
118
 
116
119
  const byId = (tid) => tickets.peek().find(t => t.id === tid);
117
- const replace = (t) => { tickets.value = tickets.peek().map(x => (x.id === t.id ? {...x, ...t} : x)); refreshBadge(); return t; };
120
+ const replace = (t) => { if (here()) tickets.value = tickets.peek().map(x => (x.id === t.id ? {...x, ...t} : x)); refreshBadge(); return t; };
118
121
  const fail = (err) => E.toast(err.message || String(err), {type: 'error'});
119
122
 
120
123
  // ---- The moves: one definition each, for the row menu and the panel alike ----
@@ -126,20 +129,22 @@ export async function mount(root, ctx) {
126
129
  async urgent(t, on) { replace(await api.update(t.id, {priority: on ? 'urgent' : 'normal'})); },
127
130
  async archive(t, on) {
128
131
  replace(await api.update(t.id, {archived: on}));
129
- E.toast(on ? `Archived "${t.subject}".` : `"${t.subject}" is back in the inbox.`, {type: 'success'});
132
+ E.toast(on ? `Archived “${t.subject}”.` : `“${t.subject}” is back in the inbox.`, {type: 'success'});
130
133
  },
131
134
  async remove(t) {
132
135
  if (!await E.confirm(`Delete "${esc(t.subject)}" for good? It goes from ${esc(t.siteName)}'s admin too. Archive it instead to keep it.`)) return false;
133
136
  await api.remove(t.id);
134
- if (openPanel_?.tid === t.id) openPanel_.close();
137
+ // Deleted on purpose: its panel goes without asking, and so does a reply half-written on it.
138
+ const showing = panels.get('detail');
139
+ if (showing?.key === t.id) showing.close?.(true);
140
+ store.remove(DRAFT_KEYS.answer(t.id));
141
+ if (!here()) return true;
135
142
  tickets.value = tickets.peek().filter(x => x.id !== t.id);
136
143
  refreshBadge();
137
144
  E.toast('Deleted.', {type: 'success'});
138
145
  return true;
139
146
  }
140
147
  };
141
- /** The request open in a panel, so a delete from its row menu closes it. */
142
- let openPanel_ = null;
143
148
  const act = (name, ...args) => async (t) => { try { return await actions[name](t, ...args); } catch (err) { fail(err); return false; } };
144
149
 
145
150
  function menuItems(t) {
@@ -164,12 +169,41 @@ export async function mount(root, ctx) {
164
169
 
165
170
  // ---- A request, in a panel ----
166
171
 
172
+ /** One request open at a time: opening another closes (and may ask about) the one showing. */
167
173
  async function open(tid) {
174
+ const showing = panels.get('detail');
175
+ if (showing?.key === tid) return;
176
+ if (showing) { await showing.close?.(); if (panels.get('detail')) return; }
177
+ if (!here() || !panels.claim('detail')) return;
178
+ let opened = false;
179
+ try { opened = await openTicket(tid); } catch (err) { fail(err); }
180
+ if (!opened) panels.release('detail');
181
+ }
182
+
183
+ async function openTicket(tid) {
184
+ const key = DRAFT_KEYS.answer(tid);
168
185
  // Read fresh: the panel shows where a reply will be emailed, which only the detail route works out.
169
186
  let t;
170
- try { t = await api.ticket(tid); } catch { t = byId(tid); }
171
- if (!t || !here()) { if (!t) E.toast('That request is no longer here.', {type: 'warning'}); return; }
187
+ let gone = false;
188
+ try { t = await api.ticket(tid); } catch (err) {
189
+ gone = err?.status === 404 || /no such/i.test(err?.message || '');
190
+ if (!here()) return false;
191
+ // Only a 404 is a deletion. A slow or failing server says so, and any draft stays for next time.
192
+ if (!gone) { E.toast(`Could not open the request: ${err?.message || err}`, {type: 'error'}); return false; }
193
+ }
194
+ if (!here()) return false;
195
+ if (!t) {
196
+ // Deleted - with a reply or notes half-written on it: say so, and offer the words back.
197
+ const offer = await offerDraft(key, {here, question: (d) => draftQuestion(d, null, {what: 'a request'})});
198
+ if (offer.state === 'keep' && await copyText(draftText(offer.draft), 'Your unsent reply and notes are copied')) store.remove(key);
199
+ if (offer.state === 'none') E.toast('That request is no longer here.', {type: 'warning'});
200
+ if (gone && here()) tickets.value = tickets.peek().filter(x => x.id !== tid);
201
+ return false;
202
+ }
172
203
  if (!byId(tid)) tickets.value = [t, ...tickets.peek()];
204
+ const offer = await offerDraft(key, {here, question: (d) => draftQuestion(d, ticketPrint(t, {notes: true}), {what: `the request “${t.subject}”`,
205
+ changed: 'The customer has written, or the notes have changed, since', changedAt: t.lastFrom === 'customer' ? t.lastAt : t.updatedAt})});
206
+ if (offer.state === 'gone') return false;
173
207
  const rt = t.replyTo || {email: t.userEmail || '', source: t.userEmail ? 'profile' : 'none'};
174
208
  const replyNote = rt.source === 'registration' ? `Replies are emailed to ${rt.email} - the address the site was bought with.`
175
209
  : rt.source === 'profile' ? `Replies are emailed to ${rt.email} - from the requester's profile on their site (this site was not bought through Customers).`
@@ -188,13 +222,17 @@ export async function mount(root, ctx) {
188
222
  const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
189
223
  const urgent = M.computed(() => cur.value.priority === 'urgent');
190
224
  const archived = M.computed(() => Boolean(cur.value.archived));
191
- const draft = M.observable('');
225
+ const saved = {reply: '', notes: String(t.notes || '')};
226
+ const start = offer.state === 'keep' ? draftValues(offer.draft, saved) : saved;
227
+ const draft = M.observable(start.reply);
192
228
  // Bindings may not call methods (.trim()), so the test is a computed.
193
229
  const hasDraft = M.computed(() => Boolean(draft.value.trim()));
194
230
  const after = M.observable('answered');
195
- const notes = M.observable(t.notes || '');
231
+ const notes = M.observable(start.notes);
196
232
  const busy = M.observable(false);
197
233
  let panel = null;
234
+ let guard = null;
235
+ let savedNotes = saved.notes;
198
236
  let stopScroll = () => {};
199
237
  let menu = null;
200
238
 
@@ -203,6 +241,8 @@ export async function mount(root, ctx) {
203
241
  busy.value = true;
204
242
  try { await fn(); } catch (err) { fail(err); } finally { busy.value = false; }
205
243
  };
244
+ /** Our own change is not "changed since": the draft measures from here on. */
245
+ const ours = (next) => guard?.rebase({reply: '', notes: savedNotes}, ticketPrint(next, {notes: true}));
206
246
  const siteUrl = t.siteHost ? `https://${t.siteHost}/admin/` : '';
207
247
  panel = await openPanel({
208
248
  title: t.subject,
@@ -211,7 +251,7 @@ export async function mount(root, ctx) {
211
251
  cleanup: () => {
212
252
  stopScroll(); stopFiles(); shown.dispose(); menu?.destroy?.();
213
253
  [cur, thread, status, statusLabel, tone, urgent, archived, hasDraft].forEach(c => c.dispose?.());
214
- if (openPanel_?.tid === tid) openPanel_ = null;
254
+ panels.release('detail');
215
255
  },
216
256
  html: `<div class="sp-detail">
217
257
  <dl class="sp-facts">
@@ -226,11 +266,12 @@ export async function mount(root, ctx) {
226
266
  </dl>
227
267
  <div class="sp-thread-host">${THREAD}</div>
228
268
  <div class="sp-reply">
229
- <label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email. Right-click a message to copy or quote it."></span></label>
269
+ <label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email. Right-click a message (or its ⋮, or Shift+F10) to copy or quote it. A reply you leave unsent is kept, and offered back when you open this request again."></span></label>
230
270
  <p class="sp-note sp-small">${esc(replyNote)}</p>
231
271
  <textarea id="sp-answer" class="form-textarea" rows="5" maxlength="8000" data-model="draft.value" placeholder="Write to ${esc(t.userName || 'the customer')}"></textarea>
232
272
  <div class="sp-actions">
233
- <label class="sp-inline">Then <select class="form-select" data-model="after.value"><option value="answered">wait for them</option><option value="closed">mark resolved</option></select></label>
273
+ <label class="sp-inline">Then <select class="form-select" data-model="after.value"><option value="answered">wait for them</option><option value="closed">mark resolved</option></select>
274
+ <span data-help="What the request becomes once this reply goes: Answered (the customer's turn - it leaves Waiting on Domma), or Resolved (the customer is told it is done; writing again reopens it)."></span></label>
234
275
  <span class="sp-spacer"></span>
235
276
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="resolve" data-bind-hidden="status.value === 'closed'" data-bind-disabled="busy.value">Resolve without reply</button>
236
277
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="reopen" data-bind-hidden="status.value !== 'closed'" data-bind-disabled="busy.value">Reopen</button>
@@ -251,13 +292,16 @@ export async function mount(root, ctx) {
251
292
  </div>
252
293
  </div>`,
253
294
  methods: {
295
+ // The panel stays open: what is typed while it sends stays in the box, unsent and kept.
254
296
  send: () => run(async () => {
255
- replace(await api.reply(tid, {body: draft.peek(), status: after.peek()}));
256
- draft.value = '';
297
+ const sent = draft.peek();
298
+ const next = replace(await api.reply(tid, {body: sent, status: after.peek()}));
299
+ draft.value = afterSend(draft.peek(), sent);
300
+ ours(next);
257
301
  E.toast(rt.email ? `Sent - emailed to ${rt.email}.` : 'Sent - it shows in their admin.', {type: 'success'});
258
302
  }),
259
- resolve: () => run(() => actions.resolve(cur.peek())),
260
- reopen: () => run(() => actions.reopen(cur.peek())),
303
+ resolve: () => run(async () => { await actions.resolve(cur.peek()); ours(cur.peek()); }),
304
+ reopen: () => run(async () => { await actions.reopen(cur.peek()); ours(cur.peek()); }),
261
305
  toggleUrgent: (e) => run(async () => {
262
306
  // A failed save puts the box back: the binding only re-runs when the value changes.
263
307
  try { await actions.urgent(cur.peek(), Boolean(e.target.checked)); } finally { e.target.checked = urgent.peek(); }
@@ -265,20 +309,34 @@ export async function mount(root, ctx) {
265
309
  archive: () => run(async () => {
266
310
  const on = !archived.peek();
267
311
  await actions.archive(cur.peek(), on);
268
- if (on) panel?.close();
312
+ if (on) guard?.close();
313
+ }),
314
+ // Notes typed while they save stay unsaved (the baseline is what was sent, not what is there now).
315
+ saveNotes: () => run(async () => {
316
+ const sent = notes.peek();
317
+ const next = replace(await api.update(tid, {notes: sent}));
318
+ savedNotes = sent;
319
+ ours(next);
320
+ E.toast('Notes saved.', {type: 'success'});
269
321
  }),
270
- saveNotes: () => run(async () => { replace(await api.update(tid, {notes: notes.peek()})); E.toast('Notes saved.', {type: 'success'}); }),
271
- remove: () => run(async () => { if (await actions.remove(cur.peek())) panel?.close(); })
322
+ remove: () => run(async () => { await actions.remove(cur.peek()); })
272
323
  }
273
324
  });
274
- openPanel_ = {tid, close: () => panel?.close()};
325
+ guard = guardPanel(panel, {
326
+ key,
327
+ values: () => ({reply: draft.value, notes: notes.value}),
328
+ baseline: saved,
329
+ was: offer.state === 'keep' ? offer.draft.was : ticketPrint(t, {notes: true})
330
+ });
331
+ panels.set('detail', {key: tid, close: (how) => guard.close(how)});
275
332
  // Keep the newest message in view as the thread grows.
276
333
  const box = panel.panel.querySelector('.sp-thread-host');
277
334
  stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
278
335
  // Thumbnails for the images that have arrived; click a file to open it in a new tab.
279
336
  const fileStop = M.effect(() => { shown.load(cur.value.messages || []); });
280
337
  const keysOff = fileKeys(box, (fid) => shown.open(findFile(cur.peek().messages, fid)));
281
- stopFiles = () => { fileStop(); keysOff(); };
338
+ const msgOff = msgKeys(box, kit.openMenuAt);
339
+ stopFiles = () => { fileStop(); keysOff(); msgOff(); };
282
340
  // A message: copy it, or quote it into the reply.
283
341
  menu = E.contextMenu(box, {
284
342
  cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
@@ -295,6 +353,7 @@ export async function mount(root, ctx) {
295
353
  ];
296
354
  }
297
355
  });
356
+ return true;
298
357
  }
299
358
 
300
359
  const bindings = M.applyBindings({tickets, loading, error, filter, query, rows, counts, emptyText}, view, {methods: {}});
@@ -52,7 +52,7 @@ export const supportView = {
52
52
  }
53
53
  if (!here()) return;
54
54
  // The cog (1.1.5) - this side's settings, for those who may change them. Never blocks the screen.
55
- addSettingsCog($container, kit).catch(() => {});
55
+ addSettingsCog($container, kit, {scope, here}).catch(() => {});
56
56
  try {
57
57
  const screen = mode.mode === 'receiver' ? 'inbox' : 'desk';
58
58
  const mod = await import(`/plugins/support/admin/views/${screen}.js?v=${V}`);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "support",
3
3
  "displayName": "Support",
4
- "version": "1.1.6",
4
+ "version": "1.2.0",
5
5
  "tier": "free",
6
6
  "description": "Get help from Domma straight from your admin: our email, phone and hours always to hand, and a conversation with the Domma team for anything you need - urgent requests first. Right-click a request to archive, resolve or remove it.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-13",
8
+ "date": "2026-10-15",
9
9
  "icon": "headphones",
10
10
  "permissions": [
11
11
  {
@@ -61,7 +61,7 @@
61
61
  ],
62
62
  "views": {
63
63
  "plugin-support": {
64
- "entry": "support/admin/views/support.js?v=1.1.6",
64
+ "entry": "support/admin/views/support.js?v=1.2.0",
65
65
  "exportName": "supportView"
66
66
  }
67
67
  }