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.
- package/admin/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
const
|
|
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 = () =>
|
|
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: () => {
|
|
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
|
-
|
|
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.
|
|
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 (!
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
}
|