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,190 @@
1
+ /**
2
+ * Shop admin - what every screen shares: the admin's helpers (loaded at run
3
+ * time; a plugin never imports admin/ statically - the updater replaces it),
4
+ * the API, escaping, money in and out, slideover forms, and rows with a
5
+ * right-click / ⋮ / Shift+F10 menu.
6
+ *
7
+ * @module shopping-cart/admin/lib/kit
8
+ */
9
+
10
+ import {formatMoney, fromMinor, toMinor} from '/plugins/shopping-cart/public/lib/money.js';
11
+
12
+ export const SIDEBAR_URL = '#/plugins/shopping-cart';
13
+ export const BASE = '/api/plugins/shopping-cart';
14
+
15
+ let kit = null;
16
+
17
+ /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
18
+ export function loadKit() {
19
+ if (!kit) {
20
+ kit = Promise.all([
21
+ import('/admin/js/lib/tool-kit.js').catch(() => ({})),
22
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
23
+ import('/admin/js/lib/help-popover.js').catch(() => ({}))
24
+ ]).then(([tk, chrome, help]) => ({
25
+ onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
26
+ openMenuAt: tk.openMenuAt || ((el) => {
27
+ const r = el.getBoundingClientRect();
28
+ el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
29
+ }),
30
+ viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
31
+ addBannerAction: chrome.addBannerAction || (() => null),
32
+ helpPopovers: help.helpPopovers || (() => () => {})
33
+ }));
34
+ }
35
+ return kit;
36
+ }
37
+
38
+ /** The Shop's API. H attaches the token and refreshes it on a 401; it throws with the server's `error`. */
39
+ const seg = (id) => encodeURIComponent(String(id));
40
+ export const api = {
41
+ summary: () => H.get(`${BASE}/summary`),
42
+ extensions: () => H.get(`${BASE}/extensions`),
43
+ products: {
44
+ list: () => H.get(`${BASE}/products`),
45
+ create: (d) => H.post(`${BASE}/products`, d),
46
+ update: (id, d) => H.put(`${BASE}/products/${seg(id)}`, d),
47
+ patch: (id, d) => H.patch(`${BASE}/products/${seg(id)}`, d),
48
+ duplicate: (id) => H.post(`${BASE}/products/${seg(id)}/duplicate`, {}),
49
+ remove: (id) => H.delete(`${BASE}/products/${seg(id)}`),
50
+ samples: () => H.post(`${BASE}/samples`, {live: true})
51
+ },
52
+ orders: {
53
+ list: () => H.get(`${BASE}/orders`),
54
+ get: (id) => H.get(`${BASE}/orders/${seg(id)}`),
55
+ move: (id, body) => H.put(`${BASE}/orders/${seg(id)}/status`, body),
56
+ note: (id, body) => H.put(`${BASE}/orders/${seg(id)}/note`, body),
57
+ refund: (id, body) => H.post(`${BASE}/orders/${seg(id)}/refund`, body),
58
+ remove: (id) => H.delete(`${BASE}/orders/${seg(id)}`)
59
+ },
60
+ settings: {
61
+ get: () => H.get(`${BASE}/settings`),
62
+ save: (d) => H.put(`${BASE}/settings`, d),
63
+ test: (provider, d) => H.post(`${BASE}/settings/test/${seg(provider)}`, d)
64
+ }
65
+ };
66
+
67
+ /** A list route's entries, whatever shape they came back in. */
68
+ export const entriesOf = (res) => (Array.isArray(res) ? res : res?.entries ?? res?.data ?? []);
69
+
70
+ /** E.confirm / titles render HTML: user text goes in escaped. */
71
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
72
+
73
+ /** A small DOM tree from an HTML string that holds NO unescaped user data. */
74
+ export function fragment(html) {
75
+ const t = document.createElement('template');
76
+ t.innerHTML = html.trim();
77
+ return t.content.firstElementChild;
78
+ }
79
+
80
+ export const store = {
81
+ get(k) { try { return S.get(k); } catch { return null; } },
82
+ set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
83
+ };
84
+
85
+ /** The sidebar re-asks the Shop's badge. */
86
+ export function refreshBadge() {
87
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
88
+ }
89
+
90
+ // ---- Money ------------------------------------------------------------------------
91
+
92
+ let currency = 'GBP';
93
+ export const setCurrency = (c) => { currency = c || 'GBP'; };
94
+ export const getCurrency = () => currency;
95
+ export const money = (n, opts) => formatMoney(n, currency, opts);
96
+ /** For an input: "12.50". Blank for null. */
97
+ export const moneyIn = (n) => (n === null || n === undefined || n === '' ? '' : fromMinor(n, currency));
98
+ /** From an input: 1250, null when blank, NaN when not an amount. */
99
+ export const moneyOut = (text) => {
100
+ if (String(text ?? '').trim() === '') return null;
101
+ const m = toMinor(text, currency);
102
+ return m === null ? NaN : m;
103
+ };
104
+
105
+ /** "3 min ago", "26 Sep". */
106
+ export function when(iso) {
107
+ const t = new Date(iso).getTime();
108
+ if (!Number.isFinite(t)) return '';
109
+ const s = (Date.now() - t) / 1000;
110
+ if (s < 60) return 'just now';
111
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
112
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
113
+ if (s < 86400 * 6) return `${Math.round(s / 86400)} d ago`;
114
+ return new Date(t).toLocaleDateString(undefined, {day: 'numeric', month: 'short', ...(s > 86400 * 300 ? {year: 'numeric'} : {})});
115
+ }
116
+
117
+ // ---- Slideovers ---------------------------------------------------------------------
118
+
119
+ /**
120
+ * Open a slideover holding `html`, bound to `model`. Closing with something
121
+ * changed asks first (once). Everything is disposed and removed on close.
122
+ *
123
+ * @returns {Promise<{so, panel, close: (force?: boolean) => void}>}
124
+ */
125
+ export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before}) {
126
+ const {onceClosed, helpPopovers} = await loadKit();
127
+ const so = E.slideover({title: esc(title), size, position: 'right'});
128
+ const panel = fragment(html);
129
+ so.element.appendChild(panel);
130
+ before?.(panel);
131
+ const disposeHelp = helpPopovers(panel);
132
+ const handle = M.applyBindings(model, panel, {methods});
133
+ onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
134
+ const close = so.close.bind(so);
135
+ let closed = false;
136
+ let asking = false;
137
+ so.close = async (force) => {
138
+ if (!closed && force !== true && dirty?.()) {
139
+ if (asking) return undefined;
140
+ asking = true;
141
+ const ok = await E.confirm('Discard your changes?');
142
+ asking = false;
143
+ if (!ok) return undefined;
144
+ }
145
+ closed = true;
146
+ return close();
147
+ };
148
+ panel.addEventListener('keydown', (e) => {
149
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 's') && methods.save) { e.preventDefault(); methods.save(); }
150
+ });
151
+ I.scan(panel);
152
+ so.open();
153
+ return {so, panel, close: (force) => so.close(force)};
154
+ }
155
+
156
+ /**
157
+ * Rows: click (or Enter) = the main action; right-click, ⋮ or Shift+F10 = the
158
+ * menu. `scope` receives the teardown.
159
+ */
160
+ export function wireRows(list, {items, main, openMenuAt, scope, match = '.shp-row'}) {
161
+ const menu = E.contextMenu(list, {cursor: 'glyph', match, inherit: false, density: 'compact',
162
+ items: (row) => items(row.dataset.id)});
163
+ const onClick = (e) => {
164
+ const row = e.target.closest(match);
165
+ if (!row) return;
166
+ if (e.target.closest('[data-stop]')) return;
167
+ const kebab = e.target.closest('.shp-kebab');
168
+ if (kebab) { openMenuAt(kebab); return; }
169
+ main(row.dataset.id);
170
+ };
171
+ const onKey = (e) => {
172
+ const row = e.target.closest(match);
173
+ if (!row) return;
174
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
175
+ else if (e.key === 'Enter' && e.target === row) main(row.dataset.id);
176
+ else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
177
+ e.preventDefault();
178
+ const rows = [...list.querySelectorAll(match)].filter(r => r.offsetParent);
179
+ rows[rows.indexOf(row) + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
180
+ }
181
+ };
182
+ list.addEventListener('click', onClick);
183
+ list.addEventListener('keydown', onKey);
184
+ scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
185
+ }
186
+
187
+ /** Copy text, and say so. */
188
+ export function copy(text, what = 'Copied') {
189
+ navigator.clipboard?.writeText(text).then(() => E.toast(`${what}.`, {type: 'success'}), () => E.toast(text));
190
+ }
@@ -0,0 +1 @@
1
+ <div class="shp" data-shop-admin></div>
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Shop › Orders.
3
+ *
4
+ * Chips by status (with counts), search over number, name, email and items,
5
+ * rows with a right-click / ⋮ / Shift+F10 menu of the moves the order can make
6
+ * (the same NEXT table the server enforces). Click opens the order in a
7
+ * slideover: the items and totals, the shopper, delivery and tracking, a note
8
+ * for staff, its history, and the actions - fulfil, refund, cancel, a packing
9
+ * slip. A notification's link (#/plugins/shopping-cart/orders/<id>) opens one.
10
+ */
11
+
12
+ import {api, copy, entriesOf, esc, money, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
13
+ import {NEXT, STATUS_LABEL, STATUS_TONE, addressLines, orderLabel} from '/plugins/shopping-cart/public/lib/orders.js';
14
+
15
+ const FILTERS = [
16
+ {key: 'open', label: 'To do', match: (s) => s === 'paid' || s === 'awaiting'},
17
+ {key: 'paid', label: 'To fulfil', match: (s) => s === 'paid'},
18
+ {key: 'awaiting', label: 'Awaiting payment', match: (s) => s === 'awaiting'},
19
+ {key: 'fulfilled', label: 'Fulfilled', match: (s) => s === 'fulfilled'},
20
+ {key: 'pending', label: 'Started', match: (s) => s === 'pending'},
21
+ {key: 'closed', label: 'Cancelled & refunded', match: (s) => s === 'cancelled' || s === 'refunded'},
22
+ {key: 'all', label: 'All', match: (s) => s !== 'pending'}
23
+ ];
24
+
25
+ const ROW = `
26
+ <div class="shp-row" data-id="{{id}}" tabindex="0">
27
+ <span class="shp-num" data-bind-text="num"></span>
28
+ <span class="shp-main"><strong data-bind-text="who"></strong><small data-bind-text="meta"></small></span>
29
+ <span class="shp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
30
+ <span class="shp-amt" data-bind-text="amount"></span>
31
+ <button type="button" class="shp-kebab" aria-label="Actions" title="Actions">⋮</button>
32
+ </div>`;
33
+
34
+ const PROVIDER = {stripe: 'Stripe', paypal: 'PayPal', offline: 'Offline', free: 'No payment'};
35
+
36
+ export async function mount(root, ctx) {
37
+ const {kit, scope, here, settings} = ctx;
38
+ const orders = M.observable([]);
39
+ const loading = M.observable(true);
40
+ const error = M.observable('');
41
+ const filter = M.observable(store.get('shopping-cart.orders-filter') || 'open');
42
+ const query = M.observable('');
43
+ scope(M.effect(() => store.set('shopping-cart.orders-filter', filter.value)));
44
+
45
+ const counts = M.computed(() => {
46
+ const out = {};
47
+ for (const f of FILTERS) out[f.key] = orders.value.filter(o => f.match(o.data?.status)).length;
48
+ return out;
49
+ });
50
+ const rows = M.observable([]);
51
+ scope(M.effect(() => {
52
+ const f = FILTERS.find(x => x.key === filter.value) || FILTERS[0];
53
+ const q = query.value.trim().toLowerCase();
54
+ rows.value = orders.value
55
+ .filter(o => f.match(o.data?.status))
56
+ .filter(o => !q || [o.data.number, o.data.customer?.name, o.data.email, ...(o.data.lines || []).map(l => l.name)]
57
+ .join(' ').toLowerCase().includes(q.replace(/^#/, '')))
58
+ .map(o => {
59
+ const items = (o.data.lines || []).reduce((s, l) => s + l.qty, 0);
60
+ return {
61
+ id: o.id,
62
+ num: orderLabel(o.data),
63
+ who: o.data.customer?.name || o.data.email || 'Shopper',
64
+ meta: `${when(o.meta?.createdAt)} · ${items} item${items === 1 ? '' : 's'} · ${PROVIDER[o.data.provider] || o.data.provider}${o.data.delivery === 'collect' ? ' · collecting' : ''}`,
65
+ statusLabel: STATUS_LABEL[o.data.status] || o.data.status,
66
+ tone: `is-${STATUS_TONE[o.data.status] || 'muted'}`,
67
+ amount: money(o.data.total)
68
+ };
69
+ });
70
+ }));
71
+ const emptyText = M.computed(() => (orders.value.length
72
+ ? (query.value ? 'No orders match.' : 'Nothing here.')
73
+ : 'No orders yet. They appear here the moment a shopper checks out.'));
74
+
75
+ root.innerHTML = '';
76
+ const view = document.createElement('div');
77
+ view.className = 'shp-card shp-orders';
78
+ view.innerHTML = `
79
+ <div class="shp-bar">
80
+ <div class="shp-seg" role="group" aria-label="Show">
81
+ ${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('')}
82
+ </div>
83
+ <input type="search" class="form-input shp-search" placeholder="Search # , name, email, item…" aria-label="Search orders" data-model="query.value">
84
+ <span data-help-title="Orders" data-help="Click an order to open it. Right-click (or ⋮, or Shift+F10) for its moves: mark it paid, fulfilled, refunded or cancelled. 'Started' are checkouts that never finished paying - they hold no stock and can be deleted."></span>
85
+ </div>
86
+ <p class="shp-note" data-bind-hidden="!loading.value">Loading…</p>
87
+ <p class="shp-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
88
+ <p class="shp-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
89
+ <div class="shp-list" data-each="rows.value key=id"></div>`;
90
+ root.appendChild(view);
91
+ const list = view.querySelector('.shp-list');
92
+ list.innerHTML = ROW;
93
+ scope(kit.helpPopovers(view, {position: 'bottom'}));
94
+ const bindings = M.applyBindings({orders, loading, error, filter, query, rows, counts, emptyText}, view);
95
+ scope(() => bindings.dispose());
96
+ view.querySelector('.shp-seg').addEventListener('click', (e) => {
97
+ const b = e.target.closest('[data-filter]');
98
+ if (b) filter.value = b.dataset.filter;
99
+ });
100
+
101
+ async function load() {
102
+ try {
103
+ orders.value = entriesOf(await api.orders.list());
104
+ error.value = '';
105
+ } catch (err) {
106
+ error.value = `Could not load the orders: ${err.message || err}`;
107
+ } finally {
108
+ loading.value = false;
109
+ }
110
+ }
111
+ const byId = (id) => orders.peek().find(o => o.id === id);
112
+ const replace = (o) => { orders.value = orders.peek().map(x => (x.id === o.id ? o : x)); };
113
+
114
+ // ---- Moves ----------------------------------------------------------------
115
+
116
+ async function move(o, status, extra = {}) {
117
+ try {
118
+ const next = await api.orders.move(o.id, {status, ...extra});
119
+ replace(next);
120
+ refreshBadge();
121
+ E.toast(`${orderLabel(o.data)} is ${STATUS_LABEL[status].toLowerCase()}.`, {type: 'success'});
122
+ return next;
123
+ } catch (err) {
124
+ E.toast(`Not changed: ${err.message || err}`, {type: 'error'});
125
+ return null;
126
+ }
127
+ }
128
+
129
+ async function markPaid(o) {
130
+ if (!await E.confirm(`Mark ${orderLabel(o.data)} as paid (${esc(money(o.data.total))})? Stock is taken now.`)) return null;
131
+ return move(o, 'paid', {note: 'Payment received'});
132
+ }
133
+
134
+ async function cancel(o) {
135
+ if (!await E.confirm(`Cancel ${orderLabel(o.data)}? Nothing was paid, so nothing is refunded.`)) return null;
136
+ return move(o, 'cancelled');
137
+ }
138
+
139
+ /** Fulfil, with tracking if there is any - a small form of its own. */
140
+ function fulfil(o) {
141
+ return new Promise((resolve) => {
142
+ const t = o.data.tracking || {};
143
+ const f = {carrier: M.observable(t.carrier || ''), number: M.observable(t.number || ''), url: M.observable(t.url || '')};
144
+ let panel = null;
145
+ const collect = o.data.delivery === 'collect';
146
+ openPanel({
147
+ title: `Fulfil ${orderLabel(o.data)}`,
148
+ size: 'sm',
149
+ model: f,
150
+ html: `<div class="shp-form">
151
+ <p class="shp-note">${collect ? 'Mark it collected.' : o.data.requiresShipping ? 'Mark it sent. Add tracking if you have it - the shopper sees it on their receipt.' : 'Mark it delivered.'}</p>
152
+ ${collect || !o.data.requiresShipping ? '' : `<label class="form-label">Carrier</label><input class="form-input" data-model="carrier.value" placeholder="Royal Mail, DPD…" list="shp-carriers">
153
+ <datalist id="shp-carriers"><option>Royal Mail</option><option>Parcelforce</option><option>DPD</option><option>DHL</option><option>Evri</option><option>UPS</option><option>FedEx</option><option>Yodel</option></datalist>
154
+ <label class="form-label">Tracking number</label><input class="form-input" data-model="number.value">
155
+ <label class="form-label">Tracking link <span data-help="An https:// address where the parcel can be followed."></span></label><input class="form-input" type="url" data-model="url.value" placeholder="https://…">`}
156
+ <div class="shp-actions"><span class="shp-spacer"></span>
157
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
158
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save"><span data-icon="check" data-icon-size="13"></span> ${collect ? 'Collected' : 'Fulfilled'}</button></div>
159
+ </div>`,
160
+ cleanup: () => resolve(null),
161
+ methods: {
162
+ cancel: () => panel?.close(true),
163
+ async save() {
164
+ const next = await move(o, 'fulfilled', {tracking: {carrier: f.carrier.peek(), number: f.number.peek(), url: f.url.peek()}});
165
+ if (next) { resolve(next); panel?.close(true); }
166
+ }
167
+ }
168
+ }).then(p => { panel = p; });
169
+ });
170
+ }
171
+
172
+ async function refund(o) {
173
+ const via = {stripe: 'through Stripe', paypal: 'through PayPal'}[o.data.provider];
174
+ const html = `<p>Refund ${esc(orderLabel(o.data))}, <strong>${esc(money(o.data.total))}</strong>, in full?</p>
175
+ <p>${via && o.data.paymentRef ? `The money goes back ${via} straight away.` : 'This only marks it refunded - give the money back the way it came.'}</p>
176
+ <label class="shp-check"><input type="checkbox" id="shp-restock" ${o.data.stockTaken ? 'checked' : 'disabled'}> Put the items back in stock</label>`;
177
+ let restock = Boolean(o.data.stockTaken);
178
+ const onChange = (e) => { if (e.target.id === 'shp-restock') restock = e.target.checked; };
179
+ document.addEventListener('change', onChange);
180
+ const ok = await E.confirm(html);
181
+ document.removeEventListener('change', onChange);
182
+ if (!ok) return null;
183
+ try {
184
+ const r = await api.orders.refund(o.id, {restock});
185
+ replace(r.order);
186
+ refreshBadge();
187
+ E.toast(`${orderLabel(o.data)} ${r.how}.`, {type: 'success'});
188
+ return r.order;
189
+ } catch (err) {
190
+ E.toast(err.message || String(err), {type: 'error'});
191
+ return null;
192
+ }
193
+ }
194
+
195
+ async function remove(o) {
196
+ if (!await E.confirm(`Delete ${orderLabel(o.data)}? It never took money; it cannot be brought back.`)) return false;
197
+ try {
198
+ await api.orders.remove(o.id);
199
+ orders.value = orders.peek().filter(x => x.id !== o.id);
200
+ E.toast(`${orderLabel(o.data)} deleted.`, {type: 'success'});
201
+ return true;
202
+ } catch (err) {
203
+ E.toast(err.message || String(err), {type: 'error'});
204
+ return false;
205
+ }
206
+ }
207
+
208
+ const receiptUrl = (o) => `${location.origin}${settings.shopPath}/order/${o.id}?t=${o.data.token}`;
209
+
210
+ /** A packing slip, printed from a window of its own. Everything escaped. */
211
+ function packingSlip(o) {
212
+ const d = o.data;
213
+ const w = window.open('', '_blank', 'width=720,height=900');
214
+ if (!w) { E.toast('Allow pop-ups to print the slip.', {type: 'warning'}); return; }
215
+ const addr = addressLines(d.address).map(esc).join('<br>');
216
+ w.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>Packing slip ${esc(orderLabel(d))}</title>
217
+ <style>body{font:14px/1.5 system-ui,sans-serif;margin:2.5rem;color:#111}h1{font-size:1.5rem;margin:0}table{width:100%;border-collapse:collapse;margin:1.5rem 0}
218
+ th,td{text-align:left;padding:.5rem;border-bottom:1px solid #ddd}td.q{width:4rem;text-align:center;font-weight:700}.box{display:flex;justify-content:space-between;gap:2rem}
219
+ .muted{color:#666}.note{border:1px dashed #999;padding:.75rem;margin-top:1rem}</style></head><body>
220
+ <div class="box"><div><h1>${esc(settings.shopTitle || 'Shop')}</h1><p class="muted">Packing slip · ${esc(orderLabel(d))} · ${esc(new Date(o.meta?.createdAt).toLocaleDateString())}</p></div>
221
+ <div><strong>${esc(d.customer?.name || '')}</strong><br>${d.delivery === 'collect' ? 'Collecting in person' : addr}</div></div>
222
+ <table><thead><tr><th>Qty</th><th>Item</th><th>SKU</th></tr></thead><tbody>
223
+ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l.variantName ? ` - ${esc(l.variantName)}` : ''}</td><td class="muted">${esc(l.sku || '')}</td></tr>`).join('')}
224
+ </tbody></table>${d.note ? `<div class="note"><strong>Note from the shopper:</strong><br>${esc(d.note)}</div>` : ''}
225
+ <p class="muted">Thank you for your order.</p><script>onload=()=>print()<\/script></body></html>`);
226
+ w.document.close();
227
+ }
228
+
229
+ // ---- The menu (one definition: right-click, ⋮, Shift+F10) --------------------
230
+
231
+ function actions(o) {
232
+ const s = o.data.status;
233
+ const can = (to) => (NEXT[s] || []).includes(to);
234
+ return [
235
+ ...(can('paid') && s !== 'fulfilled' ? [{label: 'Mark paid…', icon: 'check-circle', run: () => markPaid(o)}] : []),
236
+ ...(can('fulfilled') ? [{label: o.data.delivery === 'collect' ? 'Mark collected…' : 'Mark fulfilled…', icon: 'truck', run: () => fulfil(o)}] : []),
237
+ ...(s === 'fulfilled' ? [{label: 'Back to "to fulfil"', icon: 'rotate-ccw', run: () => move(o, 'paid', {note: 'Reopened'})}] : []),
238
+ ...(can('refunded') ? [{label: 'Refund…', icon: 'credit-card', danger: true, run: () => refund(o)}] : []),
239
+ ...(can('cancelled') ? [{label: 'Cancel order…', icon: 'x-circle', danger: true, run: () => cancel(o)}] : [])
240
+ ];
241
+ }
242
+
243
+ function items(id) {
244
+ const o = byId(id);
245
+ if (!o) return [];
246
+ const acts = actions(o);
247
+ return [
248
+ {header: `${orderLabel(o.data)} · ${o.data.customer?.name || o.data.email}`},
249
+ {label: 'Open', icon: 'eye', action: () => openOrder(id)},
250
+ ...(acts.length ? [{type: 'divider'}] : []),
251
+ ...acts.map(a => ({label: a.label, icon: a.icon, danger: a.danger, action: a.run})),
252
+ {type: 'divider'},
253
+ {label: 'Packing slip', icon: 'printer', action: () => packingSlip(o)},
254
+ {label: 'Copy receipt link', icon: 'link', action: () => copy(receiptUrl(o), 'Receipt link copied')},
255
+ {label: 'Email the shopper', icon: 'mail', action: () => { location.href = `mailto:${encodeURIComponent(o.data.email)}?subject=${encodeURIComponent(`Your order ${orderLabel(o.data)}`)}`; }},
256
+ ...(['pending', 'cancelled', 'awaiting'].includes(o.data.status) ? [{type: 'divider'}, {label: 'Delete…', icon: 'trash', danger: true, action: () => remove(o)}] : [])
257
+ ];
258
+ }
259
+
260
+ // ---- One order ----------------------------------------------------------------
261
+
262
+ const panels = new Set();
263
+ async function openOrder(id) {
264
+ let o = byId(id);
265
+ if (!o) {
266
+ try { o = await api.orders.get(id); } catch { E.toast('That order is gone.', {type: 'warning'}); return; }
267
+ }
268
+ const d = o.data;
269
+ const staffNote = M.observable(d.staffNote || '');
270
+ const status = M.observable(d.status);
271
+ let panel = null;
272
+ const ext = Object.entries(d.ext || {}).flatMap(([owner, facts]) => Object.entries(facts || {}).map(([k, v]) => ({owner, k, v})))
273
+ .filter(x => ['string', 'number', 'boolean'].includes(typeof x.v));
274
+ const addr = addressLines(d.address);
275
+ const html = `<div class="shp-order">
276
+ <div class="shp-order-head">
277
+ <span class="shp-chip is-${STATUS_TONE[d.status]}" data-part="status">${esc(STATUS_LABEL[d.status])}</span>
278
+ <span class="shp-muted">${esc(new Date(o.meta?.createdAt).toLocaleString())} · ${esc(PROVIDER[d.provider] || d.provider)}</span>
279
+ <span class="shp-spacer"></span>
280
+ <div class="shp-order-acts" data-part="acts"></div>
281
+ <button type="button" class="shp-kebab" data-part="more" aria-label="More">⋮</button>
282
+ </div>
283
+ <table class="shp-lines">
284
+ ${(d.lines || []).map(l => `<tr><td class="shp-lines-img">${l.image ? `<img src="${esc(l.image)}" alt="">` : ''}</td>
285
+ <td><strong>${esc(l.name)}</strong>${l.variantName ? `<small>${esc(l.optionName || 'Option')}: ${esc(l.variantName)}</small>` : ''}${l.sku ? `<small>SKU ${esc(l.sku)}</small>` : ''}</td>
286
+ <td class="shp-num">${l.qty} × ${esc(money(l.unitPrice))}</td><td class="shp-amt">${esc(money(l.lineTotal))}</td></tr>`).join('')}
287
+ </table>
288
+ <dl class="shp-totals">
289
+ <div><dt>Subtotal</dt><dd>${esc(money(d.subtotal))}</dd></div>
290
+ ${(d.adjustments || []).map(a => `<div class="${a.included ? 'is-included' : ''}"><dt>${esc(a.label)}${a.code ? ` <code>${esc(a.code)}</code>` : ''}</dt><dd>${esc(money(a.amount))}</dd></div>`).join('')}
291
+ <div class="is-total"><dt>Total</dt><dd>${esc(money(d.total))}</dd></div>
292
+ </dl>
293
+ <div class="shp-order-cols">
294
+ <section><h4>Shopper</h4><p><strong>${esc(d.customer?.name || '')}</strong><br><a href="mailto:${esc(d.email)}">${esc(d.email)}</a>${d.customer?.phone ? `<br><a href="tel:${esc(d.customer.phone)}">${esc(d.customer.phone)}</a>` : ''}</p></section>
295
+ <section><h4>${d.delivery === 'collect' ? 'Collecting' : d.requiresShipping ? 'Deliver to' : 'Delivery'}</h4>
296
+ <p>${d.delivery === 'collect' ? esc(settings.collectionLabel || 'In person') : addr.length ? addr.map(esc).join('<br>') : 'Nothing to ship.'}</p>
297
+ ${d.tracking ? `<p class="shp-muted">${esc([d.tracking.carrier, d.tracking.number].filter(Boolean).join(' · '))}${d.tracking.url ? ` · <a href="${esc(d.tracking.url)}" target="_blank" rel="noopener">track</a>` : ''}</p>` : ''}</section>
298
+ </div>
299
+ ${d.note ? `<div class="shp-callout"><strong>Note from the shopper</strong><p></p></div>` : ''}
300
+ <label class="form-label">Staff note <span data-help="Only you see this. Saved when you click away."></span></label>
301
+ <textarea class="form-input" rows="2" data-model="staffNote.value" data-part="staff" placeholder="Wrapped as a gift, sent second class…"></textarea>
302
+ ${d.paymentRef || d.providerRef ? `<p class="shp-muted shp-ref">Payment reference: <code>${esc(d.paymentRef || d.providerRef)}</code></p>` : ''}
303
+ ${ext.length ? `<div class="shp-ext">${ext.map(x => `<span class="shp-chip is-muted">${esc(x.k)}: ${esc(String(x.v))}</span>`).join('')}</div>` : ''}
304
+ <h4>History</h4>
305
+ <ol class="shp-history">${(d.history || []).slice().reverse().map(h => `<li><span class="shp-chip is-${STATUS_TONE[h.status] || 'muted'}">${esc(STATUS_LABEL[h.status] || h.status)}</span>
306
+ <span>${esc(new Date(h.at).toLocaleString())} · ${esc(h.by || '')}${h.note ? ` - ${esc(h.note)}` : ''}</span></li>`).join('')}</ol>
307
+ </div>`;
308
+ panel = await openPanel({
309
+ title: `Order ${orderLabel(d)}`,
310
+ size: 'lg',
311
+ model: {staffNote, status},
312
+ html,
313
+ before: (el) => { const n = el.querySelector('.shp-callout p'); if (n) n.textContent = d.note; },
314
+ cleanup: () => panels.delete(panel?.so)
315
+ });
316
+ panels.add(panel.so);
317
+ const el = panel.panel;
318
+
319
+ // Main actions as buttons, the rest in ⋮.
320
+ const renderActs = (cur) => {
321
+ const box = el.querySelector('[data-part="acts"]');
322
+ box.innerHTML = '';
323
+ for (const a of actions(cur).slice(0, 2)) {
324
+ const b = document.createElement('button');
325
+ b.type = 'button';
326
+ b.className = `btn btn-sm ${a.danger ? 'btn-outline' : 'btn-primary'}`;
327
+ b.innerHTML = `<span data-icon="${a.icon}" data-icon-size="13"></span> `;
328
+ b.appendChild(document.createTextNode(a.label.replace('…', '')));
329
+ b.addEventListener('click', async () => {
330
+ const next = await a.run();
331
+ if (next && typeof next === 'object') { panel.close(true); openOrder(next.id); }
332
+ });
333
+ box.appendChild(b);
334
+ }
335
+ I.scan(box);
336
+ };
337
+ renderActs(o);
338
+ const more = el.querySelector('[data-part="more"]');
339
+ const menu = E.contextMenu(more, {items: () => items(o.id).filter(x => x.label !== 'Open'), inherit: false, density: 'compact'});
340
+ more.addEventListener('click', () => kit.openMenuAt(more));
341
+ panel.so.element.addEventListener('remove', () => menu?.destroy?.());
342
+
343
+ const staff = el.querySelector('[data-part="staff"]');
344
+ staff.addEventListener('blur', async () => {
345
+ if (staffNote.peek() === (byId(o.id)?.data.staffNote || '')) return;
346
+ try {
347
+ replace(await api.orders.note(o.id, {staffNote: staffNote.peek()}));
348
+ E.toast('Note saved.', {type: 'success'});
349
+ } catch (err) {
350
+ E.toast(`Note not saved: ${err.message || err}`, {type: 'error'});
351
+ }
352
+ });
353
+ }
354
+
355
+ wireRows(list, {openMenuAt: kit.openMenuAt, scope, items, main: openOrder});
356
+ scope(() => [...panels].forEach(so => { try { so.close(true); } catch { /* gone */ } }));
357
+
358
+ await load();
359
+ if (!here()) return;
360
+ I.scan(view);
361
+ if (ctx.id) {
362
+ if (byId(ctx.id) && !FILTERS.find(f => f.key === filter.peek()).match(byId(ctx.id).data.status)) filter.value = 'all';
363
+ openOrder(ctx.id);
364
+ }
365
+ }