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
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "security",
3
3
  "displayName": "Security",
4
- "version": "1.1.3",
4
+ "version": "1.2.0",
5
5
  "tier": "free",
6
6
  "description": "Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-09-27",
8
+ "date": "2026-10-15",
9
9
  "minCmsVersion": "0.83.0",
10
10
  "icon": "shield",
11
11
  "permissions": [
@@ -0,0 +1,69 @@
1
+ /** Security - the screen's pure decisions (admin/lib/screen-state.js). */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {applySettingsChanges, settingsChanges, draftKey, draftNote, draftValues, fingerprint, signinCounts, signinInResult, signinMatches, stableJson, usableDraft, withCount} from '../admin/lib/screen-state.js';
5
+
6
+ test('fingerprint ignores key order and changes with any value', () => {
7
+ assert.equal(stableJson({b: 1, a: [1, {d: 2, c: 3}]}), '{"a":[1,{"c":3,"d":2}],"b":1}');
8
+ assert.equal(fingerprint({a: 1, b: {c: 2}}), fingerprint({b: {c: 2}, a: 1}));
9
+ assert.notEqual(fingerprint({a: 1}), fingerprint({a: 2}));
10
+ assert.notEqual(fingerprint({lockout: {enabled: true}}), fingerprint({lockout: {enabled: false}}));
11
+ });
12
+
13
+ test('a kept draft says when the settings were saved since; an old or matching one says nothing', () => {
14
+ const fp = fingerprint({signinDays: 30});
15
+ assert.equal(draftNote({was: fp}, fp), '');
16
+ assert.match(draftNote({was: fp}, fingerprint({signinDays: 60})), /those later changes stay/);
17
+ assert.match(draftNote({was: fp, full: true}, fingerprint({signinDays: 60})), /older changes over them/);
18
+ assert.equal(draftNote({was: ''}, fp), '', 'a draft without was cannot know');
19
+ assert.equal(draftNote({was: fp}, ''), '', 'the stored settings could not be read');
20
+ });
21
+
22
+ test('usableDraft checks what storage holds; draftValues keeps only the form\'s keys', () => {
23
+ assert.equal(usableDraft(null), null);
24
+ assert.equal(usableDraft('x'), null);
25
+ assert.equal(usableDraft({values: [1]}), null);
26
+ assert.deepEqual(usableDraft({changes: {acc: '5'}, was: 'w', at: 't'}), {changes: {acc: '5'}, was: 'w', at: 't', full: false});
27
+ assert.deepEqual(usableDraft({values: {acc: '5'}, was: 7}), {changes: {acc: '5'}, was: '', at: '', full: true}, 'an older full-values draft is still read');
28
+ assert.equal(usableDraft({changes: {}}), null, 'nothing changed: nothing to offer');
29
+ assert.equal(usableDraft({changes: ['x']}), null);
30
+ assert.deepEqual(draftValues({acc: '5', evil: 'x', lockOn: false}, ['acc', 'lockOn', 'max']), {acc: '5', lockOn: false});
31
+ assert.equal(draftKey(''), '');
32
+ assert.equal(draftKey('u1'), 'security.settings-draft.u1');
33
+ });
34
+
35
+ test('sign-in counts agree with the filters the list uses', () => {
36
+ const log = [
37
+ {email: 'a@x.test', ip: '1.1.1.1', ok: true},
38
+ {email: 'a@x.test', ip: '1.1.1.1', ok: false, reason: 'password'},
39
+ {email: 'b@x.test', ip: '2.2.2.2', ok: false, reason: 'unknown'},
40
+ {email: 'b@x.test', ip: '2.2.2.2', ok: true, kind: 'reset'}
41
+ ];
42
+ assert.deepEqual(signinCounts(log), {all: 4, failed: 2, ok: 1, resets: 1});
43
+ assert.deepEqual(signinCounts(log, {ip: '2.2.2.2'}), {all: 2, failed: 1, ok: 0, resets: 1});
44
+ assert.deepEqual(signinCounts(log, {query: 'A@X'}), {all: 2, failed: 1, ok: 1, resets: 0});
45
+ for (const r of ['all', 'failed', 'ok', 'resets']) {
46
+ assert.equal(log.filter(e => signinInResult(e, r) && signinMatches(e, {})).length, signinCounts(log)[r], r);
47
+ }
48
+ assert.equal(signinInResult(log[3], 'ok'), false, 'a reset\'s ok is not a sign-in');
49
+ });
50
+
51
+ test('a count on each button; none while nothing is loaded', () => {
52
+ assert.equal(withCount('Failed', 3), 'Failed 3');
53
+ assert.equal(withCount('All', 1234), 'All 1,234');
54
+ assert.equal(withCount('Failed', undefined), 'Failed');
55
+ });
56
+
57
+ test('a draft keeps only what was changed, and lays just that over the settings as they are now', () => {
58
+ const opened = {acc: '5', lockOn: true, days: '30', stale: '90'};
59
+ const typed = {...opened, days: '45'};
60
+ const changes = settingsChanges(typed, opened);
61
+ assert.deepEqual(changes, {days: '45'});
62
+ assert.deepEqual(settingsChanges(opened, opened), {});
63
+ assert.deepEqual(settingsChanges({...opened, lockOn: false}, opened), {lockOn: false}, 'a switch turned off is a change');
64
+ // Someone else saved stale = 120 and switched the lockout off since: both stay.
65
+ const now = {acc: '5', lockOn: false, days: '30', stale: '120'};
66
+ assert.deepEqual(applySettingsChanges(now, changes), {acc: '5', lockOn: false, days: '45', stale: '120'});
67
+ assert.deepEqual(applySettingsChanges(now, {evil: 'x'}), now, 'a key the form lacks is never added');
68
+ assert.deepEqual(applySettingsChanges(now, null), now);
69
+ });
@@ -250,3 +250,16 @@ no amount, reason or restock box, and OK refunded the WHOLE order. `refund()` in
250
250
  slideover (`openPanel`, like Fulfil): amount (prefilled with what is left), reason, restock box (greyed out unless the
251
251
  amount is all that is left), a Refund button. Never put form fields in `E.confirm` / `E.alert`.
252
252
  `tests/pure.test.js` "admin: no form fields inside E.confirm" guards it across the admin views.
253
+
254
+ ## 1.7.0 (2026-10-15 Tool Upgrade, second pass)
255
+
256
+ - Product and settings drafts live in `admin/lib/drafts.js` (pure, tested): one map under
257
+ `shopping-cart.product-drafts` and `shopping-cart.settings-draft`. Payment keys are never kept (`SECRET_KEYS`), a
258
+ draft carries `was`, and OK on the restore question means throw away. Pro's Downloads values are not drafted.
259
+ - `openPanel` takes `busy`, `discarded` and `lock`. `wireRows` handles Shift+F10 once (`keyboardTrigger: false`) and
260
+ leaves fields to the browser.
261
+ - Anything a key press leads to that may open an E.confirm runs on the next task (`kit.soon` / `deferItems`;
262
+ `wireRows` defers Enter and menu actions). Domma's dialog otherwise reads the same Enter as OK, and every OK here is
263
+ destructive.
264
+ - Fulfil and Refund panels are in `actionPanels`, closed by the screen teardown; their saves refuse once it has gone.
265
+ - The accounts test sets a Site URL in `config/site.json` and restores it (`/account/register` needs a link base).
@@ -9,6 +9,16 @@
9
9
  .shp-note.is-bad { color: var(--dm-danger); }
10
10
  .shp-spacer { flex: 1; }
11
11
  .shp-loading { color: var(--dm-text-muted); padding: 2rem 0; }
12
+ /* A skeleton slot: a block of its own (a flex row would collapse the skeleton to a sliver). */
13
+ .shp-loading-slot { display: block; color: var(--dm-text-muted); font-size: var(--dm-text-sm); min-height: 1.5rem; }
14
+ .shp-sales.is-busy .shp-tiles, .shp-sales.is-busy .shp-card { opacity: .55; transition: opacity .15s; }
15
+ /* Drafts left unsaved with no row to show them on (a new product, one deleted since). */
16
+ .shp-drafts { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; padding: .5rem .7rem; border: 1px dashed color-mix(in srgb, var(--dm-primary) 45%, var(--dm-border));
17
+ border-radius: var(--dm-radius-md); font-size: var(--dm-text-sm); color: var(--dm-text-muted); }
18
+ .shp-drafts button.shp-chip { border: 0; cursor: pointer; font: inherit; font-size: var(--dm-text-xs); font-weight: 600; }
19
+ .shp-drafts button.shp-chip:hover { background: color-mix(in srgb, var(--dm-primary) 24%, transparent); }
20
+ .shp-form.is-locked { opacity: .7; cursor: progress; }
21
+ .shp-restored { border-left-color: var(--dm-primary); background: color-mix(in srgb, var(--dm-primary) 8%, transparent); font-size: var(--dm-text-sm); }
12
22
 
13
23
  /* Tabs under the banner */
14
24
  .shp-nav { display: flex; flex-wrap: wrap; gap: .15rem; border-bottom: 1px solid var(--dm-border); }
@@ -106,6 +116,9 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
106
116
  .shp-mini-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
107
117
  .shp-mini-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
108
118
  .shp-mini-amt { font-weight: 600; font-variant-numeric: tabular-nums; }
119
+ .shp-top.is-link { cursor: pointer; border-radius: var(--dm-radius-sm); }
120
+ .shp-top.is-link:hover, .shp-top.is-link:focus-visible { background: var(--shp-soft); outline: none; }
121
+ .shp-mini .shp-kebab { flex: none; }
109
122
  .shp-top { display: grid; grid-template-columns: minmax(0, 1fr) 30% auto; gap: .75rem; align-items: center; padding: .4rem 0; }
110
123
  .shp-top-name { display: flex; flex-direction: column; min-width: 0; }
111
124
  .shp-top-name small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
@@ -121,7 +134,7 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
121
134
  border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem 1rem; }
122
135
  .shp-steps li::before { content: counter(st); grid-row: 1 / 3; width: 2rem; height: 2rem; border-radius: 50%; display: grid; place-items: center;
123
136
  background: var(--shp-soft); font-weight: 700; }
124
- .shp-steps li.is-done::before { content: '✓'; background: var(--dm-success); color: #fff; }
137
+ .shp-steps li.is-done::before { content: '✓'; background: color-mix(in srgb, var(--dm-success) 18%, transparent); color: var(--dm-success); }
125
138
  .shp-steps li span { grid-column: 2; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
126
139
  .shp-steps li .btn { grid-column: 3; grid-row: 1 / 3; }
127
140
 
@@ -158,9 +171,12 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
158
171
  .shp-pic, .shp-pic-add { position: relative; width: 5.5rem; height: 5.5rem; border-radius: var(--dm-radius-md); overflow: hidden; }
159
172
  .shp-pic { cursor: grab; background: var(--shp-soft); }
160
173
  .shp-pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
161
- .shp-pic-x { position: absolute; top: .25rem; right: .25rem; width: 1.4rem; height: 1.4rem; border-radius: 50%; border: 0; background: rgba(0,0,0,.6); color: #fff;
162
- cursor: pointer; line-height: 1; opacity: 0; transition: opacity .15s; }
163
- .shp-pic:hover .shp-pic-x, .shp-pic-x:focus { opacity: 1; }
174
+ .shp-pic-x, .shp-pic-more { position: absolute; top: .25rem; right: .25rem; width: 1.4rem; height: 1.4rem; border-radius: 50%; border: 1px solid var(--dm-border);
175
+ background: color-mix(in srgb, var(--dm-surface, var(--dm-body-bg)) 88%, transparent); color: var(--dm-text); cursor: pointer; line-height: 1; padding: 0;
176
+ opacity: 0; transition: opacity .15s; }
177
+ .shp-pic-more { right: auto; left: .25rem; }
178
+ .shp-pic:hover .shp-pic-x, .shp-pic-x:focus, .shp-pic:hover .shp-pic-more, .shp-pic-more:focus, .shp-pic:focus-within .shp-pic-more, .shp-pic:focus-within .shp-pic-x { opacity: 1; }
179
+ .shp-pic:focus-visible { outline: 2px solid var(--dm-primary); outline-offset: 2px; }
164
180
  .shp-pic-main { position: absolute; left: .25rem; bottom: .25rem; font-size: .62rem; font-weight: 700; background: var(--dm-primary); color: var(--dm-primary-text, #fff);
165
181
  padding: .05rem .35rem; border-radius: 999px; }
166
182
  .shp-pic-add { border: 1.5px dashed var(--dm-border); background: none; color: var(--dm-text-muted); cursor: pointer; display: flex; flex-direction: column;
@@ -178,7 +194,7 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
178
194
  .shp-variants-head .form-label { margin: 0; white-space: nowrap; }
179
195
  .shp-optname { max-width: 12rem; }
180
196
  .shp-vtable { width: 100%; display: flex; flex-direction: column; gap: .35rem; }
181
- .shp-vrow { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, .9fr) 1.8rem; gap: .35rem; align-items: center; }
197
+ .shp-vrow { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, .9fr) 1.6rem 1.6rem; gap: .35rem; align-items: center; }
182
198
  .shp-vrow.is-head { font-size: var(--dm-text-xs); color: var(--dm-text-muted); font-weight: 600; }
183
199
  .shp-vrow .form-input { padding: .35rem .5rem; font-size: var(--dm-text-sm); }
184
200
 
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Shop admin - kept drafts (pure; tested in ../../tests/drafts.test.js).
3
+ *
4
+ * The product editor keeps what was left unsaved, one entry per product in ONE
5
+ * stored map (`shopping-cart.product-drafts`, key = the product's id, `new` for
6
+ * one not yet added). Every change reads the map fresh, changes its own entry
7
+ * and writes it straight back - nothing holds a copy across an await, so a
8
+ * screen that has gone cannot write an old map over a newer one.
9
+ *
10
+ * A draft carries `was` - the product's updatedAt when the edits it holds
11
+ * began - so the restore question can say the product was changed (or
12
+ * deleted) since. Shop settings keep a draft too, WITHOUT the payment keys
13
+ * (`SECRET_KEYS` never reach storage); their `was` is a fingerprint of the
14
+ * settings as they were, since settings have no updatedAt.
15
+ *
16
+ * Every restore question puts the DESTRUCTIVE answer on OK ("Throw away"):
17
+ * Domma's E.confirm answers false for Cancel, Escape and the backdrop alike,
18
+ * so a false must always keep the changes.
19
+ */
20
+
21
+ export const PRODUCT_DRAFTS_KEY = 'shopping-cart.product-drafts';
22
+ export const SETTINGS_DRAFT_KEY = 'shopping-cart.settings-draft';
23
+ export const NEW_KEY = 'new';
24
+
25
+ /** Settings that are never kept in a draft (the browser's storage is not a safe). */
26
+ export const SECRET_KEYS = ['stripeSecretKey', 'stripeWebhookSecret', 'paypalClientId', 'paypalSecret'];
27
+
28
+ /** The map's key for a product id ('' / null = a new product). */
29
+ export const draftKey = (id) => (id ? String(id) : NEW_KEY);
30
+
31
+ /** JSON with object keys sorted, so two equal values always compare equal. */
32
+ export function stableJson(v) {
33
+ if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;
34
+ if (v && typeof v === 'object') {
35
+ return `{${Object.keys(v).sort().filter(k => v[k] !== undefined).map(k => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(',')}}`;
36
+ }
37
+ return JSON.stringify(v ?? null);
38
+ }
39
+
40
+ /** Is this a usable kept draft (this module's shape)? */
41
+ export function isDraft(d) {
42
+ return Boolean(d && typeof d === 'object' && d.values && typeof d.values === 'object' && !Array.isArray(d.values));
43
+ }
44
+
45
+ /** The stored map, whatever an older build or a broken write left there. */
46
+ export function readMap(raw) {
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
48
+ const out = {};
49
+ for (const [k, d] of Object.entries(raw)) if (isDraft(d)) out[k] = d;
50
+ return out;
51
+ }
52
+
53
+ /** The map with `key` set to `draft` (null removes it). A new object; the input is untouched. */
54
+ export function withDraft(map, key, draft) {
55
+ const out = {...readMap(map)};
56
+ if (draft) out[key] = draft; else delete out[key];
57
+ return out;
58
+ }
59
+
60
+ /** Does the draft hold anything the form does not already show? Only the form's own fields count. */
61
+ export function draftDiffers(draft, form) {
62
+ if (!isDraft(draft)) return false;
63
+ const keys = Object.keys(form || {});
64
+ const pick = (o) => Object.fromEntries(keys.map(k => [k, o?.[k]]));
65
+ return stableJson(pick(draft.values)) !== stableJson(pick(form));
66
+ }
67
+
68
+ /** A draft's values, limited to the form's own fields (an older build may have kept others). */
69
+ export function draftValues(draft, form) {
70
+ const out = {};
71
+ for (const k of Object.keys(form || {})) if (isDraft(draft) && k in draft.values) out[k] = draft.values[k];
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * What became of the record since the draft's edits began.
77
+ * @param {{was?: string|null, at?: string}} draft
78
+ * @param {string|null|undefined} now - the record's updatedAt (or fingerprint) now; null = it has gone
79
+ * @returns {'deleted'|'changed'|''}
80
+ */
81
+ export function draftFate(draft, now) {
82
+ if (now === null) return 'deleted';
83
+ if (!now) return '';
84
+ if (draft?.was) return now !== draft.was ? 'changed' : '';
85
+ return '';
86
+ }
87
+
88
+ /** E.confirm renders its message as HTML: a product's name goes in escaped. */
89
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
90
+
91
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
92
+ /** "15 Oct, 14:05" - local time; '' for anything unreadable. */
93
+ export function when(iso) {
94
+ const t = Date.parse(iso);
95
+ if (!Number.isFinite(t)) return '';
96
+ const d = new Date(t);
97
+ const p = (n) => String(n).padStart(2, '0');
98
+ return `${d.getDate()} ${MONTHS[d.getMonth()]}, ${p(d.getHours())}:${p(d.getMinutes())}`;
99
+ }
100
+
101
+ const KEEP = {title: 'Unsaved changes', confirmText: 'Throw away', cancelText: 'Keep them'};
102
+
103
+ /**
104
+ * The question a kept product draft is offered back with. OK throws it away;
105
+ * Cancel / Escape / backdrop keep it - put back in the form, or (`asNew`: the
106
+ * product has been deleted) made into a new product.
107
+ * @param {object} draft
108
+ * @param {{name?: string, updatedAt?: string}|null} product - as stored now; null when it has gone
109
+ * @param {{isNew?: boolean}} [opts]
110
+ * @returns {{text: string, asNew: boolean, options: object}}
111
+ */
112
+ export function productDraftQuestion(draft, product, {isNew = false} = {}) {
113
+ const name = esc(String(product?.name || draft?.values?.name || '').trim());
114
+ const what = isNew ? `a new product${name ? ` (“${name}”)` : ''}` : (name ? `“${name}”` : 'this product');
115
+ const at = when(draft?.at);
116
+ const ask = `Throw away the changes you left unsaved on ${what}${at ? ` (${at})` : ''}?`;
117
+ if (isNew) return {asNew: false, options: KEEP, text: `${ask} Keeping them puts them back in the form.`};
118
+ switch (draftFate(draft, product ? (product.updatedAt || '') : null)) {
119
+ case 'deleted':
120
+ return {asNew: true, options: {...KEEP, cancelText: 'Keep them as a new product'},
121
+ text: `${ask} The product has been deleted since - keeping them makes a new product from them.`};
122
+ case 'changed':
123
+ return {asNew: false, options: KEEP,
124
+ text: `${ask} The product has been changed since${product.updatedAt ? ` (${when(product.updatedAt)})` : ''} - keeping them puts your changes over that when you save.`};
125
+ default:
126
+ return {asNew: false, options: KEEP, text: `${ask} Keeping them puts them back in the form.`};
127
+ }
128
+ }
129
+
130
+ /**
131
+ * A deleted product's draft, made a new product's: its `was` meant the old
132
+ * record, so it starts empty; its options lose their ids (they belonged to the
133
+ * deleted product); `adopted` tells the editor the person has already said keep.
134
+ */
135
+ export function adoptAsNew(draft, now = new Date().toISOString()) {
136
+ const values = {...(draft?.values || {})};
137
+ if (Array.isArray(values.variants)) values.variants = values.variants.map(v => ({...v, id: ''}));
138
+ return {values, slugTouched: Boolean(draft?.slugTouched), was: null, at: draft?.at || now, adopted: true};
139
+ }
140
+
141
+ /** The question for another new-product draft an adopted one would replace. */
142
+ export function replaceNewDraftQuestion(other) {
143
+ const name = esc(String(other?.values?.name || '').trim());
144
+ const at = when(other?.at);
145
+ return {
146
+ text: `There is already a new product left unsaved${name ? ` - “${name}”` : ''}${at ? ` (${at})` : ''}. Throw it away to make room for these changes?`,
147
+ options: {title: 'Unsaved new product', confirmText: 'Throw it away', cancelText: 'Keep both'}
148
+ };
149
+ }
150
+
151
+ // ---- Settings ---------------------------------------------------------------------
152
+
153
+ /** Settings values safe to keep: everything but the payment keys. */
154
+ export function keepableSettings(values) {
155
+ const out = {};
156
+ for (const [k, v] of Object.entries(values || {})) if (!SECRET_KEYS.includes(k)) out[k] = v;
157
+ return out;
158
+ }
159
+
160
+ /** A fingerprint of the stored settings (secrets left out - they arrive masked anyway). */
161
+ export const settingsFingerprint = (settings) => stableJson(keepableSettings(settings));
162
+
163
+ /** The settings' restore question. OK throws the draft away; anything else puts it back. */
164
+ export function settingsDraftQuestion(draft, nowFingerprint) {
165
+ const at = when(draft?.at);
166
+ const ask = `Throw away the changes you left unsaved in Shop settings${at ? ` (${at})` : ''}?`;
167
+ const changed = draftFate(draft, nowFingerprint || '') === 'changed';
168
+ return {
169
+ options: KEEP,
170
+ text: changed
171
+ ? `${ask} The settings have been changed since - keeping them puts your changes over that when you save. Payment keys are never kept; type them again if you changed any.`
172
+ : `${ask} Keeping them puts them back in the form. Payment keys are never kept; type them again if you changed any.`
173
+ };
174
+ }
@@ -17,22 +17,27 @@ export const BASE = '/api/plugins/shopping-cart';
17
17
 
18
18
  let kit = null;
19
19
 
20
- /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
20
+ /** tool-kit, plugin-chrome, help-popover and loading, once, with stand-ins for an older admin. */
21
21
  export function loadKit() {
22
22
  if (!kit) {
23
23
  kit = Promise.all([
24
24
  import('/admin/js/lib/tool-kit.js').catch(() => ({})),
25
25
  import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
26
- import('/admin/js/lib/help-popover.js').catch(() => ({}))
27
- ]).then(([tk, chrome, help]) => ({
26
+ import('/admin/js/lib/help-popover.js').catch(() => ({})),
27
+ import('/admin/js/lib/loading.js').catch(() => ({}))
28
+ ]).then(([tk, chrome, help, loading]) => ({
28
29
  onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
29
30
  openMenuAt: tk.openMenuAt || ((el) => {
30
31
  const r = el.getBoundingClientRect();
31
32
  el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
32
33
  }),
33
- viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
34
+ viewLifetime: tk.viewLifetime || (() => ({add() { return () => {}; }, reset() {}})),
35
+ promptWith: tk.promptWith || ((message, options, value) => E.prompt(message, {...options, inputValue: String(value ?? '')})),
34
36
  addBannerAction: chrome.addBannerAction || (() => null),
35
- helpPopovers: help.helpPopovers || (() => () => {})
37
+ helpPopovers: help.helpPopovers || (() => () => {}),
38
+ // A skeleton while a first load runs (core's loading.js); an older core just waits.
39
+ whileLoading: loading.whileLoading || ((slot, promise) => (typeof promise === 'function' ? promise() : promise)),
40
+ SHAPES: loading.SHAPES || {}
36
41
  }));
37
42
  }
38
43
  return kit;
@@ -95,11 +100,61 @@ export function fragment(html) {
95
100
  return t.content.firstElementChild;
96
101
  }
97
102
 
103
+ /**
104
+ * `fn`, run on the next task instead of now. Anything a key press leads to that may open a dialog goes through
105
+ * this: Domma's dialog listens for keys on the document from the moment it opens, so one opened while an Enter
106
+ * is still on its way up reads that same Enter as OK - and every OK here is the destructive answer (Throw away,
107
+ * Discard, Delete). A click does not need it, but it costs nothing, so buttons and menu items use it too.
108
+ */
109
+ export const soon = (fn) => (...args) => { setTimeout(() => fn(...args), 0); };
110
+
111
+ /** Storage that never throws (blocked storage, private windows). */
98
112
  export const store = {
99
- get(k) { try { return S.get(k); } catch { return null; } },
100
- set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
113
+ get(k) { try { return S.get(k) ?? null; } catch { return null; } },
114
+ set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } },
115
+ remove(k) { try { if (S.remove) S.remove(k); else S.set(k, null); } catch { /* a convenience */ } }
101
116
  };
102
117
 
118
+ /**
119
+ * E.confirm that a screen can take back: `dismiss()` answers it with Cancel -
120
+ * which every question here makes the keeping, non-destructive answer - so a
121
+ * question about a screen the person has left does not stay up over the next
122
+ * one. The caller ignores the answer once its screen has gone.
123
+ *
124
+ * @returns {{answer: Promise<boolean>, dismiss: () => void}}
125
+ */
126
+ export function ask(text, options = {}) {
127
+ const answer = E.confirm(text, options);
128
+ const box = [...document.querySelectorAll('.dm-dialog')].at(-1) || null;
129
+ return {
130
+ answer,
131
+ dismiss() {
132
+ if (!box?.isConnected) return;
133
+ const label = options.cancelText || 'Cancel';
134
+ [...box.querySelectorAll('button')].find(b => b.textContent.trim() === label)?.click();
135
+ }
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Once-only claims for panels, per mount: `take(key)` answers a release
141
+ * function, or null when that panel is already opening or open (a double
142
+ * click, a row clicked while its panel is still loading). Whoever takes a
143
+ * claim must release it if opening fails, or the panel never opens again.
144
+ */
145
+ export function claims() {
146
+ const held = new Map();
147
+ return {
148
+ take(key) {
149
+ if (held.has(key)) return null;
150
+ const token = {};
151
+ held.set(key, token);
152
+ return () => { if (held.get(key) === token) held.delete(key); };
153
+ },
154
+ has: (key) => held.has(key)
155
+ };
156
+ }
157
+
103
158
  /** The sidebar re-asks the Shop's badge. */
104
159
  export function refreshBadge() {
105
160
  try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
@@ -135,12 +190,22 @@ export function when(iso) {
135
190
  // ---- Slideovers ---------------------------------------------------------------------
136
191
 
137
192
  /**
138
- * Open a slideover holding `html`, bound to `model`. Closing with something
139
- * changed asks first (once). Everything is disposed and removed on close.
193
+ * Open a slideover holding `html`, bound to `model`. Everything is disposed
194
+ * and removed on close.
140
195
  *
141
- * @returns {Promise<{so, panel, close: (force?: boolean) => void}>}
196
+ * - `dirty()`: closing (X, Escape, the backdrop, Close) asks first - OK
197
+ * discards, Cancel / Escape keep the panel open.
198
+ * - `busy()`: while true (a save on its way) the person cannot close it; only
199
+ * code can (`close(true)`), so the panel the save reports back to is still there.
200
+ * - `discarded()`: called when the PERSON closes it (after saying discard, or
201
+ * with nothing to lose) - not when code force-closes it (a save, the screen
202
+ * going) - so a kept draft is dropped only on their say-so.
203
+ * - `lock(on)` makes the form inert while a save that closes the panel runs, so
204
+ * nothing typed meanwhile is lost with it.
205
+ *
206
+ * @returns {Promise<{so, panel, close: (force?: boolean) => void, isOpen: () => boolean, lock: (on: boolean) => void}>}
142
207
  */
143
- export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before}) {
208
+ export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, busy, discarded, cleanup = () => {}, before}) {
144
209
  const {onceClosed, helpPopovers} = await loadKit();
145
210
  const so = E.slideover({title: esc(title), size, position: 'right'});
146
211
  const panel = fragment(html);
@@ -148,17 +213,25 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
148
213
  before?.(panel);
149
214
  const disposeHelp = helpPopovers(panel);
150
215
  const handle = M.applyBindings(model, panel, {methods});
151
- onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
216
+ let question = null;
217
+ onceClosed(so, () => { question?.dismiss(); cleanup(); handle.dispose(); disposeHelp(); });
152
218
  const close = so.close.bind(so);
153
219
  let closed = false;
154
- let asking = false;
155
220
  so.close = async (force) => {
156
- if (!closed && force !== true && dirty?.()) {
157
- if (asking) return undefined;
158
- asking = true;
159
- const ok = await E.confirm('Discard your changes?');
160
- asking = false;
161
- if (!ok) return undefined;
221
+ if (!closed && force !== true) {
222
+ if (busy?.()) return undefined;
223
+ if (dirty?.()) {
224
+ if (question) return undefined;
225
+ // Not inside the key press that asked to close (Enter on Close, Escape): see soon().
226
+ question = {dismiss() {}};
227
+ await new Promise(r => setTimeout(r, 0));
228
+ if (closed) { question = null; return undefined; }
229
+ question = ask('Discard your changes?', {title: 'Unsaved changes', confirmText: 'Discard', cancelText: 'Keep editing'});
230
+ const ok = await question.answer;
231
+ question = null;
232
+ if (!ok || closed || busy?.()) return undefined;
233
+ }
234
+ try { discarded?.(); } catch { /* the panel still closes */ }
162
235
  }
163
236
  closed = true;
164
237
  return close();
@@ -168,7 +241,12 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
168
241
  });
169
242
  I.scan(panel);
170
243
  so.open();
171
- return {so, panel, close: (force) => so.close(force)};
244
+ return {
245
+ so, panel,
246
+ close: (force) => so.close(force),
247
+ isOpen: () => !closed,
248
+ lock(on) { panel.inert = Boolean(on); panel.classList.toggle('is-locked', Boolean(on)); }
249
+ };
172
250
  }
173
251
 
174
252
  /**
@@ -212,26 +290,47 @@ export async function mountPanels(anchor, {on, ctx, info = {}}) {
212
290
  return {panels, destroy: () => undo.forEach(fn => { try { fn(); } catch { /* gone */ } })};
213
291
  }
214
292
 
293
+ /** Typing fields keep the browser's own right-click (paste) and their own clicks. */
294
+ const FIELDS = 'input, textarea, select';
295
+
215
296
  /**
216
297
  * Rows: click (or Enter) = the main action; right-click, ⋮ or Shift+F10 = the
217
- * menu. `scope` receives the teardown.
298
+ * menu. A right-click or click on a field inside a row is left to the
299
+ * browser. `scope` receives the teardown.
218
300
  */
219
301
  export function wireRows(list, {items, main, openMenuAt, scope, match = '.shp-row'}) {
220
- const menu = E.contextMenu(list, {cursor: 'glyph', match, inherit: false, density: 'compact',
221
- items: (row) => items(row.dataset.id)});
302
+ // keyboardTrigger off: Shift+F10 is answered here, once (Domma's own document listener answering it too
303
+ // opened the menu and closed it again at once), and the ContextMenu key is left alone (Chrome on Windows
304
+ // opens its own menu on key-up).
305
+ // Menu items run on the next task: an item picked with Enter that asks something must not hand it that Enter.
306
+ const menu = E.contextMenu(list, {cursor: 'glyph', match, exclude: FIELDS, inherit: false, density: 'compact', keyboardTrigger: false,
307
+ items: (row) => deferItems(items(row.dataset.id))});
222
308
  const onClick = (e) => {
223
309
  const row = e.target.closest(match);
224
- if (!row) return;
225
- if (e.target.closest('[data-stop]')) return;
310
+ if (!row || !list.contains(row)) return;
311
+ if (e.target.closest('[data-stop]') || e.target.closest(FIELDS)) return;
226
312
  const kebab = e.target.closest('.shp-kebab');
227
313
  if (kebab) { openMenuAt(kebab); return; }
228
314
  main(row.dataset.id);
229
315
  };
230
316
  const onKey = (e) => {
231
317
  const row = e.target.closest(match);
232
- if (!row) return;
233
- if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
234
- else if (e.key === 'Enter' && e.target === row) main(row.dataset.id);
318
+ if (!row || !list.contains(row)) return;
319
+ if (e.key === 'F10' && e.shiftKey) {
320
+ if (e.target.closest(FIELDS)) return;
321
+ e.preventDefault();
322
+ e.stopPropagation();
323
+ openMenuAt(row.querySelector('.shp-kebab') || row);
324
+ }
325
+ else if (e.key === 'Enter' && e.target === row) {
326
+ // A link row follows itself. Otherwise the Enter stops here and the main action runs after it,
327
+ // so a question it opens (a kept draft's) does not read this Enter as OK.
328
+ if (row.matches('a[href]')) return;
329
+ e.preventDefault();
330
+ e.stopPropagation();
331
+ const id = row.dataset.id;
332
+ setTimeout(() => main(id), 0);
333
+ }
235
334
  else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
236
335
  e.preventDefault();
237
336
  const rows = [...list.querySelectorAll(match)].filter(r => r.offsetParent);
@@ -243,6 +342,15 @@ export function wireRows(list, {items, main, openMenuAt, scope, match = '.shp-ro
243
342
  scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
244
343
  }
245
344
 
345
+ /** Menu items with every action deferred (soon), submenus included. */
346
+ export function deferItems(list) {
347
+ return (list || []).map(x => ({
348
+ ...x,
349
+ ...(typeof x.action === 'function' ? {action: soon(x.action)} : {}),
350
+ ...(Array.isArray(x.items) ? {items: deferItems(x.items)} : {})
351
+ }));
352
+ }
353
+
246
354
  /** Copy text, and say so. */
247
355
  export function copy(text, what = 'Copied') {
248
356
  navigator.clipboard?.writeText(text).then(() => E.toast(`${what}.`, {type: 'success'}), () => E.toast(text));