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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.111.0",
3
+ "version": "0.112.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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 left = (await allBookings())
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: left.length,
425
- label: left.length === 1 ? '1 booking still to come today' : `${left.length} bookings still to come today`,
426
- details: left.slice(0, 8).map(b => `${wallClock(b.startAt, s.timeZone).time} ${b.serviceName}: ${b.name}`).join('\n')
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.0",
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-09-30",
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 waiting = outbox.all().filter(i => i.state === 'waiting').length;
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: unread.length ? `${unread.length} new ${unread.length === 1 ? 'reply' : 'replies'} from Domma` : '',
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: fresh.length,
224
- label: fresh.length ? `${fresh.length} new` : '',
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.2",
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-09-27",
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
- assert.equal((await call(acme, 'GET', '/count')).body.count, 0);
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@f582d78",
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.0",
76
+ "version": "1.6.1",
77
77
  "files": {
78
- "CLAUDE.md": "9bcab41375a2aaecbdd332766ceca5b18bf9dafa99d4c8315549e38033c1f5c5",
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": "186e2419f44ff5b13dfbcea709dc8616f3817c260717839a9fa51834ab73f718",
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": "90424e681de6c086dcc316c734ddb907c7572e452949ced4cc756205a4798f6e",
103
- "plugin.json": "8f7a5b1ed1e0bbfb1e933906b2c63bff4fbfc62868244b2077b1090d87fc3cc0",
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": "43ae40477a43a03d21096038f317d745bc08cd51767c97548bf0601e99ebdd97",
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.2",
119
+ "version": "1.1.3",
120
120
  "files": {
121
- "CLAUDE.md": "38122c71cacfda28fa87afd3321ae77b8a8b02facfa2f0d1bc637e6cfa59e448",
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": "96c95eb4c4c0b84c23822eb6e69aa8ae050d1ba14b283be848bcaa5b236a1ceb",
135
- "plugin.json": "1cc8f582be6b73e39fa1600ea738a5173d6c7378b01b092d35999d8f5de80741",
136
- "tests/api.test.js": "e822b0b9cd2dc5d32eb1f77ae76e34adfe13fb7d0fbe77fe3a7c3cca2a9c6b11",
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.3",
178
+ "version": "1.0.4",
179
179
  "files": {
180
- "CLAUDE.md": "fb9167502ceac9b1cc264f6c287c2e778e628137c87b6f87f84959618df4f1cb",
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": "cc9935594a14ea8cda1eb0d9c507bbc580ba30c8d6beb9265f55f9b03a4c1ba1",
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": "a510386e28eb19d9617f488551d1057819ae9f76fc85c744f2e5dbce492607fe",
201
- "plugin.js": "594c2174e36566f90ac1e9ff11cb76656ad111dc392dc8e9556d731b7dd0a41a",
202
- "plugin.json": "1d76fea187dea958b82e752e5d6e7a729217b08b061490c8590e2c6e6537496e",
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": "a806df62981574a98d435eefc98143b2b03071b5f58a4e7db3cab93850c0e8ae",
223
- "tests/pure.test.js": "dbe35ed7e94cea2f96d42d5abe7d41173393180ff5764c0539799329aa61bfa0"
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
- /** The sidebar badge: paid orders waiting to be sent. Details in the hover. */
102
- export function badge(orders, settings, now = Date.now()) {
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
- count: n,
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.3",
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
- assert.equal((await call('GET', '/count')).body.count, 0);
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
- /** GET /api/analytics/active-count - the sidebar badge: visitors on the site right now. */
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
- const today = todayKey();
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 open = (await loadAll(userId(request))).filter(t => t.status !== 'completed');
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 = `${plural(open.length, 'open task')}${overdue.length ? `, ${overdue.length} overdue` : ''}`;
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
- count: open.length, label, tone: overdue.length ? 'danger' : 'info',
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
  /**