domma-cms 1.6.31 → 1.6.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +19 -19
- package/plugins/shopping-cart/CLAUDE.md +25 -0
- package/plugins/shopping-cart/admin/css/index.css +2 -0
- package/plugins/shopping-cart/admin/lib/kit.js +41 -0
- package/plugins/shopping-cart/admin/views/orders.js +11 -2
- package/plugins/shopping-cart/admin/views/products.js +19 -4
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +2 -0
- package/plugins/shopping-cart/inject/body.html +1 -1
- package/plugins/shopping-cart/lib/extensions.js +71 -0
- package/plugins/shopping-cart/lib/render.js +6 -2
- package/plugins/shopping-cart/lib/shop.js +36 -12
- package/plugins/shopping-cart/plugin.js +19 -5
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/plugin.public.js +2 -1
- package/plugins/shopping-cart/public/shop.css +12 -0
- package/plugins/shopping-cart/tests/api.test.js +51 -0
- package/plugins/shopping-cart/tests/pure.test.js +49 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@91d03cf",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.5",
|
|
@@ -175,32 +175,32 @@
|
|
|
175
175
|
}
|
|
176
176
|
},
|
|
177
177
|
"shopping-cart": {
|
|
178
|
-
"version": "1.
|
|
178
|
+
"version": "1.2.0",
|
|
179
179
|
"files": {
|
|
180
|
-
"CLAUDE.md": "
|
|
181
|
-
"admin/css/index.css": "
|
|
182
|
-
"admin/lib/kit.js": "
|
|
180
|
+
"CLAUDE.md": "0a0d019e6eceb2260cf965d3eb589c9a53615c388f212af382a00a129cb2c0dc",
|
|
181
|
+
"admin/css/index.css": "2ea348d9f969193f198dac25b1962a400967566b835c7bf1402e17e33d81bfd2",
|
|
182
|
+
"admin/lib/kit.js": "ebb439a31123b9c4fbda8a2c299cc79d1ec50e66d019b5cc49dcd00f269124d5",
|
|
183
183
|
"admin/templates/shop.html": "c5bb03f1f7cbabfbd8202f3533783eb1848103755b1ba25fdcf1fd8e9478cb8e",
|
|
184
|
-
"admin/views/orders.js": "
|
|
184
|
+
"admin/views/orders.js": "c10789a82cf3efb4419f223668a5fa71b021420a094cda291c8d492a644b25da",
|
|
185
185
|
"admin/views/overview.js": "6abd9779cf5fc359c4708f96cf0e8ecad155fb52583b2ec854400b288fc9ba98",
|
|
186
|
-
"admin/views/products.js": "
|
|
186
|
+
"admin/views/products.js": "e109660355aaec8db07eb65fa1169e7d2ec20bc2ec45293cc20e34f7c1bd62bf",
|
|
187
187
|
"admin/views/settings.js": "28cb0b5e8d45e3723ffe77e46299bfc13fbad02a2ae8103843c6894dc232387a",
|
|
188
|
-
"admin/views/shop.js": "
|
|
188
|
+
"admin/views/shop.js": "1b6ee66b4ef768983ed32752711ed4ca8438bd37eb33be9587f6679452557b2c",
|
|
189
189
|
"collections/shop-orders.json": "0bca8eef2d5f9108651f73d273480265c167dc983aa115ce59613d13015ca255",
|
|
190
190
|
"collections/shop-products.json": "4d4613ecec7b8a5a0744c4f0a57b17a4429b461e060206a4bbdea5ad7c8c75dc",
|
|
191
191
|
"config.js": "dcb91db2839c1ab9e40bb1811397024fad9e37e3753d17e2baa2b9b31d4a7d7c",
|
|
192
|
-
"docs/guide.md": "
|
|
193
|
-
"inject/body.html": "
|
|
194
|
-
"lib/extensions.js": "
|
|
192
|
+
"docs/guide.md": "4b4fbf3d61d73114f3b1b42ba48c72debb720e2c65bcd17df2feaa372463882f",
|
|
193
|
+
"inject/body.html": "9deda0324d60ef1e62f653513748faa4c6ac546493906c8bd195e445a55f7476",
|
|
194
|
+
"lib/extensions.js": "180820bcfb8ada9acfb260d0cc0a1c8cc4f37f9ab969400f4b6e8bf2d5babb3f",
|
|
195
195
|
"lib/payments.js": "84ad14813688de1f38448b4935180a786cda8af2f7f4b8890a8f1d426982d1aa",
|
|
196
196
|
"lib/pricing.js": "3ae965e042f5af0acdcecc66d27145ca5f608c656717288832b881a2a181dd2e",
|
|
197
|
-
"lib/render.js": "
|
|
197
|
+
"lib/render.js": "442cd305810e53edcfa184796db19371b304567100e2662c96b4b3bee2cf7231",
|
|
198
198
|
"lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
|
|
199
|
-
"lib/shop.js": "
|
|
199
|
+
"lib/shop.js": "6edd3618d53ecd614a03bf476abe5cb8615fd7d19ac765406a6ac550ebcf75e9",
|
|
200
200
|
"lib/summary.js": "5697ffa45c9cc5338bbcb6eada08671c3e8376c49c629d2ae99b022397523f71",
|
|
201
|
-
"plugin.js": "
|
|
202
|
-
"plugin.json": "
|
|
203
|
-
"plugin.public.js": "
|
|
201
|
+
"plugin.js": "c72c526ccd71d8c055b82ef7e166afc542d4e8c36251e2f4ca1861505d5febb4",
|
|
202
|
+
"plugin.json": "bb7e9268ce075ec400f9df0932a74b26f61ca8930471ebbf41f3a8deef2e813a",
|
|
203
|
+
"plugin.public.js": "99ef559dc8c3bd369047091623bbad29a1f6067322f07ac0db547938d97fe014",
|
|
204
204
|
"public/cart.js": "fcc706773a36e4bfd02ea235bcdad629137e996218e0a0e23892e33c689f2a6e",
|
|
205
205
|
"public/lib/money.js": "d22b2b6f80dde6f7887b69e24188c05ea93c02217aba639b5331aa3bd616f4fc",
|
|
206
206
|
"public/lib/orders.js": "09478e20d238adc8e387b3d9efcb13c441898fab18387ce6a1a8a92304ac4c5b",
|
|
@@ -217,10 +217,10 @@
|
|
|
217
217
|
"public/samples/tee.svg": "287a82d7fc00acb1543b3209927ae20dcbe3d4b2a2b3de03574ab0c463385f3c",
|
|
218
218
|
"public/samples/tote-2.svg": "9c7f41cc57866e71973e413a6c87f46be48f7488d3490ceea58e24559c334813",
|
|
219
219
|
"public/samples/tote.svg": "01de817a32c8d3d8ea47ff491049f439ca3e4d4fce943dbdc62a4187139e016c",
|
|
220
|
-
"public/shop.css": "
|
|
220
|
+
"public/shop.css": "c5adbc7b08cff3dc3b1c2b796ba693ad52c586ef35d236d0bba9a0cb7c3673c1",
|
|
221
221
|
"templates/shop.html": "4649e57d23458833a157799dc80141824eb0f658d96734bd1859c68e0ba9a8b9",
|
|
222
|
-
"tests/api.test.js": "
|
|
223
|
-
"tests/pure.test.js": "
|
|
222
|
+
"tests/api.test.js": "8b71353b9649d6274c4e6d9358bac8fa6737d3effee00c35c62dab6f2a377968",
|
|
223
|
+
"tests/pure.test.js": "27a40f04315c17b92e4782d6f8621c965ae89bb73df377064775eec71335a5b7"
|
|
224
224
|
}
|
|
225
225
|
},
|
|
226
226
|
"support": {
|
|
@@ -112,3 +112,28 @@ A cached page can show a stale stock flag until it is re-rendered; checkout re-p
|
|
|
112
112
|
- The product form can never set or clear the two fields (POST strips them, PUT keeps the stored ones).
|
|
113
113
|
- A copy is the shop's own.
|
|
114
114
|
- `services.saveProduct` and `services.removeProduct` are how the extension manages them.
|
|
115
|
+
|
|
116
|
+
## 1.2.0: extensions add to products, orders and the receipt
|
|
117
|
+
|
|
118
|
+
For Shopping Cart Pro's downloads. All optional: a Cart without extensions, or an extension that registers none of
|
|
119
|
+
these, behaves as 1.1.
|
|
120
|
+
- **`panels: [{key, label, icon, module, help, on}]`** (`on`: `product` | `order`) - a `<details>` section in the
|
|
121
|
+
product editor (collapsed) or an order (open), marked Pro. `admin/lib/kit.js` `mountPanels` imports the module
|
|
122
|
+
(`/plugins/...` only) and calls `mount(body, {product | order, variants(), changed(order), ctx})`, which may
|
|
123
|
+
return `{value(), dirty(), destroy()}`. A section that fails to load says so and sends nothing.
|
|
124
|
+
- **`product.clean(input, {had, copy, product})` -> `{data, errors}`** - the editor saves each panel's `value()` as
|
|
125
|
+
`ext[owner]`; `registry.cleanProductExt` hands each part to its own extension. Not sent, or the extension not
|
|
126
|
+
running (lapsed licence), or its cleaner throwing: the stored part stays. `data: null` removes it; errors come
|
|
127
|
+
back keyed `<owner>.<field>` and refuse the save. PATCH (quick edits) never touches `ext`. A copy starts empty
|
|
128
|
+
and each extension decides what carries over (`copy: true`). `publicProduct` never includes `ext`, but the admin
|
|
129
|
+
product list does - anything secret (licence keys) is visible to `shop:read`.
|
|
130
|
+
- **`receipt(order, {settings})` -> HTML** - `registry.receiptParts` collects them for the receipt page, above the
|
|
131
|
+
items, in `.shop-r-ext[data-ext=owner]`. The extension escapes its own HTML. Each has 3 s; one that throws or
|
|
132
|
+
dawdles is left out. The page is server-rendered and reloads once paid, but `on.paid` does not wait, so a
|
|
133
|
+
receipt handler should not assume `paid` has run.
|
|
134
|
+
- **`services.updateProductExt(id, owner, fn)`** - `fn(part) -> part` (null removes) on the product as it is now,
|
|
135
|
+
under the order lock, no form check: an extension's own bookkeeping (Pro taking licence keys). `saveProduct` runs
|
|
136
|
+
under the same lock, so a sale's stock, an edit and this never overwrite each other.
|
|
137
|
+
- A copy (`POST /products/:id/duplicate`) gives options new ids and passes `variantMap` (old -> new) to `clean`.
|
|
138
|
+
- **`services.moveOrder(id, to, {by, note})`** - moves an order as the admin's status route does (same rules,
|
|
139
|
+
409 on a move the table refuses) and emits `status`.
|
|
@@ -151,6 +151,8 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
|
|
|
151
151
|
.shp-sect > summary { cursor: pointer; font-weight: 600; padding: .2rem 0; }
|
|
152
152
|
.shp-sect[open] > summary { margin-bottom: .6rem; }
|
|
153
153
|
.shp-sect > * + * { margin-top: .6rem; }
|
|
154
|
+
.shp-sect.is-ext > summary::after { content: 'Pro'; margin-left: .4rem; font-size: .62rem; font-weight: 700; letter-spacing: .04em; padding: .05rem .35rem;
|
|
155
|
+
border-radius: 999px; background: color-mix(in srgb, var(--dm-primary) 15%, transparent); color: var(--dm-primary); vertical-align: middle; }
|
|
154
156
|
|
|
155
157
|
.shp-pics { display: flex; flex-wrap: wrap; gap: .5rem; }
|
|
156
158
|
.shp-pic, .shp-pic-add { position: relative; width: 5.5rem; height: 5.5rem; border-radius: var(--dm-radius-md); overflow: hidden; }
|
|
@@ -156,6 +156,47 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
|
|
|
156
156
|
return {so, panel, close: (force) => so.close(force)};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Extensions' sections in the product editor or an order (`ctx.extensions.panels`,
|
|
161
|
+
* on 'product' | 'order'), each a <details> put before `anchor` - collapsed in
|
|
162
|
+
* the editor, open on an order; a module can open its own. The module's
|
|
163
|
+
* `mount(body, {...info, ctx})` may return `{value(), dirty(), destroy()}`:
|
|
164
|
+
* the editor saves `value()` as `ext[owner]`. One that fails to load says so
|
|
165
|
+
* and sends nothing, so what it stored is kept.
|
|
166
|
+
*
|
|
167
|
+
* @returns {Promise<{panels: Array<{owner: string, handle: object}>, destroy: () => void}>}
|
|
168
|
+
*/
|
|
169
|
+
export async function mountPanels(anchor, {on, ctx, info = {}}) {
|
|
170
|
+
const {helpPopovers} = await loadKit();
|
|
171
|
+
const panels = [];
|
|
172
|
+
const undo = [];
|
|
173
|
+
for (const p of (ctx.extensions?.panels || []).filter(x => x.on === on)) {
|
|
174
|
+
const sect = fragment(`<details class="shp-sect is-ext" data-ext="${esc(p.owner)}"${on === 'order' ? ' open' : ''}>
|
|
175
|
+
<summary><span data-icon="${esc(p.icon)}" data-icon-size="14"></span> ${esc(p.label)}${p.help ? ` <span data-help="${esc(p.help)}"></span>` : ''}</summary>
|
|
176
|
+
<div class="shp-ext-body"></div>
|
|
177
|
+
</details>`);
|
|
178
|
+
anchor.before(sect);
|
|
179
|
+
const body = sect.querySelector('.shp-ext-body');
|
|
180
|
+
try {
|
|
181
|
+
const mod = await import(p.module);
|
|
182
|
+
const mount = mod.mount || mod.default?.mount;
|
|
183
|
+
const handle = (await mount(body, {...info, ctx})) || {};
|
|
184
|
+
panels.push({owner: p.owner, handle});
|
|
185
|
+
if (typeof handle.destroy === 'function') undo.push(() => handle.destroy());
|
|
186
|
+
} catch (err) {
|
|
187
|
+
body.textContent = '';
|
|
188
|
+
const e = document.createElement('p');
|
|
189
|
+
e.className = 'shp-error';
|
|
190
|
+
e.textContent = `This section could not load: ${err.message || err}`;
|
|
191
|
+
body.appendChild(e);
|
|
192
|
+
}
|
|
193
|
+
// After the mount, so the section's own help marks work too.
|
|
194
|
+
I.scan(sect);
|
|
195
|
+
undo.push(helpPopovers(sect));
|
|
196
|
+
}
|
|
197
|
+
return {panels, destroy: () => undo.forEach(fn => { try { fn(); } catch { /* gone */ } })};
|
|
198
|
+
}
|
|
199
|
+
|
|
159
200
|
/**
|
|
160
201
|
* Rows: click (or Enter) = the main action; right-click, ⋮ or Shift+F10 = the
|
|
161
202
|
* menu. `scope` receives the teardown.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* slip. A notification's link (#/plugins/shopping-cart/orders/<id>) opens one.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {api, copy, entriesOf, esc, fmtDate, fmtDateTime, money, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
|
|
12
|
+
import {api, copy, entriesOf, esc, fmtDate, fmtDateTime, money, mountPanels, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
|
|
13
13
|
import {NEXT, STATUS_LABEL, STATUS_TONE, addressLines, orderLabel} from '/plugins/shopping-cart/public/lib/orders.js';
|
|
14
14
|
|
|
15
15
|
const FILTERS = [
|
|
@@ -269,6 +269,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
|
|
|
269
269
|
const staffNote = M.observable(d.staffNote || '');
|
|
270
270
|
const status = M.observable(d.status);
|
|
271
271
|
let panel = null;
|
|
272
|
+
let extPanels = null;
|
|
272
273
|
const ext = Object.entries(d.ext || {}).flatMap(([owner, facts]) => Object.entries(facts || {}).map(([k, v]) => ({owner, k, v})))
|
|
273
274
|
.filter(x => ['string', 'number', 'boolean'].includes(typeof x.v));
|
|
274
275
|
const addr = addressLines(d.address);
|
|
@@ -301,6 +302,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
|
|
|
301
302
|
<textarea class="form-input" rows="2" data-model="staffNote.value" data-part="staff" placeholder="Wrapped as a gift, sent second class…"></textarea>
|
|
302
303
|
${d.paymentRef || d.providerRef ? `<p class="shp-muted shp-ref">Payment reference: <code>${esc(d.paymentRef || d.providerRef)}</code></p>` : ''}
|
|
303
304
|
${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>` : ''}
|
|
305
|
+
<div data-part="ext-panels" hidden></div>
|
|
304
306
|
<h4>History</h4>
|
|
305
307
|
<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
308
|
<span>${esc(fmtDateTime(h.at))} · ${esc(h.by || '')}${h.note ? ` - ${esc(h.note)}` : ''}</span></li>`).join('')}</ol>
|
|
@@ -311,10 +313,17 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
|
|
|
311
313
|
model: {staffNote, status},
|
|
312
314
|
html,
|
|
313
315
|
before: (el) => { const n = el.querySelector('.shp-callout p'); if (n) n.textContent = d.note; },
|
|
314
|
-
cleanup: () => panels.delete(panel?.so)
|
|
316
|
+
cleanup: () => { panels.delete(panel?.so); extPanels?.destroy(); }
|
|
315
317
|
});
|
|
316
318
|
panels.add(panel.so);
|
|
317
319
|
const el = panel.panel;
|
|
320
|
+
// Extensions' sections (Shopping Cart Pro's Downloads). `changed` takes the order an extension
|
|
321
|
+
// updated, so the list keeps up.
|
|
322
|
+
extPanels = await mountPanels(el.querySelector('[data-part="ext-panels"]'), {on: 'order', ctx, info: {
|
|
323
|
+
order: o,
|
|
324
|
+
changed: (next) => { if (next?.id === o.id) replace(next); }
|
|
325
|
+
}});
|
|
326
|
+
if (!el.isConnected) extPanels.destroy();
|
|
318
327
|
|
|
319
328
|
// Main actions as buttons, the rest in ⋮.
|
|
320
329
|
const renderActs = (cur) => {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* adds one; #/plugins/shopping-cart/products/new opens a blank one.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {api, entriesOf, esc, getCurrency, money, moneyIn, moneyOut, openPanel, store, wireRows} from '../lib/kit.js';
|
|
13
|
+
import {api, entriesOf, esc, getCurrency, money, moneyIn, moneyOut, mountPanels, openPanel, store, wireRows} from '../lib/kit.js';
|
|
14
14
|
import {STATUS_LABEL, cleanProduct, priceRange, slugify, totalStock} from '/plugins/shopping-cart/public/lib/products.js';
|
|
15
15
|
|
|
16
16
|
const FILTERS = [
|
|
@@ -205,7 +205,9 @@ export async function mount(root, ctx) {
|
|
|
205
205
|
|
|
206
206
|
const snapshot = () => JSON.stringify([Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])), images.peek(), variants.peek()]);
|
|
207
207
|
const opened = snapshot();
|
|
208
|
-
|
|
208
|
+
// Extensions' sections (Shopping Cart Pro's Downloads), mounted once the panel is open.
|
|
209
|
+
let ext = {panels: [], destroy() {}};
|
|
210
|
+
const dirty = () => snapshot() !== opened || ext.panels.some(x => x.handle.dirty?.());
|
|
209
211
|
|
|
210
212
|
const payload = () => ({
|
|
211
213
|
...Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])),
|
|
@@ -216,8 +218,15 @@ export async function mount(root, ctx) {
|
|
|
216
218
|
weight: f.weight.peek() === '' ? null : Number(f.weight.peek()),
|
|
217
219
|
images: images.peek(),
|
|
218
220
|
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)}))
|
|
221
|
+
price: moneyOut(v.price), stock: v.stock === '' || v.stock === null ? null : Number(v.stock)})),
|
|
222
|
+
...extPayload()
|
|
220
223
|
});
|
|
224
|
+
// Only the sections that loaded send their part; the server keeps the rest as stored.
|
|
225
|
+
const extPayload = () => {
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const {owner, handle} of ext.panels) if (typeof handle.value === 'function') out[owner] = handle.value();
|
|
228
|
+
return Object.keys(out).length ? {ext: out} : {};
|
|
229
|
+
};
|
|
221
230
|
// Checked as typed, by the server's own rules.
|
|
222
231
|
const check = M.computed(() => {
|
|
223
232
|
f.name.value; f.price.value; f.compareAt.value; f.stock.value; f.weight.value; images.value; variants.value;
|
|
@@ -402,7 +411,7 @@ export async function mount(root, ctx) {
|
|
|
402
411
|
renderPics(el);
|
|
403
412
|
renderVariants(el);
|
|
404
413
|
},
|
|
405
|
-
cleanup: () => { panels.delete(panel?.so); stopSlug?.(); },
|
|
414
|
+
cleanup: () => { panels.delete(panel?.so); stopSlug?.(); ext.destroy(); },
|
|
406
415
|
methods: {
|
|
407
416
|
cancel: () => panel?.close(),
|
|
408
417
|
editSlug: () => { panel.panel.querySelector('[data-part="slug"]').hidden = false; slugTouched.value = true; panel.panel.querySelector('#sp-slug').focus(); },
|
|
@@ -431,6 +440,12 @@ export async function mount(root, ctx) {
|
|
|
431
440
|
});
|
|
432
441
|
panels.add(panel.so);
|
|
433
442
|
const el = panel.panel;
|
|
443
|
+
ext = await mountPanels(el.querySelector('.shp-actions'), {on: 'product', ctx, info: {
|
|
444
|
+
product: had ? {id: had.id, data: had.data} : null,
|
|
445
|
+
// Options as they are in the form; one added since the last save has no id yet.
|
|
446
|
+
variants: () => variants.peek().map(v => ({id: v.id || '', name: v.name}))
|
|
447
|
+
}});
|
|
448
|
+
if (!el.isConnected) ext.destroy();
|
|
434
449
|
|
|
435
450
|
// Pictures: add from Media, remove, drag to reorder.
|
|
436
451
|
const pics = el.querySelector('[data-part="pics"]');
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import {api, loadKit, setCurrency, store} from '../lib/kit.js';
|
|
16
16
|
|
|
17
17
|
const ROUTE = /^#\/plugins\/shopping-cart(\/|$)/;
|
|
18
|
-
const V = '1.
|
|
18
|
+
const V = '1.2.0';
|
|
19
19
|
const TABS = [
|
|
20
20
|
{key: 'overview', label: 'Overview', icon: 'bar-chart', module: `/plugins/shopping-cart/admin/views/overview.js?v=${V}`},
|
|
21
21
|
{key: 'orders', label: 'Orders', icon: 'receipt', module: `/plugins/shopping-cart/admin/views/orders.js?v=${V}`},
|
|
@@ -91,6 +91,8 @@ The product form:
|
|
|
91
91
|
- **Options & stock** - tick **Count stock** to track stock; it then sells out at 0. Add options with **Add an option**, each with a name, price (empty uses the product's price), SKU and stock. **Options are** names them (for example Size).
|
|
92
92
|
- **Delivery & tax** - untick **Needs delivering** for downloads, services and tickets (no delivery charge, no address asked). **Weight (g)** and **Tax class** are used by Shopping Cart Pro.
|
|
93
93
|
|
|
94
|
+
Shopping Cart Pro adds sections of its own, marked **Pro**, to the product form and to an order.
|
|
95
|
+
|
|
94
96
|
Ctrl+Enter saves the form.
|
|
95
97
|
|
|
96
98
|
## Settings
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
<link rel="stylesheet" href="/plugins/shopping-cart/public/shop.css?v=1.
|
|
1
|
+
<link rel="stylesheet" href="/plugins/shopping-cart/public/shop.css?v=1.2.0">
|
|
2
2
|
<script type="module" src="/plugins/shopping-cart/public/cart.js?v=1.0.0"></script>
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* stages: {discount, shipping, tax}, // see lib/pricing.js
|
|
15
15
|
* on: {paid(order), status(order, from, to), checkout(order)},
|
|
16
16
|
* tabs: [{key, label, icon, module, help}], // admin screens in the Shop
|
|
17
|
+
* panels: [{key, label, icon, module, help, on}], // a section in the product editor or an order (on: 'product' | 'order')
|
|
18
|
+
* product: {clean(input, {had, copy, product, variantMap})}, // checks what a product panel saves (product.ext[owner])
|
|
19
|
+
* receipt(order, {settings}), // HTML for the shopper's receipt page (escape it yourself)
|
|
17
20
|
* checkout: {codes: true} // storefront features to show
|
|
18
21
|
* });
|
|
19
22
|
*
|
|
@@ -28,6 +31,10 @@ export const KEY = Symbol.for('domma.shopping-cart');
|
|
|
28
31
|
export const VERSION = 1;
|
|
29
32
|
|
|
30
33
|
const EVENTS = ['paid', 'status', 'checkout'];
|
|
34
|
+
const PANEL_ON = ['product', 'order'];
|
|
35
|
+
const RECEIPT_WAIT = 3000;
|
|
36
|
+
|
|
37
|
+
const panelOk = (t) => /^[a-z0-9-]{1,40}$/.test(t.key) && t.module.startsWith('/plugins/') && PANEL_ON.includes(t.on);
|
|
31
38
|
|
|
32
39
|
/** The registry, made once per process. */
|
|
33
40
|
export function cartRegistry() {
|
|
@@ -45,6 +52,9 @@ export function cartRegistry() {
|
|
|
45
52
|
stages: ext.stages || {},
|
|
46
53
|
on: ext.on || {},
|
|
47
54
|
tabs: Array.isArray(ext.tabs) ? ext.tabs : [],
|
|
55
|
+
panels: Array.isArray(ext.panels) ? ext.panels : [],
|
|
56
|
+
product: ext.product || {},
|
|
57
|
+
receipt: typeof ext.receipt === 'function' ? ext.receipt : null,
|
|
48
58
|
checkout: ext.checkout || {},
|
|
49
59
|
order: Number(ext.order) || 0
|
|
50
60
|
});
|
|
@@ -76,6 +86,67 @@ export function cartRegistry() {
|
|
|
76
86
|
}))).filter(t => /^[a-z0-9-]{1,40}$/.test(t.key) && t.module.startsWith('/plugins/'));
|
|
77
87
|
},
|
|
78
88
|
|
|
89
|
+
/** Sections an extension adds to the product editor and the order view. */
|
|
90
|
+
panels() {
|
|
91
|
+
return reg.list().flatMap(e => e.panels.map(t => ({
|
|
92
|
+
key: String(t.key), label: String(t.label || t.key), icon: String(t.icon || 'puzzle'),
|
|
93
|
+
module: String(t.module || ''), help: String(t.help || ''), on: String(t.on || 'product'), owner: e.owner
|
|
94
|
+
}))).filter(panelOk);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A product's `ext`, saved. What each extension keeps on a product is
|
|
99
|
+
* its own: `input` (from the product form) is checked by that
|
|
100
|
+
* extension's `product.clean`, and anything it does not send, or an
|
|
101
|
+
* extension that is not running (a lapsed licence) left there, is kept
|
|
102
|
+
* as it was. A copy starts with nothing and asks each extension what it
|
|
103
|
+
* carries over (`copy: true`). A cleaner that throws keeps what was
|
|
104
|
+
* stored: an extension cannot stop a product being saved.
|
|
105
|
+
*
|
|
106
|
+
* @returns {Promise<{ext: object, errors: object}>}
|
|
107
|
+
*/
|
|
108
|
+
async cleanProductExt(input, had, {copy = false, product = null, variantMap = null} = {}) {
|
|
109
|
+
const ext = copy ? {} : {...(had && typeof had === 'object' ? had : {})};
|
|
110
|
+
const errors = {};
|
|
111
|
+
if (!input || typeof input !== 'object') return {ext, errors};
|
|
112
|
+
for (const e of reg.list()) {
|
|
113
|
+
const clean = e.product?.clean;
|
|
114
|
+
if (typeof clean !== 'function' || !Object.hasOwn(input, e.owner)) continue;
|
|
115
|
+
try {
|
|
116
|
+
const r = await clean(input[e.owner], {had: had?.[e.owner] ?? null, copy, product, variantMap});
|
|
117
|
+
for (const [k, v] of Object.entries(r?.errors || {})) errors[`${e.owner}.${k}`] = String(v);
|
|
118
|
+
if (r?.data === null || r?.data === undefined) delete ext[e.owner];
|
|
119
|
+
else ext[e.owner] = r.data;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.warn(`[shopping-cart] ${e.owner} could not check a product: ${err?.message || err}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {ext, errors};
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What extensions add to a shopper's receipt: [{owner, html}]. Each has
|
|
129
|
+
* a few seconds; one that fails or dawdles is left out, never the page.
|
|
130
|
+
*/
|
|
131
|
+
async receiptParts(order, ctx = {}) {
|
|
132
|
+
const parts = await Promise.all(reg.list().filter(e => e.receipt).map(async (e) => {
|
|
133
|
+
let timer;
|
|
134
|
+
try {
|
|
135
|
+
const html = await Promise.race([
|
|
136
|
+
Promise.resolve().then(() => e.receipt(order, ctx)),
|
|
137
|
+
new Promise((_, no) => { timer = setTimeout(() => no(new Error('took too long')), RECEIPT_WAIT); })
|
|
138
|
+
]);
|
|
139
|
+
return typeof html === 'string' && html.trim() ? {owner: e.owner, html} : null;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.warn(`[shopping-cart] ${e.owner} failed on the receipt: ${err?.message || err}`);
|
|
142
|
+
return null;
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
}));
|
|
147
|
+
return parts.filter(Boolean);
|
|
148
|
+
},
|
|
149
|
+
|
|
79
150
|
/**
|
|
80
151
|
* Tell every extension. Never throws and never waits: an email that
|
|
81
152
|
* fails must not fail the payment that triggered it.
|
|
@@ -233,8 +233,11 @@ export function cartHtml({settings, base}) {
|
|
|
233
233
|
</div>`;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
/**
|
|
237
|
-
|
|
236
|
+
/**
|
|
237
|
+
* A shopper's receipt. `parts` are extensions' sections ([{owner, html}],
|
|
238
|
+
* their own HTML, escaped by them) - Shopping Cart Pro's downloads.
|
|
239
|
+
*/
|
|
240
|
+
export function receiptHtml({order, settings, base, problem = '', parts = []}) {
|
|
238
241
|
const cur = order.currency;
|
|
239
242
|
const status = order.status;
|
|
240
243
|
const waiting = status === 'pending';
|
|
@@ -271,6 +274,7 @@ export function receiptHtml({order, settings, base, problem = ''}) {
|
|
|
271
274
|
${status === 'awaiting' && settings.offlineInstructions ? `<div class="shop-r-note"><strong>${esc(settings.offlineLabel || 'How to pay')}</strong><p>${esc(settings.offlineInstructions)}</p><p>Please quote <strong>#${esc(order.number)}</strong> with your payment - ${esc(formatMoney(order.total, cur))}.</p></div>` : ''}
|
|
272
275
|
${closed ? '' : `<ol class="shop-r-steps">${steps.map(s => `<li class="${s.done ? 'is-done' : ''}"><span></span>${esc(s.label)}</li>`).join('')}</ol>`}
|
|
273
276
|
${t ? `<div class="shop-r-note">${ICON.truck} <strong>${esc(t.carrier || 'Tracking')}</strong> ${esc(t.number || '')} ${t.url ? `<a href="${esc(t.url)}" target="_blank" rel="noopener">Track it</a>` : ''}</div>` : ''}
|
|
277
|
+
${parts.map(p => `<div class="shop-r-ext" data-ext="${esc(p.owner)}">${p.html}</div>`).join('')}
|
|
274
278
|
<div class="shop-r-grid">
|
|
275
279
|
<section><h2>Items</h2><ul class="shop-r-lines">${lines}</ul>${totals}</section>
|
|
276
280
|
<section class="shop-r-side">
|
|
@@ -17,6 +17,7 @@ import {getPluginSettings, getPluginStates, savePluginState} from '../../../serv
|
|
|
17
17
|
import {cleanProduct} from '../public/lib/products.js';
|
|
18
18
|
import {NEXT, SOLD, canMove} from '../public/lib/orders.js';
|
|
19
19
|
import {currencyOf} from '../public/lib/money.js';
|
|
20
|
+
import {cartRegistry} from './extensions.js';
|
|
20
21
|
import defaults from '../config.js';
|
|
21
22
|
|
|
22
23
|
export const NAME = 'shopping-cart';
|
|
@@ -174,22 +175,45 @@ async function uniqueSlug(slug, exceptId = null) {
|
|
|
174
175
|
return `${slug}-${Date.now()}`;
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
178
|
+
/**
|
|
179
|
+
* Save a product. Under the order lock: a sale's stock and an extension's
|
|
180
|
+
* `updateProductExt` (licence keys taken) never interleave with an edit.
|
|
181
|
+
* `variantMap` (a copy) is old option id -> the copy's, for extensions' parts.
|
|
182
|
+
*/
|
|
183
|
+
export function saveProduct(input, {id = null, currency = 'GBP', by = null, copy = false, variantMap = null} = {}) {
|
|
184
|
+
return withLock(async () => {
|
|
185
|
+
const had = id ? await getProduct(id) : null;
|
|
186
|
+
if (id && !had) return {notFound: true};
|
|
187
|
+
const {data, errors} = cleanProduct(input, {currency, minor: true});
|
|
188
|
+
// What extensions keep on a product (Shopping Cart Pro's downloads), each checked by its own extension.
|
|
189
|
+
const x = await cartRegistry().cleanProductExt(input?.ext, copy ? input?.ext : had?.data?.ext, {copy, product: data, variantMap});
|
|
190
|
+
Object.assign(errors, x.errors);
|
|
191
|
+
if (Object.keys(errors).length) return {errors};
|
|
192
|
+
if (Object.keys(x.ext).length) data.ext = x.ext;
|
|
193
|
+
data.slug = await uniqueSlug(data.slug, id);
|
|
194
|
+
const entry = id ? await updateEntry(PRODUCTS, id, data) : await createEntry(PRODUCTS, data, {createdBy: by, source: 'plugin'});
|
|
185
195
|
await refreshShelf();
|
|
186
196
|
return {entry};
|
|
187
|
-
}
|
|
188
|
-
const entry = await createEntry(PRODUCTS, data, {createdBy: by, source: 'plugin'});
|
|
189
|
-
await refreshShelf();
|
|
190
|
-
return {entry};
|
|
197
|
+
});
|
|
191
198
|
}
|
|
192
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Change only an extension's part of a product, under the order lock, from
|
|
202
|
+
* the product as it is now: `fn(part) -> part` (null removes it). No form
|
|
203
|
+
* check - the extension's own write (Shopping Cart Pro taking licence keys).
|
|
204
|
+
*/
|
|
205
|
+
export const updateProductExt = (id, owner, fn) => withLock(async () => {
|
|
206
|
+
const had = await getProduct(id);
|
|
207
|
+
if (!had) throw Object.assign(new Error('No such product.'), {status: 404});
|
|
208
|
+
const next = await fn(had.data.ext?.[owner] ?? null);
|
|
209
|
+
const ext = {...(had.data.ext || {})};
|
|
210
|
+
if (next === null || next === undefined) delete ext[owner];
|
|
211
|
+
else ext[owner] = next;
|
|
212
|
+
const entry = await updateEntry(PRODUCTS, id, {...had.data, ext});
|
|
213
|
+
await refreshShelf();
|
|
214
|
+
return entry;
|
|
215
|
+
});
|
|
216
|
+
|
|
193
217
|
export async function removeProduct(id) {
|
|
194
218
|
await deleteEntry(PRODUCTS, id);
|
|
195
219
|
await refreshShelf();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Admin (the `shop` permission; read to look, manage to change):
|
|
5
5
|
* GET /summary Overview figures
|
|
6
6
|
* GET /count sidebar badge: paid orders to fulfil (else how many products)
|
|
7
|
-
* GET /extensions admin tabs extensions (Pro) add
|
|
7
|
+
* GET /extensions admin tabs and panels extensions (Pro) add
|
|
8
8
|
* GET /products · POST /products · PUT /products/:id · DELETE /products/:id
|
|
9
9
|
* POST /products/:id/duplicate · PUT /products/:id/stock
|
|
10
10
|
* GET /orders · GET /orders/:id · PUT /orders/:id/status · PUT /orders/:id/note
|
|
@@ -34,7 +34,7 @@ import * as pay from './lib/payments.js';
|
|
|
34
34
|
import {
|
|
35
35
|
NAME, allOrders, allProducts, annotateOrder, cleanSettings, cleanTracking, createOrder, ensureCollections, extendOrder,
|
|
36
36
|
getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, publicOrder, removeOrder,
|
|
37
|
-
refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk
|
|
37
|
+
refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt
|
|
38
38
|
} from './lib/shop.js';
|
|
39
39
|
import {cardHtml, esc, gridHtml} from './lib/render.js';
|
|
40
40
|
|
|
@@ -177,6 +177,14 @@ export default async function shoppingCart(fastify, options) {
|
|
|
177
177
|
liveSettings, allOrders, getOrder, allProducts, getProduct, extendOrder, publicOrder,
|
|
178
178
|
formatMoney, orderLabel,
|
|
179
179
|
siteBase: () => siteBase(null),
|
|
180
|
+
/** Change only an extension's part of a product, under the order lock (lib/shop.js updateProductExt). */
|
|
181
|
+
updateProductExt,
|
|
182
|
+
/** Move an order on (Shopping Cart Pro fulfils an order with nothing to post), telling every extension. */
|
|
183
|
+
async moveOrder(id, to, {by = 'shop', note = ''} = {}) {
|
|
184
|
+
const {order, from} = await moveOrder(id, String(to), {by, note});
|
|
185
|
+
announceMove(order, from, order.data.status);
|
|
186
|
+
return order;
|
|
187
|
+
},
|
|
180
188
|
receiptUrl: (order) => liveSettings().then(s => `${siteBase(null)}${receiptPath(s, order)}`)
|
|
181
189
|
};
|
|
182
190
|
|
|
@@ -199,6 +207,7 @@ export default async function shoppingCart(fastify, options) {
|
|
|
199
207
|
|
|
200
208
|
fastify.get('/extensions', can('read'), async () => ({
|
|
201
209
|
tabs: registry.tabs(),
|
|
210
|
+
panels: registry.panels(),
|
|
202
211
|
extensions: registry.list().map(e => ({owner: e.owner, label: e.label}))
|
|
203
212
|
}));
|
|
204
213
|
|
|
@@ -237,7 +246,9 @@ export default async function shoppingCart(fastify, options) {
|
|
|
237
246
|
const patch = {};
|
|
238
247
|
for (const k of ['status', 'featured', 'stock', 'trackStock', 'position', 'variants']) if (b[k] !== undefined) patch[k] = b[k];
|
|
239
248
|
const s = await liveSettings();
|
|
240
|
-
|
|
249
|
+
// An extension's part of the product (ext) is left as it is: no quick edit touches it.
|
|
250
|
+
const {ext, ...data} = had.data;
|
|
251
|
+
const r = await saveProduct({...data, ...patch}, {id: had.id, currency: s.currency});
|
|
241
252
|
if (r.errors) return fail(reply, 400, Object.values(r.errors)[0], {errors: r.errors});
|
|
242
253
|
return r.entry;
|
|
243
254
|
});
|
|
@@ -247,8 +258,11 @@ export default async function shoppingCart(fastify, options) {
|
|
|
247
258
|
if (!had) return fail(reply, 404, 'That product is gone.');
|
|
248
259
|
const s = await liveSettings();
|
|
249
260
|
// A copy is the shop's own product, even of an extension's: listed, and nobody else's to change.
|
|
250
|
-
|
|
251
|
-
|
|
261
|
+
// Its options get new ids; extensions are told which old one became which (a file for one option).
|
|
262
|
+
const variantMap = {};
|
|
263
|
+
const variants = (had.data.variants || []).map(v => { variantMap[v.id] = Math.random().toString(36).slice(2, 10); return {...v, id: variantMap[v.id]}; });
|
|
264
|
+
const r = await saveProduct({...had.data, name: `${had.data.name} (copy)`, slug: '', status: 'draft', unlisted: false, owner: '', variants},
|
|
265
|
+
{currency: s.currency, by: request.user?.id, copy: true, variantMap});
|
|
252
266
|
if (r.errors) return fail(reply, 400, Object.values(r.errors)[0]);
|
|
253
267
|
return reply.code(201).send(r.entry);
|
|
254
268
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopping-cart",
|
|
3
3
|
"displayName": "Shopping Cart",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-
|
|
8
|
+
"date": "2026-10-12",
|
|
9
9
|
"icon": "shopping-bag",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
],
|
|
46
46
|
"views": {
|
|
47
47
|
"plugin-shopping-cart": {
|
|
48
|
-
"entry": "shopping-cart/admin/views/shop.js?v=1.0
|
|
48
|
+
"entry": "shopping-cart/admin/views/shop.js?v=1.2.0",
|
|
49
49
|
"exportName": "shopView"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -118,9 +118,10 @@ export default async function shopPublic(fastify) {
|
|
|
118
118
|
const confirm = cartRegistry().services?.confirmStripe;
|
|
119
119
|
if (confirm) entry = await confirm(entry, request.query?.session_id, request.log);
|
|
120
120
|
const order = publicOrder(entry);
|
|
121
|
+
const parts = await cartRegistry().receiptParts(entry, {settings: s});
|
|
121
122
|
return send(reply, request, s, {
|
|
122
123
|
page: 'order',
|
|
123
|
-
body: receiptHtml({order, settings: s, base: s.shopPath, problem: String(request.query?.problem || '')}),
|
|
124
|
+
body: receiptHtml({order, settings: s, base: s.shopPath, problem: String(request.query?.problem || ''), parts}),
|
|
124
125
|
title: `Order #${order.number}`,
|
|
125
126
|
urlPath: `${s.shopPath}/order/${id}`,
|
|
126
127
|
noindex: true,
|
|
@@ -300,6 +300,17 @@ body {
|
|
|
300
300
|
@keyframes shop-spin { to { transform: rotate(360deg); } }
|
|
301
301
|
.shop-r-note { border-radius: var(--shop-radius-sm); padding: 1rem 1.2rem; background: var(--shop-softer); border: 1px solid var(--shop-line); margin: 0 0 1.25rem; }
|
|
302
302
|
.shop-r-note p { margin: .35rem 0 0; }
|
|
303
|
+
.shop-r-ext { margin: 0 0 1.25rem; }
|
|
304
|
+
.shop-r-ext > :last-child { margin-bottom: 0; }
|
|
305
|
+
/* Files and keys on the receipt (Shopping Cart Pro's downloads). */
|
|
306
|
+
.shop-r-files { list-style: none; padding: 0; margin: .6rem 0 0; display: grid; gap: .6rem; }
|
|
307
|
+
.shop-r-files li { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
|
308
|
+
.shop-r-fname { flex: 1 1 12rem; font-weight: 600; }
|
|
309
|
+
.shop-r-fname small { display: block; color: var(--shop-muted); font-weight: 400; }
|
|
310
|
+
.shop-r-files code { padding: .15rem .45rem; border-radius: 6px; background: var(--shop-soft); user-select: all; word-break: break-all; }
|
|
311
|
+
.shop-r-get { display: inline-flex; align-items: center; gap: .35rem; border-radius: 999px; padding: .45rem 1.1rem; background: var(--shop-accent);
|
|
312
|
+
color: var(--shop-accent-ink); text-decoration: none; font-weight: 600; }
|
|
313
|
+
.shop-r-get:hover { filter: brightness(1.05); }
|
|
303
314
|
.shop-r-steps { list-style: none; padding: 0; margin: 0 0 2rem; display: grid; grid-template-columns: repeat(3, 1fr); counter-reset: s; }
|
|
304
315
|
.shop-r-steps li { position: relative; padding-top: 1.6rem; font-size: .88rem; color: var(--shop-muted); text-align: center; }
|
|
305
316
|
.shop-r-steps li span { position: absolute; top: .3rem; left: 50%; width: .9rem; height: .9rem; margin-left: -.45rem; border-radius: 50%;
|
|
@@ -400,6 +411,7 @@ html.shop-locked { overflow: hidden; }
|
|
|
400
411
|
.shop-page .shop-qty input, .shop-drawer .shop-qty input, .shop-search input {
|
|
401
412
|
background: transparent !important; border: 0 !important; box-shadow: none !important; outline: 0; padding-left: 0; padding-right: 0; margin: 0; min-height: 0; }
|
|
402
413
|
.shop-card-title a, .shop-card-title a:hover, .shop-line-name, .shop-crumbs a, .shop-quick { text-decoration: none !important; }
|
|
414
|
+
.shop-r-get, .shop-r-get:hover, .shop-r-get:visited { color: var(--shop-accent-ink) !important; text-decoration: none !important; }
|
|
403
415
|
.shop-card-title a:hover { color: var(--shop-accent); }
|
|
404
416
|
|
|
405
417
|
@media (max-width: 640px) {
|
|
@@ -188,3 +188,54 @@ test('an extension sells its own unlisted product through services.startCheckout
|
|
|
188
188
|
reg.unregister('test-credits');
|
|
189
189
|
}
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
test('an extension keeps its own part of a product, and moves its orders on', async () => {
|
|
193
|
+
const {cartRegistry} = await import('../lib/extensions.js');
|
|
194
|
+
const reg = cartRegistry();
|
|
195
|
+
const moves = [];
|
|
196
|
+
reg.register('test-dl', {
|
|
197
|
+
product: {clean: (input, {copy}) => (input?.file === 'x' ? {data: null, errors: {file: 'No such file.'}}
|
|
198
|
+
: {data: {file: String(input?.file || ''), keys: copy ? [] : [].concat(input?.keys || [])}})},
|
|
199
|
+
on: {status: (order, from, to) => moves.push(`${from}>${to}`)}
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB']});
|
|
203
|
+
let r = await call('POST', '/products', {name: 'E-book', price: 500, status: 'active', shippable: false, ext: {'test-dl': {file: 'f1', keys: ['K1']}}});
|
|
204
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
205
|
+
const book = r.body;
|
|
206
|
+
assert.deepEqual(book.data.ext, {'test-dl': {file: 'f1', keys: ['K1']}});
|
|
207
|
+
// Never on the storefront.
|
|
208
|
+
assert.ok(!('ext' in (await call('GET', '/catalogue')).body.products.find(p => p.id === book.id)));
|
|
209
|
+
// Refused by the extension: not saved, its message back.
|
|
210
|
+
r = await call('PUT', `/products/${book.id}`, {...book.data, ext: {'test-dl': {file: 'x'}}});
|
|
211
|
+
assert.equal(r.status, 400);
|
|
212
|
+
assert.equal(r.body.error, 'No such file.');
|
|
213
|
+
// A form that sends no ext (the section did not load) keeps it; quick edits keep it.
|
|
214
|
+
r = await call('PUT', `/products/${book.id}`, {...book.data, ext: undefined, name: 'The E-book'});
|
|
215
|
+
assert.deepEqual(r.body.data.ext, book.data.ext);
|
|
216
|
+
r = await call('PATCH', `/products/${book.id}`, {featured: true});
|
|
217
|
+
assert.deepEqual(r.body.data.ext, book.data.ext);
|
|
218
|
+
// A copy carries what the extension says (no licence keys).
|
|
219
|
+
r = await call('POST', `/products/${book.id}/duplicate`);
|
|
220
|
+
assert.deepEqual(r.body.data.ext, {'test-dl': {file: 'f1', keys: []}});
|
|
221
|
+
// updateProductExt changes only that part, from the product as it is now, with no form check.
|
|
222
|
+
await call('PATCH', `/products/${book.id}`, {stock: 7});
|
|
223
|
+
const up = await reg.services.updateProductExt(book.id, 'test-dl', (part) => ({...part, keys: part.keys.slice(1)}));
|
|
224
|
+
assert.deepEqual(up.data.ext['test-dl'].keys, []);
|
|
225
|
+
assert.equal(up.data.stock, 7, 'the rest of the product as it is now');
|
|
226
|
+
assert.equal(up.data.name, 'The E-book');
|
|
227
|
+
|
|
228
|
+
// services.moveOrder moves an order and tells every extension.
|
|
229
|
+
r = await call('POST', '/checkout', {lines: [{productId: book.id, qty: 1}], provider: 'offline', customer: {name: 'Ann', email: 'ann@example.com'}});
|
|
230
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
231
|
+
const id = r.body.orderId;
|
|
232
|
+
await call('PUT', `/orders/${id}/status`, {status: 'paid'});
|
|
233
|
+
const moved = await reg.services.moveOrder(id, 'fulfilled', {by: 'test-dl', note: 'Nothing to post'});
|
|
234
|
+
assert.equal(moved.data.status, 'fulfilled');
|
|
235
|
+
await new Promise(res => setTimeout(res, 20));
|
|
236
|
+
assert.ok(moves.includes('paid>fulfilled'));
|
|
237
|
+
await assert.rejects(reg.services.moveOrder(id, 'awaiting'), /cannot become/);
|
|
238
|
+
} finally {
|
|
239
|
+
reg.unregister('test-dl');
|
|
240
|
+
}
|
|
241
|
+
});
|
|
@@ -10,6 +10,7 @@ import {badge, summarise} from '../lib/summary.js';
|
|
|
10
10
|
import {cartRegistry} from '../lib/extensions.js';
|
|
11
11
|
import {matchShopPath} from '../plugin.public.js';
|
|
12
12
|
import {cleanPath, cleanSettings, MASK, maskSettings} from '../lib/shop.js';
|
|
13
|
+
import {receiptHtml} from '../lib/render.js';
|
|
13
14
|
import defaults from '../config.js';
|
|
14
15
|
|
|
15
16
|
test('money: minor units in, formatted out', () => {
|
|
@@ -126,6 +127,54 @@ test('registry: stages, tabs, and emit that never throws', async () => {
|
|
|
126
127
|
assert.equal(reg.list().length, 0);
|
|
127
128
|
});
|
|
128
129
|
|
|
130
|
+
test('registry: panels, a product\'s ext kept per extension, receipt parts', async () => {
|
|
131
|
+
const reg = cartRegistry();
|
|
132
|
+
reg.register('test-dl', {
|
|
133
|
+
panels: [{key: 'files', label: 'Downloads', module: '/plugins/test-dl/admin/files.js', on: 'product'},
|
|
134
|
+
{key: 'files', label: 'Downloads', module: '/plugins/test-dl/admin/order.js', on: 'order'},
|
|
135
|
+
{key: 'bad', module: 'https://evil/x.js', on: 'product'}, {key: 'where', module: '/plugins/test-dl/x.js', on: 'basket'}],
|
|
136
|
+
product: {clean: (input, {had, copy}) => {
|
|
137
|
+
if (input?.files === 'bad') return {data: had, errors: {files: 'Not a file.'}};
|
|
138
|
+
if (input?.files === 'none') return {data: null};
|
|
139
|
+
return {data: {files: [].concat(input?.files || []), keys: copy ? [] : (input?.keys || [])}};
|
|
140
|
+
}},
|
|
141
|
+
receipt: (order) => (order.data.status === 'paid' ? '<p>Your files</p>' : '')
|
|
142
|
+
});
|
|
143
|
+
reg.register('test-boom', {product: {clean: () => { throw new Error('nope'); }}, receipt: () => { throw new Error('nope'); }});
|
|
144
|
+
try {
|
|
145
|
+
assert.deepEqual(reg.panels().map(p => `${p.owner}:${p.on}`), ['test-dl:product', 'test-dl:order']);
|
|
146
|
+
|
|
147
|
+
const had = {'test-dl': {files: ['a'], keys: ['K1']}, 'test-boom': {x: 1}, 'gone-ext': {kept: true}};
|
|
148
|
+
// Nothing sent: everything stored stays.
|
|
149
|
+
assert.deepEqual((await reg.cleanProductExt(undefined, had)).ext, had);
|
|
150
|
+
// An extension's part is its own to check; a lapsed one's and a throwing one's stay as stored.
|
|
151
|
+
let r = await reg.cleanProductExt({'test-dl': {files: ['b']}, 'test-boom': {x: 2}, 'gone-ext': {kept: false}}, had);
|
|
152
|
+
assert.deepEqual(r.ext, {'test-dl': {files: ['b'], keys: []}, 'test-boom': {x: 1}, 'gone-ext': {kept: true}});
|
|
153
|
+
r = await reg.cleanProductExt({'test-dl': {files: 'bad'}}, had);
|
|
154
|
+
assert.deepEqual(r.errors, {'test-dl.files': 'Not a file.'});
|
|
155
|
+
r = await reg.cleanProductExt({'test-dl': {files: 'none'}}, had);
|
|
156
|
+
assert.ok(!('test-dl' in r.ext), 'null data removes the part');
|
|
157
|
+
// A copy starts empty and carries what each extension says.
|
|
158
|
+
r = await reg.cleanProductExt(had, had, {copy: true});
|
|
159
|
+
assert.deepEqual(r.ext, {'test-dl': {files: ['a'], keys: []}});
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(await reg.receiptParts({data: {status: 'paid'}}), [{owner: 'test-dl', html: '<p>Your files</p>'}]);
|
|
162
|
+
assert.deepEqual(await reg.receiptParts({data: {status: 'pending'}}), []);
|
|
163
|
+
} finally {
|
|
164
|
+
reg.unregister('test-dl');
|
|
165
|
+
reg.unregister('test-boom');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('receipt: extension parts sit above the items', () => {
|
|
170
|
+
const order = {id: 'o1', number: 1001, status: 'paid', currency: 'GBP', lines: [], subtotal: 0, adjustments: [], total: 0,
|
|
171
|
+
customer: {name: 'Ann'}, email: 'ann@x.test', address: null, delivery: 'none', placedAt: new Date().toISOString()};
|
|
172
|
+
const html = receiptHtml({order, settings: {}, base: '/shop', parts: [{owner: 'test-dl', html: '<p>Your files</p>'}]});
|
|
173
|
+
assert.match(html, /<div class="shop-r-ext" data-ext="test-dl"><p>Your files<\/p><\/div>/);
|
|
174
|
+
assert.ok(html.indexOf('shop-r-ext') < html.indexOf('shop-r-grid'));
|
|
175
|
+
assert.ok(!receiptHtml({order, settings: {}, base: '/shop'}).includes('shop-r-ext'));
|
|
176
|
+
});
|
|
177
|
+
|
|
129
178
|
test('stripe: form encoding, itemising, signature, paid check', () => {
|
|
130
179
|
assert.equal(stripeForm({a: {b: [{c: 1}]}, d: 'x', e: null}).toString(), 'a%5Bb%5D%5B0%5D%5Bc%5D=1&d=x');
|
|
131
180
|
const order = {id: 'o1', number: 1001, currency: 'GBP', total: 1395,
|