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
@@ -8,9 +8,18 @@
8
8
  * stock, stock tracking, delivery and tax class - checked as you type by the
9
9
  * same rules the server applies (public/lib/products.js). "+" in the banner
10
10
  * adds one; #/plugins/shopping-cart/products/new opens a blank one.
11
+ *
12
+ * What is typed in the editor is kept as a draft while it is open (see
13
+ * ../lib/drafts.js), so leaving the screen - or the browser - loses nothing:
14
+ * opening the product again asks whether to throw those changes away (OK) or
15
+ * keep them (Cancel / Escape), and says when the product has been changed or
16
+ * deleted since. A row with a draft carries an "Unsaved" pill; drafts of a new
17
+ * product, or of one deleted since, are listed above the products.
11
18
  */
12
19
 
13
- import {api, entriesOf, esc, getCurrency, money, moneyIn, moneyOut, mountPanels, openPanel, store, wireRows} from '../lib/kit.js';
20
+ import {api, ask, claims, entriesOf, soon, esc, getCurrency, money, moneyIn, moneyOut, mountPanels, openPanel, refreshBadge, store, wireRows} from '../lib/kit.js';
21
+ import {NEW_KEY, PRODUCT_DRAFTS_KEY, adoptAsNew, draftDiffers, draftKey, draftValues, productDraftQuestion, readMap, replaceNewDraftQuestion,
22
+ stableJson, withDraft} from '../lib/drafts.js';
14
23
  import {STATUS_LABEL, cleanProduct, priceRange, slugify, totalStock} from '/plugins/shopping-cart/public/lib/products.js';
15
24
 
16
25
  const FILTERS = [
@@ -25,6 +34,7 @@ const ROW = `
25
34
  <div class="shp-row shp-prow" data-id="{{id}}" tabindex="0">
26
35
  <span class="shp-thumb" data-bind-class="img ? '' : 'is-empty'"><img data-bind-src="img" data-bind-hidden="!img" alt=""></span>
27
36
  <span class="shp-main"><strong><span data-bind-text="name"></span> <span class="shp-star" data-bind-hidden="!featured" title="Featured">★</span></strong><small data-bind-text="meta"></small></span>
37
+ <span class="shp-chip is-primary" data-bind-hidden="!unsaved" title="Changes left unsaved in the editor - open it to keep or throw them away">Unsaved</span>
28
38
  <span class="shp-stock" data-bind-class="stockTone" data-bind-text="stockText" title="Stock"></span>
29
39
  <span class="shp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
30
40
  <span class="shp-amt" data-bind-text="price"></span>
@@ -39,8 +49,23 @@ export async function mount(root, ctx) {
39
49
  const filter = M.observable(store.get('shopping-cart.products-filter') || 'all');
40
50
  const query = M.observable('');
41
51
  const low = Number(settings.lowStockAt) || 0;
52
+ // True only once the list has come back: a failed load must not make every kept draft look "deleted since".
53
+ const loaded = M.observable(false);
42
54
  scope(M.effect(() => store.set('shopping-cart.products-filter', filter.value)));
43
55
 
56
+ // Kept drafts: read fresh and written straight back on every change (see ../lib/drafts.js).
57
+ // `draftKeys` is just which keys have one, so the rows and the note above them keep up.
58
+ const draftKeys = M.observable(Object.keys(readMap(store.get(PRODUCT_DRAFTS_KEY))));
59
+ const drafts = {
60
+ get: (key) => readMap(store.get(PRODUCT_DRAFTS_KEY))[key] || null,
61
+ put(key, draft) {
62
+ const map = withDraft(store.get(PRODUCT_DRAFTS_KEY), key, draft);
63
+ if (Object.keys(map).length) store.set(PRODUCT_DRAFTS_KEY, map); else store.remove(PRODUCT_DRAFTS_KEY);
64
+ const keys = Object.keys(map);
65
+ if (stableJson(keys.slice().sort()) !== stableJson(draftKeys.peek().slice().sort())) draftKeys.value = keys;
66
+ }
67
+ };
68
+
44
69
  const counts = M.computed(() => {
45
70
  const out = {};
46
71
  for (const f of FILTERS) out[f.key] = products.value.filter(p => f.match(p.data || {}, low)).length;
@@ -48,6 +73,7 @@ export async function mount(root, ctx) {
48
73
  });
49
74
  const rows = M.observable([]);
50
75
  scope(M.effect(() => {
76
+ const kept = new Set(draftKeys.value);
51
77
  const f = FILTERS.find(x => x.key === filter.value) || FILTERS[0];
52
78
  const q = query.value.trim().toLowerCase();
53
79
  rows.value = products.value
@@ -63,6 +89,7 @@ export async function mount(root, ctx) {
63
89
  name: p.name,
64
90
  img: p.images?.[0] || '',
65
91
  featured: Boolean(p.featured),
92
+ unsaved: kept.has(e.id),
66
93
  meta: [p.category, p.variants?.length ? `${p.variants.length} ${(p.optionName || 'option').toLowerCase()}s` : '', p.sku].filter(Boolean).join(' · ') || ' ',
67
94
  stockText: stock === null ? '∞' : `${stock} in stock`,
68
95
  stockTone: stock === null ? 'is-muted' : stock === 0 ? 'is-danger' : stock <= low ? 'is-warning' : '',
@@ -73,6 +100,14 @@ export async function mount(root, ctx) {
73
100
  });
74
101
  }));
75
102
  const emptyText = M.computed(() => (products.value.length ? 'No products match.' : 'No products yet - add one with + in the banner.'));
103
+ // Drafts with no row to carry their pill: a new product's, and those of products deleted since.
104
+ const looseDrafts = M.computed(() => {
105
+ if (!loaded.value) return [];
106
+ const ids = new Set(products.value.map(p => p.id));
107
+ const map = readMap(store.get(PRODUCT_DRAFTS_KEY));
108
+ return draftKeys.value.filter(k => map[k] && (k === NEW_KEY || !ids.has(k)))
109
+ .map(k => ({key: k, name: String(map[k].values?.name || '').trim(), gone: k !== NEW_KEY}));
110
+ });
76
111
 
77
112
  root.innerHTML = '';
78
113
  const view = document.createElement('div');
@@ -86,9 +121,10 @@ export async function mount(root, ctx) {
86
121
  <button type="button" class="btn btn-sm btn-primary" data-new><span data-icon="plus" data-icon-size="13"></span> Product</button>
87
122
  <span data-help-title="Products" data-help="Click a product to edit it. Right-click (or ⋮, or Shift+F10) to duplicate it, put it on sale, feature it, set its stock or archive it. Only products On sale appear in the shop. Archive keeps a product for past orders without showing it."></span>
88
123
  </div>
89
- <p class="shp-note" data-bind-hidden="!loading.value">Loading…</p>
124
+ <div class="shp-drafts" data-part="drafts" hidden></div>
125
+ <div class="shp-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
90
126
  <p class="shp-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
91
- <p class="shp-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
127
+ <p class="shp-note shp-empty" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
92
128
  <div class="shp-list" data-each="rows.value key=id"></div>`;
93
129
  root.appendChild(view);
94
130
  const list = view.querySelector('.shp-list');
@@ -96,20 +132,43 @@ export async function mount(root, ctx) {
96
132
  scope(kit.helpPopovers(view, {position: 'bottom'}));
97
133
  const bindings = M.applyBindings({loading, error, filter, query, rows, counts, emptyText}, view);
98
134
  scope(() => bindings.dispose());
135
+
136
+ // The loose drafts, as a line of buttons above the list.
137
+ const draftBox = view.querySelector('[data-part="drafts"]');
138
+ scope(M.effect(() => {
139
+ const loose = looseDrafts.value;
140
+ draftBox.hidden = !loose.length;
141
+ draftBox.innerHTML = loose.length ? `<span data-icon="edit" data-icon-size="14"></span><span>Left unsaved:</span>${loose.map(d =>
142
+ `<button type="button" class="shp-chip is-primary" data-draft="${esc(d.key)}" title="${d.gone ? 'The product has been deleted since - open to keep the changes as a new product, or throw them away' : 'Open the new product you were adding'}">${
143
+ esc(d.name || 'Untitled')}${d.gone ? ' (deleted since)' : ' (new)'}</button>`).join('')}` : '';
144
+ I.scan(draftBox);
145
+ }));
146
+ // soon(): an Enter on the button must not answer the question it opens.
147
+ draftBox.addEventListener('click', (e) => {
148
+ const key = e.target.closest('[data-draft]')?.dataset.draft;
149
+ if (key) soon(edit)(key === NEW_KEY ? null : key);
150
+ });
99
151
  view.querySelector('.shp-seg').addEventListener('click', (e) => {
100
152
  const b = e.target.closest('[data-filter]');
101
153
  if (b) filter.value = b.dataset.filter;
102
154
  });
103
- view.querySelector('[data-new]').addEventListener('click', () => edit(null));
155
+ view.querySelector('[data-new]').addEventListener('click', soon(() => edit(null)));
104
156
 
105
- async function load() {
157
+ /** The first load (under a skeleton) or a quiet reload that keeps the list on screen. */
158
+ async function load(first = false) {
106
159
  try {
107
- products.value = entriesOf(await api.products.list());
160
+ const res = first
161
+ ? await kit.whileLoading(view.querySelector('.shp-loading-slot'), api.products.list(), kit.SHAPES.iconRows)
162
+ : await api.products.list();
163
+ if (!here()) return;
164
+ products.value = entriesOf(res);
108
165
  error.value = '';
166
+ loaded.value = true;
109
167
  } catch (err) {
168
+ if (!here()) return;
110
169
  error.value = `Could not load the products: ${err.message || err}`;
111
170
  } finally {
112
- loading.value = false;
171
+ if (here()) loading.value = false;
113
172
  }
114
173
  }
115
174
  const byId = (id) => products.peek().find(p => p.id === id);
@@ -129,19 +188,23 @@ export async function mount(root, ctx) {
129
188
 
130
189
  async function setStock(p) {
131
190
  if (p.data.variants?.length) { edit(p.id, 'stock'); return; }
132
- const v = await E.prompt(`How many of "${esc(p.data.name)}" are in stock?`, {defaultValue: String(p.data.stock ?? 0)});
133
- if (v === null || v === undefined) return;
191
+ // promptWith sets the value after the dialog opens (an inputValue is cut at a quote).
192
+ const v = await kit.promptWith(`How many of “${esc(p.data.name)}” are in stock?`, {title: 'Stock'}, String(p.data.stock ?? 0));
193
+ if (!here() || v === null || v === undefined) return;
134
194
  const n = Number(v);
135
195
  if (!Number.isInteger(n) || n < 0) { E.toast('A whole number, 0 or more.', {type: 'warning'}); return; }
136
196
  quick(p, {stock: n, trackStock: true}, `${p.data.name}: ${n} in stock.`);
137
197
  }
138
198
 
139
199
  async function remove(p) {
140
- if (!await E.confirm(`Delete "${esc(p.data.name)}"? Past orders keep their copy of it. To hide it and keep it, Archive instead.`)) return;
200
+ if (!await E.confirm(`Delete “${esc(p.data.name)}”? Past orders keep their copy of it. To hide it and keep it, Archive instead.`,
201
+ {title: 'Delete product', confirmText: 'Delete', cancelText: 'Keep it'})) return;
202
+ if (!here()) return;
141
203
  try {
142
204
  await api.products.remove(p.id);
143
205
  products.value = products.peek().filter(x => x.id !== p.id);
144
- E.toast(`"${p.data.name}" deleted.`, {type: 'success'});
206
+ refreshBadge();
207
+ E.toast(`“${p.data.name}” deleted.`, {type: 'success'});
145
208
  } catch (err) {
146
209
  E.toast(err.message || String(err), {type: 'error'});
147
210
  }
@@ -153,10 +216,10 @@ export async function mount(root, ctx) {
153
216
  const d = p.data;
154
217
  return [
155
218
  {header: d.name},
156
- {label: 'Edit', icon: 'edit', action: () => edit(id)},
219
+ {label: draftKeys.peek().includes(id) ? 'Edit (unsaved changes)…' : 'Edit', icon: 'edit', action: () => edit(id)},
157
220
  {label: 'View in the shop', icon: 'external-link', disabled: d.status !== 'active', action: () => window.open(`${settings.shopPath}/${d.slug}`, '_blank', 'noopener')},
158
221
  {label: 'Duplicate', icon: 'copy', action: async () => {
159
- try { const e = await api.products.duplicate(id); put(e); E.toast(`Copied as a draft: "${e.data.name}".`, {type: 'success'}); }
222
+ try { const e = await api.products.duplicate(id); put(e); refreshBadge(); E.toast(`Copied as a draft: “${e.data.name}”.`, {type: 'success'}); }
160
223
  catch (err) { E.toast(err.message || String(err), {type: 'error'}); }
161
224
  }},
162
225
  {type: 'divider'},
@@ -172,50 +235,146 @@ export async function mount(root, ctx) {
172
235
 
173
236
  // ---- The editor --------------------------------------------------------------
174
237
 
175
- const panels = new Set();
238
+ const panels = new Map();
239
+ const opening = claims();
176
240
  const categories = () => [...new Set(products.peek().map(p => p.data.category).filter(Boolean))].sort();
177
241
 
242
+ const BLANK = {status: 'draft', trackStock: false, shippable: true, taxClass: 'standard', images: [], variants: []};
243
+ const SCALARS = ['name', 'slug', 'summary', 'description', 'price', 'compareAt', 'sku', 'category', 'tags', 'status', 'featured', 'trackStock',
244
+ 'stock', 'optionName', 'shippable', 'weight', 'taxClass'];
245
+ const text = (v) => (v === null || v === undefined ? '' : String(v));
246
+ /** A product as the form holds it - plain values, numbers as typed text - which is also what a draft keeps. */
247
+ const formOf = (p) => ({
248
+ name: p.name || '', slug: p.slug || '', summary: p.summary || '', description: p.description || '',
249
+ price: text(moneyIn(p.price)), compareAt: text(moneyIn(p.compareAt)), sku: p.sku || '', category: p.category || '',
250
+ tags: (p.tags || []).join(', '), status: p.status || 'draft', featured: Boolean(p.featured), trackStock: Boolean(p.trackStock),
251
+ stock: text(p.stock), optionName: p.optionName || '', shippable: p.shippable !== false, weight: text(p.weight),
252
+ taxClass: p.taxClass || 'standard', images: [...(p.images || [])],
253
+ variants: (p.variants || []).map(v => ({id: v.id || '', name: v.name || '', price: text(moneyIn(v.price)), sku: v.sku || '', stock: text(v.stock)}))
254
+ });
255
+
256
+ /** Open a product in the editor - once: a second click on one already opening or open just goes to it. */
178
257
  async function edit(id, focus = '') {
258
+ const key = draftKey(id);
259
+ const open = panels.get(key);
260
+ if (open?.isOpen()) { open.panel.querySelector('input, textarea')?.focus(); return; }
261
+ const release = opening.take(key);
262
+ if (!release) return;
263
+ let owned = false;
264
+ try {
265
+ owned = await editNow(id, focus, key, release);
266
+ } catch (err) {
267
+ E.toast(`The editor could not open: ${err.message || err}`, {type: 'error'});
268
+ } finally {
269
+ // The claim goes with a panel that opened (its cleanup releases it); otherwise now, or it never opens again.
270
+ if (!owned) release();
271
+ }
272
+ }
273
+
274
+ /** @returns {Promise<boolean>} true once a panel owns the claim */
275
+ async function editNow(id, focus, key, release) {
179
276
  const had = id ? byId(id) : null;
180
- if (id && !had) { E.toast('That product is gone.', {type: 'warning'}); return; }
181
- const p = had?.data || {status: 'draft', trackStock: false, shippable: true, taxClass: 'standard', images: [], variants: []};
182
- const f = {
183
- name: M.observable(p.name || ''),
184
- slug: M.observable(p.slug || ''),
185
- summary: M.observable(p.summary || ''),
186
- description: M.observable(p.description || ''),
187
- price: M.observable(moneyIn(p.price)),
188
- compareAt: M.observable(moneyIn(p.compareAt)),
189
- sku: M.observable(p.sku || ''),
190
- category: M.observable(p.category || ''),
191
- tags: M.observable((p.tags || []).join(', ')),
192
- status: M.observable(p.status || 'draft'),
193
- featured: M.observable(Boolean(p.featured)),
194
- trackStock: M.observable(Boolean(p.trackStock)),
195
- stock: M.observable(p.stock ?? ''),
196
- optionName: M.observable(p.optionName || ''),
197
- shippable: M.observable(p.shippable !== false),
198
- weight: M.observable(p.weight ?? ''),
199
- taxClass: M.observable(p.taxClass || 'standard')
200
- };
201
- const images = M.observable([...(p.images || [])]);
202
- const variants = M.observable((p.variants || []).map(v => ({...v, price: moneyIn(v.price), stock: v.stock ?? ''})));
203
- const slugTouched = M.observable(Boolean(p.slug));
277
+ const kept = drafts.get(key);
278
+ if (id && !had && (!kept || !loaded.peek())) {
279
+ E.toast(loaded.peek() ? 'That product is gone.' : 'The products did not load - reload the page to edit one.', {type: 'warning'});
280
+ return false;
281
+ }
282
+ const record = formOf(had?.data || BLANK);
283
+ let start = record;
284
+ let was = had?.meta?.updatedAt || null;
285
+ let slugEdited = Boolean(had?.data?.slug);
286
+
287
+ if (kept && (kept.adopted || draftDiffers(kept, record))) {
288
+ if (kept.adopted) {
289
+ // Already said "keep" (a deleted product's changes, made a new one).
290
+ start = {...record, ...draftValues(kept, record)};
291
+ was = null;
292
+ slugEdited = Boolean(kept.slugTouched);
293
+ } else {
294
+ // The draft is removed only once the person has answered; leaving while asked keeps it.
295
+ const q = productDraftQuestion(kept, had ? {name: had.data.name, updatedAt: had.meta?.updatedAt || ''} : null, {isNew: !id});
296
+ const question = ask(q.text, q.options);
297
+ scope(() => question.dismiss());
298
+ const throwAway = await question.answer;
299
+ if (!here()) return false;
300
+ if (throwAway) {
301
+ drafts.put(key, null);
302
+ if (!had && id) { E.toast('Changes thrown away.', {type: 'info'}); return false; }
303
+ } else if (q.asNew) {
304
+ return keepAsNew(kept, key, release);
305
+ } else {
306
+ start = {...record, ...draftValues(kept, record)};
307
+ was = kept.was ?? null;
308
+ slugEdited = kept.slugTouched ?? slugEdited;
309
+ }
310
+ }
311
+ } else if (kept) {
312
+ drafts.put(key, null); // the same as the product: nothing to keep
313
+ }
314
+ return openEditor({id, focus, key, release, had, record, start, was, slugEdited});
315
+ }
316
+
317
+ /** A deleted product's draft becomes a new product's - asking first if that would replace another unsaved new one. */
318
+ async function keepAsNew(kept, key, release) {
319
+ const other = drafts.get(NEW_KEY);
320
+ if (other && draftDiffers(other, formOf(BLANK))) {
321
+ const q = replaceNewDraftQuestion(other);
322
+ const question = ask(q.text, q.options);
323
+ scope(() => question.dismiss());
324
+ const replace = await question.answer;
325
+ if (!here()) return false;
326
+ if (!replace) {
327
+ E.toast('Both kept. Add (or throw away) the new product you left unsaved, then come back to these changes.', {type: 'info', duration: 7000});
328
+ return false;
329
+ }
330
+ }
331
+ if (panels.get(NEW_KEY)?.isOpen() || opening.has(NEW_KEY)) {
332
+ E.toast('Finish the new product you are adding first.', {type: 'warning'});
333
+ return false;
334
+ }
335
+ drafts.put(NEW_KEY, adoptAsNew(kept));
336
+ drafts.put(key, null);
337
+ release();
338
+ edit(null);
339
+ return true;
340
+ }
341
+
342
+ async function openEditor({focus, key, release, had, record, start, was, slugEdited}) {
343
+ const p = had?.data || BLANK;
344
+ const f = Object.fromEntries(SCALARS.map(k => [k, M.observable(start[k])]));
345
+ const images = M.observable([...start.images]);
346
+ const variants = M.observable(start.variants.map(v => ({...v})));
347
+ const slugTouched = M.observable(Boolean(slugEdited));
204
348
  const saving = M.observable(false);
205
349
 
206
- const snapshot = () => JSON.stringify([Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])), images.peek(), variants.peek()]);
207
- const opened = snapshot();
350
+ /** The form's values, numbers as text - compared with the product, and kept as the draft. */
351
+ const values = () => ({
352
+ ...Object.fromEntries(SCALARS.map(k => [k, ['stock', 'weight'].includes(k) ? text(f[k].peek()) : f[k].peek()])),
353
+ images: images.peek(),
354
+ variants: variants.peek().map(v => ({id: v.id || '', name: v.name ?? '', price: text(v.price), sku: v.sku ?? '', stock: text(v.stock)}))
355
+ });
356
+ const recordJson = stableJson(record);
208
357
  // Extensions' sections (Shopping Cart Pro's Downloads), mounted once the panel is open.
209
358
  let ext = {panels: [], destroy() {}};
210
- const dirty = () => snapshot() !== opened || ext.panels.some(x => x.handle.dirty?.());
359
+ const dirty = () => stableJson(values()) !== recordJson || ext.panels.some(x => x.handle.dirty?.());
360
+
361
+ // The draft: rewritten on every change while the editor is open, so leaving (the screen, the browser) loses
362
+ // nothing. It goes when the person saves, discards or closes with nothing changed - never when the screen goes.
363
+ let drafting = true;
364
+ const keepDraft = () => {
365
+ if (!drafting) return;
366
+ const v = values();
367
+ if (stableJson(v) === recordJson) drafts.put(key, null);
368
+ else drafts.put(key, {values: v, slugTouched: slugTouched.peek(), was, at: new Date().toISOString()});
369
+ };
211
370
 
212
371
  const payload = () => ({
213
372
  ...Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])),
214
373
  slug: f.slug.peek() || slugify(f.name.peek()),
215
374
  price: moneyOut(f.price.peek()),
216
375
  compareAt: moneyOut(f.compareAt.peek()),
217
- stock: f.stock.peek() === '' ? null : Number(f.stock.peek()),
218
- weight: f.weight.peek() === '' ? null : Number(f.weight.peek()),
376
+ stock: text(f.stock.peek()) === '' ? null : Number(f.stock.peek()),
377
+ weight: text(f.weight.peek()) === '' ? null : Number(f.weight.peek()),
219
378
  images: images.peek(),
220
379
  variants: variants.peek().map(v => ({id: v.id, name: v.name, sku: v.sku,
221
380
  price: moneyOut(v.price), stock: v.stock === '' || v.stock === null ? null : Number(v.stock)})),
@@ -238,6 +397,7 @@ export async function mount(root, ctx) {
238
397
  return errors;
239
398
  });
240
399
  const stopSlug = M.effect(() => { if (!slugTouched.value) f.slug.value = slugify(f.name.value); });
400
+ const stopDraft = M.effect(() => { SCALARS.forEach(k => f[k].value); images.value; variants.value; slugTouched.value; keepDraft(); });
241
401
 
242
402
  const model = {
243
403
  ...f, images, variants, saving, isNew: !had, hasVariants: M.computed(() => variants.value.length > 0),
@@ -248,11 +408,13 @@ export async function mount(root, ctx) {
248
408
  variantErr: M.computed(() => Object.entries(check.value).filter(([k]) => k.startsWith('variant')).map(([, v]) => v).join(' ')),
249
409
  cannot: M.computed(() => saving.value || Object.keys(check.value).length > 0),
250
410
  url: M.computed(() => `${settings.shopPath}/${f.slug.value || slugify(f.name.value) || '…'}`),
251
- currency: getCurrency()
411
+ currency: getCurrency(),
412
+ restored: start !== record
252
413
  };
253
414
 
254
415
  let panel = null;
255
416
  const html = `<div class="shp-form shp-editor">
417
+ <p class="shp-callout shp-restored" data-bind-hidden="!restored">These are the changes you left unsaved${was && had?.meta?.updatedAt && was !== had.meta.updatedAt ? ' - the product has been changed since; saving puts them over that' : ''}. Save to keep them, or close and discard them.</p>
256
418
  <div class="shp-grid">
257
419
  <div class="shp-wide">
258
420
  <label class="form-label" for="sp-name">Name</label>
@@ -360,17 +522,27 @@ export async function mount(root, ctx) {
360
522
  const tile = document.createElement('div');
361
523
  tile.className = 'shp-pic';
362
524
  tile.draggable = true;
525
+ tile.tabIndex = 0;
363
526
  tile.dataset.i = String(i);
527
+ tile.setAttribute('aria-label', `Picture ${i + 1}${i === 0 ? ' (card)' : ''} - Shift+F10 for its menu`);
364
528
  const img = document.createElement('img');
365
529
  img.src = src;
366
530
  img.alt = '';
531
+ const more = document.createElement('button');
532
+ more.type = 'button';
533
+ more.className = 'shp-pic-more';
534
+ more.title = 'More';
535
+ more.setAttribute('aria-label', 'More');
536
+ more.textContent = '⋮';
537
+ more.dataset.more = String(i);
367
538
  const x = document.createElement('button');
368
539
  x.type = 'button';
369
540
  x.className = 'shp-pic-x';
370
541
  x.title = 'Remove';
542
+ x.setAttribute('aria-label', 'Remove');
371
543
  x.textContent = '×';
372
544
  x.dataset.remove = String(i);
373
- tile.append(img, x);
545
+ tile.append(img, more, x);
374
546
  if (i === 0) { const b = document.createElement('span'); b.className = 'shp-pic-main'; b.textContent = 'Card'; tile.appendChild(b); }
375
547
  box.appendChild(tile);
376
548
  });
@@ -386,13 +558,14 @@ export async function mount(root, ctx) {
386
558
  const renderVariants = (el) => {
387
559
  const box = el.querySelector('[data-part="variants"]');
388
560
  const vs = variants.peek();
389
- box.innerHTML = vs.length ? `<div class="shp-vrow is-head"><span>Name</span><span>Price</span><span>SKU</span><span>Stock</span><span></span></div>` : '';
561
+ box.innerHTML = vs.length ? `<div class="shp-vrow is-head"><span>Name</span><span>Price</span><span>SKU</span><span>Stock</span><span></span><span></span></div>` : '';
390
562
  vs.forEach((v, i) => {
391
563
  const row = fragment(`<div class="shp-vrow" data-i="${i}">
392
- <input class="form-input" data-k="name" placeholder="Large" maxlength="80">
393
- <input class="form-input" data-k="price" inputmode="decimal" placeholder="same">
394
- <input class="form-input" data-k="sku" maxlength="60">
395
- <input class="form-input" data-k="stock" type="number" min="0" step="1" placeholder="${f.trackStock.peek() ? '0' : '∞'}">
564
+ <input class="form-input" data-k="name" placeholder="Large" maxlength="80" aria-label="Option name">
565
+ <input class="form-input" data-k="price" inputmode="decimal" placeholder="same" aria-label="Price">
566
+ <input class="form-input" data-k="sku" maxlength="60" aria-label="SKU">
567
+ <input class="form-input" data-k="stock" type="number" min="0" step="1" placeholder="${f.trackStock.peek() ? '0' : '∞'}" aria-label="Stock">
568
+ <button type="button" class="shp-kebab" data-vmore="${i}" title="More" aria-label="More">⋮</button>
396
569
  <button type="button" class="shp-kebab" data-vremove="${i}" title="Remove this option" aria-label="Remove">×</button>
397
570
  </div>`);
398
571
  for (const inp of row.querySelectorAll('[data-k]')) inp.value = v[inp.dataset.k] ?? '';
@@ -400,18 +573,30 @@ export async function mount(root, ctx) {
400
573
  });
401
574
  };
402
575
 
576
+ const menus = [];
403
577
  panel = await openPanel({
404
578
  title: had ? p.name : 'New product',
405
579
  size: 'lg',
406
580
  html,
407
581
  model,
408
582
  dirty,
583
+ busy: () => saving.peek(),
584
+ // The person closed it (discarding, or with nothing changed): the draft goes with it.
585
+ discarded: () => { drafting = false; drafts.put(key, null); },
409
586
  before: (el) => {
410
587
  wireChecks(el);
411
588
  renderPics(el);
412
589
  renderVariants(el);
413
590
  },
414
- cleanup: () => { panels.delete(panel?.so); stopSlug?.(); ext.destroy(); },
591
+ cleanup: () => {
592
+ drafting = false;
593
+ if (panels.get(key) === panel) panels.delete(key);
594
+ release();
595
+ stopSlug?.();
596
+ stopDraft?.();
597
+ ext.destroy();
598
+ menus.forEach(m => { try { m?.destroy?.(); } catch { /* gone */ } });
599
+ },
415
600
  methods: {
416
601
  cancel: () => panel?.close(),
417
602
  editSlug: () => { panel.panel.querySelector('[data-part="slug"]').hidden = false; slugTouched.value = true; panel.panel.querySelector('#sp-slug').focus(); },
@@ -422,41 +607,83 @@ export async function mount(root, ctx) {
422
607
  panel.panel.querySelector('.shp-vrow:last-child [data-k="name"]')?.focus();
423
608
  },
424
609
  async save() {
610
+ if (saving.peek() || !panel?.isOpen()) return;
425
611
  if (model.cannot.peek()) { E.toast(Object.values(check.peek())[0] || 'Check the form.', {type: 'warning'}); return; }
612
+ // The panel closes after a save, so the form is locked while it runs: nothing typed meanwhile is lost.
426
613
  saving.value = true;
614
+ panel.lock(true);
615
+ const sent = values();
427
616
  try {
428
617
  const pl = payload();
429
618
  const e = had ? await api.products.update(had.id, pl) : await api.products.create(pl);
430
619
  put(e);
431
- E.toast(had ? `"${e.data.name}" saved.` : `"${e.data.name}" added${e.data.status === 'active' ? ' - it is on sale' : ' as a draft'}.`, {type: 'success'});
620
+ if (!had) refreshBadge();
621
+ drafting = false;
622
+ // Only the draft of what was saved goes - not one a newer screen has written since.
623
+ if (stableJson(drafts.get(key)?.values) === stableJson(sent)) drafts.put(key, null);
624
+ E.toast(had ? `“${e.data.name}” saved.` : `“${e.data.name}” added${e.data.status === 'active' ? ' - it is on sale' : ' as a draft'}.`, {type: 'success'});
625
+ saving.value = false;
432
626
  panel.close(true);
433
627
  } catch (err) {
434
628
  E.toast(`Not saved: ${err.message || err}`, {type: 'error'});
435
629
  } finally {
436
630
  saving.value = false;
631
+ if (panel.isOpen()) panel.lock(false);
437
632
  }
438
633
  }
439
634
  }
440
635
  });
441
- panels.add(panel.so);
636
+ panels.set(key, panel);
637
+ // The screen went while the panel was opening: its teardown has already run, so close it here.
638
+ if (!here()) { panel.close(true); return true; }
442
639
  const el = panel.panel;
443
640
  ext = await mountPanels(el.querySelector('.shp-actions'), {on: 'product', ctx, info: {
444
641
  product: had ? {id: had.id, data: had.data} : null,
445
642
  // Options as they are in the form; one added since the last save has no id yet.
446
643
  variants: () => variants.peek().map(v => ({id: v.id || '', name: v.name}))
447
644
  }});
448
- if (!el.isConnected) ext.destroy();
645
+ if (!panel.isOpen() || !here()) { ext.destroy(); return true; }
449
646
 
450
- // Pictures: add from Media, remove, drag to reorder.
647
+ // Pictures: add from Media, remove, drag to reorder; a menu on each (right-click, ⋮, Shift+F10).
451
648
  const pics = el.querySelector('[data-part="pics"]');
649
+ const setImages = (next) => { images.value = next; renderPics(el); };
650
+ const movePic = (i, to) => {
651
+ const next = images.peek().slice();
652
+ const [m] = next.splice(i, 1);
653
+ next.splice(to, 0, m);
654
+ setImages(next);
655
+ pics.querySelector(`.shp-pic[data-i="${to}"]`)?.focus();
656
+ };
657
+ menus.push(E.contextMenu(pics, {match: '.shp-pic', inherit: false, density: 'compact', keyboardTrigger: false, items: (tile) => {
658
+ const i = Number(tile.dataset.i);
659
+ const all = images.peek();
660
+ if (!all[i]) return [];
661
+ return [
662
+ {header: `Picture ${i + 1} of ${all.length}`},
663
+ {label: 'Use on the card', icon: 'star', disabled: i === 0, action: () => movePic(i, 0)},
664
+ {label: 'Move left', icon: 'arrow-left', disabled: i === 0, action: () => movePic(i, i - 1)},
665
+ {label: 'Move right', icon: 'arrow-right', disabled: i === all.length - 1, action: () => movePic(i, i + 1)},
666
+ {label: 'Open the picture', icon: 'external-link', action: () => window.open(all[i], '_blank', 'noopener')},
667
+ {type: 'divider'},
668
+ {label: 'Remove', icon: 'trash', danger: true, action: () => setImages(images.peek().filter((_, j) => j !== i))}
669
+ ];
670
+ }}));
452
671
  pics.addEventListener('click', async (e) => {
672
+ const more = e.target.closest('[data-more]');
673
+ if (more) { kit.openMenuAt(more); return; }
453
674
  const rm = e.target.closest('[data-remove]');
454
- if (rm) { images.value = images.peek().filter((_, i) => i !== Number(rm.dataset.remove)); renderPics(el); return; }
675
+ if (rm) { setImages(images.peek().filter((_, i) => i !== Number(rm.dataset.remove))); return; }
455
676
  if (e.target.closest('[data-add]')) {
456
677
  const urls = await pickImages();
457
- if (urls?.length) { images.value = [...new Set([...images.peek(), ...urls])].slice(0, 12); renderPics(el); }
678
+ if (!panel.isOpen()) return;
679
+ if (urls?.length) setImages([...new Set([...images.peek(), ...urls])].slice(0, 12));
458
680
  }
459
681
  });
682
+ pics.addEventListener('keydown', (e) => {
683
+ const tile = e.target.closest('.shp-pic');
684
+ if (!tile || e.target !== tile) return;
685
+ if ((e.key === 'F10' && e.shiftKey) || e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); kit.openMenuAt(tile.querySelector('.shp-pic-more') || tile); }
686
+ });
460
687
  let dragFrom = -1;
461
688
  pics.addEventListener('dragstart', (e) => { dragFrom = Number(e.target.closest('.shp-pic')?.dataset.i ?? -1); });
462
689
  pics.addEventListener('dragover', (e) => { if (dragFrom >= 0) e.preventDefault(); });
@@ -464,16 +691,33 @@ export async function mount(root, ctx) {
464
691
  const to = Number(e.target.closest('.shp-pic')?.dataset.i ?? -1);
465
692
  if (dragFrom < 0 || to < 0 || to === dragFrom) return;
466
693
  e.preventDefault();
467
- const next = images.peek().slice();
468
- const [m] = next.splice(dragFrom, 1);
469
- next.splice(to, 0, m);
470
- images.value = next;
694
+ const from = dragFrom;
471
695
  dragFrom = -1;
472
- renderPics(el);
696
+ movePic(from, to);
473
697
  });
474
698
 
475
- // Options: edits go straight into the list.
699
+ // Options: edits go straight into the list; each row has a menu (right-click, ⋮) - its fields keep the browser's own.
476
700
  const vbox = el.querySelector('[data-part="variants"]');
701
+ const setVariants = (next, focusAt = -1) => {
702
+ variants.value = next;
703
+ renderVariants(el);
704
+ if (focusAt >= 0) vbox.querySelector(`.shp-vrow[data-i="${focusAt}"] [data-k="name"]`)?.focus();
705
+ };
706
+ menus.push(E.contextMenu(vbox, {match: '.shp-vrow:not(.is-head)', exclude: 'input, textarea, select', inherit: false, density: 'compact', keyboardTrigger: false, items: (row) => {
707
+ const i = Number(row.dataset.i);
708
+ const vs = variants.peek();
709
+ if (!vs[i]) return [];
710
+ const swap = (a, b) => { const next = vs.slice(); [next[a], next[b]] = [next[b], next[a]]; setVariants(next, b); };
711
+ return [
712
+ {header: vs[i].name || `Option ${i + 1}`},
713
+ {label: 'Move up', icon: 'arrow-up', disabled: i === 0, action: () => swap(i, i - 1)},
714
+ {label: 'Move down', icon: 'arrow-down', disabled: i === vs.length - 1, action: () => swap(i, i + 1)},
715
+ // A copy is a new option: no id, so the server gives it one.
716
+ {label: 'Duplicate', icon: 'copy', action: () => setVariants([...vs.slice(0, i + 1), {...vs[i], id: '', name: `${vs[i].name || 'Option'} (copy)`}, ...vs.slice(i + 1)], i + 1)},
717
+ {type: 'divider'},
718
+ {label: 'Remove', icon: 'trash', danger: true, action: () => setVariants(vs.filter((_, j) => j !== i))}
719
+ ];
720
+ }}));
477
721
  vbox.addEventListener('input', (e) => {
478
722
  const inp = e.target.closest('[data-k]');
479
723
  if (!inp) return;
@@ -481,14 +725,17 @@ export async function mount(root, ctx) {
481
725
  variants.value = variants.peek().map((v, j) => (j === i ? {...v, [inp.dataset.k]: inp.value} : v));
482
726
  });
483
727
  vbox.addEventListener('click', (e) => {
728
+ const more = e.target.closest('[data-vmore]');
729
+ if (more) { kit.openMenuAt(more); return; }
484
730
  const rm = e.target.closest('[data-vremove]');
485
731
  if (!rm) return;
486
- variants.value = variants.peek().filter((_, j) => j !== Number(rm.dataset.vremove));
487
- renderVariants(el);
732
+ setVariants(variants.peek().filter((_, j) => j !== Number(rm.dataset.vremove)));
488
733
  });
489
734
 
735
+ if (model.restored && slugTouched.peek() && start.slug !== record.slug) el.querySelector('[data-part="slug"]').hidden = false;
490
736
  if (focus === 'stock') el.querySelector('.shp-vrow:not(.is-head) [data-k="stock"]')?.focus();
491
737
  else if (!had) el.querySelector('#sp-name').focus();
738
+ return true;
492
739
  }
493
740
 
494
741
  /** Pick pictures from Media, several at once. */
@@ -548,15 +795,17 @@ export async function mount(root, ctx) {
548
795
  const fragment = (h) => { const t = document.createElement('template'); t.innerHTML = h.trim(); return t.content.firstElementChild; };
549
796
 
550
797
  wireRows(list, {openMenuAt: kit.openMenuAt, scope, items, main: (id) => edit(id)});
551
- kit.addBannerAction(ctx.container, {id: 'shop-new', icon: 'plus', title: 'Add a product', onClick: () => edit(null)});
798
+ kit.addBannerAction(ctx.container, {id: 'shop-new', icon: 'plus', title: 'Add a product', onClick: soon(() => edit(null))});
552
799
  scope(() => {
553
800
  ctx.container.querySelector('[data-banner-action="shop-new"]')?.remove();
554
- [...panels].forEach(so => { try { so.close(true); } catch { /* gone */ } });
801
+ [...panels.values()].forEach(pn => { try { pn.close(true); } catch { /* gone */ } });
555
802
  });
556
803
 
557
- await load();
804
+ await load(true);
558
805
  if (!here()) return;
559
806
  I.scan(view);
807
+ // A failed first load opens no editor at all - not even a new one (its drafts could not be checked).
808
+ if (!loaded.peek()) return;
560
809
  if (ctx.id === 'new') edit(null);
561
810
  else if (ctx.id) edit(ctx.id);
562
811
  }