domma-cms 0.84.1 → 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 (50) 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 +49 -1
  7. package/plugins/shopping-cart/CLAUDE.md +93 -0
  8. package/plugins/shopping-cart/admin/css/index.css +224 -0
  9. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  10. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  11. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  12. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  13. package/plugins/shopping-cart/admin/views/products.js +547 -0
  14. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  15. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  16. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  17. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  18. package/plugins/shopping-cart/config.js +52 -0
  19. package/plugins/shopping-cart/inject/body.html +2 -0
  20. package/plugins/shopping-cart/lib/extensions.js +96 -0
  21. package/plugins/shopping-cart/lib/payments.js +281 -0
  22. package/plugins/shopping-cart/lib/pricing.js +232 -0
  23. package/plugins/shopping-cart/lib/render.js +282 -0
  24. package/plugins/shopping-cart/lib/samples.js +34 -0
  25. package/plugins/shopping-cart/lib/shop.js +436 -0
  26. package/plugins/shopping-cart/lib/summary.js +127 -0
  27. package/plugins/shopping-cart/plugin.js +559 -0
  28. package/plugins/shopping-cart/plugin.json +52 -0
  29. package/plugins/shopping-cart/plugin.public.js +144 -0
  30. package/plugins/shopping-cart/public/cart.js +715 -0
  31. package/plugins/shopping-cart/public/lib/money.js +105 -0
  32. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  33. package/plugins/shopping-cart/public/lib/products.js +235 -0
  34. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  35. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  36. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  37. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  38. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  39. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  40. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  41. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  42. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  43. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  44. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  45. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  46. package/plugins/shopping-cart/public/shop.css +415 -0
  47. package/plugins/shopping-cart/templates/shop.html +4 -0
  48. package/plugins/shopping-cart/tests/api.test.js +144 -0
  49. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  50. package/scripts/setup.js +1 -0
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Shop settings - the banner cog's slideover: Storefront, Delivery, Tax and
3
+ * Payment, each field explained in place. Secrets arrive masked and are never
4
+ * shown; leaving a mask as it is keeps the stored key. "Test" tries the keys
5
+ * typed (before saving) against Stripe or PayPal.
6
+ */
7
+
8
+ import {api, copy, esc, moneyIn, moneyOut, openPanel, setCurrency} from '../lib/kit.js';
9
+ import {CURRENCIES} from '/plugins/shopping-cart/public/lib/money.js';
10
+ import {COUNTRIES} from '/plugins/shopping-cart/public/lib/orders.js';
11
+
12
+ const SECTIONS = [
13
+ {key: 'store', label: 'Storefront', icon: 'shopping-bag'},
14
+ {key: 'delivery', label: 'Delivery', icon: 'truck'},
15
+ {key: 'tax', label: 'Tax', icon: 'percent'},
16
+ {key: 'payment', label: 'Payment', icon: 'credit-card'}
17
+ ];
18
+ const MONEY_KEYS = ['shippingFlat', 'freeShippingOver'];
19
+ const BOOL_KEYS = ['showSearch', 'showCategories', 'floatingCart', 'showStockLeft', 'allowCollection', 'pricesIncludeTax', 'taxShipping',
20
+ 'stripeEnabled', 'paypalEnabled', 'offlineEnabled'];
21
+
22
+ export async function openSettings(ctx, section = 'store') {
23
+ let info;
24
+ try { info = await api.settings.get(); } catch (err) { E.toast(`Settings did not load: ${err.message || err}`, {type: 'error'}); return; }
25
+ const s = info.settings;
26
+ const f = {};
27
+ for (const [k, v] of Object.entries(s)) f[k] = M.observable(MONEY_KEYS.includes(k) ? moneyIn(v) : Array.isArray(v) ? [...v] : v);
28
+ const tab = M.observable(SECTIONS.some(x => x.key === section) ? section : 'store');
29
+ const saving = M.observable(false);
30
+ const testing = M.observable('');
31
+ const snapshot = () => JSON.stringify(Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])));
32
+ const opened = snapshot();
33
+
34
+ const model = {
35
+ ...f, tab, saving, testing,
36
+ isStore: M.computed(() => tab.value === 'store'),
37
+ isDelivery: M.computed(() => tab.value === 'delivery'),
38
+ isTax: M.computed(() => tab.value === 'tax'),
39
+ isPayment: M.computed(() => tab.value === 'payment'),
40
+ stripeOff: M.computed(() => !f.stripeEnabled.value),
41
+ paypalOff: M.computed(() => !f.paypalEnabled.value),
42
+ offlineOff: M.computed(() => !f.offlineEnabled.value),
43
+ collectOff: M.computed(() => !f.allowCollection.value),
44
+ taxExample: M.computed(() => {
45
+ const r = Number(f.taxRate.value) || 0;
46
+ if (!r) return 'No tax is added or shown.';
47
+ return f.pricesIncludeTax.value
48
+ ? `A £120.00 price shows "Includes ${f.taxLabel.value || 'tax'} £${(120 * r / (100 + r)).toFixed(2)}" and costs £120.00.`
49
+ : `A £100.00 price adds ${f.taxLabel.value || 'tax'} of £${(100 * r / 100).toFixed(2)} at checkout.`;
50
+ }),
51
+ stripeMode: info.stripeMode ? `${info.stripeMode} mode` : '',
52
+ webhookUrl: info.webhookUrl
53
+ };
54
+
55
+ const check = (k, label, help = '') => `<label class="shp-check"><input type="checkbox" data-bool="${k}"> ${label}${help ? ` <span data-help="${esc(help)}"></span>` : ''}</label>`;
56
+ const html = `<div class="shp-form shp-settings">
57
+ <div class="shp-seg shp-settings-nav" role="tablist">
58
+ ${SECTIONS.map(x => `<button type="button" role="tab" data-tab="${x.key}" data-bind-class="tab.value === '${x.key}' && 'is-on'"><span data-icon="${x.icon}" data-icon-size="13"></span> ${x.label}</button>`).join('')}
59
+ </div>
60
+
61
+ <section data-bind-hidden="!isStore.value" class="shp-grid">
62
+ <div><label class="form-label">Shop name <span data-help="The heading on the shop's page, and the name shoppers see on the payment page."></span></label>
63
+ <input class="form-input" data-model="shopTitle.value" maxlength="80"></div>
64
+ <div><label class="form-label">Address <span data-help="Where the shop lives on your site: /shop gives /shop, /shop/cart and a page per product. Changing it takes effect at once; links people saved to the old address stop working."></span></label>
65
+ <input class="form-input" data-model="shopPath.value" maxlength="60"></div>
66
+ <div class="shp-wide"><label class="form-label">Introduction <span data-help="A line or two under the shop's name."></span></label>
67
+ <textarea class="form-input" rows="2" data-model="shopIntro.value" maxlength="1000"></textarea></div>
68
+ <div><label class="form-label">Currency <span data-help="Every price in the shop. Changing it does not convert the prices you have entered."></span></label>
69
+ <select class="form-select" data-model="currency.value">${Object.entries(CURRENCIES).map(([c, x]) => `<option value="${c}">${c} - ${esc(x.label)}</option>`).join('')}</select></div>
70
+ <div><label class="form-label">Products per row</label>
71
+ <select class="form-select" data-model="columns.value"><option value="2">2</option><option value="3">3</option><option value="4">4</option></select></div>
72
+ <div class="shp-wide shp-checks">
73
+ ${check('showCategories', 'Category filters', 'Chips above the products, one per category, when there are two or more.')}
74
+ ${check('showSearch', 'Search box', 'Shown once the shop has more than four products.')}
75
+ ${check('floatingCart', 'Basket button on every page', 'A floating basket button, bottom corner, on any page of the site once something is in the basket. Off: only on the shop’s own pages.')}
76
+ ${check('showStockLeft', 'Say "Only 3 left"', 'Shown when a product with counted stock is at or below the low-stock level.')}
77
+ </div>
78
+ <div><label class="form-label">Basket button side</label>
79
+ <select class="form-select" data-model="cartPosition.value"><option value="right">Right</option><option value="left">Left</option></select></div>
80
+ <div><label class="form-label">Low stock at <span data-help="At or below this, a product is flagged in the Overview and the Low stock filter."></span></label>
81
+ <input class="form-input" type="number" min="0" data-model="lowStockAt.value"></div>
82
+ <div class="shp-wide"><label class="form-label">Terms of sale page <span data-help="If set, shoppers tick 'I agree to the terms of sale' (linked here) before paying. /terms or a full https:// address."></span></label>
83
+ <input class="form-input" data-model="termsUrl.value" placeholder="/terms"></div>
84
+ </section>
85
+
86
+ <section data-bind-hidden="!isDelivery.value" class="shp-grid">
87
+ <div class="shp-wide"><label class="form-label">We deliver to <span data-help="Checkout offers only these countries. Products that need no delivery can be bought from anywhere."></span></label>
88
+ <div class="shp-countries" data-part="countries"></div>
89
+ <select class="form-select shp-add-country" data-part="add-country"><option value="">Add a country…</option>${Object.entries(COUNTRIES).map(([c, n]) => `<option value="${c}">${esc(n)}</option>`).join('')}</select></div>
90
+ <div><label class="form-label">Delivery charge <span data-help="One charge per order. Shopping Cart Pro adds zones and rates by weight or order value."></span></label>
91
+ <div class="shp-money"><span data-bind-text="currency.value"></span><input class="form-input" inputmode="decimal" data-model="shippingFlat.value" placeholder="0.00"></div></div>
92
+ <div><label class="form-label">Called</label><input class="form-input" data-model="shippingLabel.value" maxlength="60"></div>
93
+ <div><label class="form-label">Free over <span data-help="Orders at or above this (after any discount) go free. 0 = never. Shoppers see how far they are from it in the basket."></span></label>
94
+ <div class="shp-money"><span data-bind-text="currency.value"></span><input class="form-input" inputmode="decimal" data-model="freeShippingOver.value" placeholder="0.00"></div></div>
95
+ <div></div>
96
+ <div class="shp-wide">${check('allowCollection', 'Offer collection', 'Shoppers can choose to collect in person - no delivery charge, no address.')}</div>
97
+ <div class="shp-wide" data-bind-hidden="collectOff.value"><label class="form-label">Collection is called</label><input class="form-input" data-model="collectionLabel.value" maxlength="60"></div>
98
+ </section>
99
+
100
+ <section data-bind-hidden="!isTax.value" class="shp-grid">
101
+ <div><label class="form-label">Tax rate % <span data-help="One rate for every product. Shopping Cart Pro adds rates by country and tax class."></span></label>
102
+ <input class="form-input" type="number" min="0" max="100" step="0.001" data-model="taxRate.value"></div>
103
+ <div><label class="form-label">Called</label><input class="form-input" data-model="taxLabel.value" maxlength="30" placeholder="VAT"></div>
104
+ <div class="shp-wide shp-checks">
105
+ ${check('pricesIncludeTax', 'My prices include tax', 'On (usual in the UK and Europe): the price is what the shopper pays, and the receipt shows the tax inside it. Off (usual in the US): tax is added at checkout.')}
106
+ ${check('taxShipping', 'Tax the delivery charge too')}
107
+ </div>
108
+ <p class="shp-wide shp-note" data-bind-text="taxExample.value"></p>
109
+ </section>
110
+
111
+ <section data-bind-hidden="!isPayment.value" class="shp-pay">
112
+ <div class="shp-provider">
113
+ <div class="shp-provider-head">${check('stripeEnabled', '<strong>Stripe</strong> - cards, Apple Pay, Google Pay', 'Shoppers pay on Stripe’s own secure page; card details never touch this site. Needs a Stripe account.')}
114
+ <span class="shp-chip is-muted" data-bind-hidden="!stripeMode" data-bind-text="stripeMode"></span></div>
115
+ <div class="shp-grid" data-bind-hidden="stripeOff.value">
116
+ <div class="shp-wide"><label class="form-label">Secret key <span data-help="Stripe dashboard › Developers › API keys. Starts sk_test_ (for testing) or sk_live_. Kept on the server and never shown again."></span></label>
117
+ <input class="form-input" type="password" autocomplete="off" data-model="stripeSecretKey.value" placeholder="sk_live_…"></div>
118
+ <div class="shp-wide"><label class="form-label">Webhook <span data-help="Optional but wise: it confirms a payment even when the shopper closes the tab before coming back. In Stripe › Developers › Webhooks add this address for the event checkout.session.completed, then paste its signing secret below."></span></label>
119
+ <div class="shp-copy"><code data-bind-text="webhookUrl"></code><button type="button" class="btn btn-sm btn-ghost" data-on-click="copyHook"><span data-icon="copy" data-icon-size="13"></span></button></div></div>
120
+ <div class="shp-wide"><label class="form-label">Webhook signing secret</label>
121
+ <input class="form-input" type="password" autocomplete="off" data-model="stripeWebhookSecret.value" placeholder="whsec_…"></div>
122
+ <div class="shp-wide"><button type="button" class="btn btn-sm btn-outline" data-on-click="testStripe" data-bind-disabled="testing.value"><span data-icon="zap" data-icon-size="13"></span> Test the key</button></div>
123
+ </div>
124
+ </div>
125
+ <div class="shp-provider">
126
+ <div class="shp-provider-head">${check('paypalEnabled', '<strong>PayPal</strong>', 'Shoppers approve the payment on PayPal and come back; the money is taken as they return. Needs a PayPal Business account and a REST app.')}</div>
127
+ <div class="shp-grid" data-bind-hidden="paypalOff.value">
128
+ <div><label class="form-label">Mode <span data-help="Sandbox takes pretend money from PayPal's test accounts. Switch to Live with your live app's keys."></span></label>
129
+ <select class="form-select" data-model="paypalMode.value"><option value="sandbox">Sandbox (testing)</option><option value="live">Live</option></select></div>
130
+ <div></div>
131
+ <div class="shp-wide"><label class="form-label">Client ID <span data-help="developer.paypal.com › Apps & Credentials › your app."></span></label>
132
+ <input class="form-input" autocomplete="off" data-model="paypalClientId.value"></div>
133
+ <div class="shp-wide"><label class="form-label">Secret</label>
134
+ <input class="form-input" type="password" autocomplete="off" data-model="paypalSecret.value"></div>
135
+ <div class="shp-wide"><button type="button" class="btn btn-sm btn-outline" data-on-click="testPaypal" data-bind-disabled="testing.value"><span data-icon="zap" data-icon-size="13"></span> Test the keys</button></div>
136
+ </div>
137
+ </div>
138
+ <div class="shp-provider">
139
+ <div class="shp-provider-head">${check('offlineEnabled', '<strong>Pay offline</strong> - bank transfer, cash on collection', 'The order is placed as Awaiting payment and nothing is charged. Mark it paid when the money arrives - stock is taken then.')}</div>
140
+ <div class="shp-grid" data-bind-hidden="offlineOff.value">
141
+ <div class="shp-wide"><label class="form-label">Called</label><input class="form-input" data-model="offlineLabel.value" maxlength="60"></div>
142
+ <div class="shp-wide"><label class="form-label">What to tell the shopper <span data-help="Shown on their receipt with the order number and total. Your bank details, or when to collect and pay."></span></label>
143
+ <textarea class="form-input" rows="3" data-model="offlineInstructions.value" maxlength="2000"></textarea></div>
144
+ </div>
145
+ </div>
146
+ </section>
147
+
148
+ <div class="shp-actions">
149
+ <span class="shp-spacer"></span>
150
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
151
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="saving.value"><span data-icon="check" data-icon-size="13"></span> Save</button>
152
+ </div>
153
+ </div>`;
154
+
155
+ const renderCountries = (el) => {
156
+ const box = el.querySelector('[data-part="countries"]');
157
+ box.innerHTML = '';
158
+ for (const c of f.countries.peek()) {
159
+ const chip = document.createElement('span');
160
+ chip.className = 'shp-chip is-primary shp-country';
161
+ chip.textContent = COUNTRIES[c] || c;
162
+ const x = document.createElement('button');
163
+ x.type = 'button';
164
+ x.textContent = '×';
165
+ x.title = 'Remove';
166
+ x.dataset.drop = c;
167
+ chip.appendChild(x);
168
+ box.appendChild(chip);
169
+ }
170
+ };
171
+
172
+ let panel = null;
173
+ const typedSecrets = () => ({stripeSecretKey: f.stripeSecretKey.peek(), paypalClientId: f.paypalClientId.peek(),
174
+ paypalSecret: f.paypalSecret.peek(), paypalMode: f.paypalMode.peek()});
175
+ async function test(provider) {
176
+ testing.value = provider;
177
+ try {
178
+ const r = await api.settings.test(provider, typedSecrets());
179
+ E.toast(`${provider === 'stripe' ? 'Stripe' : 'PayPal'} answered - the key works (${r.mode} mode).`, {type: 'success'});
180
+ } catch (err) {
181
+ E.toast(err.message || String(err), {type: 'error', duration: 8000});
182
+ } finally {
183
+ testing.value = '';
184
+ }
185
+ }
186
+
187
+ panel = await openPanel({
188
+ title: 'Shop settings',
189
+ size: 'md',
190
+ html,
191
+ model,
192
+ dirty: () => snapshot() !== opened,
193
+ before: (el) => {
194
+ for (const box of el.querySelectorAll('[data-bool]')) {
195
+ box.checked = Boolean(f[box.dataset.bool].peek());
196
+ box.addEventListener('change', () => { f[box.dataset.bool].value = box.checked; });
197
+ }
198
+ renderCountries(el);
199
+ el.querySelector('[data-part="countries"]').addEventListener('click', (e) => {
200
+ const c = e.target.closest('[data-drop]')?.dataset.drop;
201
+ if (c) { f.countries.value = f.countries.peek().filter(x => x !== c); renderCountries(el); }
202
+ });
203
+ el.querySelector('[data-part="add-country"]').addEventListener('change', (e) => {
204
+ const c = e.target.value;
205
+ if (c && !f.countries.peek().includes(c)) { f.countries.value = [...f.countries.peek(), c]; renderCountries(el); }
206
+ e.target.value = '';
207
+ });
208
+ el.querySelector('.shp-settings-nav').addEventListener('click', (e) => {
209
+ const b = e.target.closest('[data-tab]');
210
+ if (b) tab.value = b.dataset.tab;
211
+ });
212
+ },
213
+ methods: {
214
+ cancel: () => panel?.close(),
215
+ copyHook: () => copy(info.webhookUrl, 'Webhook address copied'),
216
+ testStripe: () => test('stripe'),
217
+ testPaypal: () => test('paypal'),
218
+ async save() {
219
+ const out = {};
220
+ for (const [k, o] of Object.entries(f)) out[k] = o.peek();
221
+ for (const k of MONEY_KEYS) {
222
+ const m = moneyOut(out[k]);
223
+ if (Number.isNaN(m)) { E.toast('Check the amounts under Delivery.', {type: 'warning'}); tab.value = 'delivery'; return; }
224
+ out[k] = m ?? 0;
225
+ }
226
+ for (const k of BOOL_KEYS) out[k] = Boolean(out[k]);
227
+ out.columns = Number(out.columns);
228
+ out.taxRate = Number(out.taxRate) || 0;
229
+ out.lowStockAt = Number(out.lowStockAt) || 0;
230
+ saving.value = true;
231
+ try {
232
+ const r = await api.settings.save(out);
233
+ Object.assign(ctx.settings, r.settings);
234
+ setCurrency(r.settings.currency);
235
+ E.toast('Shop settings saved.', {type: 'success'});
236
+ const moved = r.settings.currency !== s.currency || r.settings.shopPath !== s.shopPath;
237
+ panel.close(true);
238
+ if (moved) location.reload();
239
+ } catch (err) {
240
+ E.toast(`Not saved: ${err.message || err}`, {type: 'error', duration: 8000});
241
+ } finally {
242
+ saving.value = false;
243
+ }
244
+ }
245
+ }
246
+ });
247
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The Shop - one Tool, one sidebar entry, its screens as tabs under the
3
+ * banner: Overview, Orders, Products, and any an extension adds (Shopping
4
+ * Cart Pro's Discounts, Shipping & tax, Emails, Insights).
5
+ *
6
+ * #/plugins/shopping-cart Overview
7
+ * #/plugins/shopping-cart/orders[/id] Orders (an id opens that order)
8
+ * #/plugins/shopping-cart/products[/id|new]
9
+ * #/plugins/shopping-cart/<ext-key> an extension's screen
10
+ *
11
+ * Path segments, not ?query: the admin router 404s a query on a fresh load.
12
+ * Settings are behind the banner's cog, in a slideover.
13
+ */
14
+
15
+ import {api, loadKit, setCurrency, store} from '../lib/kit.js';
16
+
17
+ const ROUTE = /^#\/plugins\/shopping-cart(\/|$)/;
18
+ const V = '1.0.0';
19
+ const TABS = [
20
+ {key: 'overview', label: 'Overview', icon: 'bar-chart', module: `/plugins/shopping-cart/admin/views/overview.js?v=${V}`},
21
+ {key: 'orders', label: 'Orders', icon: 'receipt', module: `/plugins/shopping-cart/admin/views/orders.js?v=${V}`},
22
+ {key: 'products', label: 'Products', icon: 'package', module: `/plugins/shopping-cart/admin/views/products.js?v=${V}`}
23
+ ];
24
+
25
+ let lifetime = null;
26
+
27
+ /** tab and id from the address. */
28
+ function where() {
29
+ const m = location.hash.match(/^#\/plugins\/shopping-cart(?:\/([a-z0-9-]+))?(?:\/([^/?#]+))?/);
30
+ return {tab: m?.[1] || 'overview', id: m?.[2] ? decodeURIComponent(m[2]) : ''};
31
+ }
32
+
33
+ export const shopView = {
34
+ templateUrl: '/plugins/shopping-cart/admin/templates/shop.html',
35
+
36
+ async onMount($container) {
37
+ const kit = await loadKit();
38
+ lifetime ??= kit.viewLifetime(ROUTE);
39
+ if (!ROUTE.test(location.hash)) return;
40
+ lifetime.reset();
41
+ const scope = (fn) => { lifetime.add(fn); return fn; };
42
+ const container = $container.get(0);
43
+ const root = container.querySelector('[data-shop-admin]') || container;
44
+ const mountedFor = location.hash;
45
+ const here = () => location.hash === mountedFor && root.isConnected;
46
+ const {tab, id} = where();
47
+
48
+ let settingsRes = null;
49
+ let ext = {tabs: []};
50
+ try {
51
+ [settingsRes, ext] = await Promise.all([api.settings.get(), api.extensions().catch(() => ({tabs: []}))]);
52
+ } catch (err) {
53
+ root.innerHTML = '';
54
+ const p = document.createElement('p');
55
+ p.className = 'shp-error';
56
+ p.textContent = `The Shop could not load: ${err.message || err}`;
57
+ root.appendChild(p);
58
+ return;
59
+ }
60
+ if (!here()) return;
61
+ const settings = settingsRes.settings;
62
+ setCurrency(settings.currency);
63
+
64
+ const tabs = [...TABS, ...(ext.tabs || [])];
65
+ const current = tabs.find(t => t.key === tab) || TABS[0];
66
+
67
+ // "Shop › Orders" in the banner.
68
+ const name = container.querySelector('.dm-plugin-banner-name');
69
+ if (name) {
70
+ name.textContent = '';
71
+ const a = document.createElement('span');
72
+ a.className = 'dm-plugin-banner-parent';
73
+ a.textContent = 'Shop';
74
+ const sep = document.createElement('span');
75
+ sep.className = 'dm-plugin-banner-sep';
76
+ sep.textContent = '›';
77
+ sep.setAttribute('aria-hidden', 'true');
78
+ name.append(a, sep, document.createTextNode(current.label));
79
+ }
80
+
81
+ // The tab row.
82
+ root.innerHTML = '';
83
+ const nav = document.createElement('nav');
84
+ nav.className = 'shp-nav';
85
+ nav.setAttribute('aria-label', 'Shop');
86
+ for (const t of tabs) {
87
+ const a = document.createElement('a');
88
+ a.href = t.key === 'overview' ? '#/plugins/shopping-cart' : `#/plugins/shopping-cart/${t.key}`;
89
+ const icon = document.createElement('span');
90
+ icon.setAttribute('data-icon', t.icon || 'puzzle');
91
+ icon.setAttribute('data-icon-size', '15');
92
+ a.append(icon, document.createTextNode(t.label));
93
+ if (t.owner) a.classList.add('is-ext');
94
+ if (t === current) { a.classList.add('is-on'); a.setAttribute('aria-current', 'page'); }
95
+ nav.appendChild(a);
96
+ }
97
+ const body = document.createElement('div');
98
+ body.className = 'shp-body';
99
+ root.append(nav, body);
100
+ I.scan(nav);
101
+
102
+ const ctx = {
103
+ kit, scope, here, root: body, container, settings, settingsInfo: settingsRes, id, extensions: ext,
104
+ go(t, i = '') { location.hash = `#/plugins/shopping-cart${t && t !== 'overview' ? `/${t}` : ''}${i ? `/${encodeURIComponent(i)}` : ''}`; },
105
+ async openSettings(section) {
106
+ const m = await import(`/plugins/shopping-cart/admin/views/settings.js?v=${V}`);
107
+ m.openSettings(ctx, section);
108
+ }
109
+ };
110
+
111
+ kit.addBannerAction($container, {id: 'shop-settings', icon: 'settings', title: 'Shop settings', onClick: () => ctx.openSettings()});
112
+ kit.addBannerAction($container, {id: 'shop-visit', icon: 'external-link', title: 'Open the shop in a new tab',
113
+ onClick: () => window.open(settings.shopPath, '_blank', 'noopener')});
114
+
115
+ try {
116
+ const mod = await import(current.module);
117
+ if (!here()) return;
118
+ const mount = mod.mount || mod.default?.mount;
119
+ const cleanup = await mount(body, ctx);
120
+ if (typeof cleanup === 'function') scope(cleanup);
121
+ } catch (err) {
122
+ body.innerHTML = '';
123
+ const p = document.createElement('p');
124
+ p.className = 'shp-error';
125
+ p.textContent = `This screen could not load: ${err.message || err}`;
126
+ body.appendChild(p);
127
+ }
128
+ store.set('shopping-cart.last-tab', current.key);
129
+ }
130
+ };
@@ -0,0 +1,20 @@
1
+ {
2
+ "slug": "shop-orders",
3
+ "title": "Shop Orders",
4
+ "description": "Orders placed in the Shop. Amounts are minor units (pence). Move and refund them in the Shop, never here: stock and payments follow the status.",
5
+ "plugin": "shopping-cart",
6
+ "fields": [
7
+ {"name": "number", "label": "Number", "type": "number"},
8
+ {"name": "status", "label": "Status", "type": "text"},
9
+ {"name": "total", "label": "Total (minor units)", "type": "number"},
10
+ {"name": "currency", "label": "Currency", "type": "text"},
11
+ {"name": "provider", "label": "Payment", "type": "text"},
12
+ {"name": "email", "label": "Email", "type": "text"}
13
+ ],
14
+ "api": {
15
+ "create": {"enabled": false, "access": "admin"},
16
+ "read": {"enabled": false, "access": "admin"},
17
+ "update": {"enabled": false, "access": "admin"},
18
+ "delete": {"enabled": false, "access": "admin"}
19
+ }
20
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "slug": "shop-products",
3
+ "title": "Shop Products",
4
+ "description": "Products on sale in the Shop. Amounts are minor units (pence). Edit them in the Shop, which checks every field.",
5
+ "plugin": "shopping-cart",
6
+ "fields": [
7
+ {"name": "name", "label": "Name", "type": "text", "required": true},
8
+ {"name": "slug", "label": "Address", "type": "text"},
9
+ {"name": "summary", "label": "Summary", "type": "text"},
10
+ {"name": "description", "label": "Description", "type": "textarea"},
11
+ {"name": "price", "label": "Price (minor units)", "type": "number"},
12
+ {"name": "compareAt", "label": "Was (minor units)", "type": "number"},
13
+ {"name": "sku", "label": "SKU", "type": "text"},
14
+ {"name": "category", "label": "Category", "type": "text"},
15
+ {"name": "status", "label": "Status", "type": "text"},
16
+ {"name": "stock", "label": "Stock", "type": "number"}
17
+ ],
18
+ "api": {
19
+ "create": {"enabled": false, "access": "admin"},
20
+ "read": {"enabled": false, "access": "admin"},
21
+ "update": {"enabled": false, "access": "admin"},
22
+ "delete": {"enabled": false, "access": "admin"}
23
+ }
24
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shopping Cart - default settings.
3
+ *
4
+ * Every one is editable from the cog in the Shop's banner, which writes to
5
+ * config/plugins.json under `shopping-cart.settings`. Amounts are MINOR units
6
+ * (pence): shippingFlat 395 is £3.95.
7
+ *
8
+ * Secrets (stripeSecretKey, stripeWebhookSecret, paypalSecret) are never sent
9
+ * to the browser - the settings route masks them, and saving a mask keeps the
10
+ * stored value.
11
+ */
12
+ export default {
13
+ // The storefront
14
+ shopTitle: 'Shop',
15
+ shopPath: '/shop',
16
+ shopIntro: '',
17
+ currency: 'GBP',
18
+ columns: 3,
19
+ showSearch: true,
20
+ showCategories: true,
21
+ floatingCart: true,
22
+ cartPosition: 'right',
23
+ lowStockAt: 3,
24
+ showStockLeft: true,
25
+ termsUrl: '',
26
+
27
+ // Delivery
28
+ countries: ['GB'],
29
+ shippingFlat: 0,
30
+ shippingLabel: 'Delivery',
31
+ freeShippingOver: 0,
32
+ allowCollection: false,
33
+ collectionLabel: 'Collect in person',
34
+
35
+ // Tax
36
+ taxRate: 0,
37
+ taxLabel: 'VAT',
38
+ pricesIncludeTax: true,
39
+ taxShipping: true,
40
+
41
+ // Payment
42
+ stripeEnabled: false,
43
+ stripeSecretKey: '',
44
+ stripeWebhookSecret: '',
45
+ paypalEnabled: false,
46
+ paypalClientId: '',
47
+ paypalSecret: '',
48
+ paypalMode: 'sandbox',
49
+ offlineEnabled: true,
50
+ offlineLabel: 'Pay by bank transfer',
51
+ offlineInstructions: 'Thank you! We will email you our bank details. Your order is sent as soon as payment arrives.'
52
+ };
@@ -0,0 +1,2 @@
1
+ <link rel="stylesheet" href="/plugins/shopping-cart/public/shop.css?v=1.0.0">
2
+ <script type="module" src="/plugins/shopping-cart/public/cart.js?v=1.0.0"></script>
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Cart's door for extensions - how Shopping Cart Pro (or anything else)
3
+ * adds to the free Cart without replacing it.
4
+ *
5
+ * It lives on `globalThis` under a registered symbol, so an extension reaches
6
+ * it WITHOUT importing a file from this plugin: plugins load in one process,
7
+ * in an order nobody promises, and a paid plugin must not break when this
8
+ * one's files move. An extension registers from its `onReady` hook, when every
9
+ * plugin has loaded:
10
+ *
11
+ * const cart = globalThis[Symbol.for('domma.shopping-cart')];
12
+ * if (cart?.version >= 1) cart.register('shopping-cart-pro', {
13
+ * label: 'Shopping Cart Pro',
14
+ * stages: {discount, shipping, tax}, // see lib/pricing.js
15
+ * on: {paid(order), status(order, from, to), checkout(order)},
16
+ * tabs: [{key, label, icon, module, help}], // admin screens in the Shop
17
+ * checkout: {codes: true} // storefront features to show
18
+ * });
19
+ *
20
+ * And uses `cart.services` (orders, products, settings, money) to read the
21
+ * Cart's data the way the Cart does. A licence that lapses means the extension
22
+ * never loads, never registers, and the Cart carries on alone.
23
+ *
24
+ * @module shopping-cart/lib/extensions
25
+ */
26
+
27
+ export const KEY = Symbol.for('domma.shopping-cart');
28
+ export const VERSION = 1;
29
+
30
+ const EVENTS = ['paid', 'status', 'checkout'];
31
+
32
+ /** The registry, made once per process. */
33
+ export function cartRegistry() {
34
+ if (globalThis[KEY]?.version === VERSION) return globalThis[KEY];
35
+ const extensions = new Map();
36
+ const reg = {
37
+ version: VERSION,
38
+ services: {},
39
+
40
+ register(owner, ext = {}) {
41
+ if (!/^[a-z0-9-]{2,60}$/.test(String(owner))) throw new Error('An extension needs its plugin name.');
42
+ extensions.set(owner, {
43
+ owner,
44
+ label: String(ext.label || owner),
45
+ stages: ext.stages || {},
46
+ on: ext.on || {},
47
+ tabs: Array.isArray(ext.tabs) ? ext.tabs : [],
48
+ checkout: ext.checkout || {},
49
+ order: Number(ext.order) || 0
50
+ });
51
+ },
52
+
53
+ unregister(owner) { extensions.delete(owner); },
54
+
55
+ list() { return [...extensions.values()].sort((a, b) => a.order - b.order); },
56
+
57
+ /** stage -> the handler that wins it (the last registered, by order). */
58
+ handlers() {
59
+ const out = {};
60
+ for (const ext of reg.list()) {
61
+ for (const [stage, fn] of Object.entries(ext.stages)) if (typeof fn === 'function') out[stage] = fn;
62
+ }
63
+ return out;
64
+ },
65
+
66
+ /** Storefront features the extensions ask for, merged: {codes: true, ...}. */
67
+ checkoutFeatures() {
68
+ return Object.assign({}, ...reg.list().map(e => e.checkout));
69
+ },
70
+
71
+ /** Admin screens, for the Shop's tab row. */
72
+ tabs() {
73
+ return reg.list().flatMap(e => e.tabs.map(t => ({
74
+ key: String(t.key), label: String(t.label || t.key), icon: String(t.icon || 'puzzle'),
75
+ module: String(t.module || ''), help: String(t.help || ''), owner: e.owner
76
+ }))).filter(t => /^[a-z0-9-]{1,40}$/.test(t.key) && t.module.startsWith('/plugins/'));
77
+ },
78
+
79
+ /**
80
+ * Tell every extension. Never throws and never waits: an email that
81
+ * fails must not fail the payment that triggered it.
82
+ */
83
+ emit(event, ...args) {
84
+ if (!EVENTS.includes(event)) return;
85
+ for (const ext of reg.list()) {
86
+ const fn = ext.on[event];
87
+ if (typeof fn !== 'function') continue;
88
+ Promise.resolve().then(() => fn(...args)).catch(err => {
89
+ console.warn(`[shopping-cart] ${ext.owner} failed on "${event}": ${err?.message || err}`);
90
+ });
91
+ }
92
+ }
93
+ };
94
+ globalThis[KEY] = reg;
95
+ return reg;
96
+ }