domma-cms 1.6.34 → 1.6.35

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 (67) 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/outreach/audience.js +76 -0
  16. package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
  17. package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
  18. package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
  19. package/plugins/_lib/outreach/expiry.js +46 -0
  20. package/plugins/_lib/outreach/index.js +15 -0
  21. package/plugins/_lib/outreach/ledger.js +127 -0
  22. package/plugins/_lib/outreach/sending.js +121 -0
  23. package/plugins/_lib/outreach/suppression.js +62 -0
  24. package/plugins/_lib/outreach/tokens.js +35 -0
  25. package/plugins/_lib/outreach/unsubscribe.js +46 -0
  26. package/plugins/free-tier.lock.json +33 -22
  27. package/plugins/shopping-cart/CLAUDE.md +45 -0
  28. package/plugins/shopping-cart/admin/css/index.css +16 -0
  29. package/plugins/shopping-cart/admin/lib/chart.js +64 -0
  30. package/plugins/shopping-cart/admin/lib/kit.js +15 -0
  31. package/plugins/shopping-cart/admin/views/orders.js +1 -0
  32. package/plugins/shopping-cart/admin/views/overview.js +4 -53
  33. package/plugins/shopping-cart/admin/views/sales.js +133 -0
  34. package/plugins/shopping-cart/admin/views/settings.js +3 -1
  35. package/plugins/shopping-cart/admin/views/shop.js +2 -1
  36. package/plugins/shopping-cart/config.js +1 -0
  37. package/plugins/shopping-cart/docs/guide.md +25 -0
  38. package/plugins/shopping-cart/inject/body.html +2 -2
  39. package/plugins/shopping-cart/lib/accounts.js +180 -0
  40. package/plugins/shopping-cart/lib/commission.js +90 -0
  41. package/plugins/shopping-cart/lib/extensions.js +12 -4
  42. package/plugins/shopping-cart/lib/render.js +13 -1
  43. package/plugins/shopping-cart/lib/sales.js +193 -0
  44. package/plugins/shopping-cart/lib/shop.js +13 -0
  45. package/plugins/shopping-cart/plugin.js +110 -1
  46. package/plugins/shopping-cart/plugin.json +2 -2
  47. package/plugins/shopping-cart/plugin.public.js +17 -1
  48. package/plugins/shopping-cart/public/account.js +277 -0
  49. package/plugins/shopping-cart/public/cart.js +16 -0
  50. package/plugins/shopping-cart/public/lib/session.js +82 -0
  51. package/plugins/shopping-cart/public/shop.css +27 -0
  52. package/plugins/shopping-cart/roles/shop-customer.json +8 -0
  53. package/plugins/shopping-cart/tests/accounts.test.js +127 -0
  54. package/plugins/shopping-cart/tests/api.test.js +11 -0
  55. package/plugins/shopping-cart/tests/commission.test.js +90 -0
  56. package/plugins/shopping-cart/tests/sales.test.js +73 -0
  57. package/public/css/site.css +1 -1
  58. package/public/js/effects.js +1 -1
  59. package/public/js/scribe-style.mjs +1 -1
  60. package/public/js/site.js +1 -1
  61. package/public/js/text-style.mjs +1 -1
  62. package/server/routes/api/theme.js +12 -2
  63. package/server/routes/outreach-public.js +68 -0
  64. package/server/server.js +5 -0
  65. package/server/services/markdown.js +7 -2
  66. package/server/services/outreachUnsubscribe.js +54 -0
  67. package/server/templates/page.html +3 -3
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Outreach email rendering and batch sending. Lifted from Surveys 1.4
3
+ * (lib/sending.js), generalised: the per-recipient link and the button label
4
+ * are the caller's, and a suppression check can sit in front of every send.
5
+ *
6
+ * @module _lib/outreach/sending
7
+ */
8
+ import {normaliseEmail} from './suppression.js';
9
+
10
+ export function escapeHtml(s) {
11
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
12
+ }
13
+
14
+ /** Escape a value for use inside a double-quoted HTML attribute (e.g. href). */
15
+ export function escapeAttr(s) {
16
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
17
+ }
18
+
19
+ /** 'https://example.com' from a configured address, or '' when it is not one. */
20
+ function origin(value) {
21
+ const v = String(value ?? '').trim().replace(/\/+$/, '');
22
+ return /^https?:\/\/[^\s/?#]+$/i.test(v) ? v : '';
23
+ }
24
+
25
+ /**
26
+ * Where the links in outreach emails point: the Site URL setting
27
+ * (site.baseUrl, the address core builds password-reset, canonical and
28
+ * sitemap links on), else the old `site.url`, else the address this request
29
+ * came in on.
30
+ *
31
+ * @param {object} site - getConfig('site')
32
+ * @param {{protocol?: string, host?: string, headers?: object}} [req]
33
+ * @returns {string} origin, no trailing slash
34
+ */
35
+ export function outreachBaseUrl(site, req) {
36
+ const configured = origin(site?.baseUrl) || origin(site?.url);
37
+ if (configured) return configured;
38
+ const host = req?.host || req?.headers?.host || '';
39
+ return host ? `${req?.protocol || 'http'}://${host}` : '';
40
+ }
41
+
42
+ /**
43
+ * Render one message's subject + plain-text + HTML from a template and a link.
44
+ *
45
+ * The HTML version turns each {{link}} into a clickable button; all non-link
46
+ * copy is escaped so user-authored bodies can't inject markup. The plain-text
47
+ * version keeps the raw URL. An optional `footerHtml` (already-safe markup -
48
+ * an unsubscribe line) is appended inside the wrapper.
49
+ *
50
+ * @param {{subject?: string, body?: string}} template
51
+ * @param {{name?: string}} contact
52
+ * @param {string} link
53
+ * @param {{buttonLabel?: string, defaultSubject?: string, defaultBody?: string, footerHtml?: string, footerText?: string}} [opts]
54
+ */
55
+ export function renderInvite(template, contact, link, opts = {}) {
56
+ const {
57
+ buttonLabel = 'Open the link',
58
+ defaultSubject = 'A message for you',
59
+ defaultBody = "Hi {{name}},\n\n{{link}}",
60
+ footerHtml = '',
61
+ footerText = ''
62
+ } = opts;
63
+ const name = contact.name || 'there';
64
+ const subjectTpl = (template.subject && template.subject.trim()) || defaultSubject;
65
+ const bodyTpl = (template.body && template.body.trim()) || defaultBody;
66
+
67
+ const subject = subjectTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link);
68
+ const text = bodyTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link)
69
+ + (footerText ? `\n\n${footerText}` : '');
70
+
71
+ const button = `<p style="margin:24px 0;"><a href="${escapeAttr(link)}" `
72
+ + `style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;`
73
+ + `padding:12px 24px;border-radius:6px;font-weight:600;font-size:15px;">${escapeHtml(buttonLabel)}</a></p>`;
74
+
75
+ const bodyHtml = bodyTpl
76
+ .split('{{link}}')
77
+ .map((seg) => escapeHtml(seg.replaceAll('{{name}}', name)).replace(/\n/g, '<br>'))
78
+ .join(button);
79
+
80
+ const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;`
81
+ + `max-width:560px;margin:0 auto;padding:24px;color:#1f2937;line-height:1.6;font-size:15px;">`
82
+ + bodyHtml
83
+ + `<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">`
84
+ + `<p style="font-size:12px;color:#6b7280;">If the button doesn't work, paste this link into your browser:<br>`
85
+ + `<a href="${escapeAttr(link)}" style="color:#2563eb;">${escapeHtml(link)}</a></p>`
86
+ + (footerHtml || '')
87
+ + `</div>`;
88
+
89
+ return {subject, text, html};
90
+ }
91
+
92
+ /**
93
+ * Send to each recipient; never fails wholesale. A recipient on the
94
+ * suppression set is not sent to at all and comes back
95
+ * `{inviteId, ok: false, suppressed: true}` - the caller's ledger treats
96
+ * that like a bounce, which keeps them out of reminders.
97
+ *
98
+ * @param {Array<{invite: {id: string}, contact: {name?: string, email: string}, token: string}>} recipients
99
+ * @param {{subject?: string, body?: string}} template
100
+ * @param {object} deps {sendEmail, transport, from, fromName, linkFor(recipient), suppressed?: Set<string>, render?: opts for renderInvite}
101
+ * @returns {Promise<Array<{inviteId: string, ok: boolean, suppressed?: true}>>}
102
+ */
103
+ export async function sendBatch(recipients, template, deps) {
104
+ const {sendEmail, transport, from, fromName, linkFor, suppressed = null, render = {}} = deps;
105
+ const results = [];
106
+ for (const r of recipients) {
107
+ if (suppressed && suppressed.has(normaliseEmail(r.contact.email))) {
108
+ results.push({inviteId: r.invite.id, ok: false, suppressed: true});
109
+ continue;
110
+ }
111
+ const link = linkFor(r);
112
+ const msg = renderInvite(template, r.contact, link, render);
113
+ try {
114
+ await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text});
115
+ results.push({inviteId: r.invite.id, ok: true});
116
+ } catch {
117
+ results.push({inviteId: r.invite.id, ok: false});
118
+ }
119
+ }
120
+ return results;
121
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The shared suppression list: addresses that must not be emailed by any
3
+ * outreach Tool. One opt-out silences them all - marketing email without a
4
+ * working unsubscribe is not shippable (PECR), and a surveys opt-out should
5
+ * silence campaigns too. Rows live in the shared `outreach-unsubscribes`
6
+ * collection ({email, at, source}; schema in ./collections).
7
+ *
8
+ * Addresses are compared lower-cased and trimmed. Suppressed recipients are
9
+ * not sent to and are marked `bounced` in the ledger, which keeps them out
10
+ * of reminders for good.
11
+ *
12
+ * @module _lib/outreach/suppression
13
+ */
14
+
15
+ /** Canonical form for comparison and storage. */
16
+ export const normaliseEmail = (email) => String(email ?? '').trim().toLowerCase();
17
+
18
+ /**
19
+ * @param {{createEntry,listEntries,deleteEntry?}} col collections service
20
+ * @param {{slug?: string}} [opts]
21
+ */
22
+ export function createSuppression(col, {slug = 'outreach-unsubscribes'} = {}) {
23
+ async function rows() {
24
+ const {entries} = await col.listEntries(slug, {limit: 100000}).catch(() => ({entries: []}));
25
+ return entries;
26
+ }
27
+
28
+ /** Every suppressed address as a Set - take one per send batch. */
29
+ async function suppressedSet() {
30
+ return new Set((await rows()).map(e => normaliseEmail(e.data.email)).filter(Boolean));
31
+ }
32
+
33
+ async function isSuppressed(email) {
34
+ const want = normaliseEmail(email);
35
+ if (!want) return false;
36
+ return (await suppressedSet()).has(want);
37
+ }
38
+
39
+ /** Add an address (idempotent). `source` says which Tool took the opt-out. */
40
+ async function add(email, source = 'outreach') {
41
+ const want = normaliseEmail(email);
42
+ if (!want) return null;
43
+ const existing = (await rows()).find(e => normaliseEmail(e.data.email) === want);
44
+ if (existing) return existing;
45
+ return col.createEntry(slug, {email: want, at: new Date().toISOString(), source}, {source});
46
+ }
47
+
48
+ /** Take an address off the list (they asked to hear from us again). */
49
+ async function remove(email) {
50
+ if (typeof col.deleteEntry !== 'function') return false;
51
+ const want = normaliseEmail(email);
52
+ const hits = (await rows()).filter(e => normaliseEmail(e.data.email) === want);
53
+ for (const e of hits) await col.deleteEntry(slug, e.id).catch(() => {});
54
+ return hits.length > 0;
55
+ }
56
+
57
+ async function list() {
58
+ return (await rows()).map(e => ({id: e.id, email: e.data.email, at: e.data.at ?? null, source: e.data.source ?? ''}));
59
+ }
60
+
61
+ return {isSuppressed, suppressedSet, add, remove, list, slug};
62
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Outreach tokens: short unambiguous codes for per-recipient links. Only the
3
+ * SHA-256 hash is ever stored - the plaintext travels in the emailed URL and
4
+ * nowhere else. Lifted from Surveys 1.4 (lib/tokens.js) unchanged.
5
+ *
6
+ * @module _lib/outreach/tokens
7
+ */
8
+ import crypto from 'crypto';
9
+
10
+ const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no O,0,I,1,L
11
+ const LENGTH = 8;
12
+
13
+ /** Generate an 8-char unambiguous uppercase token. */
14
+ export function generateToken() {
15
+ const bytes = crypto.randomBytes(LENGTH);
16
+ let out = '';
17
+ for (let i = 0; i < LENGTH; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
18
+ return out;
19
+ }
20
+
21
+ /** SHA-256 hex of a normalised code. The plaintext is never stored. */
22
+ export function hashToken(code) {
23
+ return crypto.createHash('sha256').update(String(code)).digest('hex');
24
+ }
25
+
26
+ /** Display form: ABCD-EFGH. */
27
+ export function formatCode(token) {
28
+ const t = String(token).toUpperCase();
29
+ return t.length === 8 ? `${t.slice(0, 4)}-${t.slice(4)}` : t;
30
+ }
31
+
32
+ /** Canonical form for hashing/lookup: strip non-alphanumerics, uppercase. */
33
+ export function normaliseCode(input) {
34
+ return String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
35
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Unsubscribe links that keep working forever: an HMAC of the address under a
3
+ * per-site secret, not a ledger token (a re-send re-issues ledger tokens, and
4
+ * the unsubscribe link in an OLD email must still work - PECR). The secret is
5
+ * the engine's (server/services/outreachUnsubscribe.js) and so is the
6
+ * `/campaign-unsubscribe` route (server/routes/outreach-public.js), so a link
7
+ * keeps working after the Tool that sent it lapses or is removed.
8
+ *
9
+ * Campaigns Pro carries a byte-identical copy for engines older than this
10
+ * file - change the signature here and the links already sent stop verifying.
11
+ *
12
+ * @module _lib/outreach/unsubscribe
13
+ */
14
+ import crypto from 'crypto';
15
+ import {normaliseEmail} from './suppression.js';
16
+
17
+ /** Where every unsubscribe link points; links already sent carry this path. */
18
+ export const UNSUBSCRIBE_PATH = '/campaign-unsubscribe';
19
+
20
+ export function mintSecret() {
21
+ return crypto.randomBytes(24).toString('hex');
22
+ }
23
+
24
+ export function signEmail(secret, email) {
25
+ return crypto.createHmac('sha256', String(secret)).update(normaliseEmail(email)).digest('hex');
26
+ }
27
+
28
+ /** The address an emailed unsubscribe link points at. */
29
+ export function unsubscribeUrl(baseUrl, secret, email) {
30
+ const e = Buffer.from(normaliseEmail(email), 'utf8').toString('base64url');
31
+ return `${baseUrl}${UNSUBSCRIBE_PATH}?e=${e}&k=${signEmail(secret, email)}`;
32
+ }
33
+
34
+ /** The verified address from ?e=&k=, or null. Constant-time on the signature. */
35
+ export function verifyUnsubscribe(secret, e, k) {
36
+ let email = '';
37
+ try { email = Buffer.from(String(e ?? ''), 'base64url').toString('utf8'); } catch { return null; }
38
+ email = normaliseEmail(email);
39
+ if (!email || !k) return null;
40
+ const want = signEmail(secret, email);
41
+ const got = String(k);
42
+ if (got.length !== want.length) return null;
43
+ try {
44
+ return crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(want, 'hex')) ? email : null;
45
+ } catch { return null; }
46
+ }
@@ -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@bd6f228",
3
+ "source": "dcms-marketplace@1978d48",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.5",
@@ -175,36 +175,43 @@
175
175
  }
176
176
  },
177
177
  "shopping-cart": {
178
- "version": "1.3.0",
178
+ "version": "1.6.0",
179
179
  "files": {
180
- "CLAUDE.md": "2115832507c6c667e36bc1c8a70e1a2d05aee03e79bdbb8e509c8d533a4e2ab6",
181
- "admin/css/index.css": "2ea348d9f969193f198dac25b1962a400967566b835c7bf1402e17e33d81bfd2",
182
- "admin/lib/kit.js": "ebb439a31123b9c4fbda8a2c299cc79d1ec50e66d019b5cc49dcd00f269124d5",
180
+ "CLAUDE.md": "31785e2236ff0469d3653426e7b84b21ce8ab6ad5e310e043b5a7a74aa8c0bb3",
181
+ "admin/css/index.css": "dfe1ffc05b9161af3d8a74e82bd36c46991cb3b0409223b213eb8eca6fba9d55",
182
+ "admin/lib/chart.js": "b01ce5cfac773c8df8efccab50b67d3dfa99fbead0037d817553db4a6688d4d6",
183
+ "admin/lib/kit.js": "d9aa90469b3f589beaff0c3fb6ad3d983ab3fd6d94c06965be639a665bfe052f",
183
184
  "admin/templates/shop.html": "c5bb03f1f7cbabfbd8202f3533783eb1848103755b1ba25fdcf1fd8e9478cb8e",
184
- "admin/views/orders.js": "4cacf502ae754252cd5a68e53412121a4bae8e0c4265ebcf800d5d04b4b5211a",
185
- "admin/views/overview.js": "6abd9779cf5fc359c4708f96cf0e8ecad155fb52583b2ec854400b288fc9ba98",
185
+ "admin/views/orders.js": "97699e18f0fe4be8f3a811cd2fa7c07ad6139ac312e05b70ab5e80a21e40f09b",
186
+ "admin/views/overview.js": "97045c069c6560b3f67e1b2230bdc9c4133ed92ec6f732886470f886f11c755a",
186
187
  "admin/views/products.js": "e109660355aaec8db07eb65fa1169e7d2ec20bc2ec45293cc20e34f7c1bd62bf",
187
- "admin/views/settings.js": "28cb0b5e8d45e3723ffe77e46299bfc13fbad02a2ae8103843c6894dc232387a",
188
- "admin/views/shop.js": "c36f63edf4c581cc740e62f2611872b63efc9faffd4f13491de5745efec0acd3",
188
+ "admin/views/sales.js": "da71b9fbb7d39e39ecd8be35b1187e88bc4b5b28f18baaeddc6ea4cd202bb330",
189
+ "admin/views/settings.js": "54d4b52409d0bdd1eb4c6e4d86c07a285914659eb6b7053183f77e2fb4b9feaa",
190
+ "admin/views/shop.js": "83d1cb3b053ff16768959c6fd243a9428b274a3f7cdfd98929a8adc51a71d03e",
189
191
  "collections/shop-orders.json": "0bca8eef2d5f9108651f73d273480265c167dc983aa115ce59613d13015ca255",
190
192
  "collections/shop-products.json": "4d4613ecec7b8a5a0744c4f0a57b17a4429b461e060206a4bbdea5ad7c8c75dc",
191
- "config.js": "dcb91db2839c1ab9e40bb1811397024fad9e37e3753d17e2baa2b9b31d4a7d7c",
192
- "docs/guide.md": "4b4fbf3d61d73114f3b1b42ba48c72debb720e2c65bcd17df2feaa372463882f",
193
- "inject/body.html": "9deda0324d60ef1e62f653513748faa4c6ac546493906c8bd195e445a55f7476",
194
- "lib/extensions.js": "180820bcfb8ada9acfb260d0cc0a1c8cc4f37f9ab969400f4b6e8bf2d5babb3f",
193
+ "config.js": "31b336560d21a802d799ed487d5c3116de898446ba3521068b10d192f4ebbb2d",
194
+ "docs/guide.md": "7d5e48e2a9820898017debeb7b864c8ff1d91094725be49ece65b0460608857a",
195
+ "inject/body.html": "7e7e5b408f47abb8650e0a7d5e277daf8a634f4c6877c5259f837b1bf3d6c8e8",
196
+ "lib/accounts.js": "d61e83e09c69993270ccca8da8df71e779c72ec7d17947b457aadcfe6f57dd4c",
197
+ "lib/commission.js": "5b10475d98cff2aaf4c76b9950f8d698d6262e36754a8963669690a8e3e9bd52",
198
+ "lib/extensions.js": "73d3515b52c066e3e616c425d91a7a48a6aab153e7d14ae7278b38ee32649352",
195
199
  "lib/payments.js": "84ad14813688de1f38448b4935180a786cda8af2f7f4b8890a8f1d426982d1aa",
196
200
  "lib/pricing.js": "5644f2dd55fc3cfd7f40b0169dea5fb547e8b302239b8c17f23cfa177e42eab5",
197
- "lib/render.js": "530446d75a9c99a684fe593e2acdaf9914e7e54056728333d6b84fdb60ffd7b2",
201
+ "lib/render.js": "2a2b9ab4a24728186e1e45e6429c7406a6d5ca3d4b43db1758bbd54405d105b7",
202
+ "lib/sales.js": "16804f0ba4e61e8a2887a7c4172da37533850a1cd975a177b754f3350268e946",
198
203
  "lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
199
- "lib/shop.js": "734943e332f061446a0c5372179447c3bd25a105e7077b26e80e1f5ca16a62e6",
204
+ "lib/shop.js": "116b0a2cc1513895ce67d2aa6fa216a16e90cafab67394ed85ef0f34e88c50e3",
200
205
  "lib/summary.js": "cdb13ce3d1799d429059fa03f179281711dc00427ccf2eb6b8f8aa52fef26295",
201
- "plugin.js": "976a8756ce3d3bb89172d2d13f2b72ddffc101b3806c032ebcc3d5177a588f20",
202
- "plugin.json": "b212951c39f978c0b3cc7378ac04d15717852c7c40cea9cc57e42fb40017cbd5",
203
- "plugin.public.js": "99ef559dc8c3bd369047091623bbad29a1f6067322f07ac0db547938d97fe014",
204
- "public/cart.js": "fcc706773a36e4bfd02ea235bcdad629137e996218e0a0e23892e33c689f2a6e",
206
+ "plugin.js": "d210a27a96251f3a5f358d9184d194e807ceb1da55f5a505f7b967aa003f29c0",
207
+ "plugin.json": "fff190cc0dface77302191f4bee512192556cbb64e7e8f940db90d37f87ccef4",
208
+ "plugin.public.js": "2f92e8340c9da653401c11dd99dab033dc6772d63fa2114489192c48e9e0be00",
209
+ "public/account.js": "f1a497b70c230eee464781926e36c30936820abcdebf389982eb1d8646a51cb0",
210
+ "public/cart.js": "7338141392ec653ff616eb75688d2a2ad08cff8427d7020d381f540be0037b73",
205
211
  "public/lib/money.js": "d22b2b6f80dde6f7887b69e24188c05ea93c02217aba639b5331aa3bd616f4fc",
206
212
  "public/lib/orders.js": "09478e20d238adc8e387b3d9efcb13c441898fab18387ce6a1a8a92304ac4c5b",
207
213
  "public/lib/products.js": "65a033b4b03c1b6d9cd7035e6be4c3fe47c7ad309dc14c4d417b6ac0fc10e2f4",
214
+ "public/lib/session.js": "4f71e98818a52cf2321c8dc57e33e5591deb04ebb2c0bea19dd6758827719a08",
208
215
  "public/samples/candle-2.svg": "aca5691b9168467caf6feb7622ab154ee92504be8b887ac6c818db22476e7bed",
209
216
  "public/samples/candle.svg": "ef75e95a53ce13e88928004c6f1290c45a1094765e2ba741ae1fc55d404b881f",
210
217
  "public/samples/mug-2.svg": "5f37d23e90b7e79e42d38009a56b3ea26adae03586830a0cd418565491bd4532",
@@ -217,10 +224,14 @@
217
224
  "public/samples/tee.svg": "287a82d7fc00acb1543b3209927ae20dcbe3d4b2a2b3de03574ab0c463385f3c",
218
225
  "public/samples/tote-2.svg": "9c7f41cc57866e71973e413a6c87f46be48f7488d3490ceea58e24559c334813",
219
226
  "public/samples/tote.svg": "01de817a32c8d3d8ea47ff491049f439ca3e4d4fce943dbdc62a4187139e016c",
220
- "public/shop.css": "c5adbc7b08cff3dc3b1c2b796ba693ad52c586ef35d236d0bba9a0cb7c3673c1",
227
+ "public/shop.css": "c01603589727c91ff4204222e594b3f52e3ca165c1ebf9e6cef6c9f006f6e57b",
228
+ "roles/shop-customer.json": "3f3366c536c9fbf2b2ce146bb3090b008d5fbfe22752e764eb8e5a1de9cd611c",
221
229
  "templates/shop.html": "4649e57d23458833a157799dc80141824eb0f658d96734bd1859c68e0ba9a8b9",
222
- "tests/api.test.js": "187bd5268797b2f4e5ef93da8de99e4862f6ee86005c889f48a817b406c385ef",
223
- "tests/pure.test.js": "7fd6ad3b1955dc2c3d9a9ef175b458e2150f63cdc526c1bc0c1acba83c4e01e2"
230
+ "tests/accounts.test.js": "99566264ab5be8f8602bd7f7777e89c12d07563372843845ca9a11cc15ce061e",
231
+ "tests/api.test.js": "cb929e08be430064ceaa72a3a97b00ef5bce0f6af7eb5ed10ac905bf8ed05c21",
232
+ "tests/commission.test.js": "1dfb26a90d3dfcf9f57f2174ac5d9b2310570b21f82c79bf9a2011e7f4911e85",
233
+ "tests/pure.test.js": "7fd6ad3b1955dc2c3d9a9ef175b458e2150f63cdc526c1bc0c1acba83c4e01e2",
234
+ "tests/sales.test.js": "9025bc608b04bcc14f5bb2c98b4f7c35de99f3ddab4ffa59772d045795bf2cb7"
224
235
  }
225
236
  },
226
237
  "support": {
@@ -156,3 +156,48 @@ these, behaves as 1.1.
156
156
  offline instructions; on the order and in `publicOrder`.
157
157
  - **`services.refund(id, {note, restock, viaProvider})`** - the admin's refund (through Stripe / PayPal first),
158
158
  `refundOrder()` in plugin.js, shared by `POST /orders/:id/refund`.
159
+
160
+ ## 1.4.0: customer accounts (optional; plan dcms-marketplace/docs/customer-accounts-plan.md)
161
+
162
+ `settings.accounts` (off by default). `lib/accounts.js`, routes `/account/*`, pages `<shop>/account`, `/account/confirm`,
163
+ `/account/reset` (plugin.public.js -> `accountHtml`; `public/account.js` does the work), browser session in
164
+ `public/lib/session.js` (shared with extensions - Takeaway imports it).
165
+ - A customer = core user, role `shop-customer` (roles/shop-customer.json: no permissions, admin confined to My Profile),
166
+ created INACTIVE with `meta.shop.pending` {hash, until (48 h)}; the emailed link activates it. Register answers the
167
+ same for a taken email (the holder is emailed instead) - no account discovery. A pending account that ran out is
168
+ replaced. A confirmation email that fails removes the account again (502). Needs SMTP (`mailReady`).
169
+ - Signing in, refresh, logout and the new password are CORE's routes (`/api/auth/login|refresh|logout|reset-password`);
170
+ the Cart issues core's reset token (`issueResetToken`) and mails a link to `<shop>/account/reset`, never /admin.
171
+ Tokens live in localStorage `dm-shop:account`; account routes take core's Bearer (`authenticate`).
172
+ - An account's orders = orders with its (confirmed) email, any time - nothing linked. `meta.shop` keeps phone and up
173
+ to 8 addresses. Delete my account: customers only (staff 409), with the password; orders stay.
174
+ - Checkout fills in a signed-in customer (details + first saved address); the receipt offers "Save your details"
175
+ (`[data-shop-join]`); the storefront and basket link to the account page.
176
+ - Extension hook `account(order, {settings})` -> HTML beside the order on the account page (`registry.accountParts`;
177
+ `receiptParts` and it share `registry.parts(kind)`). Takeaway: Reorder.
178
+ - Trap fixed on the way: `if (await fail(reply, …))` - awaiting a Fastify reply resolves EMPTY once sent, so the route
179
+ ran on after its 404. `accountsOff` returns a boolean and sends the reply on the side.
180
+
181
+ ## 1.5.0: Sales (plan dcms-marketplace/docs/sales-commission-tenancy-plan.md, part 1)
182
+
183
+ A **Sales** tab (admin/views/sales.js): ranges (today, this week, last 7 days, this month, last month, last 30 days,
184
+ this year, dates), by day / week / month; tiles, takings chart, where it came from, how it was paid, the figures, a
185
+ table of periods, CSV. `GET /sales?range|from&to&by`, `GET /sales.csv` (shop:read). All figures are `lib/sales.js` (PURE):
186
+ - A sale = paid / fulfilled / refunded order, dated by `paidAt` in the shop's zone (`settings.timeZone`, Europe/London
187
+ unless set - there is no setting in the UI yet); a refund dated by `refundedAt`, so a period's net is what stayed.
188
+ - Sources per line: `takeaway` (ref takeaway:…), `addons` (any other ref), `downloads` (a product Pro gave a link or
189
+ key for on that order), `shop`. Line amounts are before discounts; discount, delivery and tax are order-level.
190
+ - `admin/lib/chart.js` - the one bar chart (single series, primary hue, rounded ends, tooltips), shared with Overview.
191
+
192
+ ## 1.6.0: platform commission (plan part 2; the manager side is site-manager's Customers › Commission)
193
+
194
+ A value on each sale, per tenant, set by the platform's super admin on the manager - which writes this site's rates to
195
+ `config/commission.json` (`{rates: {default?, shop?, takeaway?, addons?: {percent, fixed}}, updatedAt, by}`); the
196
+ Cart only READS it (`lib/commission.js currentCommission`, re-read when the file's mtime changes - no restart).
197
+ - When an order becomes paid (`markPaid`, `moveOrder` -> paid) it gets `order.data.commission` = `{type, base, percent,
198
+ fixed, amount, at}` - the rate of that day, kept for good (a later rate never rewrites it). `percent` is of the
199
+ order total; `fixed` per sale; never more than the sale; a free order gets none. Type: `takeaway` (ref takeaway:),
200
+ `addons` (other ref), `shop`. A refund sets `reversed`, `reversedAt`.
201
+ - Never shown to the customer (`publicOrder` leaves it out). Shown to the shop: Sales (commission, returned, after
202
+ commission; a CSV column), the order, Shop settings › Payment ("set by your provider" - read-only).
203
+ - The manager's statements add up what the orders carry - nothing is recalculated there.
@@ -224,3 +224,19 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
224
224
  .shp-copy code { flex: 1; overflow-wrap: anywhere; font-size: var(--dm-text-xs); }
225
225
  .shp-steps li .shp-step-btns { grid-column: 3; grid-row: 1 / 3; display: flex; gap: .3rem; color: inherit; font-size: inherit; }
226
226
  .shp-steps li .shp-step-btns .btn { grid-column: auto; grid-row: auto; }
227
+
228
+ /* Sales */
229
+ .shp-sales { display: flex; flex-direction: column; gap: 1rem; }
230
+ .shp-sales-bar { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; }
231
+ .shp-sales-custom { display: inline-flex; align-items: center; gap: .4rem; }
232
+ .shp-sales-custom input { width: auto; padding-top: .25rem; padding-bottom: .25rem; }
233
+ .shp-sales-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1rem; }
234
+ .shp-sales-note { margin: .6rem 0 0; font-size: var(--dm-text-sm); }
235
+ .shp-sales-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: var(--dm-text-sm); }
236
+ .shp-sales-table th, .shp-sales-table td { padding: .4rem .5rem; border-bottom: 1px solid var(--dm-border); text-align: right; }
237
+ .shp-sales-table th:first-child, .shp-sales-table td:first-child { text-align: left; font-weight: 500; }
238
+ .shp-sales-table thead th { color: var(--dm-text-muted); font-size: var(--dm-text-xs); font-weight: 600; }
239
+ .shp-sales-table tr.is-sum th, .shp-sales-table tr.is-sum td { font-weight: 600; }
240
+ .shp-sales-table tr.is-total th, .shp-sales-table tr.is-total td { font-weight: 800; font-size: 1rem; border-bottom: 0; }
241
+ .shp-sales-table tr.is-muted th, .shp-sales-table tr.is-muted td { color: var(--dm-text-muted); font-weight: 400; }
242
+ .shp-sales-periods summary { cursor: pointer; font-weight: 600; }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The Shop's one chart: a single series of bars - one hue (the theme's primary), 2px apart, 4px rounded ends on the
3
+ * baseline, a recessive grid with three ticks, three date labels, and a tooltip on hover or keyboard focus. Shared by
4
+ * the Overview and Sales. One series, so no legend: the card's title names it.
5
+ *
6
+ * @module shopping-cart/admin/lib/chart
7
+ */
8
+ import {esc} from './kit.js';
9
+
10
+ /** A round top for the axis: 1, 2 or 5 × a power of ten, in minor units. */
11
+ export function niceMax(v) {
12
+ if (v <= 0) return 1000;
13
+ const p = 10 ** Math.floor(Math.log10(v));
14
+ for (const m of [1, 2, 5, 10]) if (m * p >= v) return m * p;
15
+ return 10 * p;
16
+ }
17
+
18
+ /**
19
+ * @param {HTMLElement} el
20
+ * @param {{value: number, label: string, tip: string}[]} series - `label` under the axis, `tip` beside the value
21
+ * @param {{money: Function, ariaLabel: string, scope: Function}} o
22
+ */
23
+ export function barChart(el, series, {money, ariaLabel, scope}) {
24
+ const W = Math.max(360, Math.round(el.clientWidth) || 600), H = 170, padL = 44, padB = 22, padT = 8;
25
+ const top = niceMax(Math.max(0, ...series.map(d => d.value)));
26
+ const n = series.length || 1;
27
+ const slot = (W - padL) / n;
28
+ const bw = Math.max(2, slot - 2);
29
+ const y = (v) => padT + (H - padT - padB) * (1 - v / top);
30
+ const bars = series.map((d, i) => {
31
+ const x = padL + i * slot + 1;
32
+ const h = Math.max(0, H - padB - y(Math.max(0, d.value)));
33
+ const r = Math.min(4, bw / 2, h);
34
+ const path = h > 0 ? `M${x},${H - padB} v${-(h - r)} q0,${-r} ${r},${-r} h${bw - 2 * r} q${r},0 ${r},${r} v${h - r} z` : '';
35
+ return `<g class="shp-bar" data-i="${i}" tabindex="0" aria-label="${esc(d.label)}: ${esc(money(d.value))}, ${esc(d.tip)}">
36
+ <rect x="${padL + i * slot}" y="${padT}" width="${slot}" height="${H - padT - padB}" fill="transparent"/>
37
+ ${path ? `<path d="${path}"/>` : `<rect class="shp-bar-zero" x="${x}" y="${H - padB - 1}" width="${bw}" height="1"/>`}
38
+ </g>`;
39
+ }).join('');
40
+ const at = [...new Set([0, Math.floor((n - 1) / 2), n - 1])].filter(i => series[i]);
41
+ const labels = at.map(i => `<text x="${padL + i * slot + slot / 2}" y="${H - 6}" text-anchor="middle">${esc(series[i].label)}</text>`).join('');
42
+ el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(ariaLabel)}">
43
+ ${[0, top / 2, top].map(t => `<line class="shp-gridline" x1="${padL}" x2="${W}" y1="${y(t)}" y2="${y(t)}"/><text class="shp-axis" x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${esc(money(t, {compact: true}))}</text>`).join('')}
44
+ ${bars}
45
+ <g class="shp-axis">${labels}</g>
46
+ </svg><div class="shp-tip" hidden></div>`;
47
+ const tip = el.querySelector('.shp-tip');
48
+ const show = (g) => {
49
+ const d = series[Number(g.dataset.i)];
50
+ tip.innerHTML = `<strong>${esc(money(d.value))}</strong><span>${esc(d.label)} · ${esc(d.tip)}</span>`;
51
+ tip.hidden = false;
52
+ const r = g.getBoundingClientRect(), box = el.getBoundingClientRect();
53
+ tip.style.left = `${Math.min(box.width - 140, Math.max(0, r.left - box.left + r.width / 2 - 70))}px`;
54
+ el.querySelectorAll('.shp-bar.is-on').forEach(b => b.classList.remove('is-on'));
55
+ g.classList.add('is-on');
56
+ };
57
+ const hide = () => { tip.hidden = true; el.querySelectorAll('.shp-bar.is-on').forEach(b => b.classList.remove('is-on')); };
58
+ const over = (e) => { const g = e.target.closest('.shp-bar'); if (g) show(g); };
59
+ el.addEventListener('mouseover', over);
60
+ el.addEventListener('focusin', over);
61
+ el.addEventListener('mouseleave', hide);
62
+ el.addEventListener('focusout', hide);
63
+ scope?.(() => { el.removeEventListener('mouseover', over); el.removeEventListener('focusin', over); el.removeEventListener('mouseleave', hide); el.removeEventListener('focusout', hide); });
64
+ }
@@ -42,6 +42,21 @@ export function loadKit() {
42
42
  const seg = (id) => encodeURIComponent(String(id));
43
43
  export const api = {
44
44
  summary: () => H.get(`${BASE}/summary`),
45
+ sales: (q) => H.get(`${BASE}/sales?${new URLSearchParams(Object.entries(q).filter(([, v]) => v && v !== 'custom'))}`),
46
+ /** The CSV: fetched with the session (authenticate is Bearer-only) and saved as a file. */
47
+ async salesCsv(q) {
48
+ let token = null;
49
+ try { token = S.get('auth_token'); } catch { /* none */ }
50
+ const res = await fetch(`${BASE}/sales.csv?${new URLSearchParams(Object.entries(q).filter(([, v]) => v && v !== 'custom'))}`, {headers: token ? {Authorization: `Bearer ${token}`} : {}});
51
+ if (!res.ok) throw new Error(`the server answered ${res.status}`);
52
+ const name = (res.headers.get('content-disposition') || '').match(/filename="([^"]+)"/)?.[1] || 'sales.csv';
53
+ const a = document.createElement('a');
54
+ a.href = URL.createObjectURL(await res.blob());
55
+ a.download = name;
56
+ document.body.appendChild(a);
57
+ a.click();
58
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
59
+ },
45
60
  extensions: () => H.get(`${BASE}/extensions`),
46
61
  products: {
47
62
  list: () => H.get(`${BASE}/products`),
@@ -301,6 +301,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
301
301
  <label class="form-label">Staff note <span data-help="Only you see this. Saved when you click away."></span></label>
302
302
  <textarea class="form-input" rows="2" data-model="staffNote.value" data-part="staff" placeholder="Wrapped as a gift, sent second class…"></textarea>
303
303
  ${d.paymentRef || d.providerRef ? `<p class="shp-muted shp-ref">Payment reference: <code>${esc(d.paymentRef || d.providerRef)}</code></p>` : ''}
304
+ ${d.commission ? `<p class="shp-muted shp-ref">Platform commission: ${esc(money(d.commission.amount))} (${esc(`${d.commission.percent}%${d.commission.fixed ? ` + ${money(d.commission.fixed)}` : ''}`)})${d.commission.reversed ? ' - returned with the refund' : ''}</p>` : ''}
304
305
  ${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
306
  <div data-part="ext-panels" hidden></div>
306
307
  <h4>History</h4>
@@ -7,6 +7,7 @@
7
7
  * steps to its first sale instead.
8
8
  */
9
9
 
10
+ import {barChart} from '../lib/chart.js';
10
11
  import {api, esc, fmtDate, money, when} from '../lib/kit.js';
11
12
  import {STATUS_LABEL, STATUS_TONE} from '/plugins/shopping-cart/public/lib/orders.js';
12
13
 
@@ -157,58 +158,8 @@ export async function mount(root, ctx) {
157
158
  I.scan(view);
158
159
  }
159
160
 
160
- /** Thirty bars, 2px apart, rounded tops on the baseline; hover (or focus) says the day and the takings. */
161
+ /** Thirty days of takings, one bar a day. */
161
162
  function drawChart(el, series, scope) {
162
- const W = Math.max(360, Math.round(el.clientWidth) || 600), H = 170, padL = 44, padB = 22, padT = 8;
163
- const max = Math.max(...series.map(d => d.revenue));
164
- const top = niceMax(max);
165
- const n = series.length;
166
- const slot = (W - padL) / n;
167
- const bw = Math.max(2, slot - 2);
168
- const y = (v) => padT + (H - padT - padB) * (1 - v / top);
169
- const ticks = [0, top / 2, top];
170
- const day = (k) => fmtDate(k);
171
- const bars = series.map((d, i) => {
172
- const x = padL + i * slot + 1;
173
- const h = Math.max(0, H - padB - y(d.revenue));
174
- const r = Math.min(4, bw / 2, h);
175
- const path = h > 0
176
- ? `M${x},${H - padB} v${-(h - r)} q0,${-r} ${r},${-r} h${bw - 2 * r} q${r},0 ${r},${r} v${h - r} z`
177
- : '';
178
- return `<g class="shp-bar" data-i="${i}" tabindex="0" aria-label="${esc(day(d.day))}: ${esc(money(d.revenue))}, ${d.orders} orders">
179
- <rect x="${padL + i * slot}" y="${padT}" width="${slot}" height="${H - padT - padB}" fill="transparent"/>
180
- ${path ? `<path d="${path}"/>` : `<rect class="shp-bar-zero" x="${x}" y="${H - padB - 1}" width="${bw}" height="1"/>`}
181
- </g>`;
182
- }).join('');
183
- const labels = [0, Math.floor(n / 2), n - 1].map(i => `<text x="${padL + i * slot + slot / 2}" y="${H - 6}" text-anchor="middle">${esc(day(series[i].day))}</text>`).join('');
184
- el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Takings for each of the last 30 days">
185
- ${ticks.map(t => `<line class="shp-gridline" x1="${padL}" x2="${W}" y1="${y(t)}" y2="${y(t)}"/><text class="shp-axis" x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${esc(money(t, {compact: true}))}</text>`).join('')}
186
- ${bars}
187
- <g class="shp-axis">${labels}</g>
188
- </svg><div class="shp-tip" hidden></div>`;
189
- const tip = el.querySelector('.shp-tip');
190
- const show = (g) => {
191
- const d = series[Number(g.dataset.i)];
192
- tip.innerHTML = `<strong>${esc(money(d.revenue))}</strong><span>${esc(day(d.day))} · ${d.orders} order${d.orders === 1 ? '' : 's'}</span>`;
193
- tip.hidden = false;
194
- const r = g.getBoundingClientRect(), box = el.getBoundingClientRect();
195
- tip.style.left = `${Math.min(box.width - 120, Math.max(0, r.left - box.left + r.width / 2 - 60))}px`;
196
- el.querySelectorAll('.shp-bar.is-on').forEach(b => b.classList.remove('is-on'));
197
- g.classList.add('is-on');
198
- };
199
- const hide = () => { tip.hidden = true; el.querySelectorAll('.shp-bar.is-on').forEach(b => b.classList.remove('is-on')); };
200
- const over = (e) => { const g = e.target.closest('.shp-bar'); if (g) show(g); };
201
- el.addEventListener('mouseover', over);
202
- el.addEventListener('focusin', over);
203
- el.addEventListener('mouseleave', hide);
204
- el.addEventListener('focusout', hide);
205
- scope(() => { el.removeEventListener('mouseover', over); el.removeEventListener('focusin', over); });
206
- }
207
-
208
- /** A round top for the axis: 1, 2 or 5 × a power of ten, in minor units. */
209
- function niceMax(v) {
210
- if (v <= 0) return 1000;
211
- const p = 10 ** Math.floor(Math.log10(v));
212
- for (const m of [1, 2, 5, 10]) if (m * p >= v) return m * p;
213
- return 10 * p;
163
+ barChart(el, series.map(d => ({value: d.revenue, label: fmtDate(d.day), tip: `${d.orders} order${d.orders === 1 ? '' : 's'}`})),
164
+ {money, ariaLabel: 'Takings for each of the last 30 days', scope});
214
165
  }