domma-cms 0.84.1 → 0.86.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 (70) 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/sidebar-renderer.js +3 -3
  5. package/admin/js/lib/themes.js +1 -1
  6. package/config/menus/admin-sidebar.json +21 -8
  7. package/config/plugins.json +0 -18
  8. package/package.json +2 -2
  9. package/plugins/_lib/mail/diagnostics.js +3 -2
  10. package/plugins/blog/CLAUDE.md +33 -0
  11. package/plugins/blog/lib/access.js +64 -0
  12. package/plugins/blog/lib/notices.js +38 -0
  13. package/plugins/blog/plugin.js +84 -26
  14. package/plugins/blog/plugin.json +173 -20
  15. package/plugins/blog/roles/blog-author.json +15 -2
  16. package/plugins/blog/tests/access.test.js +32 -0
  17. package/plugins/blog/tests/comment-notices.test.js +140 -0
  18. package/plugins/blog/tests/notices.test.js +20 -0
  19. package/plugins/blog/tests/public.test.js +3 -1
  20. package/plugins/free-tier.lock.json +63 -10
  21. package/plugins/mail-reader/plugin.js +12 -1
  22. package/plugins/mail-reader/plugin.json +23 -4
  23. package/plugins/shopping-cart/CLAUDE.md +93 -0
  24. package/plugins/shopping-cart/admin/css/index.css +224 -0
  25. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  26. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  27. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  28. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  29. package/plugins/shopping-cart/admin/views/products.js +547 -0
  30. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  31. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  32. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  33. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  34. package/plugins/shopping-cart/config.js +52 -0
  35. package/plugins/shopping-cart/inject/body.html +2 -0
  36. package/plugins/shopping-cart/lib/extensions.js +96 -0
  37. package/plugins/shopping-cart/lib/payments.js +281 -0
  38. package/plugins/shopping-cart/lib/pricing.js +232 -0
  39. package/plugins/shopping-cart/lib/render.js +282 -0
  40. package/plugins/shopping-cart/lib/samples.js +34 -0
  41. package/plugins/shopping-cart/lib/shop.js +436 -0
  42. package/plugins/shopping-cart/lib/summary.js +127 -0
  43. package/plugins/shopping-cart/plugin.js +559 -0
  44. package/plugins/shopping-cart/plugin.json +52 -0
  45. package/plugins/shopping-cart/plugin.public.js +144 -0
  46. package/plugins/shopping-cart/public/cart.js +715 -0
  47. package/plugins/shopping-cart/public/lib/money.js +105 -0
  48. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  49. package/plugins/shopping-cart/public/lib/products.js +235 -0
  50. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  51. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  52. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  53. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  54. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  55. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  56. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  57. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  58. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  59. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  60. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  61. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  62. package/plugins/shopping-cart/public/shop.css +415 -0
  63. package/plugins/shopping-cart/templates/shop.html +4 -0
  64. package/plugins/shopping-cart/tests/api.test.js +144 -0
  65. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  66. package/scripts/setup.js +1 -0
  67. package/server/routes/api/notifications.js +4 -3
  68. package/server/services/notify.js +4 -2
  69. package/server/services/pluginScope.js +85 -0
  70. package/server/services/plugins.js +7 -2
@@ -0,0 +1,715 @@
1
+ /**
2
+ * Shopping Cart - the browser half. Loaded on every public page (plugin.json
3
+ * `inject`), and does nothing at all on a page with no shop on it and an
4
+ * empty basket.
5
+ *
6
+ * - The basket: kept in this browser (localStorage), so it follows the
7
+ * shopper from page to page and visit to visit. It holds ids, options and
8
+ * quantities; PRICES always come from the server (POST /quote), which is
9
+ * also what checkout charges.
10
+ * - The basket button and drawer, on any page once there is something in it.
11
+ * - Adding: any [data-shop-add] button (cards, [buy-button]) and a product
12
+ * page's form.
13
+ * - The storefront: category chips, search and sort, without a reload.
14
+ * - Checkout (/shop/cart) and the receipt (/shop/order/…), which waits for
15
+ * the payment to be confirmed.
16
+ */
17
+
18
+ import {formatMoney} from './lib/money.js';
19
+
20
+ const API = '/api/plugins/shopping-cart';
21
+ const KEY = 'dm-shop:basket';
22
+ const CONFIG_KEY = 'dm-shop:config';
23
+ const DETAILS_KEY = 'dm-shop:details';
24
+ const PENDING_KEY = 'dm-shop:pending';
25
+
26
+ // ---- Small things --------------------------------------------------------------
27
+
28
+ const $ = (sel, root = document) => root.querySelector(sel);
29
+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
30
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
31
+ const store = {
32
+ get(k, fallback = null) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fallback; } catch { return fallback; } },
33
+ set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private window: the page still works */ } },
34
+ drop(k) { try { localStorage.removeItem(k); } catch { /* ignore */ } }
35
+ };
36
+ const session = {
37
+ get(k) { try { return JSON.parse(sessionStorage.getItem(k) || 'null'); } catch { return null; } },
38
+ set(k, v) { try { sessionStorage.setItem(k, JSON.stringify(v)); } catch { /* ignore */ } }
39
+ };
40
+ const ICON = {
41
+ truck: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h11v10H3zM14 10h4l3 3v3h-7z"/><circle cx="7" cy="18" r="2"/><circle cx="17" cy="18" r="2"/></svg>',
42
+ bag: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 7h12l-1 13H7L6 7z"/><path d="M9 7a3 3 0 0 1 6 0"/></svg>',
43
+ check: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7"/></svg>',
44
+ arrow: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',
45
+ lock: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>',
46
+ card: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg>',
47
+ bank: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="M3 10 12 4l9 6M5 10v8M9.5 10v8M14.5 10v8M19 10v8M3 20h18"/></svg>',
48
+ paypal: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M8.3 21H4.8l2.8-17h6.1c3.4 0 5.4 1.8 4.9 4.8-.6 3.7-3.1 5.3-6.4 5.3H9.9L8.3 21z" opacity=".55"/><path d="M10.2 18.5H7.6l.3-1.9h0l1.9-11.8h5.1c2.6 0 4 1.4 3.6 3.7-.5 3-2.5 4.3-5.2 4.3h-1.9l-1.2 5.7z"/></svg>'
49
+ };
50
+
51
+ // ---- Config: from the page, or remembered from the last shop page -----------------
52
+
53
+ let config = (() => {
54
+ const el = document.getElementById('dm-shop-config');
55
+ if (el) {
56
+ try {
57
+ const c = JSON.parse(el.textContent || '{}');
58
+ store.set(CONFIG_KEY, {shopPath: c.shopPath, currency: c.currency, floatingCart: c.floatingCart,
59
+ cartPosition: c.cartPosition, freeShippingOver: c.freeShippingOver, title: c.title});
60
+ return c;
61
+ } catch { /* fall through */ }
62
+ }
63
+ return store.get(CONFIG_KEY, null);
64
+ })();
65
+ const page = document.getElementById('dm-shop')?.dataset.shopPage || '';
66
+ const money = (n, cur) => formatMoney(n, cur || config?.currency || 'GBP');
67
+ const shopPath = () => config?.shopPath || '/shop';
68
+
69
+ // ---- The basket ---------------------------------------------------------------------
70
+
71
+ const basket = {
72
+ read() {
73
+ const b = store.get(KEY, null);
74
+ return Array.isArray(b?.lines) ? b.lines.filter(l => l && l.productId && l.qty > 0) : [];
75
+ },
76
+ write(lines) {
77
+ store.set(KEY, {lines, at: Date.now()});
78
+ emit();
79
+ },
80
+ count() { return basket.read().reduce((s, l) => s + l.qty, 0); },
81
+ add(item, qty = 1) {
82
+ const lines = basket.read();
83
+ const had = lines.find(l => l.productId === item.productId && (l.variantId || '') === (item.variantId || ''));
84
+ if (had) had.qty = Math.min(999, had.qty + qty);
85
+ else lines.push({productId: item.productId, variantId: item.variantId || '', qty, name: item.name || '', image: item.image || '', variantName: item.variantName || ''});
86
+ basket.write(lines);
87
+ },
88
+ set(productId, variantId, qty) {
89
+ const lines = basket.read();
90
+ const i = lines.findIndex(l => l.productId === productId && (l.variantId || '') === (variantId || ''));
91
+ if (i < 0) return;
92
+ if (qty <= 0) lines.splice(i, 1); else lines[i].qty = Math.min(999, qty);
93
+ basket.write(lines);
94
+ },
95
+ clear() { basket.write([]); }
96
+ };
97
+
98
+ const listeners = new Set();
99
+ function emit() { listeners.forEach(fn => { try { fn(); } catch { /* one listener's fault */ } }); }
100
+ // Another tab changed the basket.
101
+ window.addEventListener('storage', (e) => { if (e.key === KEY) emit(); });
102
+
103
+ /** Price the basket (the server's word), dropping from it what can no longer be bought. */
104
+ let quoteSeq = 0;
105
+ async function quote(extra = {}) {
106
+ const lines = basket.read();
107
+ const seq = ++quoteSeq;
108
+ const res = await fetch(`${API}/quote`, {method: 'POST', headers: {'Content-Type': 'application/json'},
109
+ body: JSON.stringify({lines: lines.map(({productId, variantId, qty}) => ({productId, variantId, qty})), ...extra})});
110
+ if (!res.ok) throw new Error('The basket could not be priced just now.');
111
+ const q = await res.json();
112
+ if (seq === quoteSeq) reconcile(q);
113
+ return q;
114
+ }
115
+
116
+ /** Make the stored basket match what the server priced (sold out, fewer left). */
117
+ function reconcile(q) {
118
+ const priced = new Map(q.lines.map(l => [`${l.productId}:${l.variantId}`, l]));
119
+ const lines = basket.read();
120
+ let changed = false;
121
+ const next = [];
122
+ for (const l of lines) {
123
+ const p = priced.get(`${l.productId}:${l.variantId || ''}`);
124
+ if (!p) { changed = true; continue; }
125
+ if (p.qty !== l.qty || p.name !== l.name || p.image !== l.image || p.variantName !== l.variantName) changed = true;
126
+ next.push({...l, qty: p.qty, name: p.name, image: p.image, variantName: p.variantName});
127
+ }
128
+ if (changed) store.set(KEY, {lines: next, at: Date.now()});
129
+ if (changed) listeners.forEach(fn => { if (fn !== renderDrawerSoon) { try { fn(); } catch { /* ignore */ } } });
130
+ }
131
+
132
+ // ---- The basket button and drawer ------------------------------------------------------
133
+
134
+ let fab = null;
135
+ let drawer = null;
136
+ let lastFocus = null;
137
+
138
+ function ensureFab() {
139
+ if (fab) return fab;
140
+ fab = document.createElement('button');
141
+ fab.type = 'button';
142
+ fab.className = `shop-fab is-hidden${config?.cartPosition === 'left' ? ' is-left' : ''}`;
143
+ fab.setAttribute('aria-label', 'Open your basket');
144
+ fab.innerHTML = `${ICON.bag}<span class="shop-fab-label">Basket</span><span class="shop-fab-count" aria-live="polite">0</span>`;
145
+ fab.addEventListener('click', openDrawer);
146
+ document.body.appendChild(fab);
147
+ return fab;
148
+ }
149
+
150
+ function syncFab(bump = false) {
151
+ const n = basket.count();
152
+ const wanted = n > 0 && (config?.floatingCart !== false || page) && page !== 'cart' && page !== 'order';
153
+ if (!wanted && !fab) return;
154
+ ensureFab();
155
+ fab.querySelector('.shop-fab-count').textContent = String(n);
156
+ fab.setAttribute('aria-label', `Open your basket (${n} item${n === 1 ? '' : 's'})`);
157
+ fab.classList.toggle('is-hidden', !wanted);
158
+ if (bump) {
159
+ fab.classList.remove('is-bump');
160
+ void fab.offsetWidth;
161
+ fab.classList.add('is-bump');
162
+ }
163
+ }
164
+
165
+ function ensureDrawer() {
166
+ if (drawer) return drawer;
167
+ drawer = document.createElement('div');
168
+ drawer.className = `shop-drawer${config?.cartPosition === 'left' ? ' is-left' : ''}`;
169
+ drawer.setAttribute('role', 'dialog');
170
+ drawer.setAttribute('aria-modal', 'true');
171
+ drawer.setAttribute('aria-label', 'Your basket');
172
+ drawer.innerHTML = `<div class="shop-drawer-backdrop" data-close></div>
173
+ <div class="shop-drawer-panel">
174
+ <div class="shop-drawer-head"><h2>Your basket <small data-d-count></small></h2><button type="button" class="shop-x" data-close aria-label="Close">×</button></div>
175
+ <div class="shop-drawer-body" data-d-body></div>
176
+ <div class="shop-drawer-foot" data-d-foot hidden>
177
+ <div data-d-free></div>
178
+ <div class="shop-drawer-sub"><span>Subtotal</span><span data-d-sub></span></div>
179
+ <p class="shop-drawer-hint">Delivery and any tax are worked out at checkout.</p>
180
+ <a class="shop-drawer-go" data-d-go href="#">${ICON.lock}<span>Checkout</span></a>
181
+ <button type="button" class="shop-drawer-more" data-close>Keep shopping</button>
182
+ </div>
183
+ </div>`;
184
+ drawer.addEventListener('click', (e) => {
185
+ if (e.target.closest('[data-close]')) closeDrawer();
186
+ });
187
+ drawer.addEventListener('keydown', (e) => {
188
+ if (e.key === 'Escape') closeDrawer();
189
+ if (e.key === 'Tab') {
190
+ const f = $$('a[href], button:not([disabled]), input', drawer.querySelector('.shop-drawer-panel')).filter(x => x.offsetParent);
191
+ if (!f.length) return;
192
+ if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
193
+ else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
194
+ }
195
+ });
196
+ wireLines(drawer.querySelector('[data-d-body]'));
197
+ document.body.appendChild(drawer);
198
+ return drawer;
199
+ }
200
+
201
+ function openDrawer() {
202
+ ensureDrawer();
203
+ document.querySelector('.shop-toast')?.remove();
204
+ lastFocus = document.activeElement;
205
+ drawer.querySelector('[data-d-go]').href = `${shopPath()}/cart`;
206
+ renderDrawer(null);
207
+ requestAnimationFrame(() => {
208
+ drawer.classList.add('is-open');
209
+ document.documentElement.classList.add('shop-locked');
210
+ drawer.querySelector('.shop-x').focus();
211
+ });
212
+ quote().then(renderDrawer).catch(() => renderDrawer(null));
213
+ }
214
+
215
+ function closeDrawer() {
216
+ if (!drawer?.classList.contains('is-open')) return;
217
+ drawer.classList.remove('is-open');
218
+ document.documentElement.classList.remove('shop-locked');
219
+ lastFocus?.focus?.();
220
+ }
221
+
222
+ /** One line of the basket, drawer or checkout. `q` is its priced line, when known. */
223
+ function lineHtml(l, q, cur) {
224
+ const href = q?.slug ? `${shopPath()}/${encodeURIComponent(q.slug)}` : '#';
225
+ const img = q?.image || l.image;
226
+ return `<div class="shop-line" data-line data-id="${esc(l.productId)}" data-variant="${esc(l.variantId || '')}">
227
+ <span class="shop-line-img">${img ? `<img src="${esc(img)}" alt="" loading="lazy">` : ICON.bag}</span>
228
+ <div>
229
+ <a class="shop-line-name" href="${esc(href)}">${esc(q?.name || l.name || 'Item')}</a>
230
+ <div class="shop-line-meta">${(q?.variantName || l.variantName) ? `${esc(q?.optionName || 'Option')}: ${esc(q?.variantName || l.variantName)} · ` : ''}${q ? esc(money(q.unitPrice, cur)) : ''}</div>
231
+ <div class="shop-line-controls">
232
+ <div class="shop-qty is-small"><button type="button" data-qty="-1" aria-label="One fewer">−</button><input type="number" value="${l.qty}" min="0" max="${q?.available ?? 999}" aria-label="Quantity" data-qty-input><button type="button" data-qty="1" aria-label="One more"${q?.available != null && l.qty >= q.available ? ' disabled' : ''}>+</button></div>
233
+ <button type="button" class="shop-remove" data-remove>Remove</button>
234
+ </div>
235
+ </div>
236
+ <span class="shop-line-amt">${q ? esc(money(q.lineTotal, cur)) : ''}</span>
237
+ </div>`;
238
+ }
239
+
240
+ /** Steppers and Remove, for any container of lines. */
241
+ function wireLines(root) {
242
+ const change = (row, qty) => {
243
+ if (qty <= 0) {
244
+ row.classList.add('is-leaving');
245
+ setTimeout(() => basket.set(row.dataset.id, row.dataset.variant, 0), 220);
246
+ } else {
247
+ basket.set(row.dataset.id, row.dataset.variant, qty);
248
+ }
249
+ };
250
+ root.addEventListener('click', (e) => {
251
+ const row = e.target.closest('[data-line]');
252
+ if (!row) return;
253
+ const step = e.target.closest('[data-qty]');
254
+ if (step) {
255
+ const input = row.querySelector('[data-qty-input]');
256
+ change(row, Math.max(0, (Number(input.value) || 0) + Number(step.dataset.qty)));
257
+ }
258
+ if (e.target.closest('[data-remove]')) change(row, 0);
259
+ });
260
+ root.addEventListener('change', (e) => {
261
+ const input = e.target.closest('[data-qty-input]');
262
+ if (input) change(input.closest('[data-line]'), Math.max(0, Math.floor(Number(input.value) || 0)));
263
+ });
264
+ }
265
+
266
+ function freeBarHtml(subtotal, cur) {
267
+ const over = Number(config?.freeShippingOver) || 0;
268
+ if (!over) return '';
269
+ const left = over - subtotal;
270
+ const pct = Math.max(4, Math.min(100, Math.round(subtotal / over * 100)));
271
+ return `<div class="shop-freebar"><p>${left > 0 ? `You are <strong>${esc(money(left, cur))}</strong> away from free delivery.` : `${ICON.check} <strong>Free delivery</strong> is yours.`}</p>
272
+ <div class="shop-freebar-track"><div class="shop-freebar-fill" style="width:${pct}%"></div></div></div>`;
273
+ }
274
+
275
+ function renderDrawer(q) {
276
+ if (!drawer) return;
277
+ const lines = basket.read();
278
+ const body = drawer.querySelector('[data-d-body]');
279
+ const foot = drawer.querySelector('[data-d-foot]');
280
+ const n = lines.reduce((s, l) => s + l.qty, 0);
281
+ drawer.querySelector('[data-d-count]').textContent = n ? `(${n})` : '';
282
+ if (!lines.length) {
283
+ body.innerHTML = `<div class="shop-drawer-empty"><span class="shop-bigicon">${ICON.bag}</span><p>Your basket is empty.</p>
284
+ <a class="shop-linkbtn" href="${esc(shopPath())}">Browse the shop</a></div>`;
285
+ foot.hidden = true;
286
+ return;
287
+ }
288
+ const cur = q?.currency;
289
+ const byKey = new Map((q?.lines || []).map(l => [`${l.productId}:${l.variantId}`, l]));
290
+ body.innerHTML = (q?.problems?.length ? `<ul class="shop-notes">${q.problems.map(p => `<li>${esc(p.message)}</li>`).join('')}</ul>` : '')
291
+ + lines.map(l => lineHtml(l, byKey.get(`${l.productId}:${l.variantId || ''}`), cur)).join('');
292
+ foot.hidden = false;
293
+ drawer.querySelector('[data-d-sub]').textContent = q ? money(q.subtotal, cur) : '…';
294
+ drawer.querySelector('[data-d-free]').innerHTML = q && q.requiresShipping ? freeBarHtml(q.subtotal, cur) : '';
295
+ }
296
+
297
+ // Re-price the open drawer after a change, once the clicking stops.
298
+ let drawerTimer = 0;
299
+ function renderDrawerSoon() {
300
+ if (!drawer?.classList.contains('is-open')) return;
301
+ renderDrawer(null);
302
+ clearTimeout(drawerTimer);
303
+ drawerTimer = setTimeout(() => quote().then(renderDrawer).catch(() => {}), 250);
304
+ }
305
+ listeners.add(renderDrawerSoon);
306
+ listeners.add(() => syncFab());
307
+
308
+ // ---- Adding -----------------------------------------------------------------------------
309
+
310
+ function toast(item, qty) {
311
+ $('.shop-toast')?.remove();
312
+ const t = document.createElement('div');
313
+ t.className = `shop-toast${config?.cartPosition === 'left' ? ' is-left' : ''}`;
314
+ t.setAttribute('role', 'status');
315
+ t.innerHTML = `${item.image ? `<img src="${esc(item.image)}" alt="">` : `<span class="shop-toast-img">${ICON.bag}</span>`}
316
+ <div><strong><span class="shop-toast-check">${ICON.check}</span>Added to basket</strong><small>${qty > 1 ? `${qty} × ` : ''}${esc(item.name || '')}${item.variantName ? ` · ${esc(item.variantName)}` : ''}</small></div>
317
+ <a href="#" data-open>View</a>`;
318
+ t.querySelector('[data-open]').addEventListener('click', (e) => { e.preventDefault(); t.remove(); openDrawer(); });
319
+ document.body.appendChild(t);
320
+ setTimeout(() => { t.classList.add('is-out'); setTimeout(() => t.remove(), 320); }, 3200);
321
+ }
322
+
323
+ function added(btn, item, qty) {
324
+ basket.add(item, qty);
325
+ syncFab(true);
326
+ toast(item, qty);
327
+ if (btn) {
328
+ btn.classList.add('is-added');
329
+ const label = btn.querySelector('span');
330
+ const was = label?.textContent;
331
+ if (label) label.textContent = 'Added';
332
+ setTimeout(() => { btn.classList.remove('is-added'); if (label && was) label.textContent = was; }, 1400);
333
+ }
334
+ }
335
+
336
+ document.addEventListener('click', (e) => {
337
+ const btn = e.target.closest('[data-shop-add]');
338
+ if (!btn) return;
339
+ e.preventDefault();
340
+ added(btn, {productId: btn.dataset.id, name: btn.dataset.name, image: btn.dataset.image}, 1);
341
+ });
342
+
343
+ document.addEventListener('click', (e) => {
344
+ if (e.target.closest('[data-shop-print]')) window.print();
345
+ });
346
+
347
+ // ---- A product's page ---------------------------------------------------------------------
348
+
349
+ function productPage() {
350
+ const root = $('[data-shop-product]');
351
+ if (!root) return;
352
+ const form = $('[data-shop-buy]', root);
353
+ const qtyInput = $('input[name="qty"]', form);
354
+ const priceEl = $('[data-shop-price]', root);
355
+ const stockEl = $('[data-shop-stock]', root);
356
+ const addBtn = $('[data-shop-addbtn]', root);
357
+
358
+ // Pictures.
359
+ const main = $('[data-shop-main]', root);
360
+ root.addEventListener('click', (e) => {
361
+ const t = e.target.closest('.shop-thumb');
362
+ if (!t || !main) return;
363
+ $$('.shop-thumb', root).forEach(x => x.classList.toggle('is-on', x === t));
364
+ main.style.opacity = '0';
365
+ setTimeout(() => { main.src = t.dataset.src; main.style.opacity = '1'; }, 150);
366
+ });
367
+
368
+ const chosen = () => $('input[name="variant"]:checked', form);
369
+ function syncOption() {
370
+ const v = chosen();
371
+ if (!v) {
372
+ if ($('input[name="variant"]', form)) { addBtn.disabled = true; $('span', addBtn).textContent = 'Sold out'; }
373
+ return;
374
+ }
375
+ priceEl.textContent = money(Number(v.dataset.price));
376
+ const left = v.dataset.left === '' ? null : Number(v.dataset.left);
377
+ qtyInput.max = left ?? 999;
378
+ if (left !== null && Number(qtyInput.value) > left) qtyInput.value = String(Math.max(1, left));
379
+ stockEl.textContent = left !== null && left <= 5 ? `Only ${left} left` : '';
380
+ addBtn.disabled = left === 0;
381
+ }
382
+ form.addEventListener('change', (e) => { if (e.target.name === 'variant') syncOption(); });
383
+ syncOption();
384
+
385
+ $('[data-shop-qty]', form).addEventListener('click', (e) => {
386
+ const b = e.target.closest('[data-step]');
387
+ if (!b) return;
388
+ const max = Number(qtyInput.max) || 999;
389
+ qtyInput.value = String(Math.min(max, Math.max(1, (Number(qtyInput.value) || 1) + Number(b.dataset.step))));
390
+ });
391
+
392
+ form.addEventListener('submit', (e) => {
393
+ e.preventDefault();
394
+ if (addBtn.disabled) return;
395
+ const v = chosen();
396
+ const qty = Math.max(1, Math.min(Number(qtyInput.max) || 999, Math.floor(Number(qtyInput.value) || 1)));
397
+ added(addBtn, {productId: root.dataset.id, variantId: v?.value || '', name: root.dataset.name, image: root.dataset.image,
398
+ variantName: v ? v.closest('label').querySelector('span').firstChild.textContent.trim() : ''}, qty);
399
+ });
400
+ }
401
+
402
+ // ---- The storefront: chips, search, sort ------------------------------------------------------
403
+
404
+ function storefront() {
405
+ const grid = $('[data-shop-grid]');
406
+ if (!grid || page !== 'index') return;
407
+ const cards = $$('[data-shop-card]', grid);
408
+ const order = cards.slice();
409
+ const none = $('[data-shop-none]');
410
+ const search = $('[data-shop-search]');
411
+ const sort = $('[data-shop-sort]');
412
+ let category = config?.category || '';
413
+
414
+ function apply() {
415
+ const q = (search?.value || '').trim().toLowerCase();
416
+ let shown = 0;
417
+ for (const c of cards) {
418
+ const ok = (!category || c.dataset.category === category) && (!q || c.dataset.search.includes(q));
419
+ c.hidden = !ok;
420
+ if (ok) shown++;
421
+ }
422
+ const how = sort?.value || '';
423
+ const sorted = how === 'price-asc' ? cards.slice().sort((a, b) => a.dataset.price - b.dataset.price)
424
+ : how === 'price-desc' ? cards.slice().sort((a, b) => b.dataset.price - a.dataset.price)
425
+ : how === 'name' ? cards.slice().sort((a, b) => a.dataset.name.localeCompare(b.dataset.name))
426
+ : order;
427
+ sorted.forEach(c => grid.appendChild(c));
428
+ if (none) none.hidden = shown > 0;
429
+ const url = new URL(location.href);
430
+ category ? url.searchParams.set('category', category) : url.searchParams.delete('category');
431
+ q ? url.searchParams.set('q', q) : url.searchParams.delete('q');
432
+ history.replaceState(null, '', url);
433
+ }
434
+
435
+ document.addEventListener('click', (e) => {
436
+ const chip = e.target.closest('[data-shop-cat]');
437
+ if (chip) {
438
+ category = chip.dataset.shopCat;
439
+ $$('[data-shop-cat]').forEach(x => x.classList.toggle('is-on', x === chip));
440
+ apply();
441
+ }
442
+ if (e.target.closest('[data-shop-clear]')) {
443
+ category = '';
444
+ if (search) search.value = '';
445
+ $$('[data-shop-cat]').forEach(x => x.classList.toggle('is-on', !x.dataset.shopCat));
446
+ apply();
447
+ }
448
+ });
449
+ let t = 0;
450
+ search?.addEventListener('input', () => { clearTimeout(t); t = setTimeout(apply, 120); });
451
+ sort?.addEventListener('change', apply);
452
+ if (category || search?.value) apply();
453
+ }
454
+
455
+ // ---- Checkout ---------------------------------------------------------------------------------
456
+
457
+ function checkout() {
458
+ const root = $('[data-shop-checkout]');
459
+ if (!root) return;
460
+ const linesEl = $('[data-co-lines]', root);
461
+ const form = $('[data-co-form]', root);
462
+ const summary = $('[data-co-summary]', root);
463
+ const totals = $('[data-co-totals]', root);
464
+ const methodsEl = $('[data-co-methods]', root);
465
+ const errorEl = $('[data-co-error]', root);
466
+ const payBtn = $('[data-co-pay]', root);
467
+ const codeForm = $('[data-co-code]', root);
468
+ const deliveryStep = $('[data-co-delivery]', root);
469
+ const addressEl = $('[data-co-address]', root);
470
+ const collectRow = $('[data-co-collect]', root);
471
+ let current = null;
472
+ let code = session.get('dm-shop:code') || '';
473
+ let busy = false;
474
+
475
+ // What they typed last time in this tab.
476
+ const kept = session.get(DETAILS_KEY) || {};
477
+ for (const [k, v] of Object.entries(kept)) {
478
+ const el = form.elements[k];
479
+ if (!el || el.type === 'radio' || el.type === 'checkbox') continue;
480
+ el.value = v;
481
+ }
482
+ const keep = () => {
483
+ const out = {};
484
+ for (const k of ['name', 'email', 'phone', 'line1', 'line2', 'city', 'region', 'postcode', 'country', 'note']) out[k] = form.elements[k]?.value || '';
485
+ session.set(DETAILS_KEY, out);
486
+ };
487
+ form.addEventListener('input', keep);
488
+
489
+ // A reminder email links here with the order: fill the basket from it (another device, a cleared browser).
490
+ const params = new URLSearchParams(location.search);
491
+ if (params.get('restore') && params.get('t')) {
492
+ restoreFrom(params.get('restore'), params.get('t')).finally(() => {
493
+ history.replaceState(null, '', location.pathname);
494
+ refresh();
495
+ });
496
+ }
497
+
498
+ if (config?.cancelled) {
499
+ errorEl.hidden = false;
500
+ errorEl.textContent = 'Payment was cancelled and nothing was taken. Your basket is just as you left it.';
501
+ }
502
+
503
+ wireLines(linesEl);
504
+
505
+ const delivery = () => (form.elements.delivery?.value === 'collect' ? 'collect' : 'ship');
506
+ const servicesEl = $('[data-co-services]', root);
507
+ let service = session.get('dm-shop:service') || '';
508
+
509
+ /** Delivery services to choose between, when the shop offers more than one (Shopping Cart Pro's zones). */
510
+ function renderServices(q) {
511
+ const list = q.shippingChoices || [];
512
+ servicesEl.hidden = list.length < 2 || delivery() === 'collect';
513
+ if (servicesEl.hidden) return;
514
+ const on = q.shippingChoice || list[0].id;
515
+ servicesEl.innerHTML = list.map(c => `<label class="shop-choice"><input type="radio" name="service" value="${esc(c.id)}"${c.id === on ? ' checked' : ''}>
516
+ <span><span class="shop-radio"></span>${ICON.truck} ${esc(c.label)}<span class="shop-choice-end">${c.amount === 0 ? 'Free' : esc(money(c.amount, q.currency))}</span></span></label>`).join('');
517
+ }
518
+ const country = () => form.elements.country?.value || '';
519
+
520
+ function renderTotals(q) {
521
+ const cur = q.currency;
522
+ const good = (q.adjustments || []).filter(a => a.stage === 'discount' && a.code);
523
+ totals.innerHTML = `${q.requiresShipping && delivery() === 'ship' ? freeBarHtml(q.subtotal, cur) : ''}<dl class="shop-totals">
524
+ <div><dt>Subtotal <small>(${q.itemCount} item${q.itemCount === 1 ? '' : 's'})</small></dt><dd>${esc(money(q.subtotal, cur))}</dd></div>
525
+ ${q.adjustments.map(a => `<div class="${a.included ? 'is-included' : a.amount < 0 ? 'is-discount' : ''}"><dt>${esc(a.label)}</dt><dd>${a.stage === 'shipping' && a.amount === 0 ? 'Free' : esc(money(a.amount, cur))}</dd></div>`).join('')}
526
+ <div class="is-total"><dt>Total</dt><dd>${esc(money(q.total, cur))}</dd></div>
527
+ </dl>${(q.notes || []).length || good.length ? `<ul class="shop-notes">${good.map(a => `<li class="is-good">${ICON.check} ${esc(a.code)} applied</li>`).join('')}${(q.notes || []).map(n => `<li>${esc(n.message)}</li>`).join('')}</ul>` : ''}`;
528
+ $('span', payBtn).textContent = q.total > 0 ? `Pay ${money(q.total, cur)}` : 'Place order';
529
+ }
530
+
531
+ function renderMethods(q) {
532
+ const had = form.elements.provider?.value;
533
+ if (q.total === 0) {
534
+ methodsEl.innerHTML = '<p class="shop-muted">Nothing to pay.</p>';
535
+ return;
536
+ }
537
+ if (!q.methods.length) {
538
+ methodsEl.innerHTML = '<p class="shop-co-error">This shop cannot take payment just now. Please get in touch with us to order.</p>';
539
+ payBtn.disabled = true;
540
+ return;
541
+ }
542
+ const icon = {stripe: ICON.card, paypal: ICON.paypal, offline: ICON.bank};
543
+ const end = {stripe: '<span class="shop-paymark">VISA</span><span class="shop-paymark">MC</span><span class="shop-paymark">AMEX</span>', paypal: '', offline: ''};
544
+ methodsEl.innerHTML = q.methods.map((m, i) => `<label class="shop-choice"><input type="radio" name="provider" value="${esc(m.id)}"${(had ? had === m.id : i === 0) ? ' checked' : ''}>
545
+ <span><span class="shop-radio"></span>${icon[m.id] || ''} ${esc(m.label)}<span class="shop-choice-end">${end[m.id] || ''}</span></span></label>`).join('');
546
+ }
547
+
548
+ function renderLines(q) {
549
+ const lines = basket.read();
550
+ if (!lines.length) {
551
+ linesEl.innerHTML = `<div class="shop-co-empty"><span class="shop-bigicon">${ICON.bag}</span><h2>Your basket is empty</h2>
552
+ <p class="shop-muted">Find something you like - it will wait here for you.</p><a class="shop-buy-button" href="${esc(shopPath())}">Browse the shop ${ICON.arrow}</a></div>`;
553
+ form.hidden = true;
554
+ summary.hidden = true;
555
+ return;
556
+ }
557
+ const byKey = new Map((q?.lines || []).map(l => [`${l.productId}:${l.variantId}`, l]));
558
+ linesEl.innerHTML = (q?.problems?.length ? `<ul class="shop-notes">${q.problems.map(p => `<li>${esc(p.message)}</li>`).join('')}</ul>` : '')
559
+ + lines.map(l => lineHtml(l, byKey.get(`${l.productId}:${l.variantId || ''}`), q?.currency)).join('');
560
+ form.hidden = false;
561
+ summary.hidden = false;
562
+ }
563
+
564
+ async function refresh() {
565
+ if (!basket.read().length) { renderLines(null); return; }
566
+ try {
567
+ const q = await quote({code, delivery: delivery(), service, address: {country: country()}});
568
+ current = q;
569
+ renderLines(q);
570
+ if (!basket.read().length) return;
571
+ const ships = q.requiresShipping;
572
+ deliveryStep.hidden = !ships;
573
+ collectRow.hidden = !(ships && q.features?.collection);
574
+ addressEl.hidden = !ships || delivery() === 'collect';
575
+ $('[data-co-paystep]', root).textContent = ships ? '3' : '2';
576
+ codeForm.hidden = !q.features?.codes;
577
+ if (code && codeForm.elements.code && !codeForm.elements.code.value) codeForm.elements.code.value = code;
578
+ renderServices(q);
579
+ renderTotals(q);
580
+ renderMethods(q);
581
+ } catch (err) {
582
+ linesEl.innerHTML = `<p class="shop-co-error">${esc(err.message)}</p>`;
583
+ }
584
+ }
585
+
586
+ let t = 0;
587
+ const refreshSoon = () => { clearTimeout(t); t = setTimeout(refresh, 200); };
588
+ listeners.add(refreshSoon);
589
+ form.addEventListener('change', (e) => {
590
+ if (e.target.name === 'service') { service = e.target.value; session.set('dm-shop:service', service); }
591
+ if (e.target.name === 'delivery' || e.target.name === 'country' || e.target.name === 'service') refreshSoon();
592
+ });
593
+ codeForm.addEventListener('submit', (e) => {
594
+ e.preventDefault();
595
+ code = (codeForm.elements.code.value || '').trim();
596
+ session.set('dm-shop:code', code);
597
+ refresh();
598
+ });
599
+
600
+ function showErrors(errors = {}) {
601
+ $$('.shop-field', form).forEach(f => { f.classList.remove('is-bad'); f.querySelector('.shop-field-err')?.remove(); });
602
+ let first = null;
603
+ for (const [k, msg] of Object.entries(errors)) {
604
+ const el = form.elements[k];
605
+ const field = el?.closest?.('.shop-field');
606
+ if (!field) continue;
607
+ field.classList.add('is-bad');
608
+ const s = document.createElement('span');
609
+ s.className = 'shop-field-err';
610
+ s.textContent = msg;
611
+ field.appendChild(s);
612
+ first ||= el;
613
+ }
614
+ first?.focus();
615
+ }
616
+
617
+ form.addEventListener('submit', async (e) => {
618
+ e.preventDefault();
619
+ if (busy || !current) return;
620
+ errorEl.hidden = true;
621
+ if (form.elements.terms && !form.elements.terms.checked) {
622
+ errorEl.hidden = false;
623
+ errorEl.textContent = 'Please agree to the terms of sale first.';
624
+ return;
625
+ }
626
+ const f = form.elements;
627
+ const body = {
628
+ lines: basket.read().map(({productId, variantId, qty}) => ({productId, variantId, qty})),
629
+ customer: {name: f.name.value, email: f.email.value, phone: f.phone.value},
630
+ address: {line1: f.line1.value, line2: f.line2.value, city: f.city.value, region: f.region.value, postcode: f.postcode.value, country: country()},
631
+ delivery: delivery(),
632
+ service,
633
+ note: f.note.value,
634
+ provider: f.provider?.value || '',
635
+ code,
636
+ expectTotal: current.total
637
+ };
638
+ busy = true;
639
+ payBtn.disabled = true;
640
+ const label = $('span', payBtn).textContent;
641
+ $('span', payBtn).textContent = 'One moment…';
642
+ try {
643
+ const res = await fetch(`${API}/checkout`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
644
+ const out = await res.json().catch(() => ({}));
645
+ if (!res.ok) {
646
+ if (out.errors) showErrors(out.errors);
647
+ if (out.quote) { reconcile(out.quote); await refresh(); }
648
+ errorEl.hidden = false;
649
+ errorEl.textContent = out.error || 'Something went wrong. Nothing has been charged.';
650
+ errorEl.scrollIntoView({behavior: 'smooth', block: 'center'});
651
+ return;
652
+ }
653
+ store.set(PENDING_KEY, {orderId: out.orderId, at: Date.now()});
654
+ location.href = out.redirect;
655
+ return;
656
+ } catch {
657
+ errorEl.hidden = false;
658
+ errorEl.textContent = 'Could not reach the shop - check your connection. Nothing has been charged.';
659
+ } finally {
660
+ busy = false;
661
+ payBtn.disabled = false;
662
+ $('span', payBtn).textContent = label;
663
+ }
664
+ });
665
+
666
+ refresh();
667
+ }
668
+
669
+ /** Refill the basket from an order's lines (only an order never paid). */
670
+ async function restoreFrom(id, token) {
671
+ try {
672
+ const res = await fetch(`${API}/order/${encodeURIComponent(id)}?t=${encodeURIComponent(token)}`);
673
+ if (!res.ok) return;
674
+ const o = await res.json();
675
+ if (o.status !== 'pending') return;
676
+ const lines = (o.lines || []).filter(l => l.productId).map(l => ({productId: l.productId, variantId: l.variantId || '', qty: l.qty,
677
+ name: l.name, image: l.image, variantName: l.variantName || ''}));
678
+ if (lines.length) basket.write(lines);
679
+ } catch { /* the basket stays as it was */ }
680
+ }
681
+
682
+ // ---- Receipt ----------------------------------------------------------------------------------
683
+
684
+ function receipt() {
685
+ const root = $('[data-shop-receipt]');
686
+ if (!root) return;
687
+ const {id, token} = config?.order || {};
688
+ const status = root.dataset.status;
689
+ const pending = store.get(PENDING_KEY, null);
690
+ // The basket became this order: empty it once the order is really in.
691
+ if (pending?.orderId === id && status !== 'pending') {
692
+ basket.clear();
693
+ store.drop(PENDING_KEY);
694
+ session.set('dm-shop:code', '');
695
+ }
696
+ if (status !== 'pending' || !id) return;
697
+ let tries = 0;
698
+ const poll = async () => {
699
+ tries++;
700
+ try {
701
+ const res = await fetch(`${API}/order/${encodeURIComponent(id)}?t=${encodeURIComponent(token)}`);
702
+ if (res.ok && (await res.json()).status !== 'pending') { location.reload(); return; }
703
+ } catch { /* try again */ }
704
+ if (tries < 40) setTimeout(poll, Math.min(8000, 1500 + tries * 500));
705
+ };
706
+ setTimeout(poll, 1500);
707
+ }
708
+
709
+ // ---- Start ------------------------------------------------------------------------------------
710
+
711
+ syncFab();
712
+ if (page === 'product') productPage();
713
+ if (page === 'index') storefront();
714
+ if (page === 'cart') checkout();
715
+ if (page === 'order') receipt();