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.
- package/admin/js/api.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/scribe-model.js +1 -1
- package/admin/js/lib/shortcode-preview.js +1 -1
- package/admin/js/lib/shortcode-registry.js +2 -2
- package/admin/js/templates/docs/usage-site-settings.html +2 -1
- package/admin/js/templates/page-editor.html +2 -1
- package/admin/js/templates/pages.html +1 -1
- package/admin/js/templates/theme.html +1 -1
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/theme.js +5 -5
- package/package.json +1 -1
- package/plugins/_lib/outreach/audience.js +76 -0
- package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
- package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
- package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
- package/plugins/_lib/outreach/expiry.js +46 -0
- package/plugins/_lib/outreach/index.js +15 -0
- package/plugins/_lib/outreach/ledger.js +127 -0
- package/plugins/_lib/outreach/sending.js +121 -0
- package/plugins/_lib/outreach/suppression.js +62 -0
- package/plugins/_lib/outreach/tokens.js +35 -0
- package/plugins/_lib/outreach/unsubscribe.js +46 -0
- package/plugins/free-tier.lock.json +33 -22
- package/plugins/shopping-cart/CLAUDE.md +45 -0
- package/plugins/shopping-cart/admin/css/index.css +16 -0
- package/plugins/shopping-cart/admin/lib/chart.js +64 -0
- package/plugins/shopping-cart/admin/lib/kit.js +15 -0
- package/plugins/shopping-cart/admin/views/orders.js +1 -0
- package/plugins/shopping-cart/admin/views/overview.js +4 -53
- package/plugins/shopping-cart/admin/views/sales.js +133 -0
- package/plugins/shopping-cart/admin/views/settings.js +3 -1
- package/plugins/shopping-cart/admin/views/shop.js +2 -1
- package/plugins/shopping-cart/config.js +1 -0
- package/plugins/shopping-cart/docs/guide.md +25 -0
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +180 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/render.js +13 -1
- package/plugins/shopping-cart/lib/sales.js +193 -0
- package/plugins/shopping-cart/lib/shop.js +13 -0
- package/plugins/shopping-cart/plugin.js +110 -1
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/plugin.public.js +17 -1
- package/plugins/shopping-cart/public/account.js +277 -0
- package/plugins/shopping-cart/public/cart.js +16 -0
- package/plugins/shopping-cart/public/lib/session.js +82 -0
- package/plugins/shopping-cart/public/shop.css +27 -0
- package/plugins/shopping-cart/roles/shop-customer.json +8 -0
- package/plugins/shopping-cart/tests/accounts.test.js +127 -0
- package/plugins/shopping-cart/tests/api.test.js +11 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- package/public/css/site.css +1 -1
- package/public/js/effects.js +1 -1
- package/public/js/scribe-style.mjs +1 -1
- package/public/js/site.js +1 -1
- package/public/js/text-style.mjs +1 -1
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/server.js +5 -0
- package/server/services/markdown.js +7 -2
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/templates/page.html +3 -3
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sales report. PURE: orders in, figures out - for the Sales tab and its CSV.
|
|
3
|
+
*
|
|
4
|
+
* A sale is an order that took money (paid, fulfilled - or refunded later), dated by when it was PAID, in the
|
|
5
|
+
* shop's time zone. A refund is dated by when it was refunded, so a period's net is what really stayed.
|
|
6
|
+
*
|
|
7
|
+
* Every line is put to a SOURCE, from what the order already says about itself:
|
|
8
|
+
* takeaway - an order Takeaway placed (`ref: 'takeaway:…'`)
|
|
9
|
+
* downloads - a line for a product Shopping Cart Pro gave a download link or licence key for
|
|
10
|
+
* addons - an order another extension placed (any other `ref` - Cards Pro's credits, …)
|
|
11
|
+
* shop - everything else: the Shop's own products
|
|
12
|
+
*
|
|
13
|
+
* Platform commission (lib/commission.js) is the figure each order was given when it was paid; it counts on the day it
|
|
14
|
+
* was paid and comes back on the day of a refund.
|
|
15
|
+
*
|
|
16
|
+
* Amounts are minor units. Line amounts are before discounts; the order-level figures (discount, delivery, tax)
|
|
17
|
+
* are reported beside them, not shared out across sources.
|
|
18
|
+
*
|
|
19
|
+
* @module shopping-cart/lib/sales
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const SOURCES = {shop: 'Shop', downloads: 'Downloads & keys', takeaway: 'Takeaway', addons: 'Other add-ons'};
|
|
23
|
+
const PROVIDERS = {stripe: 'Card (Stripe)', paypal: 'PayPal', offline: 'Paid offline', free: 'Free'};
|
|
24
|
+
const TOOK = new Set(['paid', 'fulfilled', 'refunded']);
|
|
25
|
+
const DAY = 86400000;
|
|
26
|
+
|
|
27
|
+
const parts = new Map();
|
|
28
|
+
/** The shop's calendar date of an instant: 'YYYY-MM-DD'. */
|
|
29
|
+
export function dateIn(t, tz = 'Europe/London') {
|
|
30
|
+
if (!parts.has(tz)) {
|
|
31
|
+
try { parts.set(tz, new Intl.DateTimeFormat('en-CA', {timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit'})); }
|
|
32
|
+
catch { parts.set(tz, new Intl.DateTimeFormat('en-CA', {timeZone: 'Europe/London', year: 'numeric', month: '2-digit', day: '2-digit'})); }
|
|
33
|
+
}
|
|
34
|
+
return parts.get(tz).format(new Date(t));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const addDays = (date, n) => { const [y, m, d] = date.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10); };
|
|
38
|
+
const weekday = (date) => { const [y, m, d] = date.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay(); };
|
|
39
|
+
|
|
40
|
+
/** The period a date falls in: the day, the Monday of its week, or the 1st of its month. */
|
|
41
|
+
export function periodOf(date, by) {
|
|
42
|
+
if (by === 'month') return `${date.slice(0, 7)}-01`;
|
|
43
|
+
if (by === 'week') return addDays(date, -((weekday(date) + 6) % 7));
|
|
44
|
+
return date;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every period from `from` to `to`, in order, so empty ones show as empty. */
|
|
48
|
+
export function periodsBetween(from, to, by) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let p = periodOf(from, by);
|
|
51
|
+
const last = periodOf(to, by);
|
|
52
|
+
for (let i = 0; p <= last && i < 800; i++) {
|
|
53
|
+
out.push(p);
|
|
54
|
+
if (by === 'month') { const [y, m] = p.split('-').map(Number); p = new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10); }
|
|
55
|
+
else p = addDays(p, by === 'week' ? 7 : 1);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Which source an order line belongs to. */
|
|
61
|
+
export function sourceOf(order, line) {
|
|
62
|
+
const ref = String(order.data?.ref || '');
|
|
63
|
+
if (ref.startsWith('takeaway:')) return 'takeaway';
|
|
64
|
+
if (ref) return 'addons';
|
|
65
|
+
const pro = order.data?.ext?.['shopping-cart-pro'];
|
|
66
|
+
const digital = new Set([...(pro?.downloads || []), ...(pro?.keys || [])].map(x => x.productId));
|
|
67
|
+
if (line.productId && digital.has(line.productId)) return 'downloads';
|
|
68
|
+
return 'shop';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One order's money, split the way the report shows it. */
|
|
72
|
+
export function orderFigures(o) {
|
|
73
|
+
const d = o.data;
|
|
74
|
+
const adj = d.adjustments || [];
|
|
75
|
+
const sum = (f) => adj.filter(f).reduce((n, a) => n + a.amount, 0);
|
|
76
|
+
return {
|
|
77
|
+
subtotal: d.subtotal || 0,
|
|
78
|
+
discount: -sum(a => a.stage === 'discount'),
|
|
79
|
+
delivery: sum(a => a.stage === 'shipping'),
|
|
80
|
+
taxAdded: sum(a => a.stage === 'tax' && !a.included),
|
|
81
|
+
taxIncluded: sum(a => a.stage === 'tax' && a.included),
|
|
82
|
+
total: d.total || 0
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The report for `from`..`to` (dates, both included), in periods of `by`.
|
|
88
|
+
*
|
|
89
|
+
* @param {{id, data, meta}[]} orders
|
|
90
|
+
* @param {{from: string, to: string, by?: 'day'|'week'|'month', timeZone?: string}} opts
|
|
91
|
+
*/
|
|
92
|
+
export function salesReport(orders, {from, to, by = 'day', timeZone = 'Europe/London'}) {
|
|
93
|
+
const inRange = (date) => date >= from && date <= to;
|
|
94
|
+
const periods = new Map(periodsBetween(from, to, by).map(p => [p, {period: p, orders: 0, takings: 0, refunds: 0, commission: 0}]));
|
|
95
|
+
const totals = {orders: 0, items: 0, subtotal: 0, discount: 0, delivery: 0, taxAdded: 0, taxIncluded: 0, takings: 0, refunds: 0, refundCount: 0,
|
|
96
|
+
commission: 0, commissionBack: 0};
|
|
97
|
+
const sources = Object.fromEntries(Object.keys(SOURCES).map(k => [k, {key: k, label: SOURCES[k], orders: 0, amount: 0}]));
|
|
98
|
+
const methods = new Map();
|
|
99
|
+
const takeaway = {collect: 0, deliver: 0};
|
|
100
|
+
|
|
101
|
+
for (const o of orders) {
|
|
102
|
+
const d = o.data || {};
|
|
103
|
+
if (!TOOK.has(d.status)) continue;
|
|
104
|
+
const paidDate = dateIn(d.paidAt || o.meta?.createdAt, timeZone);
|
|
105
|
+
if (inRange(paidDate)) {
|
|
106
|
+
const f = orderFigures(o);
|
|
107
|
+
totals.orders++;
|
|
108
|
+
for (const k of ['subtotal', 'discount', 'delivery', 'taxAdded', 'taxIncluded']) totals[k] += f[k];
|
|
109
|
+
totals.takings += f.total;
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const l of d.lines || []) {
|
|
112
|
+
const s = sourceOf(o, l);
|
|
113
|
+
sources[s].amount += l.lineTotal || 0;
|
|
114
|
+
totals.items += l.qty || 0;
|
|
115
|
+
if (!seen.has(s)) { sources[s].orders++; seen.add(s); }
|
|
116
|
+
}
|
|
117
|
+
if (String(d.ref || '').startsWith('takeaway:')) takeaway[d.delivery === 'ship' ? 'deliver' : 'collect']++;
|
|
118
|
+
const m = methods.get(d.provider) || {key: d.provider, label: PROVIDERS[d.provider] || d.provider || 'Other', orders: 0, takings: 0};
|
|
119
|
+
m.orders++; m.takings += f.total;
|
|
120
|
+
methods.set(d.provider, m);
|
|
121
|
+
const c = d.commission?.amount || 0;
|
|
122
|
+
totals.commission += c;
|
|
123
|
+
const p = periods.get(periodOf(paidDate, by));
|
|
124
|
+
if (p) { p.orders++; p.takings += f.total; p.commission += c; }
|
|
125
|
+
}
|
|
126
|
+
if (d.status === 'refunded') {
|
|
127
|
+
const refDate = dateIn(d.refundedAt || d.paidAt || o.meta?.createdAt, timeZone);
|
|
128
|
+
if (inRange(refDate)) {
|
|
129
|
+
totals.refunds += d.total || 0;
|
|
130
|
+
totals.refundCount++;
|
|
131
|
+
const p = periods.get(periodOf(refDate, by));
|
|
132
|
+
if (p) p.refunds += d.total || 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (d.commission?.reversed) {
|
|
136
|
+
const backDate = dateIn(d.commission.reversedAt || d.refundedAt || o.meta?.createdAt, timeZone);
|
|
137
|
+
if (inRange(backDate)) {
|
|
138
|
+
totals.commissionBack += d.commission.amount || 0;
|
|
139
|
+
const p = periods.get(periodOf(backDate, by));
|
|
140
|
+
if (p) p.commission -= d.commission.amount || 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
from, to, by,
|
|
146
|
+
totals: {...totals, net: totals.takings - totals.refunds, average: totals.orders ? Math.round(totals.takings / totals.orders) : 0,
|
|
147
|
+
commissionNet: totals.commission - totals.commissionBack, afterCommission: totals.takings - totals.refunds - (totals.commission - totals.commissionBack)},
|
|
148
|
+
periods: [...periods.values()].map(p => ({...p, net: p.takings - p.refunds})),
|
|
149
|
+
sources: Object.values(sources).filter(s => s.orders || s.key === 'shop'),
|
|
150
|
+
methods: [...methods.values()].sort((a, b) => b.takings - a.takings),
|
|
151
|
+
takeaway
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The named ranges the Sales tab offers, as {from, to} dates for `today`. */
|
|
156
|
+
export function rangeFor(name, today) {
|
|
157
|
+
const [y, m] = today.split('-').map(Number);
|
|
158
|
+
const monthStart = (yy, mm) => new Date(Date.UTC(yy, mm - 1, 1)).toISOString().slice(0, 10);
|
|
159
|
+
switch (name) {
|
|
160
|
+
case 'today': return {from: today, to: today};
|
|
161
|
+
case 'week': return {from: periodOf(today, 'week'), to: today};
|
|
162
|
+
case 'last7': return {from: addDays(today, -6), to: today};
|
|
163
|
+
case 'month': return {from: monthStart(y, m), to: today};
|
|
164
|
+
case 'lastmonth': return {from: monthStart(y, m - 1), to: addDays(monthStart(y, m), -1)};
|
|
165
|
+
case 'last30': return {from: addDays(today, -29), to: today};
|
|
166
|
+
case 'year': return {from: `${y}-01-01`, to: today};
|
|
167
|
+
default: return {from: addDays(today, -29), to: today};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const csvCell = (v) => { const s = String(v ?? ''); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
172
|
+
const pounds = (n) => ((n || 0) / 100).toFixed(2);
|
|
173
|
+
|
|
174
|
+
/** One row per order sold or refunded in the range - for the accountant. */
|
|
175
|
+
export function salesCsv(orders, {from, to, timeZone = 'Europe/London'}) {
|
|
176
|
+
const rows = [['Order', 'Paid', 'Status', 'Refunded', 'Sources', 'Payment', 'Customer', 'Subtotal', 'Discount', 'Delivery', 'Tax added', 'Tax included', 'Total', 'Commission', 'Commission rate']];
|
|
177
|
+
const list = orders.filter(o => TOOK.has(o.data?.status)).filter(o => {
|
|
178
|
+
const paid = dateIn(o.data.paidAt || o.meta?.createdAt, timeZone);
|
|
179
|
+
const refunded = o.data.refundedAt ? dateIn(o.data.refundedAt, timeZone) : '';
|
|
180
|
+
return (paid >= from && paid <= to) || (refunded && refunded >= from && refunded <= to);
|
|
181
|
+
}).sort((a, b) => String(a.data.paidAt || a.meta?.createdAt).localeCompare(String(b.data.paidAt || b.meta?.createdAt)));
|
|
182
|
+
for (const o of list) {
|
|
183
|
+
const d = o.data;
|
|
184
|
+
const f = orderFigures(o);
|
|
185
|
+
rows.push([`#${d.number}`, dateIn(d.paidAt || o.meta?.createdAt, timeZone), d.status, d.refundedAt ? dateIn(d.refundedAt, timeZone) : '',
|
|
186
|
+
[...new Set((d.lines || []).map(l => SOURCES[sourceOf(o, l)]))].join('; '), PROVIDERS[d.provider] || d.provider, d.customer?.name || d.email || '',
|
|
187
|
+
pounds(f.subtotal), pounds(f.discount), pounds(f.delivery), pounds(f.taxAdded), pounds(f.taxIncluded), pounds(f.total),
|
|
188
|
+
d.commission ? pounds(d.commission.reversed ? 0 : d.commission.amount) : '', d.commission ? `${d.commission.percent}% + ${pounds(d.commission.fixed)}` : '']);
|
|
189
|
+
}
|
|
190
|
+
return rows.map(r => r.map(csvCell).join(',')).join('\r\n') + '\r\n';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export {DAY};
|
|
@@ -18,6 +18,14 @@ import {cleanProduct} from '../public/lib/products.js';
|
|
|
18
18
|
import {NEXT, SOLD, canMove} from '../public/lib/orders.js';
|
|
19
19
|
import {currencyOf} from '../public/lib/money.js';
|
|
20
20
|
import {cartRegistry} from './extensions.js';
|
|
21
|
+
import {commissionFor, currentCommission} from './commission.js';
|
|
22
|
+
|
|
23
|
+
/** The platform's commission on a sale being paid now - kept if it already has one. */
|
|
24
|
+
function commissionOn(d, at) {
|
|
25
|
+
if (d.commission) return {};
|
|
26
|
+
const c = commissionFor({data: d}, currentCommission().rates, at);
|
|
27
|
+
return c ? {commission: c} : {};
|
|
28
|
+
}
|
|
21
29
|
import defaults from '../config.js';
|
|
22
30
|
|
|
23
31
|
export const NAME = 'shopping-cart';
|
|
@@ -98,6 +106,7 @@ export function cleanSettings(input, current) {
|
|
|
98
106
|
out.lowStockAt = Math.max(0, Math.min(1000, Math.round(Number(input.lowStockAt ?? current.lowStockAt) || 0)));
|
|
99
107
|
out.showStockLeft = bool('showStockLeft');
|
|
100
108
|
out.termsUrl = str('termsUrl', 300);
|
|
109
|
+
out.accounts = bool('accounts');
|
|
101
110
|
if (out.termsUrl && !/^(\/(?!\/)|https?:\/\/)/.test(out.termsUrl)) errors.termsUrl = 'A page on this site (/terms) or a full https:// address.';
|
|
102
111
|
|
|
103
112
|
const countries = Array.isArray(input.countries) ? input.countries : (current.countries || ['GB']);
|
|
@@ -360,6 +369,7 @@ export function markPaid(id, info = {}) {
|
|
|
360
369
|
stockTaken: true,
|
|
361
370
|
...(info.ref ? {providerRef: info.ref} : {}),
|
|
362
371
|
...(info.payment ? {paymentRef: info.payment} : {}),
|
|
372
|
+
...commissionOn(d, now),
|
|
363
373
|
history: [...(d.history || []), {at: now, status: 'paid', by: info.by || d.provider, note: info.note || ''}]
|
|
364
374
|
});
|
|
365
375
|
return {order, changed: true, from: d.status};
|
|
@@ -384,6 +394,9 @@ export function moveOrder(id, to, {by = 'admin', note = '', restock = false, tra
|
|
|
384
394
|
const patch = {status: to, history: [...(d.history || []), {at: now, status: to, by, note: String(note || '').slice(0, 500)}]};
|
|
385
395
|
if (to === 'paid' && !d.stockTaken) { await moveStock(d, -1); patch.stockTaken = true; }
|
|
386
396
|
if (to === 'paid' && !d.paidAt) patch.paidAt = now;
|
|
397
|
+
if (to === 'paid') Object.assign(patch, commissionOn(d, now));
|
|
398
|
+
// A refund gives the platform's commission back too (statements net it off in the month of the refund).
|
|
399
|
+
if (to === 'refunded' && d.commission && !d.commission.reversed) patch.commission = {...d.commission, reversed: true, reversedAt: now};
|
|
387
400
|
if (to === 'fulfilled') {
|
|
388
401
|
patch.fulfilledAt = now;
|
|
389
402
|
if (tracking) patch.tracking = tracking;
|
|
@@ -28,6 +28,10 @@ import {formatMoney} from './public/lib/money.js';
|
|
|
28
28
|
import {publicProduct, byShelf} from './public/lib/products.js';
|
|
29
29
|
import {cartRegistry} from './lib/extensions.js';
|
|
30
30
|
import {SAMPLES} from './lib/samples.js';
|
|
31
|
+
import * as accounts from './lib/accounts.js';
|
|
32
|
+
import {dateIn, rangeFor, salesCsv, salesReport} from './lib/sales.js';
|
|
33
|
+
import {TYPES as COMMISSION_TYPES, currentCommission, rateFor, rateText} from './lib/commission.js';
|
|
34
|
+
import {getUserById} from '../../server/services/users.js';
|
|
31
35
|
import {cleanCharges, cleanCustomLines, cleanLines, priceBasket} from './lib/pricing.js';
|
|
32
36
|
import {badge, summarise} from './lib/summary.js';
|
|
33
37
|
import * as pay from './lib/payments.js';
|
|
@@ -201,6 +205,38 @@ export default async function shoppingCart(fastify, options) {
|
|
|
201
205
|
};
|
|
202
206
|
});
|
|
203
207
|
|
|
208
|
+
/**
|
|
209
|
+
* The Sales report: ?range=today|week|last7|month|lastmonth|last30|year, or ?from=&to= (dates), and ?by=day|week|month.
|
|
210
|
+
* Dates are the shop's (settings.timeZone, Europe/London unless set).
|
|
211
|
+
*/
|
|
212
|
+
const salesRange = (q, s) => {
|
|
213
|
+
const today = dateIn(Date.now(), s.timeZone || 'Europe/London');
|
|
214
|
+
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(String(d || ''));
|
|
215
|
+
let {from, to} = isDate(q.from) && isDate(q.to) ? {from: q.from, to: q.to} : rangeFor(String(q.range || 'last30'), today);
|
|
216
|
+
if (from > to) [from, to] = [to, from];
|
|
217
|
+
return {from, to, today};
|
|
218
|
+
};
|
|
219
|
+
/** The platform's commission for this site, for people: each kind of sale and its rate. Read-only here. */
|
|
220
|
+
const commissionInfo = (s) => {
|
|
221
|
+
const {rates, updatedAt} = currentCommission();
|
|
222
|
+
const money = (n) => formatMoney(n, s.currency);
|
|
223
|
+
return {set: Object.keys(rates).length > 0, updatedAt,
|
|
224
|
+
rates: Object.entries(COMMISSION_TYPES).map(([k, label]) => ({key: k, label, text: rateText(rateFor(k, rates), money)}))};
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
fastify.get('/sales', can('read'), async (request) => {
|
|
228
|
+
const [orders, s] = await Promise.all([allOrders(), liveSettings()]);
|
|
229
|
+
const {from, to, today} = salesRange(request.query || {}, s);
|
|
230
|
+
const by = ['day', 'week', 'month'].includes(request.query?.by) ? request.query.by : 'day';
|
|
231
|
+
return {...salesReport(orders, {from, to, by, timeZone: s.timeZone || 'Europe/London'}), today, currency: s.currency, commission: commissionInfo(s)};
|
|
232
|
+
});
|
|
233
|
+
fastify.get('/sales.csv', can('read'), async (request, reply) => {
|
|
234
|
+
const [orders, s] = await Promise.all([allOrders(), liveSettings()]);
|
|
235
|
+
const {from, to} = salesRange(request.query || {}, s);
|
|
236
|
+
return reply.type('text/csv; charset=utf-8').header('Content-Disposition', `attachment; filename="sales-${from}-to-${to}.csv"`)
|
|
237
|
+
.header('Cache-Control', 'no-store').send(salesCsv(orders, {from, to, timeZone: s.timeZone || 'Europe/London'}));
|
|
238
|
+
});
|
|
239
|
+
|
|
204
240
|
fastify.get('/count', can('read'), async () => {
|
|
205
241
|
const [orders, settings, products] = await Promise.all([allOrders(), liveSettings(), allProducts()]);
|
|
206
242
|
return badge(orders, settings, Date.now(), products);
|
|
@@ -379,7 +415,8 @@ export default async function shoppingCart(fastify, options) {
|
|
|
379
415
|
return {
|
|
380
416
|
settings: maskSettings(s),
|
|
381
417
|
webhookUrl: `${siteBase(request)}/api/plugins/shopping-cart/stripe/webhook`,
|
|
382
|
-
stripeMode: pay.stripeMode(s.stripeSecretKey)
|
|
418
|
+
stripeMode: pay.stripeMode(s.stripeSecretKey),
|
|
419
|
+
commission: commissionInfo(s)
|
|
383
420
|
};
|
|
384
421
|
});
|
|
385
422
|
|
|
@@ -413,6 +450,78 @@ export default async function shoppingCart(fastify, options) {
|
|
|
413
450
|
}
|
|
414
451
|
});
|
|
415
452
|
|
|
453
|
+
// ---- Customer accounts (lib/accounts.js) --------------------------------------------
|
|
454
|
+
//
|
|
455
|
+
// Optional (settings.accounts). Made, confirmed and described here; signed in, refreshed and given a new
|
|
456
|
+
// password by core's own /api/auth routes. A signed-in customer sends core's Bearer token.
|
|
457
|
+
|
|
458
|
+
// A boolean, the reply sent on the side: awaiting a Fastify reply resolves (empty) once it is sent, so
|
|
459
|
+
// `if (await fail(...))` would carry on and run the route anyway.
|
|
460
|
+
const accountsOff = async (reply) => {
|
|
461
|
+
if ((await liveSettings()).accounts) return false;
|
|
462
|
+
fail(reply, 404, 'Customer accounts are not switched on.');
|
|
463
|
+
return true;
|
|
464
|
+
};
|
|
465
|
+
/** `rest` -> an absolute address under the shop, for emails. */
|
|
466
|
+
const shopLink = async (request) => {
|
|
467
|
+
const s = await liveSettings();
|
|
468
|
+
const base = siteBase(request);
|
|
469
|
+
return {s, link: (rest) => `${base}${s.shopPath}/${rest}`};
|
|
470
|
+
};
|
|
471
|
+
const accountFail = (reply, err) => fail(reply, err.status || 500, err.message, err.errors ? {errors: err.errors} : {});
|
|
472
|
+
|
|
473
|
+
fastify.post('/account/register', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
474
|
+
if (await accountsOff(reply)) return undefined;
|
|
475
|
+
const {s, link} = await shopLink(request);
|
|
476
|
+
try {
|
|
477
|
+
return await accounts.register(request.body || {}, {link, shopTitle: s.shopTitle || 'the shop', log: request.log});
|
|
478
|
+
} catch (err) { return accountFail(reply, err); }
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
fastify.post('/account/confirm', {config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
482
|
+
try {
|
|
483
|
+
const user = await accounts.confirm(request.body?.u, request.body?.t);
|
|
484
|
+
return {confirmed: true, email: user.email};
|
|
485
|
+
} catch (err) { return accountFail(reply, err); }
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
fastify.post('/account/forgot', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
489
|
+
if (await accountsOff(reply)) return undefined;
|
|
490
|
+
const {s, link} = await shopLink(request);
|
|
491
|
+
await accounts.forgot(request.body?.email, {link, shopTitle: s.shopTitle || 'the shop', log: request.log}).catch(() => null);
|
|
492
|
+
// The same answer whoever asked: the form must not tell anyone who has an account.
|
|
493
|
+
return {ok: true};
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
/** The signed-in customer: details, saved addresses and the orders placed with their email. */
|
|
497
|
+
fastify.get('/account', {preHandler: [authenticate]}, async (request, reply) => {
|
|
498
|
+
const user = await getUserById(request.user.id);
|
|
499
|
+
if (!user || !user.isActive) return fail(reply, 401, 'Please sign in again.');
|
|
500
|
+
const s = await liveSettings();
|
|
501
|
+
const mine = accounts.ordersFor(await allOrders(), user.email).slice(0, 50);
|
|
502
|
+
const orders = [];
|
|
503
|
+
for (const o of mine) {
|
|
504
|
+
orders.push({id: o.id, number: o.data.number, status: o.data.status, total: o.data.total, currency: o.data.currency,
|
|
505
|
+
placedAt: o.meta?.createdAt, receipt: receiptPath(s, o), lines: (o.data.lines || []).map(l => `${l.qty} × ${l.name}${l.variantName ? ` (${l.variantName})` : ''}`),
|
|
506
|
+
parts: await registry.accountParts(o, {settings: s})});
|
|
507
|
+
}
|
|
508
|
+
return {...accounts.describe(user), orders, accounts: Boolean(s.accounts)};
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
fastify.put('/account', {preHandler: [authenticate]}, async (request, reply) => {
|
|
512
|
+
try {
|
|
513
|
+
const b = request.body || {};
|
|
514
|
+
return await accounts.updateDetails(request.user.id, {name: b.name, phone: b.phone, addresses: b.addresses});
|
|
515
|
+
} catch (err) { return accountFail(reply, err); }
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
fastify.delete('/account', {preHandler: [authenticate], config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
519
|
+
try {
|
|
520
|
+
await accounts.remove(request.user.id, request.body?.password);
|
|
521
|
+
return {deleted: true};
|
|
522
|
+
} catch (err) { return accountFail(reply, err); }
|
|
523
|
+
});
|
|
524
|
+
|
|
416
525
|
// ---- Shoppers --------------------------------------------------------------------
|
|
417
526
|
|
|
418
527
|
fastify.get('/catalogue', async (request) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopping-cart",
|
|
3
3
|
"displayName": "Shopping Cart",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
|
|
7
7
|
"author": "Domma CMS",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
],
|
|
46
46
|
"views": {
|
|
47
47
|
"plugin-shopping-cart": {
|
|
48
|
-
"entry": "shopping-cart/admin/views/shop.js?v=1.
|
|
48
|
+
"entry": "shopping-cart/admin/views/shop.js?v=1.6.0",
|
|
49
49
|
"exportName": "shopView"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -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;
|