domma-cms 1.6.33 → 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 (75) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/lib/effect-defs.js +1 -1
  3. package/admin/js/lib/effects-builder.js +1 -1
  4. package/admin/js/lib/pages-arrange.js +1 -1
  5. package/admin/js/lib/scribe-composer.js +1 -1
  6. package/admin/js/lib/scribe-model.js +1 -1
  7. package/admin/js/lib/shortcode-preview.js +1 -1
  8. package/admin/js/lib/shortcode-registry.js +3 -3
  9. package/admin/js/templates/docs/usage-site-settings.html +2 -1
  10. package/admin/js/templates/page-editor.html +2 -1
  11. package/admin/js/templates/pages.html +1 -1
  12. package/admin/js/templates/theme.html +1 -1
  13. package/admin/js/views/pages.js +3 -3
  14. package/admin/js/views/theme.js +5 -5
  15. package/package.json +1 -1
  16. package/plugins/_lib/outreach/audience.js +76 -0
  17. package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
  18. package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
  19. package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
  20. package/plugins/_lib/outreach/expiry.js +46 -0
  21. package/plugins/_lib/outreach/index.js +15 -0
  22. package/plugins/_lib/outreach/ledger.js +127 -0
  23. package/plugins/_lib/outreach/sending.js +121 -0
  24. package/plugins/_lib/outreach/suppression.js +62 -0
  25. package/plugins/_lib/outreach/tokens.js +35 -0
  26. package/plugins/_lib/outreach/unsubscribe.js +46 -0
  27. package/plugins/free-tier.lock.json +35 -24
  28. package/plugins/shopping-cart/CLAUDE.md +64 -0
  29. package/plugins/shopping-cart/admin/css/index.css +16 -0
  30. package/plugins/shopping-cart/admin/lib/chart.js +64 -0
  31. package/plugins/shopping-cart/admin/lib/kit.js +15 -0
  32. package/plugins/shopping-cart/admin/views/orders.js +3 -2
  33. package/plugins/shopping-cart/admin/views/overview.js +4 -53
  34. package/plugins/shopping-cart/admin/views/sales.js +133 -0
  35. package/plugins/shopping-cart/admin/views/settings.js +3 -1
  36. package/plugins/shopping-cart/admin/views/shop.js +2 -1
  37. package/plugins/shopping-cart/config.js +1 -0
  38. package/plugins/shopping-cart/docs/guide.md +25 -0
  39. package/plugins/shopping-cart/inject/body.html +2 -2
  40. package/plugins/shopping-cart/lib/accounts.js +180 -0
  41. package/plugins/shopping-cart/lib/commission.js +90 -0
  42. package/plugins/shopping-cart/lib/extensions.js +12 -4
  43. package/plugins/shopping-cart/lib/pricing.js +65 -13
  44. package/plugins/shopping-cart/lib/render.js +17 -4
  45. package/plugins/shopping-cart/lib/sales.js +193 -0
  46. package/plugins/shopping-cart/lib/shop.js +24 -3
  47. package/plugins/shopping-cart/lib/summary.js +4 -2
  48. package/plugins/shopping-cart/plugin.js +156 -22
  49. package/plugins/shopping-cart/plugin.json +2 -2
  50. package/plugins/shopping-cart/plugin.public.js +17 -1
  51. package/plugins/shopping-cart/public/account.js +277 -0
  52. package/plugins/shopping-cart/public/cart.js +16 -0
  53. package/plugins/shopping-cart/public/lib/session.js +82 -0
  54. package/plugins/shopping-cart/public/shop.css +27 -0
  55. package/plugins/shopping-cart/roles/shop-customer.json +8 -0
  56. package/plugins/shopping-cart/tests/accounts.test.js +127 -0
  57. package/plugins/shopping-cart/tests/api.test.js +63 -0
  58. package/plugins/shopping-cart/tests/commission.test.js +90 -0
  59. package/plugins/shopping-cart/tests/pure.test.js +30 -1
  60. package/plugins/shopping-cart/tests/sales.test.js +73 -0
  61. package/public/css/site.css +1 -1
  62. package/public/js/effects.js +1 -1
  63. package/public/js/scribe-style.mjs +1 -1
  64. package/public/js/shortcode-expiry.mjs +1 -0
  65. package/public/js/site.js +1 -1
  66. package/public/js/text-style.mjs +1 -1
  67. package/server/routes/api/theme.js +12 -2
  68. package/server/routes/outreach-public.js +68 -0
  69. package/server/routes/public.js +24 -1
  70. package/server/server.js +5 -0
  71. package/server/services/content.js +7 -7
  72. package/server/services/markdown.js +15 -5
  73. package/server/services/outreachUnsubscribe.js +54 -0
  74. package/server/services/shortcodeExpiry.js +146 -0
  75. package/server/templates/page.html +3 -3
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The outreach send ledger: one row per (thing, contact) with a hashed
3
+ * per-recipient token and a status - the record that makes sends resumable
4
+ * (the row is written before the email goes) and reminders targetable.
5
+ *
6
+ * Generic over what is being sent: `refField` names the column tying a row
7
+ * to its campaign/survey/journey. Surveys passes
8
+ * `{slug: 'surveys-invites', refField: 'surveyId', source: 'surveys'}` so its stored data is
9
+ * unchanged. Lifted from Surveys 1.4 (lib/ledger.js).
10
+ *
11
+ * Statuses: pending -> sent -> completed, or bounced (any send failure,
12
+ * including a suppressed address - deliberately excluded from reminders).
13
+ *
14
+ * @module _lib/outreach/ledger
15
+ */
16
+ import {generateToken, hashToken, normaliseCode} from './tokens.js';
17
+
18
+ /**
19
+ * @param {{createEntry,listEntries,getEntry,updateEntry}} col collections service
20
+ * @param {{slug?: string, refField?: string}} [opts]
21
+ */
22
+ export function createLedger(col, {slug = 'outreach-invites', refField = 'ref', source = 'outreach'} = {}) {
23
+ async function allInvites() {
24
+ const {entries} = await col.listEntries(slug, {limit: 100000});
25
+ return entries;
26
+ }
27
+
28
+ async function invitesFor(refId) {
29
+ return (await allInvites()).filter(e => e.data[refField] === refId);
30
+ }
31
+
32
+ /** Issue pending invites for contacts that don't already have one. Returns [{id, contactId, token}]. */
33
+ async function issueInvites(refId, contactIds) {
34
+ const existing = await invitesFor(refId);
35
+ const seen = new Set(existing.map(e => e.data.contactId));
36
+ const out = [];
37
+ for (const contactId of contactIds) {
38
+ if (seen.has(contactId)) continue;
39
+ const token = generateToken();
40
+ const entry = await col.createEntry(slug, {
41
+ [refField]: refId, contactId,
42
+ tokenHash: hashToken(normaliseCode(token)),
43
+ status: 'pending', sentAt: null, completedAt: null, responseId: null
44
+ }, {source});
45
+ out.push({id: entry.id, contactId, token});
46
+ seen.add(contactId);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * Ensure every listed contact has a FRESH token to email. New contacts get
53
+ * a created invite; existing non-completed invites are re-issued with a
54
+ * new token (so re-sends and reminders carry a working personalised link);
55
+ * contacts who already completed are skipped. The previous token for a
56
+ * re-issued invite stops working - latest link wins.
57
+ *
58
+ * Returns [{id, contactId, token}] for the invites to email.
59
+ */
60
+ async function issueOrReissue(refId, contactIds) {
61
+ const existing = await invitesFor(refId);
62
+ const byContact = new Map(existing.map(e => [e.data.contactId, e]));
63
+ const out = [];
64
+ for (const contactId of contactIds) {
65
+ const ex = byContact.get(contactId);
66
+ if (ex && ex.data.status === 'completed') continue;
67
+ const token = generateToken();
68
+ const tokenHash = hashToken(normaliseCode(token));
69
+ if (ex) {
70
+ await col.updateEntry(slug, ex.id, {...ex.data, tokenHash});
71
+ out.push({id: ex.id, contactId, token});
72
+ } else {
73
+ const entry = await col.createEntry(slug, {
74
+ [refField]: refId, contactId, tokenHash,
75
+ status: 'pending', sentAt: null, completedAt: null, responseId: null
76
+ }, {source});
77
+ out.push({id: entry.id, contactId, token});
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** Find an invite by submitted code (any format). Null if unknown. */
84
+ async function findByCode(refId, code) {
85
+ const hash = hashToken(normaliseCode(code));
86
+ const invites = await invitesFor(refId);
87
+ return invites.find(e => e.data.tokenHash === hash) ?? null;
88
+ }
89
+
90
+ /** Mark sent / bounced. */
91
+ async function markSent(inviteId, ok, nowIso) {
92
+ const entry = await col.getEntry(slug, inviteId);
93
+ if (!entry) return null;
94
+ return col.updateEntry(slug, inviteId, {
95
+ ...entry.data,
96
+ status: ok ? 'sent' : 'bounced',
97
+ sentAt: ok ? nowIso : entry.data.sentAt
98
+ });
99
+ }
100
+
101
+ /** Mark completed with the stored response id. */
102
+ async function markCompleted(inviteId, responseId, nowIso) {
103
+ const entry = await col.getEntry(slug, inviteId);
104
+ if (!entry) return null;
105
+ return col.updateEntry(slug, inviteId, {
106
+ ...entry.data, status: 'completed', completedAt: nowIso, responseId: responseId ?? null
107
+ });
108
+ }
109
+
110
+ /** Invites that are neither completed nor bounced. */
111
+ async function outstanding(refId) {
112
+ const invites = await invitesFor(refId);
113
+ return invites.filter(e => e.data.status !== 'completed' && e.data.status !== 'bounced');
114
+ }
115
+
116
+ /** Sent invites, not yet completed, whose sentAt is older than afterDays before now. */
117
+ async function dueForReminder(refId, afterDays, now) {
118
+ const cutoff = now.getTime() - Number(afterDays) * 86400000;
119
+ const invites = await invitesFor(refId);
120
+ return invites.filter(e =>
121
+ e.data.status === 'sent' &&
122
+ e.data.sentAt && new Date(e.data.sentAt).getTime() <= cutoff
123
+ );
124
+ }
125
+
126
+ return {allInvites, invitesFor, issueInvites, issueOrReissue, findByCode, markSent, markCompleted, outstanding, dueForReminder, slug, refField};
127
+ }
@@ -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@91d03cf",
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.2.0",
178
+ "version": "1.6.0",
179
179
  "files": {
180
- "CLAUDE.md": "0a0d019e6eceb2260cf965d3eb589c9a53615c388f212af382a00a129cb2c0dc",
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": "c10789a82cf3efb4419f223668a5fa71b021420a094cda291c8d492a644b25da",
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": "1b6ee66b4ef768983ed32752711ed4ca8438bd37eb33be9587f6679452557b2c",
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
- "lib/pricing.js": "3ae965e042f5af0acdcecc66d27145ca5f608c656717288832b881a2a181dd2e",
197
- "lib/render.js": "442cd305810e53edcfa184796db19371b304567100e2662c96b4b3bee2cf7231",
200
+ "lib/pricing.js": "5644f2dd55fc3cfd7f40b0169dea5fb547e8b302239b8c17f23cfa177e42eab5",
201
+ "lib/render.js": "2a2b9ab4a24728186e1e45e6429c7406a6d5ca3d4b43db1758bbd54405d105b7",
202
+ "lib/sales.js": "16804f0ba4e61e8a2887a7c4172da37533850a1cd975a177b754f3350268e946",
198
203
  "lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
199
- "lib/shop.js": "6edd3618d53ecd614a03bf476abe5cb8615fd7d19ac765406a6ac550ebcf75e9",
200
- "lib/summary.js": "5697ffa45c9cc5338bbcb6eada08671c3e8376c49c629d2ae99b022397523f71",
201
- "plugin.js": "c72c526ccd71d8c055b82ef7e166afc542d4e8c36251e2f4ca1861505d5febb4",
202
- "plugin.json": "bb7e9268ce075ec400f9df0932a74b26f61ca8930471ebbf41f3a8deef2e813a",
203
- "plugin.public.js": "99ef559dc8c3bd369047091623bbad29a1f6067322f07ac0db547938d97fe014",
204
- "public/cart.js": "fcc706773a36e4bfd02ea235bcdad629137e996218e0a0e23892e33c689f2a6e",
204
+ "lib/shop.js": "116b0a2cc1513895ce67d2aa6fa216a16e90cafab67394ed85ef0f34e88c50e3",
205
+ "lib/summary.js": "cdb13ce3d1799d429059fa03f179281711dc00427ccf2eb6b8f8aa52fef26295",
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": "8b71353b9649d6274c4e6d9358bac8fa6737d3effee00c35c62dab6f2a377968",
223
- "tests/pure.test.js": "27a40f04315c17b92e4782d6f8621c965ae89bb73df377064775eec71335a5b7"
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": {
@@ -137,3 +137,67 @@ these, behaves as 1.1.
137
137
  - A copy (`POST /products/:id/duplicate`) gives options new ids and passes `variantMap` (old -> new) to `clean`.
138
138
  - **`services.moveOrder(id, to, {by, note})`** - moves an order as the admin's status route does (same rules,
139
139
  409 on a move the table refuses) and emits `status`.
140
+
141
+ ## 1.3.0: an extension's own priced lines (Takeaway)
142
+
143
+ `services.startCheckout` (and `services.quote`, the same arguments without ordering) also take:
144
+ - **`custom`**: lines the extension priced - `{name, optionName, variantName, unitPrice, qty, taxClass, detail[], image,
145
+ category, ref}` (`pricing.js cleanCustomLines`). They go through `priceBasket` like any line (discount codes and
146
+ tax rules apply), with `productId: ''`, `custom: true`, never shippable, no stock (`moveStock` skips them). Server
147
+ code only: `/quote` and `/checkout` never read `custom`. Orders keep `detail` and `ref`; the receipt, the admin
148
+ order, the packing slip and Pro's emails show `detail` under the line. Summaries key them by `ref`.
149
+ - **`charges`**: `[{label, amount}]` that stand in for the shipping stage (no shipping rules asked) - a delivery
150
+ fee is taxed as delivery is. Leave it out and shipping runs as before; `[]` means no delivery charge.
151
+ - **`delivery: 'none' | 'collect' | 'deliver'`** + `address` (needed for deliver, checked against the shop's
152
+ countries) + `note`, `code`. Stored as the order's `delivery` 'none' | 'collect' | 'ship'.
153
+ - **`ownEmails: true`** - the extension writes to the shopper itself; stored on the order, and Shopping Cart Pro
154
+ then sends that order only the shop's own new-order email.
155
+ - **`payNote`** (offline) - what the receipt says about paying ("Pay when you collect from …") instead of the shop's
156
+ offline instructions; on the order and in `publicOrder`.
157
+ - **`services.refund(id, {note, restock, viaProvider})`** - the admin's refund (through Stripe / PayPal first),
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
+ }