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
@@ -9,7 +9,7 @@
9
9
  * slip. A notification's link (#/plugins/shopping-cart/orders/<id>) opens one.
10
10
  */
11
11
 
12
- import {api, copy, entriesOf, esc, fmtDate, fmtDateTime, money, moneyIn, moneyOut, mountPanels, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
12
+ import {api, claims, copy, deferItems, entriesOf, esc, fmtDate, fmtDateTime, money, moneyIn, moneyOut, mountPanels, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
13
13
  import {NEXT, STATUS_LABEL, STATUS_TONE, addressLines, orderLabel} from '/plugins/shopping-cart/public/lib/orders.js';
14
14
 
15
15
  const FILTERS = [
@@ -83,9 +83,9 @@ export async function mount(root, ctx) {
83
83
  <input type="search" class="form-input shp-search" placeholder="Search # , name, email, item…" aria-label="Search orders" data-model="query.value">
84
84
  <span data-help-title="Orders" data-help="Click an order to open it. Right-click (or ⋮, or Shift+F10) for its moves: mark it paid, fulfilled, refunded or cancelled. 'Started' are checkouts that never finished paying - they hold no stock and can be deleted."></span>
85
85
  </div>
86
- <p class="shp-note" data-bind-hidden="!loading.value">Loading…</p>
86
+ <div class="shp-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
87
87
  <p class="shp-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
88
- <p class="shp-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
88
+ <p class="shp-note shp-empty" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
89
89
  <div class="shp-list" data-each="rows.value key=id"></div>`;
90
90
  root.appendChild(view);
91
91
  const list = view.querySelector('.shp-list');
@@ -98,16 +98,24 @@ export async function mount(root, ctx) {
98
98
  if (b) filter.value = b.dataset.filter;
99
99
  });
100
100
 
101
+ /** The first load, under a skeleton. */
101
102
  async function load() {
102
103
  try {
103
- orders.value = entriesOf(await api.orders.list());
104
+ const res = await kit.whileLoading(view.querySelector('.shp-loading-slot'), api.orders.list(), kit.SHAPES.rows);
105
+ if (!here()) return;
106
+ orders.value = entriesOf(res);
104
107
  error.value = '';
105
108
  } catch (err) {
109
+ if (!here()) return;
106
110
  error.value = `Could not load the orders: ${err.message || err}`;
107
111
  } finally {
108
- loading.value = false;
112
+ if (here()) loading.value = false;
109
113
  }
110
114
  }
115
+ // One panel per order and per action at a time (a double click, a row clicked while it is still opening).
116
+ const opening = claims();
117
+ // Fulfil and Refund panels: closed with the screen, so nothing can be sent from a view that has gone.
118
+ const actionPanels = new Set();
111
119
  const byId = (id) => orders.peek().find(o => o.id === id);
112
120
  const replace = (o) => { orders.value = orders.peek().map(x => (x.id === o.id ? o : x)); };
113
121
 
@@ -127,20 +135,29 @@ export async function mount(root, ctx) {
127
135
  }
128
136
 
129
137
  async function markPaid(o) {
130
- if (!await E.confirm(`Mark ${orderLabel(o.data)} as paid (${esc(money(o.data.total))})? Stock is taken now.`)) return null;
138
+ if (!await E.confirm(`Mark ${orderLabel(o.data)} as paid (${esc(money(o.data.total))})? Stock is taken now.`,
139
+ {title: 'Mark paid', confirmText: 'Mark paid', cancelText: 'Not yet'})) return null;
140
+ if (!here()) return null;
131
141
  return move(o, 'paid', {note: 'Payment received'});
132
142
  }
133
143
 
134
144
  async function cancel(o) {
135
- if (!await E.confirm(`Cancel ${orderLabel(o.data)}? Nothing was paid, so nothing is refunded.`)) return null;
145
+ if (!await E.confirm(`Cancel ${orderLabel(o.data)}? Nothing was paid, so nothing is refunded.`,
146
+ {title: 'Cancel order', confirmText: 'Cancel the order', cancelText: 'Keep it'})) return null;
147
+ if (!here()) return null;
136
148
  return move(o, 'cancelled');
137
149
  }
138
150
 
139
151
  /** Fulfil, with tracking if there is any - a small form of its own. */
140
152
  function fulfil(o) {
153
+ const release = opening.take(`fulfil:${o.id}`);
154
+ if (!release) return Promise.resolve(null);
141
155
  return new Promise((resolve) => {
142
156
  const t = o.data.tracking || {};
143
157
  const f = {carrier: M.observable(t.carrier || ''), number: M.observable(t.number || ''), url: M.observable(t.url || '')};
158
+ const typed = () => JSON.stringify([f.carrier.peek(), f.number.peek(), f.url.peek()]);
159
+ const opened = typed();
160
+ let saving = false;
144
161
  let panel = null;
145
162
  const collect = o.data.delivery === 'collect';
146
163
  openPanel({
@@ -157,15 +174,27 @@ export async function mount(root, ctx) {
157
174
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
158
175
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save"><span data-icon="check" data-icon-size="13"></span> ${collect ? 'Collected' : 'Fulfilled'}</button></div>
159
176
  </div>`,
160
- cleanup: () => resolve(null),
177
+ dirty: () => typed() !== opened,
178
+ busy: () => saving,
179
+ cleanup: () => { actionPanels.delete(panel); release(); resolve(null); },
161
180
  methods: {
162
- cancel: () => panel?.close(true),
181
+ cancel: () => panel?.close(),
163
182
  async save() {
183
+ if (saving || !panel?.isOpen() || !here()) return;
184
+ // The panel closes once it is done, so the form is locked while the move runs.
185
+ saving = true;
186
+ panel.lock(true);
164
187
  const next = await move(o, 'fulfilled', {tracking: {carrier: f.carrier.peek(), number: f.number.peek(), url: f.url.peek()}});
165
- if (next) { resolve(next); panel?.close(true); }
188
+ saving = false;
189
+ if (next) { resolve(next); panel.close(true); } else if (panel.isOpen()) panel.lock(false);
166
190
  }
167
191
  }
168
- }).then(p => { panel = p; });
192
+ }).then(p => {
193
+ panel = p;
194
+ actionPanels.add(p);
195
+ if (!here()) p.close(true);
196
+ else p.panel.querySelector('input')?.focus();
197
+ }, (err) => { release(); resolve(null); E.toast(err.message || String(err), {type: 'error'}); });
169
198
  });
170
199
  }
171
200
 
@@ -177,6 +206,8 @@ export async function mount(root, ctx) {
177
206
  * sanitised, which strips inputs, so the amount, reason and restock box never showed and OK refunded it all.)
178
207
  */
179
208
  function refund(o) {
209
+ const release = opening.take(`refund:${o.id}`);
210
+ if (!release) return Promise.resolve(null);
180
211
  return new Promise((resolve) => {
181
212
  const d = o.data;
182
213
  const via = {stripe: 'through Stripe', paypal: 'through PayPal'}[d.provider];
@@ -185,6 +216,7 @@ export async function mount(root, ctx) {
185
216
  const f = {amount: M.observable(moneyIn(left)), note: M.observable(''), saving: M.observable(false)};
186
217
  let restock = Boolean(d.stockTaken);
187
218
  const full = () => moneyOut(f.amount.peek()) === left;
219
+ const opened = JSON.stringify([f.amount.peek(), f.note.peek()]);
188
220
  let panel = null;
189
221
  let box = null;
190
222
  // Stock only comes back when the whole order is refunded, so the box greys out on a part refund.
@@ -212,11 +244,13 @@ export async function mount(root, ctx) {
212
244
  box.addEventListener('change', () => { restock = box.checked; });
213
245
  syncBox();
214
246
  },
215
- cleanup: () => { stop?.(); resolve(null); },
247
+ dirty: () => JSON.stringify([f.amount.peek(), f.note.peek()]) !== opened,
248
+ busy: () => f.saving.peek(),
249
+ cleanup: () => { actionPanels.delete(panel); stop?.(); release(); resolve(null); },
216
250
  methods: {
217
- cancel: () => panel?.close(true),
251
+ cancel: () => panel?.close(),
218
252
  async save() {
219
- if (f.saving.peek()) return;
253
+ if (f.saving.peek() || !panel?.isOpen() || !here()) return;
220
254
  const amount = moneyOut(f.amount.peek());
221
255
  if (!Number.isInteger(amount) || amount <= 0 || amount > left) {
222
256
  E.toast(`Enter an amount between ${money(1)} and ${money(left)}.`, {type: 'error'});
@@ -224,29 +258,40 @@ export async function mount(root, ctx) {
224
258
  }
225
259
  const all = amount === left;
226
260
  f.saving.value = true;
261
+ panel.lock(true);
227
262
  try {
228
263
  const r = await api.orders.refund(o.id, {amount: all ? undefined : amount, restock: all && restock, note: f.note.peek()});
229
264
  replace(r.order);
230
265
  refreshBadge();
231
266
  E.toast(`${orderLabel(d)} ${r.how}.`, {type: 'success'});
232
267
  resolve(r.order);
233
- panel?.close(true);
268
+ f.saving.value = false;
269
+ panel.close(true);
234
270
  } catch (err) {
235
271
  E.toast(err.message || String(err), {type: 'error'});
236
272
  } finally {
237
273
  f.saving.value = false;
274
+ if (panel.isOpen()) panel.lock(false);
238
275
  }
239
276
  }
240
277
  }
241
- }).then(p => { panel = p; p.panel.querySelector('#shp-refund-amount')?.focus(); });
278
+ }).then(p => {
279
+ panel = p;
280
+ actionPanels.add(p);
281
+ if (!here()) p.close(true);
282
+ else p.panel.querySelector('#shp-refund-amount')?.focus();
283
+ }, (err) => { release(); resolve(null); E.toast(err.message || String(err), {type: 'error'}); });
242
284
  });
243
285
  }
244
286
 
245
287
  async function remove(o) {
246
- if (!await E.confirm(`Delete ${orderLabel(o.data)}? It never took money; it cannot be brought back.`)) return false;
288
+ if (!await E.confirm(`Delete ${orderLabel(o.data)}? It never took money; it cannot be brought back.`,
289
+ {title: 'Delete order', confirmText: 'Delete', cancelText: 'Keep it'})) return false;
290
+ if (!here()) return false;
247
291
  try {
248
292
  await api.orders.remove(o.id);
249
293
  orders.value = orders.peek().filter(x => x.id !== o.id);
294
+ refreshBadge();
250
295
  E.toast(`${orderLabel(o.data)} deleted.`, {type: 'success'});
251
296
  return true;
252
297
  } catch (err) {
@@ -309,16 +354,49 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
309
354
 
310
355
  // ---- One order ----------------------------------------------------------------
311
356
 
312
- const panels = new Set();
357
+ const panels = new Map();
358
+ /** Open an order - once: a second click on one already opening or open just goes to it. */
313
359
  async function openOrder(id) {
360
+ const open = panels.get(id);
361
+ if (open?.isOpen()) { open.panel.querySelector('button, textarea')?.focus(); return; }
362
+ const release = opening.take(`order:${id}`);
363
+ if (!release) return;
364
+ let owned = false;
365
+ try {
366
+ owned = await openOrderNow(id, release);
367
+ } catch (err) {
368
+ E.toast(`The order could not open: ${err.message || err}`, {type: 'error'});
369
+ } finally {
370
+ if (!owned) release();
371
+ }
372
+ }
373
+
374
+ async function openOrderNow(id, release) {
314
375
  let o = byId(id);
315
376
  if (!o) {
316
- try { o = await api.orders.get(id); } catch { E.toast('That order is gone.', {type: 'warning'}); return; }
377
+ try { o = await api.orders.get(id); } catch { if (here()) E.toast('That order is gone.', {type: 'warning'}); return false; }
378
+ if (!here()) return false;
317
379
  }
318
380
  const d = o.data;
319
381
  const staffNote = M.observable(d.staffNote || '');
320
382
  const status = M.observable(d.status);
321
383
  let panel = null;
384
+ let menu = null;
385
+ // The note as last saved (or being saved): a blur and a close straight after it send it once.
386
+ let noteSent = d.staffNote || '';
387
+ const saveNote = async () => {
388
+ const text = staffNote.peek();
389
+ if (text === noteSent) return;
390
+ noteSent = text;
391
+ try {
392
+ const next = await api.orders.note(o.id, {staffNote: text});
393
+ replace(next);
394
+ E.toast('Note saved.', {type: 'success'});
395
+ } catch (err) {
396
+ noteSent = null;
397
+ E.toast(`Note not saved: ${err.message || err}`, {type: 'error'});
398
+ }
399
+ };
322
400
  let extPanels = null;
323
401
  const ext = Object.entries(d.ext || {}).flatMap(([owner, facts]) => Object.entries(facts || {}).map(([k, v]) => ({owner, k, v})))
324
402
  .filter(x => ['string', 'number', 'boolean'].includes(typeof x.v));
@@ -366,9 +444,17 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
366
444
  model: {staffNote, status},
367
445
  html,
368
446
  before: (el) => { const n = el.querySelector('.shp-callout p'); if (n) n.textContent = d.note; },
369
- cleanup: () => { panels.delete(panel?.so); extPanels?.destroy(); }
447
+ cleanup: () => {
448
+ if (panels.get(o.id) === panel) panels.delete(o.id);
449
+ release();
450
+ extPanels?.destroy();
451
+ menu?.destroy?.();
452
+ // "Saved when you click away" - closing (or leaving the screen) with the note changed is clicking away.
453
+ saveNote();
454
+ }
370
455
  });
371
- panels.add(panel.so);
456
+ panels.set(o.id, panel);
457
+ if (!here()) { panel.close(true); return true; }
372
458
  const el = panel.panel;
373
459
  // Extensions' sections (Shopping Cart Pro's Downloads). `changed` takes the order an extension
374
460
  // updated, so the list keeps up.
@@ -376,7 +462,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
376
462
  order: o,
377
463
  changed: (next) => { if (next?.id === o.id) replace(next); }
378
464
  }});
379
- if (!el.isConnected) extPanels.destroy();
465
+ if (!panel.isOpen() || !here()) { extPanels.destroy(); return true; }
380
466
 
381
467
  // Main actions as buttons, the rest in ⋮.
382
468
  const renderActs = (cur) => {
@@ -389,8 +475,11 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
389
475
  b.innerHTML = `<span data-icon="${a.icon}" data-icon-size="13"></span> `;
390
476
  b.appendChild(document.createTextNode(a.label.replace('…', '')));
391
477
  b.addEventListener('click', async () => {
478
+ // After this event: an Enter on the button must not answer the question it asks (see kit soon()).
479
+ await new Promise(r => setTimeout(r, 0));
480
+ if (!panel.isOpen()) return;
392
481
  const next = await a.run();
393
- if (next && typeof next === 'object') { panel.close(true); openOrder(next.id); }
482
+ if (next && typeof next === 'object' && here()) { panel.close(true); openOrder(next.id); }
394
483
  });
395
484
  box.appendChild(b);
396
485
  }
@@ -398,24 +487,16 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
398
487
  };
399
488
  renderActs(o);
400
489
  const more = el.querySelector('[data-part="more"]');
401
- const menu = E.contextMenu(more, {items: () => items(o.id).filter(x => x.label !== 'Open'), inherit: false, density: 'compact'});
490
+ menu = E.contextMenu(more, {items: () => deferItems(items(o.id).filter(x => x.label !== 'Open')), inherit: false, density: 'compact', keyboardTrigger: false});
402
491
  more.addEventListener('click', () => kit.openMenuAt(more));
403
- panel.so.element.addEventListener('remove', () => menu?.destroy?.());
492
+ more.addEventListener('keydown', (e) => { if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); e.stopPropagation(); kit.openMenuAt(more); } });
404
493
 
405
- const staff = el.querySelector('[data-part="staff"]');
406
- staff.addEventListener('blur', async () => {
407
- if (staffNote.peek() === (byId(o.id)?.data.staffNote || '')) return;
408
- try {
409
- replace(await api.orders.note(o.id, {staffNote: staffNote.peek()}));
410
- E.toast('Note saved.', {type: 'success'});
411
- } catch (err) {
412
- E.toast(`Note not saved: ${err.message || err}`, {type: 'error'});
413
- }
414
- });
494
+ el.querySelector('[data-part="staff"]').addEventListener('blur', () => saveNote());
495
+ return true;
415
496
  }
416
497
 
417
498
  wireRows(list, {openMenuAt: kit.openMenuAt, scope, items, main: openOrder});
418
- scope(() => [...panels].forEach(so => { try { so.close(true); } catch { /* gone */ } }));
499
+ scope(() => [...panels.values(), ...actionPanels].forEach(pn => { try { pn.close(true); } catch { /* gone */ } }));
419
500
 
420
501
  await load();
421
502
  if (!here()) return;
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import {barChart} from '../lib/chart.js';
11
- import {api, esc, fmtDate, money, when} from '../lib/kit.js';
11
+ import {api, copy, esc, fmtDate, money, when, wireRows} from '../lib/kit.js';
12
12
  import {STATUS_LABEL, STATUS_TONE} from '/plugins/shopping-cart/public/lib/orders.js';
13
13
 
14
14
  export async function mount(root, ctx) {
@@ -21,7 +21,7 @@ export async function mount(root, ctx) {
21
21
  view.className = 'shp-overview';
22
22
  view.innerHTML = `
23
23
  <p class="shp-error" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
24
- <div class="shp-loading" data-bind-hidden="s.value || error.value">Loading…</div>
24
+ <div class="shp-loading-slot" data-bind-hidden="s.value || error.value">Loading…</div>
25
25
  <div data-part="start" hidden></div>
26
26
  <div data-part="main" hidden>
27
27
  <div class="shp-tiles" data-part="tiles"></div>
@@ -54,12 +54,13 @@ export async function mount(root, ctx) {
54
54
  scope(() => bindings.dispose());
55
55
 
56
56
  try {
57
- s.value = await api.summary();
57
+ const res = await kit.whileLoading(view.querySelector('.shp-loading-slot'), api.summary(), kit.SHAPES.tiles);
58
+ if (!here()) return;
59
+ s.value = res;
58
60
  } catch (err) {
59
- error.value = `Could not load the figures: ${err.message || err}`;
61
+ if (here()) error.value = `Could not load the figures: ${err.message || err}`;
60
62
  return;
61
63
  }
62
- if (!here()) return;
63
64
  const d = s.peek();
64
65
 
65
66
  // Nothing sold and nothing to sell: show the way in.
@@ -141,20 +142,49 @@ export async function mount(root, ctx) {
141
142
 
142
143
  // ---- Latest orders, best sellers ----
143
144
  part('recent').innerHTML = d.recent.length ? d.recent.map(o => `
144
- <a class="shp-mini" href="#/plugins/shopping-cart/orders/${esc(o.id)}">
145
+ <a class="shp-mini" href="#/plugins/shopping-cart/orders/${esc(o.id)}" data-id="${esc(o.id)}">
145
146
  <span class="shp-mini-main"><span><strong>#${esc(o.number)}</strong> ${esc(o.name || '')}</span><small>${esc(when(o.at))} · ${o.items} item${o.items === 1 ? '' : 's'}</small></span>
146
147
  <span class="shp-chip is-${STATUS_TONE[o.status]}">${esc(STATUS_LABEL[o.status])}</span>
147
148
  <span class="shp-mini-amt">${esc(money(o.total))}</span>
149
+ <button type="button" class="shp-kebab" aria-label="Actions" title="Actions">⋮</button>
148
150
  </a>`).join('') : '<p class="shp-muted">No orders yet.</p>';
149
151
 
150
152
  const maxRev = Math.max(1, ...d.topProducts.map(p => p.revenue));
151
- part('top').innerHTML = d.topProducts.length ? d.topProducts.map(p => `
152
- <div class="shp-top">
153
+ part('top').innerHTML = d.topProducts.length ? d.topProducts.map((p, i) => `
154
+ <div class="shp-top${p.productId ? ' is-link' : ''}" data-id="${esc(p.productId || '')}" data-i="${i}"${p.productId ? ' tabindex="0"' : ''}>
153
155
  <span class="shp-top-name">${esc(p.name)}<small>${p.qty} sold</small></span>
154
156
  <span class="shp-top-bar"><span style="width:${Math.round(p.revenue / maxRev * 100)}%"></span></span>
155
157
  <span class="shp-mini-amt">${esc(money(p.revenue))}</span>
156
158
  </div>`).join('') : '<p class="shp-muted">Nothing sold in the last 30 days.</p>';
157
159
 
160
+ // Menus on both lists: right-click, ⋮ or Shift+F10; click (or Enter) opens.
161
+ const recent = part('recent');
162
+ const byOrder = (id) => d.recent.find(o => o.id === id);
163
+ // The rows are links (open in a new tab works); the ⋮ inside one must not follow it.
164
+ recent.addEventListener('click', (e) => { if (e.target.closest('.shp-kebab')) e.preventDefault(); });
165
+ // main: nothing - the row is a link and follows itself (click or Enter).
166
+ wireRows(recent, {match: '.shp-mini', openMenuAt: kit.openMenuAt, scope, main: () => {}, items: (id) => {
167
+ const o = byOrder(id);
168
+ if (!o) return [];
169
+ return [
170
+ {header: `#${o.number}${o.name ? ` · ${o.name}` : ''}`},
171
+ {label: 'Open', icon: 'eye', action: () => go('orders', id)},
172
+ {label: 'Copy the order number', icon: 'copy', action: () => copy(`#${o.number}`, 'Order number copied')},
173
+ {type: 'divider'},
174
+ {label: 'All orders', icon: 'receipt', action: () => go('orders')}
175
+ ];
176
+ }});
177
+ const top = part('top');
178
+ wireRows(top, {match: '.shp-top.is-link', openMenuAt: kit.openMenuAt, scope, main: (id) => go('products', id), items: (id) => {
179
+ const p = d.topProducts.find(x => x.productId === id);
180
+ if (!p) return [];
181
+ return [
182
+ {header: p.name},
183
+ {label: 'Edit the product', icon: 'edit', action: () => go('products', id)},
184
+ {label: 'All products', icon: 'package', action: () => go('products')}
185
+ ];
186
+ }});
187
+
158
188
  I.scan(view);
159
189
  }
160
190