domma-cms 0.111.0 → 0.112.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/booking/CLAUDE.md +1 -1
- package/plugins/booking/docs/guide.md +1 -1
- package/plugins/booking/plugin.js +18 -5
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/api.test.js +4 -0
- package/plugins/feedback/CLAUDE.md +2 -2
- package/plugins/feedback/plugin.js +18 -5
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +21 -1
- package/plugins/free-tier.lock.json +20 -20
- package/plugins/shopping-cart/CLAUDE.md +1 -1
- package/plugins/shopping-cart/docs/guide.md +1 -1
- package/plugins/shopping-cart/lib/summary.js +15 -5
- package/plugins/shopping-cart/plugin.js +3 -3
- package/plugins/shopping-cart/plugin.json +1 -1
- package/plugins/shopping-cart/tests/api.test.js +5 -1
- package/plugins/shopping-cart/tests/pure.test.js +12 -0
- package/server/routes/api/analytics.js +56 -30
- package/server/routes/api/todo.js +15 -4
- package/server/services/siteGitignore.js +1 -1
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ and resources (rooms, equipment) with weekly hours, days off and business-wide c
|
|
|
7
7
|
buffers before and after, a start step, notice and how far ahead; a public booking page at a configurable address
|
|
8
8
|
(`/booking`) and the `[booking /]` shortcode; confirmation, reminder and owner emails with an .ics; a private link for
|
|
9
9
|
the customer to move or cancel within the notice; a Diary (day / week), a Bookings list, Services and People & rooms
|
|
10
|
-
in the admin; a sidebar badge for bookings still to come today.
|
|
10
|
+
in the admin; a sidebar badge for bookings still to come today (none left: the next 7 days' bookings, as an inventory).
|
|
11
11
|
|
|
12
12
|
Links to other Tools, each absent when that Tool is: customers become contacts (the booked person's address book,
|
|
13
13
|
else the settings' owner, else whoever booked); "Book {name}…" on a contact's menu and a Bookings panel on the
|
|
@@ -11,7 +11,7 @@ Let people book time with you, your team, or a room or piece of equipment, strai
|
|
|
11
11
|
- **People and rooms.** A person is someone customers book an appointment with. A room or resource is something they book, such as a meeting room, a court or a van. Each has its own weekly hours and days off.
|
|
12
12
|
- **Services.** Each service has a length and optional buffer time before and after, for setting up, cleaning or travel. You also choose how often a start time is offered, how much notice you need, how far ahead people can book, and who provides it.
|
|
13
13
|
- **Emails.** Customers get a confirmation with a calendar file and a reminder before the booking, and you get a copy of each new booking. Each email has a private link that lets the customer move or cancel within the notice you set.
|
|
14
|
-
- **The Diary** in the admin, by day or by week, plus a list of every booking. The sidebar badge shows how many bookings are still to come today.
|
|
14
|
+
- **The Diary** in the admin, by day or by week, plus a list of every booking. The sidebar badge shows how many bookings are still to come today; once none are left, it shows how many there are in the next 7 days.
|
|
15
15
|
- **Nobody is double-booked.** A time is only offered while it is free, and it is checked again at the moment of booking.
|
|
16
16
|
|
|
17
17
|
## Getting started
|
|
@@ -417,13 +417,26 @@ export default async function booking(fastify, options) {
|
|
|
417
417
|
const s = await liveSettings();
|
|
418
418
|
const now = Date.now();
|
|
419
419
|
const today = wallClock(now, s.timeZone).date;
|
|
420
|
-
const
|
|
421
|
-
.filter(b => b.status === 'confirmed' && Date.parse(b.endAt) > now && wallClock(b.startAt, s.timeZone).date === today)
|
|
420
|
+
const confirmed = (await allBookings()).filter(b => b.status === 'confirmed')
|
|
422
421
|
.sort((a, b) => a.startAt.localeCompare(b.startAt));
|
|
422
|
+
const left = confirmed.filter(b => Date.parse(b.endAt) > now && wallClock(b.startAt, s.timeZone).date === today);
|
|
423
|
+
const line = (b) => ({text: `${b.serviceName}: ${b.name}`, meta: `${wallClock(b.startAt, s.timeZone).date} ${wallClock(b.startAt, s.timeZone).time}`});
|
|
424
|
+
if (left.length) {
|
|
425
|
+
return {
|
|
426
|
+
count: left.length,
|
|
427
|
+
label: left.length === 1 ? '1 booking still to come today' : `${left.length} bookings still to come today`,
|
|
428
|
+
tone: 'info',
|
|
429
|
+
details: {title: 'Still to come today', items: left.slice(0, 8).map(line), empty: 'Nothing left today.'}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
// Calm: nothing left today, so how many bookings the next 7 days hold (an inventory).
|
|
433
|
+
const week = confirmed.filter(b => { const t = Date.parse(b.startAt); return t > now && t < now + 7 * 86_400_000; });
|
|
423
434
|
return {
|
|
424
|
-
count:
|
|
425
|
-
label:
|
|
426
|
-
|
|
435
|
+
count: week.length,
|
|
436
|
+
label: `${week.length} booking${week.length === 1 ? '' : 's'} in the next 7 days - none left today`,
|
|
437
|
+
tone: 'info',
|
|
438
|
+
inventory: true,
|
|
439
|
+
details: {title: 'The next 7 days', items: week.slice(0, 8).map(line), empty: 'No bookings in the next 7 days.'}
|
|
427
440
|
};
|
|
428
441
|
});
|
|
429
442
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "booking",
|
|
3
3
|
"displayName": "Booking",
|
|
4
|
-
"version": "1.6.
|
|
4
|
+
"version": "1.6.1",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-
|
|
8
|
+
"date": "2026-10-03",
|
|
9
9
|
"icon": "clipboard-check",
|
|
10
10
|
"uses": {
|
|
11
11
|
"contacts": "Customers become contacts; Book… on a contact and a Bookings panel on the contact card",
|
|
@@ -172,6 +172,10 @@ test('set up, book from the page, clash, move, cancel', async () => {
|
|
|
172
172
|
// Badge shape; the email menu action is registered with its permission.
|
|
173
173
|
r = await call('GET', '/count');
|
|
174
174
|
assert.equal(typeof r.body.count, 'number');
|
|
175
|
+
assert.ok(r.body.label, 'never an empty label');
|
|
176
|
+
assert.equal(typeof r.body.details, 'object', 'details is the popover object, not text');
|
|
177
|
+
assert.ok(Array.isArray(r.body.details.items));
|
|
178
|
+
if (r.body.inventory) assert.match(r.body.label, /in the next 7 days - none left today$/);
|
|
175
179
|
assert.equal(related.message.actions[0].permission.resource, 'booking');
|
|
176
180
|
assert.match(related.message.actions[0].url, /\{email\}/);
|
|
177
181
|
});
|
|
@@ -41,7 +41,7 @@ Sender (permission `feedback` action `send`):
|
|
|
41
41
|
|---|---|---|
|
|
42
42
|
| GET | `/mode` | `{mode:'sender', connected, site}` |
|
|
43
43
|
| GET | `/meta` | types, statuses, severities |
|
|
44
|
-
| GET | `/count` | sidebar badge: replies not yet opened (from the outbox copy - never calls the manager) |
|
|
44
|
+
| GET | `/count` | sidebar badge: replies not yet opened (from the outbox copy - never calls the manager); none -> reports sent, `inventory: true` |
|
|
45
45
|
| GET | `/sent` | sends anything waiting, then the manager's `/fleet/mine` merged with the outbox |
|
|
46
46
|
| POST | `/send` | a report: written to the outbox FIRST, then sent; answers `state: sent|waiting` |
|
|
47
47
|
| PUT | `/sent/:id/seen` | `{replyAt}` - this reply has been read |
|
|
@@ -53,7 +53,7 @@ Receiver (inbox routes: permission `feedback` action `manage`):
|
|
|
53
53
|
| POST | `/fleet/submit` | a site's report. **No session**: `Authorization: Bearer <fleet token>` names the site. bodyLimit 32 KB, per-site throttle |
|
|
54
54
|
| GET | `/fleet/mine` | that site's reports, `senderView()` fields only |
|
|
55
55
|
| GET | `/mode` | `{mode:'receiver', waypoint, canSend}` - `canSend` = the user is level 0 |
|
|
56
|
-
| GET | `/count` | badge: status `new` (danger tone if any is a blocker); level 0 also gets a details row of unread replies to its own reports |
|
|
56
|
+
| GET | `/count` | badge: status `new` (danger tone if any is a blocker); level 0 also gets a details row of unread replies to its own reports (and, with nothing new, counts those replies); otherwise every report, `inventory: true` |
|
|
57
57
|
| GET | `/reports` · `/reports/:id` | the inbox; reading one freshens its Waypoint status |
|
|
58
58
|
| PUT | `/reports/:id` | `{status?, reply?, notes?}` |
|
|
59
59
|
| DELETE | `/reports/:id` | gone for the site too |
|
|
@@ -118,11 +118,17 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options})
|
|
|
118
118
|
const seen = outbox.seen();
|
|
119
119
|
// From the last sync's copy: the badge must not call the manager every minute.
|
|
120
120
|
const unread = outbox.all().filter(i => i.reply && i.replyAt && seen[i.remoteId] !== i.replyAt);
|
|
121
|
-
const
|
|
121
|
+
const items = outbox.all();
|
|
122
|
+
const waiting = items.filter(i => i.state === 'waiting').length;
|
|
123
|
+
// Calm: how many reports this site has sent (an inventory, left out of a closed folder's total).
|
|
124
|
+
const calm = !unread.length;
|
|
125
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
122
126
|
return {
|
|
123
|
-
count: unread.length,
|
|
124
|
-
label:
|
|
127
|
+
count: calm ? items.length : unread.length,
|
|
128
|
+
label: calm ? `${plural(items.length - waiting, 'report')} sent${waiting ? `, ${waiting} waiting to send` : ''} - no new replies`
|
|
129
|
+
: `${unread.length} new ${unread.length === 1 ? 'reply' : 'replies'} from Domma`,
|
|
125
130
|
tone: 'info',
|
|
131
|
+
...(calm && {inventory: true}),
|
|
126
132
|
details: {title: 'Feedback', rows: [{label: 'New replies', value: String(unread.length)}, {label: 'Waiting to send', value: String(waiting)}],
|
|
127
133
|
items: unread.slice(0, 5).map(i => ({text: i.payload?.title || 'A report'})), empty: 'Nothing new from Domma.'}
|
|
128
134
|
};
|
|
@@ -219,10 +225,17 @@ async function receiverRoutes(fastify, {can, authenticate, requirePermission, ho
|
|
|
219
225
|
const seen = ownSeen.seen();
|
|
220
226
|
const replies = isTopLevel(request.user)
|
|
221
227
|
? all.filter(e => e.data?.site === rx.MANAGER_SITE && e.data.reply && e.data.replyAt && seen[e.id] !== e.data.replyAt).length : 0;
|
|
228
|
+
// Nothing new: a level-0 user's unread replies are still news; otherwise the inbox's size (an inventory).
|
|
229
|
+
const plural = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
|
|
230
|
+
const count = fresh.length || replies || all.length;
|
|
231
|
+
const label = fresh.length ? `${fresh.length} new`
|
|
232
|
+
: replies ? `${replies} new ${replies === 1 ? 'reply' : 'replies'} to your own feedback`
|
|
233
|
+
: `${plural(all.length, 'report')} - nothing new`;
|
|
222
234
|
return {
|
|
223
|
-
count
|
|
224
|
-
label
|
|
235
|
+
count,
|
|
236
|
+
label,
|
|
225
237
|
tone: fresh.some(e => e.data?.severity === 'blocker') ? 'danger' : 'info',
|
|
238
|
+
...(!fresh.length && !replies && {inventory: true}),
|
|
226
239
|
details: {title: 'New feedback', items: fresh.slice(0, 6).map(e => ({text: e.data.title, meta: n[e.data.site] || e.data.site})),
|
|
227
240
|
...(replies && {rows: [{label: 'New', value: String(fresh.length)}, {label: 'Replies to your own feedback', value: String(replies)}]}),
|
|
228
241
|
empty: 'Nothing new.'}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedback",
|
|
3
3
|
"displayName": "Feedback",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.3",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-
|
|
8
|
+
"date": "2026-10-03",
|
|
9
9
|
"icon": "message-square",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -143,6 +143,20 @@ test('the per-site throttle and the body limit', async () => {
|
|
|
143
143
|
assert.equal(big.statusCode, 413);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
test('the calm badge: every report counted, the waiting ones not called sent', async () => {
|
|
147
|
+
const beta = await site({token: 'tok-beta', user: {id: 'u3', name: 'Bea'}});
|
|
148
|
+
for (const title of ['Calm one', 'Calm two']) assert.equal((await call(beta, 'POST', '/send', {type: 'idea', title, description: 'd'})).body.state, 'sent');
|
|
149
|
+
managerUp = false;
|
|
150
|
+
try {
|
|
151
|
+
assert.equal((await call(beta, 'POST', '/send', {type: 'idea', title: 'Calm three', description: 'd'})).body.state, 'waiting');
|
|
152
|
+
} finally { managerUp = true; }
|
|
153
|
+
const b = (await call(beta, 'GET', '/count')).body;
|
|
154
|
+
assert.equal(b.count, 3);
|
|
155
|
+
assert.equal(b.inventory, true);
|
|
156
|
+
assert.equal(b.label, '2 reports sent, 1 waiting to send - no new replies');
|
|
157
|
+
await beta.close();
|
|
158
|
+
});
|
|
159
|
+
|
|
146
160
|
test('the manager restarting: the report waits, then goes', async () => {
|
|
147
161
|
const acme = await site({token: 'tok-acme', user: {id: 'u2', name: 'Al'}});
|
|
148
162
|
managerUp = false;
|
|
@@ -174,7 +188,13 @@ test('a reply: the site sees it, is told once, and the badge counts it until rea
|
|
|
174
188
|
assert.ok(siteNotices.some(n => n.source === 'reply'));
|
|
175
189
|
assert.equal((await call(acme, 'GET', '/count')).body.count, 1);
|
|
176
190
|
await call(acme, 'PUT', `/sent/${sent.id}/seen`, {replyAt: sent.replyAt});
|
|
177
|
-
|
|
191
|
+
// Read: the badge falls back to how many reports this site has sent - an inventory, never an empty label.
|
|
192
|
+
const calm = (await call(acme, 'GET', '/count')).body;
|
|
193
|
+
assert.equal(calm.inventory, true);
|
|
194
|
+
assert.equal(calm.count, 1, 'this site sent one report (from its own outbox copy)');
|
|
195
|
+
assert.match(calm.label, /reports? sent.* - no new replies$/);
|
|
196
|
+
const inbox = (await call(manager, 'GET', '/api/plugins/feedback/count')).body;
|
|
197
|
+
assert.ok(inbox.label, 'never an empty label');
|
|
178
198
|
await acme.close();
|
|
179
199
|
});
|
|
180
200
|
|
|
@@ -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@
|
|
3
|
+
"source": "dcms-marketplace@b7cee0e",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.4",
|
|
@@ -73,9 +73,9 @@
|
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
75
|
"booking": {
|
|
76
|
-
"version": "1.6.
|
|
76
|
+
"version": "1.6.1",
|
|
77
77
|
"files": {
|
|
78
|
-
"CLAUDE.md": "
|
|
78
|
+
"CLAUDE.md": "9a862d4f9ee4676c67af9699820015d7e0ab98b14b62deee6d51cefe0cc28471",
|
|
79
79
|
"admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
|
|
80
80
|
"admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
|
|
81
81
|
"admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
|
|
@@ -93,14 +93,14 @@
|
|
|
93
93
|
"collections/booking-bookings.json": "fa0b15cc81b4a2114734a0bc8adc4b4d25049d16cf62549b83634cdbeb543b52",
|
|
94
94
|
"collections/booking-services.json": "cecb500a9215a0eba894e6b4c9ae78f5fbeb209830d07d47050c56c75143f66a",
|
|
95
95
|
"config.js": "5565fd627481cf809322257cc300a7cbb7ccbf6cae076b4cf8a7453fcf554bd3",
|
|
96
|
-
"docs/guide.md": "
|
|
96
|
+
"docs/guide.md": "5dbe9a4e0cabdc85a3c6a0b40ea4e1bc02976ad465891d28001c1485b0b1cd50",
|
|
97
97
|
"inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
|
|
98
98
|
"lib/call.js": "87c11b8531e1a6d247d52d4e03ea8e5aa06a1af49cbd7cbd0af97e2586237f7b",
|
|
99
99
|
"lib/extensions.js": "8cec28ff8685daad8083b23f034f1c3bf82fd2322943dd19d46570ccdc311105",
|
|
100
100
|
"lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
|
|
101
101
|
"lib/store.js": "7aa7967e4694e5a3b85378f60302e5a47394b1d90c7dd86e09578156f1c13e03",
|
|
102
|
-
"plugin.js": "
|
|
103
|
-
"plugin.json": "
|
|
102
|
+
"plugin.js": "948cf76ee8495d9a6bce85c4f5549107385c8a947bc7690ff08d6d2b870f458d",
|
|
103
|
+
"plugin.json": "ce7b3e0154550b8c25e447571dc0deb329f4adff13408d569573ea592d91073c",
|
|
104
104
|
"plugin.public.js": "4d5b4e8ad6943182ee3b6c6e6f6762a2d3f676d8d7ee363fcb07bd47d3d6532e",
|
|
105
105
|
"public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
|
|
106
106
|
"public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
|
|
@@ -110,15 +110,15 @@
|
|
|
110
110
|
"public/lib/time.js": "48e13a23e3d7a4916e2097e8f87c03a87d37b71c4ef037a5a16143c6a4185c41",
|
|
111
111
|
"templates/booking.html": "a4995de46dfdab7e692b56f21347f29c76320cf3b0477889dc3c5d14e0b6dcd7",
|
|
112
112
|
"tests/admin-diary.test.js": "4e34b447d62b41272ceec49bad9d491ae00dd04f4760d8a7a3046be1912f4abe",
|
|
113
|
-
"tests/api.test.js": "
|
|
113
|
+
"tests/api.test.js": "b9076e6a5fb7d43cf13cd937751aa20a05e17fa13c55fc3fc52caa1f206d5d10",
|
|
114
114
|
"tests/public.test.js": "0b7b626d60df5a3d73d7c0077b73f59b62a9fded98ce84a8d93cd32cd0333159",
|
|
115
115
|
"tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
|
|
116
116
|
}
|
|
117
117
|
},
|
|
118
118
|
"feedback": {
|
|
119
|
-
"version": "1.1.
|
|
119
|
+
"version": "1.1.3",
|
|
120
120
|
"files": {
|
|
121
|
-
"CLAUDE.md": "
|
|
121
|
+
"CLAUDE.md": "7d6cbce582050a8e0cb74b7e177cbee0051b940369cd9a438546a66eb53051c2",
|
|
122
122
|
"admin/css/index.css": "f2e934c8b94f883340148aba17945dfa725eeb2b0ddb6cabb8a8c17e2487c1de",
|
|
123
123
|
"admin/lib/kit.js": "913761a2cc19b69bed3f05267d45fdb1c08338b1edb081b9272231733d9b5790",
|
|
124
124
|
"admin/templates/feedback.html": "a366dce78f6c3e84a7cb0ac63eba53e1980a4eb44154496a0536f5214c5bb36f",
|
|
@@ -131,9 +131,9 @@
|
|
|
131
131
|
"lib/receiver.js": "c6ab2858dc7bfff6de19b41c84295c0c918ba50ddc57f6d3d83f1352662ebd91",
|
|
132
132
|
"lib/sender.js": "037f9b4469a34ffe1d302a633e664860d625f7fb3be4dfadd01b8a3d094849b7",
|
|
133
133
|
"lib/shape.js": "67acf62f812c2c9a01b52776641ed7ae1601c59ebab8ea278a07d1087a9f299b",
|
|
134
|
-
"plugin.js": "
|
|
135
|
-
"plugin.json": "
|
|
136
|
-
"tests/api.test.js": "
|
|
134
|
+
"plugin.js": "c388bbc2a49b5902773b15a74379c40eeaea58396058136e7901e405b3a396f8",
|
|
135
|
+
"plugin.json": "a2e627cc6c126878a5756080782cc7f29a4024b50b1c485211ecdd0ce6399d94",
|
|
136
|
+
"tests/api.test.js": "3fd27c561af593168ff0c2d717900feda89da4e306f8e0ecac0a6ad68c6d3359",
|
|
137
137
|
"tests/shape.test.js": "5133578142a79cc1a100b3215436372129f01fb0de8c757fd743ff99e1d7e37f"
|
|
138
138
|
}
|
|
139
139
|
},
|
|
@@ -175,9 +175,9 @@
|
|
|
175
175
|
}
|
|
176
176
|
},
|
|
177
177
|
"shopping-cart": {
|
|
178
|
-
"version": "1.0.
|
|
178
|
+
"version": "1.0.4",
|
|
179
179
|
"files": {
|
|
180
|
-
"CLAUDE.md": "
|
|
180
|
+
"CLAUDE.md": "7ca2d1b447923d48600b261907f2ecd421150fd9927f4b402d9f913124a7d408",
|
|
181
181
|
"admin/css/index.css": "a6bab1d0072e364ee1d94c2d7d7b99c3660ba91c5eba537e269a579f5b17f924",
|
|
182
182
|
"admin/lib/kit.js": "c2111d1b10c6c8b18bc061f9f55c83ee8baecc2bcff66f74eb38298f39886d86",
|
|
183
183
|
"admin/templates/shop.html": "c5bb03f1f7cbabfbd8202f3533783eb1848103755b1ba25fdcf1fd8e9478cb8e",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"collections/shop-orders.json": "0bca8eef2d5f9108651f73d273480265c167dc983aa115ce59613d13015ca255",
|
|
190
190
|
"collections/shop-products.json": "4d4613ecec7b8a5a0744c4f0a57b17a4429b461e060206a4bbdea5ad7c8c75dc",
|
|
191
191
|
"config.js": "dcb91db2839c1ab9e40bb1811397024fad9e37e3753d17e2baa2b9b31d4a7d7c",
|
|
192
|
-
"docs/guide.md": "
|
|
192
|
+
"docs/guide.md": "2aae4382723fb2c6a3b4a953547cda28c3d2f19024b736e10e0548af0b106360",
|
|
193
193
|
"inject/body.html": "de20f92c83229545d20e5186fd37ef0046a8820881bff34e7df5391cd7a2c75d",
|
|
194
194
|
"lib/extensions.js": "2cba90efef5b437ace557c7265fe7363ac7923d155681087a63e31e1168f6601",
|
|
195
195
|
"lib/payments.js": "84ad14813688de1f38448b4935180a786cda8af2f7f4b8890a8f1d426982d1aa",
|
|
@@ -197,9 +197,9 @@
|
|
|
197
197
|
"lib/render.js": "310d528f69cd0160db3bd3d3c88f16221e43004c93856587175c0bb149fe3a3d",
|
|
198
198
|
"lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
|
|
199
199
|
"lib/shop.js": "36e1cea342e23f1a53b9fac63b993f860afc1bcb74b25bef9f7bf8eaf07d2d85",
|
|
200
|
-
"lib/summary.js": "
|
|
201
|
-
"plugin.js": "
|
|
202
|
-
"plugin.json": "
|
|
200
|
+
"lib/summary.js": "5697ffa45c9cc5338bbcb6eada08671c3e8376c49c629d2ae99b022397523f71",
|
|
201
|
+
"plugin.js": "16f2c8d93c83cc8a002d3e8629d5f969b2fa03c7e7bb6677b7ac89e43aa2d3d9",
|
|
202
|
+
"plugin.json": "e17bc5158cd94f0110b0131179c346c2324e2bd4d0bacb6715cd0746e6657ba7",
|
|
203
203
|
"plugin.public.js": "ad901378eb28758d18b9f257e4a76d5a453f89fb984ed02a10f62f1a13cd2a62",
|
|
204
204
|
"public/cart.js": "fcc706773a36e4bfd02ea235bcdad629137e996218e0a0e23892e33c689f2a6e",
|
|
205
205
|
"public/lib/money.js": "d22b2b6f80dde6f7887b69e24188c05ea93c02217aba639b5331aa3bd616f4fc",
|
|
@@ -219,8 +219,8 @@
|
|
|
219
219
|
"public/samples/tote.svg": "01de817a32c8d3d8ea47ff491049f439ca3e4d4fce943dbdc62a4187139e016c",
|
|
220
220
|
"public/shop.css": "277904382e491f0ecc76aad916126970b87e161ef06ac6deeff193358b4e4c31",
|
|
221
221
|
"templates/shop.html": "4649e57d23458833a157799dc80141824eb0f658d96734bd1859c68e0ba9a8b9",
|
|
222
|
-
"tests/api.test.js": "
|
|
223
|
-
"tests/pure.test.js": "
|
|
222
|
+
"tests/api.test.js": "6c12db3d3da4887b4334d199d521555fd61cfce1b4d80c0427a92e98bf3cb867",
|
|
223
|
+
"tests/pure.test.js": "f00f27ee8c941d6268eb780520007b7413b0872e2590487f1b460ce5ea09dbb1"
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
}
|
|
@@ -10,7 +10,7 @@ offline; a receipt page that waits for the payment to be confirmed; orders in th
|
|
|
10
10
|
refund (through the provider), cancel, packing slips; an Overview with takings and what needs doing; a sidebar
|
|
11
11
|
badge for paid orders to fulfil; sample products with bundled pictures. 1.0.2 (2026-09-27): every date a person sees
|
|
12
12
|
(order lists, packing slips, the receipt page, the Overview chart) reads dd/mm/yyyy through
|
|
13
|
-
`plugins/_lib/admin/ui/dates.js`.
|
|
13
|
+
`plugins/_lib/admin/ui/dates.js`. 1.0.4: with no orders waiting the badge counts the products (`inventory: true`).
|
|
14
14
|
|
|
15
15
|
`tier: free`. Shopping Cart Pro (plugins/shopping-cart-pro) is an ADD-ON that plugs into this one - it does not
|
|
16
16
|
supersede it (see Extensions).
|
|
@@ -13,7 +13,7 @@ Sell from your own site. Add products, choose how you get paid, and run orders f
|
|
|
13
13
|
- A one-page checkout at `/shop/cart` that takes payment by Stripe, PayPal or offline (for example bank transfer).
|
|
14
14
|
- A receipt page for each order that the shopper can come back to.
|
|
15
15
|
- Orders in the admin: mark paid, fulfil with tracking, refund, cancel, print a packing slip.
|
|
16
|
-
- A badge on the Shop sidebar item showing paid orders waiting to be sent.
|
|
16
|
+
- A badge on the Shop sidebar item showing paid orders waiting to be sent, or how many products you have when nothing is waiting.
|
|
17
17
|
|
|
18
18
|
The free Shopping Cart sends no emails at all - not to the shopper and not to you. The receipt page is the shopper's record of the order. Order confirmation and dispatch emails come with Shopping Cart Pro.
|
|
19
19
|
|
|
@@ -98,24 +98,34 @@ export function summarise(orders, products, settings, now = Date.now()) {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The sidebar badge: paid orders waiting to be sent. Details in the hover.
|
|
103
|
+
* With none waiting it is an inventory instead - how many products the shop
|
|
104
|
+
* has (`inventory: true`, left out of a closed folder's total) - rather than
|
|
105
|
+
* a 0 the sidebar hides.
|
|
106
|
+
*/
|
|
107
|
+
export function badge(orders, settings, now = Date.now(), products = []) {
|
|
103
108
|
const cur = settings.currency;
|
|
104
109
|
const toFulfil = orders.filter(o => o.data?.status === 'paid')
|
|
105
110
|
.sort((a, b) => new Date(a.data.paidAt || a.meta?.createdAt) - new Date(b.data.paidAt || b.meta?.createdAt));
|
|
106
111
|
const awaiting = orders.filter(o => o.data?.status === 'awaiting').length;
|
|
107
112
|
const n = toFulfil.length;
|
|
108
113
|
const oldest = n ? now - new Date(toFulfil[0].data.paidAt || toFulfil[0].meta?.createdAt).getTime() : 0;
|
|
114
|
+
const stock = products.length;
|
|
115
|
+
const calm = n
|
|
116
|
+
? {count: n, label: `${n} order${n === 1 ? '' : 's'} to fulfil`}
|
|
117
|
+
: {count: stock, inventory: true, tone: 'info',
|
|
118
|
+
label: `${stock ? `${stock} product${stock === 1 ? '' : 's'} - no` : 'No'} orders waiting`};
|
|
109
119
|
return {
|
|
110
|
-
|
|
111
|
-
label: n ? `${n} order${n === 1 ? '' : 's'} to fulfil` : 'No orders waiting',
|
|
120
|
+
...calm,
|
|
112
121
|
// Red only when something has waited more than two days.
|
|
113
122
|
...(oldest > 2 * DAY ? {tone: 'danger'} : {}),
|
|
114
123
|
details: {
|
|
115
124
|
title: 'Shop',
|
|
116
125
|
rows: [
|
|
117
126
|
{label: 'To fulfil', value: String(n)},
|
|
118
|
-
...(awaiting ? [{label: 'Awaiting payment', value: String(awaiting)}] : [])
|
|
127
|
+
...(awaiting ? [{label: 'Awaiting payment', value: String(awaiting)}] : []),
|
|
128
|
+
{label: 'Products', value: String(stock)}
|
|
119
129
|
],
|
|
120
130
|
items: toFulfil.slice(0, 6).map(o => ({
|
|
121
131
|
text: `${orderLabel(o.data)} ${o.data.customer?.name || o.data.email || ''}`.trim(),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Admin (the `shop` permission; read to look, manage to change):
|
|
5
5
|
* GET /summary Overview figures
|
|
6
|
-
* GET /count sidebar badge: paid orders to fulfil
|
|
6
|
+
* GET /count sidebar badge: paid orders to fulfil (else how many products)
|
|
7
7
|
* GET /extensions admin tabs extensions (Pro) add
|
|
8
8
|
* GET /products · POST /products · PUT /products/:id · DELETE /products/:id
|
|
9
9
|
* POST /products/:id/duplicate · PUT /products/:id/stock
|
|
@@ -192,8 +192,8 @@ export default async function shoppingCart(fastify, options) {
|
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
fastify.get('/count', can('read'), async () => {
|
|
195
|
-
const [orders, settings] = await Promise.all([allOrders(), liveSettings()]);
|
|
196
|
-
return badge(orders, settings);
|
|
195
|
+
const [orders, settings, products] = await Promise.all([allOrders(), liveSettings(), allProducts()]);
|
|
196
|
+
return badge(orders, settings, Date.now(), products);
|
|
197
197
|
});
|
|
198
198
|
|
|
199
199
|
fastify.get('/extensions', can('read'), async () => ({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopping-cart",
|
|
3
3
|
"displayName": "Shopping Cart",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.4",
|
|
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",
|
|
@@ -97,7 +97,11 @@ test('a sale, from product to refund', async () => {
|
|
|
97
97
|
|
|
98
98
|
r = await call('PUT', `/orders/${orderId}/status`, {status: 'fulfilled', tracking: {carrier: 'Royal Mail', number: 'AB123', url: 'javascript:x'}});
|
|
99
99
|
assert.equal(r.body.data.tracking.url, '', 'only https tracking links');
|
|
100
|
-
|
|
100
|
+
// Sent: nothing waiting, so the badge counts the products instead (an inventory).
|
|
101
|
+
const calm = (await call('GET', '/count')).body;
|
|
102
|
+
assert.equal(calm.inventory, true);
|
|
103
|
+
assert.equal(calm.count, (await call('GET', '/products')).body.length);
|
|
104
|
+
assert.match(calm.label, /products? - no orders waiting$/);
|
|
101
105
|
|
|
102
106
|
// A paid order cannot be deleted; it can be refunded (offline: marked), with the stock back.
|
|
103
107
|
assert.equal((await call('DELETE', `/orders/${orderId}`)).status, 409);
|
|
@@ -192,6 +192,18 @@ test('summary and badge', () => {
|
|
|
192
192
|
const b = badge(orders, S, now);
|
|
193
193
|
assert.equal(b.count, 2);
|
|
194
194
|
assert.equal(b.tone, 'danger', 'one has waited 40 days');
|
|
195
|
+
assert.equal(b.inventory, undefined);
|
|
196
|
+
|
|
197
|
+
// Nothing to send: how many products, as an inventory; none at all stays 0 (hidden).
|
|
198
|
+
const calm = badge([o('fulfilled', 1500, 3)], S, now, [{id: 'p1'}, {id: 'p2'}]);
|
|
199
|
+
assert.equal(calm.count, 2);
|
|
200
|
+
assert.equal(calm.inventory, true);
|
|
201
|
+
assert.equal(calm.tone, 'info');
|
|
202
|
+
assert.equal(calm.label, '2 products - no orders waiting');
|
|
203
|
+
assert.equal(calm.details.rows.at(-1).value, '2');
|
|
204
|
+
const empty = badge([], S, now);
|
|
205
|
+
assert.equal(empty.count, 0);
|
|
206
|
+
assert.equal(empty.label, 'No orders waiting');
|
|
195
207
|
});
|
|
196
208
|
|
|
197
209
|
test('pricing: a shipping handler can offer services to choose between', async () => {
|
|
@@ -106,6 +106,56 @@ function todayKey(date = new Date()) {
|
|
|
106
106
|
return date.toISOString().slice(0, 10);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Analytics' sidebar badge. Two answers, the Layouts pattern:
|
|
111
|
+
*
|
|
112
|
+
* - somebody on the site in the last 5 minutes: that many visitors, tone
|
|
113
|
+
* `live` (the pulsing dot), counted into a closed folder's total;
|
|
114
|
+
* - nobody: today's page views, tone `info`, `inventory: true` - a how-many,
|
|
115
|
+
* so it is shown but left out of the folder's total. No views yet today is
|
|
116
|
+
* no badge.
|
|
117
|
+
*
|
|
118
|
+
* Pure: the counters are read by the route. `today` is the server's day,
|
|
119
|
+
* because that is how daily.json and journeys.json are keyed.
|
|
120
|
+
*
|
|
121
|
+
* @param {{journeys?: object, daily?: object, now?: number, today?: string}} input
|
|
122
|
+
*/
|
|
123
|
+
export function analyticsBadge({journeys = {}, daily = {}, now = Date.now(), today = todayKey(new Date(now))} = {}) {
|
|
124
|
+
const cutoff = now - REALTIME_WINDOW_MS;
|
|
125
|
+
const events = Array.isArray(journeys[today]) ? journeys[today] : [];
|
|
126
|
+
const recent = events.filter(ev => ev.t >= cutoff);
|
|
127
|
+
const n = new Set(recent.map(ev => ev.sid)).size;
|
|
128
|
+
const mins = REALTIME_WINDOW_MS / 60000;
|
|
129
|
+
|
|
130
|
+
// What each active visitor is on now: their latest page.
|
|
131
|
+
const latest = new Map();
|
|
132
|
+
for (const ev of recent) if (!latest.has(ev.sid) || latest.get(ev.sid).t < ev.t) latest.set(ev.sid, ev);
|
|
133
|
+
const onPage = {};
|
|
134
|
+
for (const ev of latest.values()) onPage[ev.url] = (onPage[ev.url] || 0) + 1;
|
|
135
|
+
const viewsToday = Object.values(daily[today] || {}).reduce((a, b) => a + (Number(b) || 0), 0);
|
|
136
|
+
const sessionsToday = new Set(events.map(ev => ev.sid)).size;
|
|
137
|
+
const views = viewsToday.toLocaleString('en-GB');
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...(n
|
|
141
|
+
? {count: n, label: `${plural(n, 'visitor')} on the site in the last ${mins} minutes`, tone: 'live'}
|
|
142
|
+
: {count: viewsToday, inventory: true, tone: 'info',
|
|
143
|
+
label: `${views} view${viewsToday === 1 ? '' : 's'} today - nobody on the site right now`}),
|
|
144
|
+
details: {
|
|
145
|
+
title: 'Analytics',
|
|
146
|
+
rows: [
|
|
147
|
+
{label: 'Active now', value: String(n)},
|
|
148
|
+
{label: 'Views today', value: views},
|
|
149
|
+
{label: 'Sessions today', value: sessionsToday.toLocaleString('en-GB')}
|
|
150
|
+
],
|
|
151
|
+
// URLs come from the PUBLIC hit counter - plain text only, set with textContent.
|
|
152
|
+
items: Object.entries(onPage).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
153
|
+
.map(([url, c]) => ({text: clip(url, 48), meta: `${plural(c, 'visitor')} here now`})),
|
|
154
|
+
empty: `Nobody on the site in the last ${mins} minutes.`
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
109
159
|
function rangeToDates(range, from, to) {
|
|
110
160
|
const today = new Date();
|
|
111
161
|
today.setUTCHours(0, 0, 0, 0);
|
|
@@ -455,38 +505,14 @@ export async function analyticsRoutes(fastify, opts = {}) {
|
|
|
455
505
|
return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
|
|
456
506
|
});
|
|
457
507
|
|
|
458
|
-
/**
|
|
508
|
+
/**
|
|
509
|
+
* GET /api/analytics/active-count - the sidebar badge: visitors on the site
|
|
510
|
+
* right now (tone live) or, while nobody is, today's page views as an
|
|
511
|
+
* inventory. See analyticsBadge().
|
|
512
|
+
*/
|
|
459
513
|
fastify.get('/active-count', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
460
514
|
const [journeys, daily] = await Promise.all([readJson(JOURNEYS_FILE, {}), readJson(DAILY_FILE, {})]);
|
|
461
|
-
|
|
462
|
-
const cutoff = Date.now() - REALTIME_WINDOW_MS;
|
|
463
|
-
const recent = (journeys[today] || []).filter(ev => ev.t >= cutoff);
|
|
464
|
-
const n = new Set(recent.map(ev => ev.sid)).size;
|
|
465
|
-
const mins = REALTIME_WINDOW_MS / 60000;
|
|
466
|
-
|
|
467
|
-
// What each active visitor is on now: their latest page.
|
|
468
|
-
const latest = new Map();
|
|
469
|
-
for (const ev of recent) if (!latest.has(ev.sid) || latest.get(ev.sid).t < ev.t) latest.set(ev.sid, ev);
|
|
470
|
-
const onPage = {};
|
|
471
|
-
for (const ev of latest.values()) onPage[ev.url] = (onPage[ev.url] || 0) + 1;
|
|
472
|
-
const viewsToday = Object.values(daily[today] || {}).reduce((a, b) => a + b, 0);
|
|
473
|
-
const sessionsToday = new Set((journeys[today] || []).map(ev => ev.sid)).size;
|
|
474
|
-
|
|
475
|
-
return {
|
|
476
|
-
count: n, label: `${plural(n, 'visitor')} on the site in the last ${mins} minutes`, tone: 'live',
|
|
477
|
-
details: {
|
|
478
|
-
title: 'Analytics',
|
|
479
|
-
rows: [
|
|
480
|
-
{label: 'Active now', value: String(n)},
|
|
481
|
-
{label: 'Views today', value: viewsToday.toLocaleString('en-GB')},
|
|
482
|
-
{label: 'Sessions today', value: sessionsToday.toLocaleString('en-GB')}
|
|
483
|
-
],
|
|
484
|
-
// URLs come from the PUBLIC hit counter - plain text only, set with textContent.
|
|
485
|
-
items: Object.entries(onPage).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
486
|
-
.map(([url, c]) => ({text: clip(url, 48), meta: `${plural(c, 'visitor')} here now`})),
|
|
487
|
-
empty: `Nobody on the site in the last ${mins} minutes.`
|
|
488
|
-
}
|
|
489
|
-
};
|
|
515
|
+
return analyticsBadge({journeys, daily});
|
|
490
516
|
});
|
|
491
517
|
|
|
492
518
|
fastify.get('/spikes', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
@@ -107,11 +107,17 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
107
107
|
* overdue. `?today=YYYY-MM-DD` is the BROWSER's calendar day - due dates
|
|
108
108
|
* are local days, and the server's today is not the reader's near midnight.
|
|
109
109
|
* Answers {count, label, tone}; tone is 'danger' while anything is overdue.
|
|
110
|
+
*
|
|
111
|
+
* Nothing open: the tasks you have completed, as an inventory (shown, but
|
|
112
|
+
* left out of a closed folder's total) - "All done" with a number says more
|
|
113
|
+
* than a bare 0 would. No tasks at all is no badge.
|
|
110
114
|
*/
|
|
111
115
|
fastify.get('/count', {preHandler: [authenticate, requirePermission('todo', 'read')]}, async (request) => {
|
|
112
116
|
const given = String(request.query?.today ?? '');
|
|
113
117
|
const today = /^\d{4}-\d{2}-\d{2}$/.test(given) ? given : localDay(new Date());
|
|
114
|
-
const
|
|
118
|
+
const all = await loadAll(userId(request));
|
|
119
|
+
const open = all.filter(t => t.status !== 'completed');
|
|
120
|
+
const done = all.length - open.length;
|
|
115
121
|
const dayOf = (t) => /^(\d{4}-\d{2}-\d{2})/.exec(String(t.dueAt ?? ''))?.[1] ?? null;
|
|
116
122
|
const daysBetween = (a, b) => Math.round((Date.parse(a) - Date.parse(b)) / 86_400_000);
|
|
117
123
|
const overdue = open.filter(t => dayOf(t) && dayOf(t) < today);
|
|
@@ -130,16 +136,21 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
130
136
|
return {text: clip(t.text), meta, tone: d !== null && d < 0 ? 'danger' : undefined};
|
|
131
137
|
});
|
|
132
138
|
|
|
133
|
-
const label =
|
|
139
|
+
const label = open.length
|
|
140
|
+
? `${plural(open.length, 'open task')}${overdue.length ? `, ${overdue.length} overdue` : ''}`
|
|
141
|
+
: `All done - ${plural(done, 'task')} completed`;
|
|
134
142
|
return {
|
|
135
|
-
|
|
143
|
+
...(open.length
|
|
144
|
+
? {count: open.length, label, tone: overdue.length ? 'danger' : 'info'}
|
|
145
|
+
: {count: done, label, tone: 'info', inventory: true}),
|
|
136
146
|
details: {
|
|
137
147
|
title: 'Todo',
|
|
138
148
|
rows: [
|
|
139
149
|
{label: 'Open', value: String(open.length)},
|
|
140
150
|
{label: 'Overdue', value: String(overdue.length), tone: overdue.length ? 'danger' : undefined},
|
|
141
151
|
{label: 'Due today', value: String(dueToday.length)},
|
|
142
|
-
{label: 'In progress', value: String(open.filter(t => t.status === 'in-progress').length)}
|
|
152
|
+
{label: 'In progress', value: String(open.filter(t => t.status === 'in-progress').length)},
|
|
153
|
+
{label: 'Completed', value: String(done)}
|
|
143
154
|
],
|
|
144
155
|
items,
|
|
145
156
|
empty: 'Nothing open - all done.'
|
|
@@ -49,7 +49,7 @@ const SITE_ROOTS = ['content', 'config', 'plugins'];
|
|
|
49
49
|
*/
|
|
50
50
|
export const RETIRED_PLUGINS = [
|
|
51
51
|
'analytics', 'contacts', 'notes', 'todo', 'demo-viewer', 'theme-switcher', 'site-search',
|
|
52
|
-
'seo', 'seo-pro', 'shopping-cart', 'form-builder'
|
|
52
|
+
'seo', 'seo-pro', 'shopping-cart', 'form-builder', 'calculator'
|
|
53
53
|
];
|
|
54
54
|
|
|
55
55
|
/**
|