domma-cms 1.6.34 → 1.6.36

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 (106) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/lib/effects-builder.js +1 -1
  3. package/admin/js/lib/pages-arrange.js +1 -1
  4. package/admin/js/lib/scribe-composer.js +1 -1
  5. package/admin/js/lib/scribe-model.js +1 -1
  6. package/admin/js/lib/shortcode-preview.js +1 -1
  7. package/admin/js/lib/shortcode-registry.js +2 -2
  8. package/admin/js/templates/docs/usage-site-settings.html +2 -1
  9. package/admin/js/templates/page-editor.html +2 -1
  10. package/admin/js/templates/pages.html +1 -1
  11. package/admin/js/templates/theme.html +1 -1
  12. package/admin/js/views/pages.js +3 -3
  13. package/admin/js/views/theme.js +5 -5
  14. package/package.json +1 -1
  15. package/plugins/_lib/call/ice.js +5 -2
  16. package/plugins/_lib/call/rooms.js +9 -3
  17. package/plugins/_lib/call/routes.js +7 -4
  18. package/plugins/_lib/mail/defaults.js +12 -0
  19. package/plugins/_lib/mail/hostGuard.js +86 -0
  20. package/plugins/_lib/mail/readRoutes.js +20 -1
  21. package/plugins/_lib/outreach/audience.js +76 -0
  22. package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
  23. package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
  24. package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
  25. package/plugins/_lib/outreach/expiry.js +46 -0
  26. package/plugins/_lib/outreach/index.js +15 -0
  27. package/plugins/_lib/outreach/ledger.js +127 -0
  28. package/plugins/_lib/outreach/sending.js +121 -0
  29. package/plugins/_lib/outreach/suppression.js +62 -0
  30. package/plugins/_lib/outreach/tokens.js +35 -0
  31. package/plugins/_lib/outreach/unsubscribe.js +46 -0
  32. package/plugins/_lib/public/call/call.js +3 -1
  33. package/plugins/blog/CLAUDE.md +11 -1
  34. package/plugins/blog/docs/guide.md +1 -1
  35. package/plugins/blog/lib/page.js +32 -0
  36. package/plugins/blog/plugin.js +58 -16
  37. package/plugins/blog/plugin.json +2 -2
  38. package/plugins/blog/plugin.public.js +5 -4
  39. package/plugins/blog/tests/public.test.js +30 -1
  40. package/plugins/booking/CLAUDE.md +4 -1
  41. package/plugins/booking/docs/guide.md +1 -1
  42. package/plugins/booking/plugin.js +16 -21
  43. package/plugins/booking/plugin.json +2 -2
  44. package/plugins/booking/tests/api.test.js +26 -1
  45. package/plugins/feedback/CLAUDE.md +7 -2
  46. package/plugins/feedback/admin/views/feedback.js +1 -1
  47. package/plugins/feedback/lib/receiver.js +1 -1
  48. package/plugins/feedback/plugin.json +3 -3
  49. package/plugins/free-tier.lock.json +63 -51
  50. package/plugins/mail-reader/CLAUDE.md +6 -0
  51. package/plugins/mail-reader/docs/guide.md +1 -0
  52. package/plugins/mail-reader/plugin.json +2 -2
  53. package/plugins/shopping-cart/CLAUDE.md +62 -1
  54. package/plugins/shopping-cart/admin/css/index.css +16 -0
  55. package/plugins/shopping-cart/admin/lib/chart.js +64 -0
  56. package/plugins/shopping-cart/admin/lib/kit.js +15 -0
  57. package/plugins/shopping-cart/admin/views/orders.js +1 -0
  58. package/plugins/shopping-cart/admin/views/overview.js +4 -53
  59. package/plugins/shopping-cart/admin/views/sales.js +133 -0
  60. package/plugins/shopping-cart/admin/views/settings.js +3 -1
  61. package/plugins/shopping-cart/admin/views/shop.js +2 -1
  62. package/plugins/shopping-cart/config.js +1 -0
  63. package/plugins/shopping-cart/docs/guide.md +26 -1
  64. package/plugins/shopping-cart/inject/body.html +2 -2
  65. package/plugins/shopping-cart/lib/accounts.js +185 -0
  66. package/plugins/shopping-cart/lib/commission.js +90 -0
  67. package/plugins/shopping-cart/lib/extensions.js +12 -4
  68. package/plugins/shopping-cart/lib/payments.js +7 -4
  69. package/plugins/shopping-cart/lib/render.js +13 -1
  70. package/plugins/shopping-cart/lib/sales.js +193 -0
  71. package/plugins/shopping-cart/lib/shop.js +21 -3
  72. package/plugins/shopping-cart/plugin.js +180 -35
  73. package/plugins/shopping-cart/plugin.json +3 -3
  74. package/plugins/shopping-cart/plugin.public.js +17 -1
  75. package/plugins/shopping-cart/public/account.js +277 -0
  76. package/plugins/shopping-cart/public/cart.js +16 -0
  77. package/plugins/shopping-cart/public/lib/session.js +82 -0
  78. package/plugins/shopping-cart/public/shop.css +27 -0
  79. package/plugins/shopping-cart/roles/shop-customer.json +8 -0
  80. package/plugins/shopping-cart/tests/accounts.test.js +136 -0
  81. package/plugins/shopping-cart/tests/api.test.js +34 -0
  82. package/plugins/shopping-cart/tests/commission.test.js +90 -0
  83. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  84. package/plugins/shopping-cart/tests/sales.test.js +73 -0
  85. package/plugins/support/CLAUDE.md +6 -0
  86. package/plugins/support/admin/views/support.js +1 -1
  87. package/plugins/support/docs/guide.md +90 -0
  88. package/plugins/support/lib/receiver.js +1 -1
  89. package/plugins/support/plugin.json +3 -3
  90. package/public/css/site.css +1 -1
  91. package/public/js/effects.js +1 -1
  92. package/public/js/scribe-style.mjs +1 -1
  93. package/public/js/site.js +1 -1
  94. package/public/js/text-style.mjs +1 -1
  95. package/server/routes/api/analytics.js +32 -0
  96. package/server/routes/api/collections.js +148 -15
  97. package/server/routes/api/theme.js +12 -2
  98. package/server/routes/outreach-public.js +68 -0
  99. package/server/server.js +10 -1
  100. package/server/services/authChecks.js +8 -0
  101. package/server/services/collectionAccess.js +191 -0
  102. package/server/services/collections.js +8 -2
  103. package/server/services/markdown.js +7 -2
  104. package/server/services/outreachUnsubscribe.js +54 -0
  105. package/server/services/request-log.js +70 -0
  106. package/server/templates/page.html +3 -3
@@ -6,6 +6,8 @@
6
6
  * /shop/<slug> one product
7
7
  * /shop/cart the basket and checkout, one page
8
8
  * /shop/order/<id>?t= a shopper's receipt
9
+ * /shop/account a customer's account: sign in, create one, details, addresses, orders (settings.accounts)
10
+ * /shop/account/confirm?u=&t= /shop/account/reset?t= the links emailed to them
9
11
  *
10
12
  * Like the Blog, the pages are answered from ONE onRequest hook matching the
11
13
  * address as it is saved NOW, so moving the shop needs no restart. Every page
@@ -16,7 +18,7 @@ import {fileURLToPath} from 'url';
16
18
  import {renderBlogPage} from '../../server/services/renderer.js';
17
19
  import {parseMarkdown} from '../../server/services/markdown.js';
18
20
  import {byShelf, safeImage} from './public/lib/products.js';
19
- import {cartHtml, esc, productHtml, receiptHtml, scriptJson, storefrontHtml} from './lib/render.js';
21
+ import {accountHtml, cartHtml, esc, productHtml, receiptHtml, scriptJson, storefrontHtml} from './lib/render.js';
20
22
  import {allProducts, getOrder, liveSettings, publicOrder, tokenOk} from './lib/shop.js';
21
23
  import {cartRegistry} from './lib/extensions.js';
22
24
 
@@ -31,6 +33,8 @@ export function matchShopPath(pathname, base) {
31
33
  try { parts = pathname.slice(base.length + 1).split('/').map(decodeURIComponent); } catch { return null; }
32
34
  if (parts.length === 1 && parts[0] === '') return {name: 'slash', params: {}};
33
35
  if (parts.length === 1 && (parts[0] === 'cart' || parts[0] === 'checkout' || parts[0] === 'basket')) return {name: 'cart', params: {}};
36
+ // Before products: a product called "account" would otherwise take the page.
37
+ if (parts[0] === 'account' && parts.length <= 2 && ['', 'confirm', 'reset'].includes(parts[1] || '')) return {name: 'account', params: {view: parts[1] || 'account'}};
34
38
  if (parts.length === 2 && parts[0] === 'order' && /^[\w-]{8,64}$/.test(parts[1])) return {name: 'order', params: {id: parts[1]}};
35
39
  if (parts.length === 1 && /^[a-z0-9-]{1,80}$/.test(parts[0])) return {name: 'product', params: {slug: parts[0]}};
36
40
  return null;
@@ -112,6 +116,18 @@ export default async function shopPublic(fastify) {
112
116
  });
113
117
  },
114
118
 
119
+ async account(request, reply, s, {view}) {
120
+ if (!s.accounts) return null;
121
+ return send(reply, request, s, {
122
+ page: 'account',
123
+ body: accountHtml({settings: s, view}),
124
+ title: view === 'reset' ? 'Set a new password' : view === 'confirm' ? 'Confirm your account' : `Your account - ${s.shopTitle || 'Shop'}`,
125
+ urlPath: `${s.shopPath}/account`,
126
+ noindex: true,
127
+ extra: {account: {view}}
128
+ });
129
+ },
130
+
115
131
  async order(request, reply, s, {id}) {
116
132
  let entry = await getOrder(id);
117
133
  if (!entry || !tokenOk(entry, request.query?.t)) return null;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Customer accounts in the browser (loaded by cart.js on the account pages, and on a receipt that offers to make
3
+ * one): sign in, create an account, forgotten password; signed in - details, saved addresses, orders (with what
4
+ * extensions add beside each: Takeaway's Reorder), sign out, delete my account. The confirm and reset links
5
+ * emailed to customers land on /account/confirm and /account/reset.
6
+ *
7
+ * @module shopping-cart/public/account
8
+ */
9
+ import {formatMoney} from './lib/money.js';
10
+ import {COUNTRIES, STATUS_PUBLIC} from './lib/orders.js';
11
+ import {account, me, signIn, signOut, signedIn} from './lib/session.js';
12
+
13
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+ const el = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
15
+ const params = new URLSearchParams(location.search);
16
+ const config = (() => { try { return JSON.parse(document.getElementById('dm-shop-config')?.textContent || '{}'); } catch { return {}; } })();
17
+ const shopPath = config.shopPath || '/shop';
18
+ const dmy = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; };
19
+
20
+ /** A form's error line. */
21
+ const say = (box, msg, good = false) => { box.textContent = msg; box.hidden = !msg; box.classList.toggle('is-good', good); };
22
+ async function busy(btn, fn) {
23
+ const was = btn.textContent;
24
+ btn.disabled = true;
25
+ btn.textContent = 'One moment…';
26
+ try { return await fn(); } finally { btn.disabled = false; btn.textContent = was; }
27
+ }
28
+
29
+ // ---- Signed out: sign in, create, forgotten -----------------------------------------------------
30
+
31
+ function signedOutView(body, {email = '', notice = ''} = {}) {
32
+ body.innerHTML = '';
33
+ const view = el(`<div class="shop-acc">
34
+ ${notice ? `<p class="shop-acc-note is-good">${esc(notice)}</p>` : ''}
35
+ <div class="shop-seg" role="tablist"><button type="button" data-tab="in" class="is-on">Sign in</button><button type="button" data-tab="new">Create an account</button></div>
36
+ <form class="shop-acc-form" data-form="in" novalidate>
37
+ <label class="shop-field"><span>Email</span><input name="email" type="email" autocomplete="email" required></label>
38
+ <label class="shop-field"><span>Password</span><input name="password" type="password" autocomplete="current-password" required></label>
39
+ <p class="shop-co-error" data-err hidden></p>
40
+ <button type="submit" class="shop-drawer-go">Sign in</button>
41
+ <button type="button" class="shop-linkbtn" data-forgot>Forgotten your password?</button>
42
+ </form>
43
+ <form class="shop-acc-form" data-form="new" novalidate hidden>
44
+ <p class="shop-muted">Your details are filled in when you check out, and your orders are listed here. You can still check out as a guest.</p>
45
+ <label class="shop-field"><span>Name</span><input name="name" autocomplete="name" required maxlength="120"></label>
46
+ <label class="shop-field"><span>Email</span><input name="email" type="email" autocomplete="email" required></label>
47
+ <label class="shop-field"><span>Password <em>8 characters or more</em></span><input name="password" type="password" autocomplete="new-password" required minlength="8"></label>
48
+ <p class="shop-co-error" data-err hidden></p>
49
+ <button type="submit" class="shop-drawer-go">Create my account</button>
50
+ </form>
51
+ <form class="shop-acc-form" data-form="forgot" novalidate hidden>
52
+ <p class="shop-muted">We will email you a link to set a new password.</p>
53
+ <label class="shop-field"><span>Email</span><input name="email" type="email" autocomplete="email" required></label>
54
+ <p class="shop-co-error" data-err hidden></p>
55
+ <button type="submit" class="shop-drawer-go">Send the link</button>
56
+ <button type="button" class="shop-linkbtn" data-back>Back to sign in</button>
57
+ </form>
58
+ </div>`);
59
+ body.appendChild(view);
60
+ const show = (key) => {
61
+ for (const f of view.querySelectorAll('[data-form]')) f.hidden = f.dataset.form !== key;
62
+ for (const b of view.querySelectorAll('[data-tab]')) b.classList.toggle('is-on', b.dataset.tab === key);
63
+ view.querySelector(`[data-form="${key}"] input`)?.focus();
64
+ };
65
+ for (const f of view.querySelectorAll('input[name="email"]')) f.value = email;
66
+ view.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => show(b.dataset.tab)));
67
+ view.querySelector('[data-forgot]').addEventListener('click', () => { view.querySelector('[data-form="forgot"] [name="email"]').value = view.querySelector('[data-form="in"] [name="email"]').value; show('forgot'); });
68
+ view.querySelector('[data-back]').addEventListener('click', () => show('in'));
69
+
70
+ view.querySelector('[data-form="in"]').addEventListener('submit', async (e) => {
71
+ e.preventDefault();
72
+ const f = e.currentTarget;
73
+ const err = f.querySelector('[data-err]');
74
+ say(err, '');
75
+ try {
76
+ await busy(f.querySelector('button[type="submit"]'), () => signIn(f.email.value.trim(), f.password.value));
77
+ await render();
78
+ } catch (ex) {
79
+ say(err, ex.status === 401 || ex.status === 400 ? 'That email and password do not match an account - or the account is not confirmed yet.' : ex.message);
80
+ }
81
+ });
82
+ view.querySelector('[data-form="new"]').addEventListener('submit', async (e) => {
83
+ e.preventDefault();
84
+ const f = e.currentTarget;
85
+ const err = f.querySelector('[data-err]');
86
+ say(err, '');
87
+ try {
88
+ await busy(f.querySelector('button[type="submit"]'), () => account.register({name: f.name.value, email: f.email.value.trim(), password: f.password.value}));
89
+ f.innerHTML = `<p class="shop-acc-note is-good">Nearly there - we have emailed <strong>${esc(f.email.value.trim())}</strong> a link to confirm your account. It works for 48 hours.</p>`;
90
+ } catch (ex) { say(err, ex.message); }
91
+ });
92
+ view.querySelector('[data-form="forgot"]').addEventListener('submit', async (e) => {
93
+ e.preventDefault();
94
+ const f = e.currentTarget;
95
+ try {
96
+ await busy(f.querySelector('button[type="submit"]'), () => account.forgot(f.email.value.trim()));
97
+ f.innerHTML = '<p class="shop-acc-note is-good">If there is an account with that email, a link to set a new password is on its way.</p>';
98
+ } catch (ex) { say(f.querySelector('[data-err]'), ex.message); }
99
+ });
100
+ if (params.get('join') === '1') show('new');
101
+ }
102
+
103
+ // ---- Signed in: details, addresses, orders ------------------------------------------------------
104
+
105
+ const addressText = (a) => [a.line1, a.line2, a.city, a.postcode].filter(Boolean).join(', ');
106
+
107
+ function signedInView(body, me) {
108
+ let addresses = me.addresses.map(a => ({...a}));
109
+ body.innerHTML = '';
110
+ const countries = Object.entries(COUNTRIES).map(([c, n]) => `<option value="${c}">${esc(n)}</option>`).join('');
111
+ const view = el(`<div class="shop-acc is-in">
112
+ <p class="shop-muted">Signed in as <strong>${esc(me.email)}</strong> · <button type="button" class="shop-linkbtn" data-out>Sign out</button></p>
113
+ <section class="shop-acc-card"><h2>Your orders</h2><div data-orders></div></section>
114
+ <section class="shop-acc-card"><h2>Your details</h2>
115
+ <form data-details novalidate>
116
+ <label class="shop-field"><span>Name</span><input name="name" autocomplete="name" maxlength="120"></label>
117
+ <label class="shop-field"><span>Phone</span><input name="phone" type="tel" autocomplete="tel" maxlength="40"></label>
118
+ <p class="shop-co-error" data-err hidden></p>
119
+ <button type="submit" class="shop-pay-btn">Save</button>
120
+ </form>
121
+ </section>
122
+ <section class="shop-acc-card"><h2>Saved addresses</h2><ul class="shop-acc-addrs" data-addrs></ul>
123
+ <button type="button" class="shop-linkbtn" data-add>Add an address</button>
124
+ <form class="shop-acc-addr" data-addr-form hidden novalidate>
125
+ <input type="hidden" name="id">
126
+ <label class="shop-field"><span>Label <em>optional</em></span><input name="label" maxlength="40" placeholder="Home, Work…"></label>
127
+ <label class="shop-field"><span>Address</span><input name="line1" autocomplete="address-line1" maxlength="160"></label>
128
+ <label class="shop-field"><span class="sr-only">Address line 2</span><input name="line2" autocomplete="address-line2" maxlength="160" placeholder="Flat, building (optional)"></label>
129
+ <label class="shop-field"><span>Town or city</span><input name="city" autocomplete="address-level2" maxlength="100"></label>
130
+ <label class="shop-field"><span>Postcode</span><input name="postcode" autocomplete="postal-code" maxlength="20"></label>
131
+ <label class="shop-field"><span>Country</span><select name="country">${countries}</select></label>
132
+ <p class="shop-co-error" data-err hidden></p>
133
+ <button type="submit" class="shop-pay-btn">Save address</button> <button type="button" class="shop-linkbtn" data-cancel>Cancel</button>
134
+ </form>
135
+ </section>
136
+ <details class="shop-acc-card shop-acc-danger"><summary>Delete my account</summary>
137
+ <form data-delete novalidate><p class="shop-muted">Your sign-in and saved details go. Orders you have placed stay with the shop as a record of the sale.</p>
138
+ <label class="shop-field"><span>Your password, to confirm</span><input name="password" type="password" autocomplete="current-password"></label>
139
+ <p class="shop-co-error" data-err hidden></p>
140
+ <button type="submit" class="shop-linkbtn is-danger">Delete my account</button></form>
141
+ </details>
142
+ </div>`);
143
+ body.appendChild(view);
144
+ const details = view.querySelector('[data-details]');
145
+ details.name.value = me.name;
146
+ details.phone.value = me.phone;
147
+
148
+ // Orders, newest first, with what extensions add (Takeaway: Reorder).
149
+ view.querySelector('[data-orders]').innerHTML = me.orders.length ? `<ul class="shop-acc-orders">${me.orders.map(o => `<li>
150
+ <a href="${esc(o.receipt)}" class="shop-acc-order"><strong>#${esc(o.number)}</strong><span>${esc(dmy(o.placedAt))}</span><span>${esc(STATUS_PUBLIC[o.status] || o.status)}</span><span>${esc(formatMoney(o.total, o.currency))}</span></a>
151
+ <small>${esc(o.lines.slice(0, 4).join(', '))}${o.lines.length > 4 ? '…' : ''}</small>
152
+ ${o.parts.map(p => `<div class="shop-acc-ext" data-ext="${esc(p.owner)}">${p.html}</div>`).join('')}</li>`).join('')}</ul>`
153
+ : '<p class="shop-muted">No orders yet - everything you order with this email shows here.</p>';
154
+
155
+ const addrList = view.querySelector('[data-addrs]');
156
+ const addrForm = view.querySelector('[data-addr-form]');
157
+ const drawAddresses = () => {
158
+ addrList.innerHTML = addresses.length ? addresses.map(a => `<li data-id="${esc(a.id)}"><span>${a.label ? `<strong>${esc(a.label)}</strong> ` : ''}${esc(addressText(a))}</span>
159
+ <span><button type="button" class="shop-linkbtn" data-edit>Edit</button> · <button type="button" class="shop-linkbtn" data-remove>Remove</button></span></li>`).join('') : '<li class="shop-muted">None yet.</li>';
160
+ };
161
+ drawAddresses();
162
+ const saveAll = async (patch) => {
163
+ const r = await account.save(patch);
164
+ addresses = r.addresses.map(a => ({...a}));
165
+ drawAddresses();
166
+ return r;
167
+ };
168
+ const openAddr = (a = {}) => {
169
+ addrForm.hidden = false;
170
+ for (const k of ['id', 'label', 'line1', 'line2', 'city', 'postcode']) addrForm.elements[k].value = a[k] || '';
171
+ addrForm.country.value = a.country || 'GB';
172
+ addrForm.line1.focus();
173
+ };
174
+ view.querySelector('[data-add]').addEventListener('click', () => openAddr());
175
+ view.querySelector('[data-cancel]').addEventListener('click', () => { addrForm.hidden = true; });
176
+ addrList.addEventListener('click', async (e) => {
177
+ const id = e.target.closest('[data-id]')?.dataset.id;
178
+ if (!id) return;
179
+ if (e.target.closest('[data-edit]')) openAddr(addresses.find(a => a.id === id));
180
+ if (e.target.closest('[data-remove]')) await saveAll({addresses: addresses.filter(a => a.id !== id)}).catch(err => alert(err.message));
181
+ });
182
+ addrForm.addEventListener('submit', async (e) => {
183
+ e.preventDefault();
184
+ const a = Object.fromEntries(['id', 'label', 'line1', 'line2', 'city', 'postcode', 'country'].map(k => [k, addrForm.elements[k].value.trim()]));
185
+ const err = addrForm.querySelector('[data-err]');
186
+ if (!a.line1 || !a.city) return say(err, 'The first line of the address and the town.');
187
+ const next = a.id ? addresses.map(x => (x.id === a.id ? {...x, ...a} : x)) : [...addresses, a];
188
+ try { await saveAll({addresses: next}); addrForm.hidden = true; say(err, ''); } catch (ex) { say(err, ex.message); }
189
+ });
190
+
191
+ details.addEventListener('submit', async (e) => {
192
+ e.preventDefault();
193
+ const err = details.querySelector('[data-err]');
194
+ try { await busy(details.querySelector('button'), () => account.save({name: details.name.value, phone: details.phone.value})); say(err, 'Saved.', true); }
195
+ catch (ex) { say(err, ex.message); }
196
+ });
197
+ view.querySelector('[data-out]').addEventListener('click', async () => { await signOut(); render({notice: 'You are signed out.'}); });
198
+ view.querySelector('[data-delete]').addEventListener('submit', async (e) => {
199
+ e.preventDefault();
200
+ const f = e.currentTarget;
201
+ if (!confirm('Delete your account? This cannot be undone.')) return;
202
+ try { await account.remove(f.password.value); await signOut(); render({notice: 'Your account has been deleted.'}); }
203
+ catch (ex) { say(f.querySelector('[data-err]'), ex.message); }
204
+ });
205
+ }
206
+
207
+ // ---- The emailed links --------------------------------------------------------------------------
208
+
209
+ async function confirmView(body) {
210
+ try {
211
+ await account.confirm(params.get('u'), params.get('t'));
212
+ document.querySelector('.shop-account h1').textContent = 'Your account';
213
+ signedOutView(body, {notice: 'Thank you - your account is confirmed. Sign in to carry on.'});
214
+ } catch (err) {
215
+ document.querySelector('.shop-account h1').textContent = 'That link did not work';
216
+ body.innerHTML = `<p class="shop-co-error">${esc(err.message)}</p><p><a class="shop-linkbtn" href="${esc(shopPath)}/account">Sign in or create an account</a></p>`;
217
+ }
218
+ }
219
+
220
+ function resetView(body) {
221
+ body.innerHTML = '';
222
+ const f = el(`<form class="shop-acc-form" novalidate>
223
+ <label class="shop-field"><span>New password <em>8 characters or more</em></span><input name="password" type="password" autocomplete="new-password" minlength="8"></label>
224
+ <label class="shop-field"><span>Again</span><input name="again" type="password" autocomplete="new-password"></label>
225
+ <p class="shop-co-error" data-err hidden></p>
226
+ <button type="submit" class="shop-drawer-go">Set my password</button>
227
+ </form>`);
228
+ body.appendChild(f);
229
+ f.addEventListener('submit', async (e) => {
230
+ e.preventDefault();
231
+ const err = f.querySelector('[data-err]');
232
+ if (f.password.value.length < 8) return say(err, '8 characters or more, please.');
233
+ if (f.password.value !== f.again.value) return say(err, 'The two passwords are not the same.');
234
+ try {
235
+ await busy(f.querySelector('button'), () => account.reset(params.get('t') || '', f.password.value));
236
+ document.querySelector('.shop-account h1').textContent = 'Your account';
237
+ signedOutView(body, {notice: 'Your password is set - sign in with it now.'});
238
+ } catch (ex) { say(err, ex.status === 400 && /token/i.test(ex.message) ? 'That link has run out or was used already - ask for a new one.' : ex.message); }
239
+ });
240
+ }
241
+
242
+ // ---- The receipt: "save your details" -------------------------------------------------------------
243
+
244
+ function joinBox(box) {
245
+ if (signedIn()) return;
246
+ box.hidden = false;
247
+ box.innerHTML = `<h2>Save your details for next time</h2>
248
+ <p class="shop-muted">Create an account with <strong>${esc(box.dataset.email)}</strong> - your details are filled in next time, and this order is listed in it.</p>
249
+ <form novalidate><label class="shop-field"><span>Choose a password <em>8 characters or more</em></span><input name="password" type="password" autocomplete="new-password" minlength="8"></label>
250
+ <p class="shop-co-error" data-err hidden></p><button type="submit" class="shop-pay-btn">Create my account</button></form>`;
251
+ const f = box.querySelector('form');
252
+ f.addEventListener('submit', async (e) => {
253
+ e.preventDefault();
254
+ try {
255
+ await busy(f.querySelector('button'), () => account.register({name: box.dataset.name || box.dataset.email.split('@')[0], email: box.dataset.email, password: f.password.value}));
256
+ f.innerHTML = `<p class="shop-acc-note is-good">We have emailed you a link to confirm your account.</p>`;
257
+ } catch (ex) { say(f.querySelector('[data-err]'), ex.message); }
258
+ });
259
+ }
260
+
261
+ // ---- Start ---------------------------------------------------------------------------------
262
+
263
+ async function render({notice = ''} = {}) {
264
+ const body = document.querySelector('[data-acc-body]');
265
+ if (!body) return;
266
+ const view = document.querySelector('[data-shop-account]').dataset.shopAccount;
267
+ if (view === 'confirm') return confirmView(body);
268
+ if (view === 'reset') return resetView(body);
269
+ let mine = null;
270
+ try { mine = await me(); } catch { /* signed out below */ }
271
+ if (mine) signedInView(body, mine);
272
+ else signedOutView(body, {notice});
273
+ }
274
+
275
+ const join = document.querySelector('[data-shop-join]');
276
+ if (join) joinBox(join);
277
+ render();
@@ -486,6 +486,20 @@ function checkout() {
486
486
  };
487
487
  form.addEventListener('input', keep);
488
488
 
489
+ // A signed-in customer: their details, and their first saved address, where nothing is typed yet.
490
+ import('./lib/session.js').then(async ({me}) => {
491
+ const mine = await me().catch(() => null);
492
+ if (!mine) return;
493
+ document.querySelector('[data-acc-link]')?.remove();
494
+ const a = mine.addresses[0] || {};
495
+ const fill = {name: mine.name, email: mine.email, phone: mine.phone, line1: a.line1, line2: a.line2, city: a.city, region: a.region, postcode: a.postcode, country: a.country};
496
+ for (const [k, v] of Object.entries(fill)) {
497
+ const el = form.elements[k];
498
+ if (el && v && !el.value) el.value = v;
499
+ }
500
+ keep();
501
+ }).catch(() => {});
502
+
489
503
  // A reminder email links here with the order: fill the basket from it (another device, a cleared browser).
490
504
  const params = new URLSearchParams(location.search);
491
505
  if (params.get('restore') && params.get('t')) {
@@ -713,3 +727,5 @@ if (page === 'product') productPage();
713
727
  if (page === 'index') storefront();
714
728
  if (page === 'cart') checkout();
715
729
  if (page === 'order') receipt();
730
+ // Customer accounts: their own pages, and a receipt's "save your details" box.
731
+ if (page === 'account' || document.querySelector('[data-shop-join]')) import('./account.js').catch(() => {});
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A customer's sign-in, in the browser - shared by the Shop's pages and any extension's (Takeaway).
3
+ *
4
+ * Signing in is core's own `/api/auth/login`; the tokens it gives are kept in this browser (localStorage, one
5
+ * key for the whole site) and sent as a Bearer header to the Cart's account routes. A 401 refreshes once through
6
+ * `/api/auth/refresh`; a refresh that fails signs the customer out here.
7
+ *
8
+ * @module shopping-cart/public/lib/session
9
+ */
10
+
11
+ const KEY = 'dm-shop:account';
12
+ const ACCOUNT_API = '/api/plugins/shopping-cart/account';
13
+
14
+ const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
15
+ const write = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* private mode */ } };
16
+
17
+ /** {name, email} when this browser holds a sign-in, else null. */
18
+ export function signedIn() {
19
+ const s = read();
20
+ return s?.token ? {name: s.name || '', email: s.email || ''} : null;
21
+ }
22
+
23
+ async function json(res) {
24
+ const body = await res.json().catch(() => ({}));
25
+ if (!res.ok) throw Object.assign(new Error(body.error || body.message || `The server answered ${res.status}`), {status: res.status, body});
26
+ return body;
27
+ }
28
+
29
+ /**
30
+ * Sign in with core's login. A staff account with a sign-in check (a code) is sent to /admin.
31
+ * @returns {Promise<{name: string, email: string}>}
32
+ */
33
+ export async function signIn(email, password) {
34
+ const body = await json(await fetch('/api/auth/login', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({email, password})}));
35
+ if (!body.token) throw new Error('This account signs in at /admin.');
36
+ write({token: body.token, refreshToken: body.refreshToken, name: body.user?.name || '', email: body.user?.email || email});
37
+ return signedIn();
38
+ }
39
+
40
+ export async function signOut() {
41
+ const s = read();
42
+ write(null);
43
+ if (s?.refreshToken) await fetch('/api/auth/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({refreshToken: s.refreshToken})}).catch(() => {});
44
+ }
45
+
46
+ async function refresh() {
47
+ const s = read();
48
+ if (!s?.refreshToken) return false;
49
+ try {
50
+ const r = await fetch('/api/auth/refresh', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({refreshToken: s.refreshToken})});
51
+ if (!r.ok) { write(null); return false; }
52
+ const {token} = await r.json();
53
+ write({...s, token});
54
+ return true;
55
+ } catch { return false; }
56
+ }
57
+
58
+ /** A request as the signed-in customer; one refresh on a 401. */
59
+ export async function authed(url, {method = 'GET', body} = {}) {
60
+ const go = () => fetch(url, {method, headers: {...(body ? {'Content-Type': 'application/json'} : {}), Authorization: `Bearer ${read()?.token || ''}`},
61
+ body: body ? JSON.stringify(body) : undefined});
62
+ let res = await go();
63
+ if (res.status === 401 && await refresh()) res = await go();
64
+ if (res.status === 401) write(null);
65
+ return json(res);
66
+ }
67
+
68
+ /** The signed-in customer's account (details, addresses, orders), or null when nobody is signed in here. */
69
+ export async function me() {
70
+ if (!read()?.token) return null;
71
+ try { return await authed(ACCOUNT_API); } catch (err) { if (err.status === 401) return null; throw err; }
72
+ }
73
+
74
+ export const account = {
75
+ register: (d) => fetch(`${ACCOUNT_API}/register`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(d)}).then(json),
76
+ confirm: (u, t) => fetch(`${ACCOUNT_API}/confirm`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({u, t})}).then(json),
77
+ forgot: (email) => fetch(`${ACCOUNT_API}/forgot`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({email})}).then(json),
78
+ /** Core's own reset, with the token from the emailed link. */
79
+ reset: (token, password) => fetch('/api/auth/reset-password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({token, password})}).then(json),
80
+ save: (d) => authed(ACCOUNT_API, {method: 'PUT', body: d}),
81
+ remove: (password) => authed(ACCOUNT_API, {method: 'DELETE', body: {password}})
82
+ };
@@ -425,3 +425,30 @@ html.shop-locked { overflow: hidden; }
425
425
  .shop-line-controls { flex-wrap: wrap; }
426
426
  }
427
427
  .shop-co-title { font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: -.02em; margin: 0 0 1.25rem; }
428
+ /* Customer accounts */
429
+ .shop-account { max-width: 44rem; margin: 2rem auto; }
430
+ .shop-account h1 { margin: 0 0 1rem; }
431
+ .shop-acc { display: flex; flex-direction: column; gap: 1rem; }
432
+ .shop-acc-form { display: flex; flex-direction: column; gap: .75rem; max-width: 26rem; }
433
+ .shop-acc-note { padding: .75rem 1rem; border-radius: var(--shop-radius-sm); background: var(--shop-softer); border: 1px solid var(--shop-line); margin: 0; }
434
+ .shop-acc-note.is-good, .shop-co-error.is-good { color: var(--shop-good); }
435
+ .shop-seg { display: inline-flex; gap: .25rem; padding: .25rem; border-radius: 999px; background: var(--shop-soft); align-self: flex-start; }
436
+ .shop-seg button { border: 0; background: none; color: var(--shop-text); font: inherit; font-weight: 600; padding: .45rem 1rem; border-radius: 999px; cursor: pointer; }
437
+ .shop-seg button.is-on { background: var(--shop-bg); box-shadow: 0 1px 3px rgba(0, 0, 0, .15); }
438
+ .shop-acc-card { border: 1px solid var(--shop-line); border-radius: var(--shop-radius); padding: 1rem 1.2rem; }
439
+ .shop-acc-card h2 { font-size: 1.1rem; margin: 0 0 .75rem; }
440
+ .shop-acc-card form { display: flex; flex-direction: column; gap: .6rem; max-width: 26rem; }
441
+ .shop-acc-orders, .shop-acc-addrs { list-style: none; padding: 0; margin: 0 0 .5rem; display: flex; flex-direction: column; gap: .6rem; }
442
+ .shop-acc-orders li { border-bottom: 1px solid var(--shop-line); padding-bottom: .6rem; }
443
+ .shop-acc-order { display: grid; grid-template-columns: auto 1fr auto auto; gap: .75rem; color: var(--shop-text); text-decoration: none !important; }
444
+ .shop-acc-orders small { color: var(--shop-muted); }
445
+ .shop-acc-ext { margin-top: .35rem; }
446
+ .shop-acc-addrs li { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
447
+ .shop-acc-addr { margin-top: .75rem; }
448
+ .shop-acc-danger summary { cursor: pointer; color: var(--shop-muted); }
449
+ .shop-linkbtn.is-danger { color: var(--shop-sale); }
450
+ .shop-acc-link { font-size: .92rem; }
451
+ .shop-crumbs .shop-acc-link { float: right; }
452
+ .shop-r-join { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--shop-line); }
453
+ .shop-r-join h2 { font-size: 1rem; }
454
+ .shop-r-join form { display: flex; flex-direction: column; gap: .6rem; }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "shop-customer",
3
+ "label": "Shop Customer",
4
+ "level": 5,
5
+ "permissions": [],
6
+ "badgeClass": "badge-secondary",
7
+ "admin": {"home": "/my-profile", "allow": []}
8
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Customer accounts against the real user and collection services in the domma-cms checkout `make test` runs in.
3
+ * The test's authenticate reads `x-user` (a real user id) the way core's reads a token; mail is caught with
4
+ * setAccountMailer. Every user it makes, and its collections, are removed; config/plugins.json is restored.
5
+ */
6
+ import {test, before, after} from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import Fastify from 'fastify';
11
+ import shoppingCart from '../plugin.js';
12
+ import {setAccountMailer, cleanAddress, ordersFor} from '../lib/accounts.js';
13
+ import {deleteUser, getUserByEmail, getUserById} from '../../../server/services/users.js';
14
+ import {checkResetToken} from '../../../server/services/passwordReset.js';
15
+
16
+ const PLUGINS = path.resolve('config/plugins.json');
17
+ const DIRS = ['shop-products', 'shop-orders'].map(c => path.resolve('content/collections', c));
18
+ const EMAILS = ['acc-ann@example.test', 'acc-bo@example.test'];
19
+ let saved;
20
+ let app;
21
+ const mail = [];
22
+
23
+ async function cleanUsers() {
24
+ for (const e of EMAILS) { const u = await getUserByEmail(e); if (u) await deleteUser(u.id); }
25
+ }
26
+
27
+ before(async () => {
28
+ saved = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
29
+ for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
30
+ await cleanUsers();
31
+ setAccountMailer((m) => { mail.push(m); });
32
+ delete globalThis[Symbol.for('domma.shopping-cart')];
33
+ app = Fastify();
34
+ // x-user: that real user; x-anon: nobody (401); neither: the shop's admin, for the admin routes.
35
+ const authenticate = async (req, reply) => {
36
+ if (req.headers['x-anon']) return reply.code(401).send({error: 'Unauthorised'});
37
+ if (!req.headers['x-user']) { req.user = {id: 'admin', name: 'Sam', role: 'admin'}; return undefined; }
38
+ const u = await getUserById(String(req.headers['x-user']));
39
+ if (!u) return reply.code(401).send({error: 'Unauthorised'});
40
+ req.user = {id: u.id, name: u.name, email: u.email, role: u.role};
41
+ return undefined;
42
+ };
43
+ await app.register(shoppingCart, {auth: {authenticate, requirePermission: () => async () => {}}, settings: {}, hooks: {registerShortcode() {}}});
44
+ });
45
+
46
+ after(async () => {
47
+ setAccountMailer(null);
48
+ await app?.close();
49
+ await cleanUsers();
50
+ for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
51
+ if (saved === null) await fs.rm(PLUGINS, {force: true});
52
+ else await fs.writeFile(PLUGINS, saved);
53
+ });
54
+
55
+ const call = async (method, url, body, user = null) => {
56
+ const res = await app.inject({method, url, payload: body, headers: user === 'anon' ? {'x-anon': '1'} : user ? {'x-user': user} : {}});
57
+ let json = null;
58
+ try { json = res.json(); } catch { /* not JSON */ }
59
+ return {status: res.statusCode, body: json};
60
+ };
61
+ const linkIn = (m) => /https?:\/\/\S+/.exec(m.text)?.[0] || '';
62
+
63
+ test('accounts: off by default; create, confirm by email, details, addresses, orders by email, reset, delete', async () => {
64
+ assert.equal((await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'a-long-password'})).status, 404, 'off until switched on');
65
+ assert.equal((await call('POST', '/account/confirm', {u: '00000000-0000-4000-8000-000000000000', t: '0'.repeat(48)})).status, 404, 'confirm is off too');
66
+ const put = await call('PUT', '/settings', {accounts: true, offlineEnabled: true, countries: ['GB']});
67
+ assert.equal(put.status, 200, JSON.stringify(put.body));
68
+ assert.equal(put.body.settings?.accounts ?? put.body.accounts, true, JSON.stringify(put.body).slice(0, 300));
69
+
70
+ assert.equal((await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'short'})).status, 400);
71
+ assert.equal((await call('POST', '/account/register', {name: '', email: EMAILS[0], password: 'a-long-password'})).status, 400);
72
+ let r = await call('POST', '/account/register', {name: 'Ann Example', email: EMAILS[0], password: 'a-long-password'});
73
+ assert.equal(r.status, 200, JSON.stringify(r.body));
74
+ let user = await getUserByEmail(EMAILS[0]);
75
+ assert.deepEqual([user.isActive, user.role], [false, 'shop-customer'], 'inactive until confirmed');
76
+ const confirmLink = new URL(linkIn(mail.at(-1)));
77
+ assert.match(confirmLink.pathname, /\/shop\/account\/confirm$/);
78
+
79
+ // A second try with the same email says the same, and tells the holder - nothing new is made.
80
+ r = await call('POST', '/account/register', {name: 'Someone', email: EMAILS[0], password: 'another-password'});
81
+ assert.equal(r.status, 200);
82
+ assert.equal(mail.length, 1, 'still pending: no second email, no second account');
83
+
84
+ assert.equal((await call('POST', '/account/confirm', {u: user.id, t: '0'.repeat(48)})).status, 404);
85
+ assert.equal((await call('POST', '/account/confirm', {u: user.id})).status, 404, 'no token, no answer');
86
+ r = await call('POST', '/account/confirm', {u: confirmLink.searchParams.get('u'), t: confirmLink.searchParams.get('t')});
87
+ assert.deepEqual(r.body, {confirmed: true}, 'confirms, and says nothing about the account - not even its email');
88
+ user = await getUserById(user.id);
89
+ assert.equal(user.isActive, true);
90
+ assert.ok(!user.meta.shop.pending);
91
+ // The link is spent: a second click, or any guess at an active user's id, learns nothing (ids are public).
92
+ r = await call('POST', '/account/confirm', {u: confirmLink.searchParams.get('u'), t: confirmLink.searchParams.get('t')});
93
+ assert.equal(r.status, 404);
94
+ assert.equal(JSON.stringify(r.body).includes(EMAILS[0]), false);
95
+ r = await call('POST', '/account/confirm', {u: user.id, t: 'x'});
96
+ assert.equal(r.status, 404);
97
+ assert.equal(JSON.stringify(r.body).includes(EMAILS[0]), false);
98
+ r = await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'a-long-password'});
99
+ assert.match(mail.at(-1).subject, /already have an account/);
100
+
101
+ // Signed in: details, addresses (junk dropped), and the orders placed with this email - from before it existed too.
102
+ assert.equal((await call('GET', '/account', null, 'anon')).status, 401);
103
+ const product = (await call('POST', '/products', {name: 'Mug', price: 1000, status: 'active', shippable: false})).body;
104
+ const placed = await call('POST', '/checkout', {lines: [{productId: product.id, qty: 1}], provider: 'offline', customer: {name: 'Ann', email: EMAILS[0].toUpperCase()}});
105
+ assert.equal(placed.status, 200, JSON.stringify(placed.body));
106
+ await call('POST', '/checkout', {lines: [{productId: product.id, qty: 1}], provider: 'offline', customer: {name: 'Bo', email: EMAILS[1]}});
107
+ r = await call('GET', '/account', null, user.id);
108
+ assert.equal(r.status, 200, JSON.stringify(r.body));
109
+ assert.deepEqual([r.body.name, r.body.email, r.body.orders.length], ['Ann Example', EMAILS[0], 1], 'only her own orders');
110
+ assert.match(r.body.orders[0].receipt, /^\/shop\/order\/.+\?t=/);
111
+ r = await call('PUT', '/account', {phone: '07700 900000', addresses: [{label: 'Home', line1: '1 High St', city: 'York', postcode: 'yo1 7hh'}, {line1: '', city: 'x'}]}, user.id);
112
+ assert.deepEqual([r.body.phone, r.body.addresses.length, r.body.addresses[0].postcode], ['07700 900000', 1, 'YO1 7HH']);
113
+
114
+ // Forgotten password: core's reset token, the link to the shop's page; nobody else is told anything.
115
+ const sent = mail.length;
116
+ assert.deepEqual((await call('POST', '/account/forgot', {email: 'nobody@example.test'})).body, {ok: true});
117
+ assert.equal(mail.length, sent, 'no account, no email - and the same answer');
118
+ await call('POST', '/account/forgot', {email: EMAILS[0]});
119
+ const reset = new URL(linkIn(mail.at(-1)));
120
+ assert.match(reset.pathname, /\/shop\/account\/reset$/);
121
+ assert.equal((await checkResetToken(reset.searchParams.get('t')))?.id, user.id, 'core accepts the token');
122
+
123
+ // Delete: only with the password; the orders stay.
124
+ assert.equal((await call('DELETE', '/account', {password: 'wrong'}, user.id)).status, 403);
125
+ assert.equal((await call('DELETE', '/account', {password: 'a-long-password'}, user.id)).status, 200);
126
+ assert.equal(await getUserByEmail(EMAILS[0]), null);
127
+ assert.equal((await call('GET', '/orders')).body.filter(o => o.data.email === EMAILS[0]).length, 1);
128
+ });
129
+
130
+ test('accounts: pure helpers', () => {
131
+ assert.equal(cleanAddress({line1: '1 High St'}), null, 'a town too');
132
+ assert.equal(cleanAddress({line1: '1 High St', city: 'York', country: 'ZZ'}).country, 'GB');
133
+ const o = (email, status = 'paid', at = '2026-10-01') => ({data: {email, status}, meta: {createdAt: at}});
134
+ assert.deepEqual(ordersFor([o('a@x.test'), o('A@X.test', 'paid', '2026-10-05'), o('a@x.test', 'pending'), o('b@x.test')], 'a@x.test').map(x => x.meta.createdAt),
135
+ ['2026-10-05', '2026-10-01'], 'theirs, newest first, no unfinished checkouts');
136
+ });