domma-cms 0.84.0 → 0.85.0

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 (53) hide show
  1. package/README.md +4 -4
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/js/lib/themes.js +1 -1
  5. package/package.json +2 -2
  6. package/plugins/free-tier.lock.json +53 -5
  7. package/plugins/security/plugin.js +10 -0
  8. package/plugins/security/plugin.json +1 -1
  9. package/plugins/security/server/facts.js +3 -1
  10. package/plugins/shopping-cart/CLAUDE.md +93 -0
  11. package/plugins/shopping-cart/admin/css/index.css +224 -0
  12. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  13. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  14. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  15. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  16. package/plugins/shopping-cart/admin/views/products.js +547 -0
  17. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  18. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  19. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  20. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  21. package/plugins/shopping-cart/config.js +52 -0
  22. package/plugins/shopping-cart/inject/body.html +2 -0
  23. package/plugins/shopping-cart/lib/extensions.js +96 -0
  24. package/plugins/shopping-cart/lib/payments.js +281 -0
  25. package/plugins/shopping-cart/lib/pricing.js +232 -0
  26. package/plugins/shopping-cart/lib/render.js +282 -0
  27. package/plugins/shopping-cart/lib/samples.js +34 -0
  28. package/plugins/shopping-cart/lib/shop.js +436 -0
  29. package/plugins/shopping-cart/lib/summary.js +127 -0
  30. package/plugins/shopping-cart/plugin.js +559 -0
  31. package/plugins/shopping-cart/plugin.json +52 -0
  32. package/plugins/shopping-cart/plugin.public.js +144 -0
  33. package/plugins/shopping-cart/public/cart.js +715 -0
  34. package/plugins/shopping-cart/public/lib/money.js +105 -0
  35. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  36. package/plugins/shopping-cart/public/lib/products.js +235 -0
  37. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  38. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  39. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  40. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  41. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  42. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  43. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  44. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  45. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  46. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  47. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  48. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  49. package/plugins/shopping-cart/public/shop.css +415 -0
  50. package/plugins/shopping-cart/templates/shop.html +4 -0
  51. package/plugins/shopping-cart/tests/api.test.js +144 -0
  52. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  53. package/scripts/setup.js +1 -0
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Shop › Products.
3
+ *
4
+ * Chips by status (and "Low stock"), search, rows with a picture, the price,
5
+ * the stock and a menu: edit, view in the shop, duplicate, put on sale / to
6
+ * draft / archive, feature, set stock, delete. Click edits in a slideover -
7
+ * pictures from Media, options (sizes, colours) each with its own price and
8
+ * stock, stock tracking, delivery and tax class - checked as you type by the
9
+ * same rules the server applies (public/lib/products.js). "+" in the banner
10
+ * adds one; #/plugins/shopping-cart/products/new opens a blank one.
11
+ */
12
+
13
+ import {api, entriesOf, esc, getCurrency, money, moneyIn, moneyOut, openPanel, store, wireRows} from '../lib/kit.js';
14
+ import {STATUS_LABEL, cleanProduct, priceRange, slugify, totalStock} from '/plugins/shopping-cart/public/lib/products.js';
15
+
16
+ const FILTERS = [
17
+ {key: 'all', label: 'All', match: (p) => p.status !== 'archived'},
18
+ {key: 'active', label: 'On sale', match: (p) => p.status === 'active'},
19
+ {key: 'draft', label: 'Drafts', match: (p) => p.status === 'draft'},
20
+ {key: 'low', label: 'Low stock', match: (p, low) => p.status === 'active' && p.trackStock && totalStock(p) <= low},
21
+ {key: 'archived', label: 'Archived', match: (p) => p.status === 'archived'}
22
+ ];
23
+
24
+ const ROW = `
25
+ <div class="shp-row shp-prow" data-id="{{id}}" tabindex="0">
26
+ <span class="shp-thumb" data-bind-class="img ? '' : 'is-empty'"><img data-bind-src="img" data-bind-hidden="!img" alt=""></span>
27
+ <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>
28
+ <span class="shp-stock" data-bind-class="stockTone" data-bind-text="stockText" title="Stock"></span>
29
+ <span class="shp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
30
+ <span class="shp-amt" data-bind-text="price"></span>
31
+ <button type="button" class="shp-kebab" aria-label="Actions" title="Actions">⋮</button>
32
+ </div>`;
33
+
34
+ export async function mount(root, ctx) {
35
+ const {kit, scope, here, settings} = ctx;
36
+ const products = M.observable([]);
37
+ const loading = M.observable(true);
38
+ const error = M.observable('');
39
+ const filter = M.observable(store.get('shopping-cart.products-filter') || 'all');
40
+ const query = M.observable('');
41
+ const low = Number(settings.lowStockAt) || 0;
42
+ scope(M.effect(() => store.set('shopping-cart.products-filter', filter.value)));
43
+
44
+ const counts = M.computed(() => {
45
+ const out = {};
46
+ for (const f of FILTERS) out[f.key] = products.value.filter(p => f.match(p.data || {}, low)).length;
47
+ return out;
48
+ });
49
+ const rows = M.observable([]);
50
+ scope(M.effect(() => {
51
+ const f = FILTERS.find(x => x.key === filter.value) || FILTERS[0];
52
+ const q = query.value.trim().toLowerCase();
53
+ rows.value = products.value
54
+ .filter(e => f.match(e.data || {}, low))
55
+ .filter(e => !q || [e.data.name, e.data.sku, e.data.category, ...(e.data.tags || []), ...(e.data.variants || []).map(v => `${v.name} ${v.sku}`)]
56
+ .join(' ').toLowerCase().includes(q))
57
+ .map(e => {
58
+ const p = e.data;
59
+ const stock = totalStock(p);
60
+ const r = priceRange(p);
61
+ return {
62
+ id: e.id,
63
+ name: p.name,
64
+ img: p.images?.[0] || '',
65
+ featured: Boolean(p.featured),
66
+ meta: [p.category, p.variants?.length ? `${p.variants.length} ${(p.optionName || 'option').toLowerCase()}s` : '', p.sku].filter(Boolean).join(' · ') || ' ',
67
+ stockText: stock === null ? '∞' : `${stock} in stock`,
68
+ stockTone: stock === null ? 'is-muted' : stock === 0 ? 'is-danger' : stock <= low ? 'is-warning' : '',
69
+ statusLabel: STATUS_LABEL[p.status],
70
+ tone: p.status === 'active' ? 'is-success' : p.status === 'draft' ? 'is-warning' : 'is-muted',
71
+ price: r.min === r.max ? money(r.min) : `${money(r.min)}–${money(r.max)}`
72
+ };
73
+ });
74
+ }));
75
+ const emptyText = M.computed(() => (products.value.length ? 'No products match.' : 'No products yet - add one with + in the banner.'));
76
+
77
+ root.innerHTML = '';
78
+ const view = document.createElement('div');
79
+ view.className = 'shp-card shp-products';
80
+ view.innerHTML = `
81
+ <div class="shp-bar">
82
+ <div class="shp-seg" role="group" aria-label="Show">
83
+ ${FILTERS.map(f => `<button type="button" data-filter="${f.key}" data-bind-class="filter.value === '${f.key}' && 'is-on'">${esc(f.label)} <b data-bind-text="counts.value.${f.key}"></b></button>`).join('')}
84
+ </div>
85
+ <input type="search" class="form-input shp-search" placeholder="Search name, SKU, category…" aria-label="Search products" data-model="query.value">
86
+ <button type="button" class="btn btn-sm btn-primary" data-new><span data-icon="plus" data-icon-size="13"></span> Product</button>
87
+ <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
+ </div>
89
+ <p class="shp-note" data-bind-hidden="!loading.value">Loading…</p>
90
+ <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>
92
+ <div class="shp-list" data-each="rows.value key=id"></div>`;
93
+ root.appendChild(view);
94
+ const list = view.querySelector('.shp-list');
95
+ list.innerHTML = ROW;
96
+ scope(kit.helpPopovers(view, {position: 'bottom'}));
97
+ const bindings = M.applyBindings({loading, error, filter, query, rows, counts, emptyText}, view);
98
+ scope(() => bindings.dispose());
99
+ view.querySelector('.shp-seg').addEventListener('click', (e) => {
100
+ const b = e.target.closest('[data-filter]');
101
+ if (b) filter.value = b.dataset.filter;
102
+ });
103
+ view.querySelector('[data-new]').addEventListener('click', () => edit(null));
104
+
105
+ async function load() {
106
+ try {
107
+ products.value = entriesOf(await api.products.list());
108
+ error.value = '';
109
+ } catch (err) {
110
+ error.value = `Could not load the products: ${err.message || err}`;
111
+ } finally {
112
+ loading.value = false;
113
+ }
114
+ }
115
+ const byId = (id) => products.peek().find(p => p.id === id);
116
+ const put = (e) => {
117
+ const had = products.peek().some(p => p.id === e.id);
118
+ products.value = had ? products.peek().map(p => (p.id === e.id ? e : p)) : [e, ...products.peek()];
119
+ };
120
+
121
+ async function quick(p, patch, said) {
122
+ try {
123
+ put(await api.products.patch(p.id, patch));
124
+ if (said) E.toast(said, {type: 'success'});
125
+ } catch (err) {
126
+ E.toast(`Not changed: ${err.message || err}`, {type: 'error'});
127
+ }
128
+ }
129
+
130
+ async function setStock(p) {
131
+ 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;
134
+ const n = Number(v);
135
+ if (!Number.isInteger(n) || n < 0) { E.toast('A whole number, 0 or more.', {type: 'warning'}); return; }
136
+ quick(p, {stock: n, trackStock: true}, `${p.data.name}: ${n} in stock.`);
137
+ }
138
+
139
+ 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;
141
+ try {
142
+ await api.products.remove(p.id);
143
+ products.value = products.peek().filter(x => x.id !== p.id);
144
+ E.toast(`"${p.data.name}" deleted.`, {type: 'success'});
145
+ } catch (err) {
146
+ E.toast(err.message || String(err), {type: 'error'});
147
+ }
148
+ }
149
+
150
+ function items(id) {
151
+ const p = byId(id);
152
+ if (!p) return [];
153
+ const d = p.data;
154
+ return [
155
+ {header: d.name},
156
+ {label: 'Edit', icon: 'edit', action: () => edit(id)},
157
+ {label: 'View in the shop', icon: 'external-link', disabled: d.status !== 'active', action: () => window.open(`${settings.shopPath}/${d.slug}`, '_blank', 'noopener')},
158
+ {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'}); }
160
+ catch (err) { E.toast(err.message || String(err), {type: 'error'}); }
161
+ }},
162
+ {type: 'divider'},
163
+ ...(d.status !== 'active' ? [{label: 'Put on sale', icon: 'check-circle', action: () => quick(p, {status: 'active'}, `"${d.name}" is on sale.`)}] : []),
164
+ ...(d.status !== 'draft' ? [{label: 'Back to draft', icon: 'rotate-ccw', action: () => quick(p, {status: 'draft'}, `"${d.name}" is a draft - hidden from the shop.`)}] : []),
165
+ {label: d.featured ? 'Unfeature' : 'Feature', icon: 'star', action: () => quick(p, {featured: !d.featured})},
166
+ {label: d.variants?.length ? 'Stock…' : 'Set stock…', icon: 'box', action: () => setStock(p)},
167
+ {type: 'divider'},
168
+ ...(d.status !== 'archived' ? [{label: 'Archive', icon: 'archive', action: () => quick(p, {status: 'archived'}, `"${d.name}" archived.`)}] : []),
169
+ {label: 'Delete…', icon: 'trash', danger: true, action: () => remove(p)}
170
+ ];
171
+ }
172
+
173
+ // ---- The editor --------------------------------------------------------------
174
+
175
+ const panels = new Set();
176
+ const categories = () => [...new Set(products.peek().map(p => p.data.category).filter(Boolean))].sort();
177
+
178
+ async function edit(id, focus = '') {
179
+ 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));
204
+ const saving = M.observable(false);
205
+
206
+ const snapshot = () => JSON.stringify([Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])), images.peek(), variants.peek()]);
207
+ const opened = snapshot();
208
+ const dirty = () => snapshot() !== opened;
209
+
210
+ const payload = () => ({
211
+ ...Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])),
212
+ slug: f.slug.peek() || slugify(f.name.peek()),
213
+ price: moneyOut(f.price.peek()),
214
+ compareAt: moneyOut(f.compareAt.peek()),
215
+ stock: f.stock.peek() === '' ? null : Number(f.stock.peek()),
216
+ weight: f.weight.peek() === '' ? null : Number(f.weight.peek()),
217
+ images: images.peek(),
218
+ variants: variants.peek().map(v => ({id: v.id, name: v.name, sku: v.sku,
219
+ price: moneyOut(v.price), stock: v.stock === '' || v.stock === null ? null : Number(v.stock)}))
220
+ });
221
+ // Checked as typed, by the server's own rules.
222
+ const check = M.computed(() => {
223
+ f.name.value; f.price.value; f.compareAt.value; f.stock.value; f.weight.value; images.value; variants.value;
224
+ const pl = payload();
225
+ const nanPrice = Number.isNaN(pl.price) || Number.isNaN(pl.compareAt) || pl.variants.some(v => Number.isNaN(v.price));
226
+ const {errors} = cleanProduct({...pl, price: Number.isNaN(pl.price) ? 'x' : pl.price, compareAt: Number.isNaN(pl.compareAt) ? 'x' : pl.compareAt,
227
+ variants: pl.variants.map(v => ({...v, price: Number.isNaN(v.price) ? 'x' : v.price}))}, {currency: getCurrency(), minor: true});
228
+ if (nanPrice && !errors.price) errors.price = 'Not a price.';
229
+ return errors;
230
+ });
231
+ const stopSlug = M.effect(() => { if (!slugTouched.value) f.slug.value = slugify(f.name.value); });
232
+
233
+ const model = {
234
+ ...f, images, variants, saving, isNew: !had, hasVariants: M.computed(() => variants.value.length > 0),
235
+ nameErr: M.computed(() => check.value.name || ''),
236
+ priceErr: M.computed(() => check.value.price || ''),
237
+ wasErr: M.computed(() => check.value.compareAt || ''),
238
+ stockErr: M.computed(() => check.value.stock || ''),
239
+ variantErr: M.computed(() => Object.entries(check.value).filter(([k]) => k.startsWith('variant')).map(([, v]) => v).join(' ')),
240
+ cannot: M.computed(() => saving.value || Object.keys(check.value).length > 0),
241
+ url: M.computed(() => `${settings.shopPath}/${f.slug.value || slugify(f.name.value) || '…'}`),
242
+ currency: getCurrency()
243
+ };
244
+
245
+ let panel = null;
246
+ const html = `<div class="shp-form shp-editor">
247
+ <div class="shp-grid">
248
+ <div class="shp-wide">
249
+ <label class="form-label" for="sp-name">Name</label>
250
+ <input id="sp-name" class="form-input shp-input-lg" data-model="name.value" maxlength="140" placeholder="Hand-thrown mug">
251
+ <small class="shp-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
252
+ <small class="shp-hint">Address: <code data-bind-text="url.value"></code> <button type="button" class="shp-link" data-on-click="editSlug">change</button></small>
253
+ </div>
254
+ <div class="shp-wide" data-part="slug" hidden>
255
+ <label class="form-label" for="sp-slug">Address <span data-help="The last part of the product's web address. Letters, numbers and dashes. Changing it breaks links people have saved."></span></label>
256
+ <input id="sp-slug" class="form-input" data-model="slug.value" maxlength="80">
257
+ </div>
258
+ <div class="shp-wide">
259
+ <label class="form-label">Pictures <span data-help="The first picture is the one on the card; the second shows when a shopper hovers over it. Drag to reorder. Pictures come from Media."></span></label>
260
+ <div class="shp-pics" data-part="pics"></div>
261
+ </div>
262
+ <div>
263
+ <label class="form-label" for="sp-price">Price <span data-help="What a shopper pays for one. Options can have their own price below."></span></label>
264
+ <div class="shp-money"><span data-bind-text="currency"></span><input id="sp-price" class="form-input" inputmode="decimal" data-model="price.value" placeholder="0.00"></div>
265
+ <small class="shp-err" data-bind-hidden="!priceErr.value" data-bind-text="priceErr.value"></small>
266
+ </div>
267
+ <div>
268
+ <label class="form-label" for="sp-was">Was <span data-help="An earlier, higher price. Shown struck through with a Sale flag. Leave empty when it is not on sale."></span></label>
269
+ <div class="shp-money"><span data-bind-text="currency"></span><input id="sp-was" class="form-input" inputmode="decimal" data-model="compareAt.value" placeholder="optional"></div>
270
+ <small class="shp-err" data-bind-hidden="!wasErr.value" data-bind-text="wasErr.value"></small>
271
+ </div>
272
+ <div class="shp-wide">
273
+ <label class="form-label" for="sp-summary">Summary <span data-help="One or two lines, shown on the card and at the top of the product's page."></span></label>
274
+ <input id="sp-summary" class="form-input" data-model="summary.value" maxlength="300">
275
+ </div>
276
+ <div class="shp-wide">
277
+ <label class="form-label" for="sp-desc">Description <span data-help="The full story, under the Add to basket button. Markdown and shortcodes work, as on any page."></span></label>
278
+ <textarea id="sp-desc" class="form-input shp-mono" rows="6" data-model="description.value"></textarea>
279
+ </div>
280
+ <div>
281
+ <label class="form-label" for="sp-cat">Category <span data-help="Shoppers filter the shop by category. Type a new one or pick one you have used."></span></label>
282
+ <input id="sp-cat" class="form-input" data-model="category.value" list="sp-cats" maxlength="60">
283
+ <datalist id="sp-cats">${categories().map(c => `<option value="${esc(c)}">`).join('')}</datalist>
284
+ </div>
285
+ <div>
286
+ <label class="form-label" for="sp-tags">Tags <span data-help="Comma-separated. Found by the shop's search, and by [shop-products tag=…]."></span></label>
287
+ <input id="sp-tags" class="form-input" data-model="tags.value" placeholder="gift, handmade">
288
+ </div>
289
+ <div>
290
+ <label class="form-label" for="sp-status">Status</label>
291
+ <select id="sp-status" class="form-select" data-model="status.value">
292
+ <option value="active">On sale</option><option value="draft">Draft - hidden</option><option value="archived">Archived</option>
293
+ </select>
294
+ </div>
295
+ <div class="shp-toggles">
296
+ <label class="shp-check"><input type="checkbox" data-part="featured"> Featured <span data-help="Shown first in the shop, with a Featured flag."></span></label>
297
+ </div>
298
+ </div>
299
+
300
+ <details class="shp-sect" data-part="sect-stock" open>
301
+ <summary>Options &amp; stock</summary>
302
+ <label class="shp-check"><input type="checkbox" data-part="trackStock"> Count stock <span data-help="Off: it never sells out. On: each sale takes one off, a refund can put it back, and it shows Sold out at 0."></span></label>
303
+ <div class="shp-grid" data-bind-hidden="hasVariants.value || !trackStock.value">
304
+ <div><label class="form-label" for="sp-stock">In stock</label><input id="sp-stock" class="form-input" type="number" min="0" step="1" data-model="stock.value">
305
+ <small class="shp-err" data-bind-hidden="!stockErr.value" data-bind-text="stockErr.value"></small></div>
306
+ <div><label class="form-label" for="sp-sku">SKU <span data-help="Your own stock code. Printed on packing slips."></span></label><input id="sp-sku" class="form-input" data-model="sku.value" maxlength="60"></div>
307
+ </div>
308
+ <div class="shp-variants">
309
+ <div class="shp-variants-head" data-bind-hidden="!hasVariants.value">
310
+ <label class="form-label" for="sp-optname">Options are</label>
311
+ <input id="sp-optname" class="form-input shp-optname" data-model="optionName.value" placeholder="Size, Colour…" maxlength="40">
312
+ <span data-help="Each option is something a shopper picks - a size, a colour - with its own price (empty = the product's price), stock code and stock."></span>
313
+ </div>
314
+ <div class="shp-vtable" data-part="variants"></div>
315
+ <small class="shp-err" data-bind-hidden="!variantErr.value" data-bind-text="variantErr.value"></small>
316
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="addVariant"><span data-icon="plus" data-icon-size="13"></span> Add an option</button>
317
+ </div>
318
+ </details>
319
+
320
+ <details class="shp-sect">
321
+ <summary>Delivery &amp; tax</summary>
322
+ <label class="shp-check"><input type="checkbox" data-part="shippable"> Needs delivering <span data-help="Off for downloads, services and tickets: no delivery charge, and checkout does not ask for an address."></span></label>
323
+ <div class="shp-grid">
324
+ <div><label class="form-label" for="sp-weight">Weight (g) <span data-help="Used by Shopping Cart Pro's weight-based delivery rates."></span></label><input id="sp-weight" class="form-input" type="number" min="0" step="1" data-model="weight.value"></div>
325
+ <div><label class="form-label" for="sp-tax">Tax class <span data-help="Standard for most things. Reduced and zero rates apply with Shopping Cart Pro's tax rules; the free Cart uses one rate for everything."></span></label>
326
+ <select id="sp-tax" class="form-select" data-model="taxClass.value"><option value="standard">Standard</option><option value="reduced">Reduced</option><option value="zero">Zero-rated</option></select></div>
327
+ </div>
328
+ </details>
329
+
330
+ <div class="shp-actions">
331
+ <small class="shp-hint">Ctrl+Enter saves.</small>
332
+ <span class="shp-spacer"></span>
333
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
334
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="check" data-icon-size="13"></span> <span data-bind-text="isNew ? 'Add product' : 'Save'"></span></button>
335
+ </div>
336
+ </div>`;
337
+
338
+ // Checkboxes: set after render, and listened to - boolean attrs do not bind reliably.
339
+ const wireChecks = (el) => {
340
+ for (const k of ['featured', 'trackStock', 'shippable']) {
341
+ const box = el.querySelector(`[data-part="${k}"]`);
342
+ box.checked = f[k].peek();
343
+ box.addEventListener('change', () => { f[k].value = box.checked; });
344
+ }
345
+ };
346
+
347
+ const renderPics = (el) => {
348
+ const box = el.querySelector('[data-part="pics"]');
349
+ box.innerHTML = '';
350
+ images.peek().forEach((src, i) => {
351
+ const tile = document.createElement('div');
352
+ tile.className = 'shp-pic';
353
+ tile.draggable = true;
354
+ tile.dataset.i = String(i);
355
+ const img = document.createElement('img');
356
+ img.src = src;
357
+ img.alt = '';
358
+ const x = document.createElement('button');
359
+ x.type = 'button';
360
+ x.className = 'shp-pic-x';
361
+ x.title = 'Remove';
362
+ x.textContent = '×';
363
+ x.dataset.remove = String(i);
364
+ tile.append(img, x);
365
+ if (i === 0) { const b = document.createElement('span'); b.className = 'shp-pic-main'; b.textContent = 'Card'; tile.appendChild(b); }
366
+ box.appendChild(tile);
367
+ });
368
+ const add = document.createElement('button');
369
+ add.type = 'button';
370
+ add.className = 'shp-pic-add';
371
+ add.dataset.add = '1';
372
+ add.innerHTML = '<span data-icon="image" data-icon-size="20"></span><span>Add</span>';
373
+ box.appendChild(add);
374
+ I.scan(add);
375
+ };
376
+
377
+ const renderVariants = (el) => {
378
+ const box = el.querySelector('[data-part="variants"]');
379
+ const vs = variants.peek();
380
+ 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>` : '';
381
+ vs.forEach((v, i) => {
382
+ const row = fragment(`<div class="shp-vrow" data-i="${i}">
383
+ <input class="form-input" data-k="name" placeholder="Large" maxlength="80">
384
+ <input class="form-input" data-k="price" inputmode="decimal" placeholder="same">
385
+ <input class="form-input" data-k="sku" maxlength="60">
386
+ <input class="form-input" data-k="stock" type="number" min="0" step="1" placeholder="${f.trackStock.peek() ? '0' : '∞'}">
387
+ <button type="button" class="shp-kebab" data-vremove="${i}" title="Remove this option" aria-label="Remove">×</button>
388
+ </div>`);
389
+ for (const inp of row.querySelectorAll('[data-k]')) inp.value = v[inp.dataset.k] ?? '';
390
+ box.appendChild(row);
391
+ });
392
+ };
393
+
394
+ panel = await openPanel({
395
+ title: had ? p.name : 'New product',
396
+ size: 'lg',
397
+ html,
398
+ model,
399
+ dirty,
400
+ before: (el) => {
401
+ wireChecks(el);
402
+ renderPics(el);
403
+ renderVariants(el);
404
+ },
405
+ cleanup: () => { panels.delete(panel?.so); stopSlug?.(); },
406
+ methods: {
407
+ cancel: () => panel?.close(),
408
+ editSlug: () => { panel.panel.querySelector('[data-part="slug"]').hidden = false; slugTouched.value = true; panel.panel.querySelector('#sp-slug').focus(); },
409
+ addVariant: () => {
410
+ if (!variants.peek().length && !f.optionName.peek()) f.optionName.value = 'Size';
411
+ variants.value = [...variants.peek(), {id: '', name: '', price: '', sku: '', stock: ''}];
412
+ renderVariants(panel.panel);
413
+ panel.panel.querySelector('.shp-vrow:last-child [data-k="name"]')?.focus();
414
+ },
415
+ async save() {
416
+ if (model.cannot.peek()) { E.toast(Object.values(check.peek())[0] || 'Check the form.', {type: 'warning'}); return; }
417
+ saving.value = true;
418
+ try {
419
+ const pl = payload();
420
+ const e = had ? await api.products.update(had.id, pl) : await api.products.create(pl);
421
+ put(e);
422
+ E.toast(had ? `"${e.data.name}" saved.` : `"${e.data.name}" added${e.data.status === 'active' ? ' - it is on sale' : ' as a draft'}.`, {type: 'success'});
423
+ panel.close(true);
424
+ } catch (err) {
425
+ E.toast(`Not saved: ${err.message || err}`, {type: 'error'});
426
+ } finally {
427
+ saving.value = false;
428
+ }
429
+ }
430
+ }
431
+ });
432
+ panels.add(panel.so);
433
+ const el = panel.panel;
434
+
435
+ // Pictures: add from Media, remove, drag to reorder.
436
+ const pics = el.querySelector('[data-part="pics"]');
437
+ pics.addEventListener('click', async (e) => {
438
+ const rm = e.target.closest('[data-remove]');
439
+ if (rm) { images.value = images.peek().filter((_, i) => i !== Number(rm.dataset.remove)); renderPics(el); return; }
440
+ if (e.target.closest('[data-add]')) {
441
+ const urls = await pickImages();
442
+ if (urls?.length) { images.value = [...new Set([...images.peek(), ...urls])].slice(0, 12); renderPics(el); }
443
+ }
444
+ });
445
+ let dragFrom = -1;
446
+ pics.addEventListener('dragstart', (e) => { dragFrom = Number(e.target.closest('.shp-pic')?.dataset.i ?? -1); });
447
+ pics.addEventListener('dragover', (e) => { if (dragFrom >= 0) e.preventDefault(); });
448
+ pics.addEventListener('drop', (e) => {
449
+ const to = Number(e.target.closest('.shp-pic')?.dataset.i ?? -1);
450
+ if (dragFrom < 0 || to < 0 || to === dragFrom) return;
451
+ e.preventDefault();
452
+ const next = images.peek().slice();
453
+ const [m] = next.splice(dragFrom, 1);
454
+ next.splice(to, 0, m);
455
+ images.value = next;
456
+ dragFrom = -1;
457
+ renderPics(el);
458
+ });
459
+
460
+ // Options: edits go straight into the list.
461
+ const vbox = el.querySelector('[data-part="variants"]');
462
+ vbox.addEventListener('input', (e) => {
463
+ const inp = e.target.closest('[data-k]');
464
+ if (!inp) return;
465
+ const i = Number(inp.closest('.shp-vrow').dataset.i);
466
+ variants.value = variants.peek().map((v, j) => (j === i ? {...v, [inp.dataset.k]: inp.value} : v));
467
+ });
468
+ vbox.addEventListener('click', (e) => {
469
+ const rm = e.target.closest('[data-vremove]');
470
+ if (!rm) return;
471
+ variants.value = variants.peek().filter((_, j) => j !== Number(rm.dataset.vremove));
472
+ renderVariants(el);
473
+ });
474
+
475
+ if (focus === 'stock') el.querySelector('.shp-vrow:not(.is-head) [data-k="stock"]')?.focus();
476
+ else if (!had) el.querySelector('#sp-name').focus();
477
+ }
478
+
479
+ /** Pick pictures from Media, several at once. */
480
+ async function pickImages() {
481
+ let media = [];
482
+ try {
483
+ const res = await H.get('/api/media');
484
+ media = (res.data ?? res ?? []).filter(m => /\.(jpe?g|png|gif|webp|avif|svg)$/i.test(m.name || m.url || ''));
485
+ } catch {
486
+ E.toast('Could not load Media.', {type: 'error'});
487
+ return null;
488
+ }
489
+ return new Promise((resolve) => {
490
+ const picked = new Set();
491
+ let result = null;
492
+ let pp = null;
493
+ openPanel({
494
+ title: 'Pictures from Media',
495
+ size: 'lg',
496
+ html: `<div class="shp-form">
497
+ <p class="shp-note">${media.length ? 'Click to choose, then Add. Upload new pictures under Media.' : 'No pictures in Media yet - upload some there first.'}</p>
498
+ <div class="shp-media" data-part="grid"></div>
499
+ <div class="shp-actions"><span class="shp-spacer"></span><button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
500
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save">Add</button></div></div>`,
501
+ before: (el) => {
502
+ const grid = el.querySelector('[data-part="grid"]');
503
+ for (const m of media) {
504
+ const b = document.createElement('button');
505
+ b.type = 'button';
506
+ b.className = 'shp-media-tile';
507
+ b.dataset.url = m.url;
508
+ const img = document.createElement('img');
509
+ img.src = m.url;
510
+ img.alt = '';
511
+ img.loading = 'lazy';
512
+ const cap = document.createElement('span');
513
+ cap.textContent = m.name;
514
+ b.append(img, cap);
515
+ grid.appendChild(b);
516
+ }
517
+ grid.addEventListener('click', (e) => {
518
+ const t = e.target.closest('.shp-media-tile');
519
+ if (!t) return;
520
+ if (picked.has(t.dataset.url)) picked.delete(t.dataset.url); else picked.add(t.dataset.url);
521
+ t.classList.toggle('is-on', picked.has(t.dataset.url));
522
+ });
523
+ },
524
+ cleanup: () => resolve(result),
525
+ methods: {
526
+ cancel: () => pp?.close(true),
527
+ save: () => { result = [...picked]; pp?.close(true); }
528
+ }
529
+ }).then(p => { pp = p; });
530
+ });
531
+ }
532
+
533
+ const fragment = (h) => { const t = document.createElement('template'); t.innerHTML = h.trim(); return t.content.firstElementChild; };
534
+
535
+ wireRows(list, {openMenuAt: kit.openMenuAt, scope, items, main: (id) => edit(id)});
536
+ kit.addBannerAction(ctx.container, {id: 'shop-new', icon: 'plus', title: 'Add a product', onClick: () => edit(null)});
537
+ scope(() => {
538
+ ctx.container.querySelector('[data-banner-action="shop-new"]')?.remove();
539
+ [...panels].forEach(so => { try { so.close(true); } catch { /* gone */ } });
540
+ });
541
+
542
+ await load();
543
+ if (!here()) return;
544
+ I.scan(view);
545
+ if (ctx.id === 'new') edit(null);
546
+ else if (ctx.id) edit(ctx.id);
547
+ }