domma-cms 1.6.37 → 1.6.39
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/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +62 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +89 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +173 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +87 -1
- package/plugins/free-tier.lock.json +74 -62
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +35 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +75 -23
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/pure.test.js +16 -0
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +58 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +96 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +76 -2
- package/server/routes/outreach-public.js +2 -2
- package/server/services/request-log.js +3 -1
|
@@ -114,6 +114,44 @@ test('a sale, from product to refund', async () => {
|
|
|
114
114
|
assert.equal(sum.days30.orders, 0, 'a refunded sale is not revenue');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test('a part refund keeps the order paid and remembers what went back; the rest closes it', async () => {
|
|
118
|
+
await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB'], taxRate: 0, shippingFlat: 0});
|
|
119
|
+
let r = await call('POST', '/products', {name: 'Teapot', price: 2500, status: 'active', trackStock: true, stock: 3, shippable: false});
|
|
120
|
+
const pot = r.body;
|
|
121
|
+
r = await call('POST', '/checkout', {lines: [{productId: pot.id, qty: 1}], provider: 'offline', customer: {name: 'Bo', email: 'bo@example.com'}});
|
|
122
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
123
|
+
const id = r.body.orderId;
|
|
124
|
+
r = await call('PUT', `/orders/${id}/status`, {status: 'paid'});
|
|
125
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
126
|
+
|
|
127
|
+
// Too much, nothing, a fraction: refused before anything is written.
|
|
128
|
+
assert.equal((await call('POST', `/orders/${id}/refund`, {amount: 2600})).status, 400);
|
|
129
|
+
assert.equal((await call('POST', `/orders/${id}/refund`, {amount: 0})).status, 400);
|
|
130
|
+
assert.equal((await call('POST', `/orders/${id}/refund`, {amount: 12.5})).status, 400);
|
|
131
|
+
|
|
132
|
+
r = await call('POST', `/orders/${id}/refund`, {amount: 1000, note: 'lid chipped', restock: true});
|
|
133
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
134
|
+
assert.equal(r.body.order.data.status, 'paid', 'a part refund is not a move');
|
|
135
|
+
assert.deepEqual(r.body.refund, {amount: 1000, refunded: 1000, remaining: 1500, full: false});
|
|
136
|
+
assert.equal(r.body.order.data.refunded, 1000);
|
|
137
|
+
assert.equal(r.body.order.data.refunds[0].note, 'lid chipped');
|
|
138
|
+
assert.match(r.body.order.data.history.at(-1).note, /^£10\.00 marked refunded - lid chipped$/);
|
|
139
|
+
assert.equal((await call('GET', '/products')).body.find(p => p.id === pot.id).data.stock, 2, 'stock stays out on a part refund');
|
|
140
|
+
assert.equal((await call('POST', `/orders/${id}/refund`, {amount: 1501})).status, 400, 'only what is left');
|
|
141
|
+
|
|
142
|
+
// The rest: now it is refunded, with both refunds on it and the stock back.
|
|
143
|
+
r = await call('POST', `/orders/${id}/refund`, {restock: true});
|
|
144
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
145
|
+
assert.equal(r.body.order.data.status, 'refunded');
|
|
146
|
+
assert.deepEqual(r.body.order.data.refunds.map(x => x.amount), [1000, 1500]);
|
|
147
|
+
assert.equal(r.body.order.data.refunded, 2500);
|
|
148
|
+
assert.equal((await call('GET', '/products')).body.find(p => p.id === pot.id).data.stock, 3);
|
|
149
|
+
assert.equal((await call('POST', `/orders/${id}/refund`, {})).status, 409);
|
|
150
|
+
|
|
151
|
+
const sales = (await call('GET', '/sales?range=today')).body;
|
|
152
|
+
assert.equal(sales.totals.refundCount >= 2, true);
|
|
153
|
+
});
|
|
154
|
+
|
|
117
155
|
test('free orders are paid at once; a provider that is off is refused', async () => {
|
|
118
156
|
let r = await call('POST', '/products', {name: 'Free guide', price: 0, status: 'active', shippable: false});
|
|
119
157
|
const guide = r.body;
|
|
@@ -325,3 +363,17 @@ test('review: links for emails never come from a request\'s Host header; two ref
|
|
|
325
363
|
assert.deepEqual(both.map(r => r.status).sort(), [200, 409], JSON.stringify(both.map(r => r.body)));
|
|
326
364
|
assert.equal((await reg.services.getOrder(order.id)).data.history.filter(h => h.status === 'refunded').length, 1);
|
|
327
365
|
});
|
|
366
|
+
|
|
367
|
+
test('1.6.2: the badge is kept between writes, and any write (here a new product) refreshes it', async () => {
|
|
368
|
+
const {writeGeneration} = await import('../lib/shop.js');
|
|
369
|
+
const first = (await call('GET', '/count')).body;
|
|
370
|
+
const gen = writeGeneration();
|
|
371
|
+
assert.deepEqual((await call('GET', '/count')).body, first, 'the same minute answers the same');
|
|
372
|
+
assert.equal(writeGeneration(), gen, 'a read writes nothing');
|
|
373
|
+
const r = await call('POST', '/products', {name: 'Badge cup', price: 500, status: 'active'});
|
|
374
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
375
|
+
assert.ok(writeGeneration() > gen, 'the write moved the generation');
|
|
376
|
+
const after = (await call('GET', '/count')).body;
|
|
377
|
+
if (first.inventory) assert.equal(after.count, first.count + 1, 'the new product is counted at once');
|
|
378
|
+
await call('DELETE', `/products/${r.body.id}`);
|
|
379
|
+
});
|
|
@@ -83,7 +83,7 @@ test('commission: stored on the sale when paid, at that day\'s rate; returned wi
|
|
|
83
83
|
const r = (await call('GET', '/sales?range=year')).body;
|
|
84
84
|
assert.deepEqual([r.totals.commission, r.totals.commissionBack, r.totals.commissionNet], [110, 110, 0]);
|
|
85
85
|
const csv = (await call('GET', '/sales.csv?range=year')).text;
|
|
86
|
-
assert.ok(csv.split('\r\n')[0].endsWith('Commission,Commission rate'));
|
|
86
|
+
assert.ok(csv.split('\r\n')[0].endsWith('Commission,Commission rate,Refunded amount'));
|
|
87
87
|
// The customer never sees it.
|
|
88
88
|
const receipt = (await call('GET', `/order/${id}?t=${o.data.token}`)).body;
|
|
89
89
|
assert.ok(!('commission' in receipt));
|
|
@@ -313,3 +313,19 @@ test('payments: Stripe refunds and sessions carry an Idempotency-Key, so a retry
|
|
|
313
313
|
assert.equal(seen[2].init.headers['Idempotency-Key'], 'shop-session-o1');
|
|
314
314
|
} finally { globalThis.fetch = realFetch; }
|
|
315
315
|
});
|
|
316
|
+
|
|
317
|
+
test('admin: no form fields inside E.confirm (its message is sanitised; inputs vanish)', async () => {
|
|
318
|
+
const {readFileSync, readdirSync} = await import('node:fs');
|
|
319
|
+
const dir = new URL('../admin/views/', import.meta.url);
|
|
320
|
+
for (const f of readdirSync(dir).filter(n => n.endsWith('.js'))) {
|
|
321
|
+
const src = readFileSync(new URL(f, dir), 'utf8');
|
|
322
|
+
for (const m of src.matchAll(/E\.confirm\(\s*([A-Za-z_$][\w$]*)\s*[,)]/g)) {
|
|
323
|
+
// A variable passed to E.confirm: find its last assignment before the call and refuse form fields in it.
|
|
324
|
+
const before = src.slice(0, m.index);
|
|
325
|
+
const at = before.lastIndexOf(`${m[1]} =`);
|
|
326
|
+
const assigned = at < 0 ? '' : before.slice(at, at + 4000);
|
|
327
|
+
assert.ok(!/<(input|select|textarea)\b/.test(assigned), `${f}: E.confirm(${m[1]}) carries form fields`);
|
|
328
|
+
}
|
|
329
|
+
for (const m of src.matchAll(/E\.confirm\(`([^`]*)`/g)) assert.ok(!/<(input|select|textarea)\b/.test(m[1]), `${f}: E.confirm with form fields`);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {refundPlan, refundable, refundedSoFar, refundsOf} from '../lib/refunds.js';
|
|
4
|
+
import {salesReport} from '../lib/sales.js';
|
|
5
|
+
|
|
6
|
+
const paid = {status: 'paid', total: 2500, currency: 'GBP', paidAt: '2026-10-01T10:00:00Z'};
|
|
7
|
+
|
|
8
|
+
test('refundPlan: the rest by default, a part when asked, never more than is left', () => {
|
|
9
|
+
assert.deepEqual(refundPlan(paid), {amount: 2500, before: 0, after: 2500, remaining: 0, full: true});
|
|
10
|
+
assert.deepEqual(refundPlan(paid, 1000), {amount: 1000, before: 0, after: 1000, remaining: 1500, full: false});
|
|
11
|
+
const part = {...paid, refunds: [{at: '2026-10-02T10:00:00Z', amount: 1000}]};
|
|
12
|
+
assert.deepEqual(refundPlan(part, '1500'), {amount: 1500, before: 1000, after: 2500, remaining: 0, full: true});
|
|
13
|
+
assert.deepEqual(refundPlan(part), {amount: 1500, before: 1000, after: 2500, remaining: 0, full: true});
|
|
14
|
+
assert.throws(() => refundPlan(part, 1501), /Only 1500 is left/);
|
|
15
|
+
assert.throws(() => refundPlan(paid, 0), {status: 400});
|
|
16
|
+
assert.throws(() => refundPlan(paid, -5), {status: 400});
|
|
17
|
+
assert.throws(() => refundPlan(paid, 12.5), {status: 400});
|
|
18
|
+
assert.throws(() => refundPlan(paid, 'ten'), {status: 400});
|
|
19
|
+
assert.throws(() => refundPlan({...paid, refunds: [{amount: 2500}]}), {status: 409});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('refunds: legacy whole-order refunds read as one refund of the total', () => {
|
|
23
|
+
const legacy = {status: 'refunded', total: 900, refundedAt: '2026-10-05T09:00:00Z'};
|
|
24
|
+
assert.deepEqual(refundsOf(legacy).map(r => [r.amount, r.at]), [[900, '2026-10-05T09:00:00Z']]);
|
|
25
|
+
assert.equal(refundedSoFar(legacy), 900);
|
|
26
|
+
assert.equal(refundable(legacy), 0);
|
|
27
|
+
assert.equal(refundable(paid), 2500);
|
|
28
|
+
assert.deepEqual(refundsOf({status: 'paid', total: 100}), []);
|
|
29
|
+
assert.throws(() => refundPlan(legacy), {status: 409});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('sales: each part refund counts on its own day; the order stays a sale', () => {
|
|
33
|
+
const o = {id: 'o1', meta: {createdAt: '2026-10-01T10:00:00Z'}, data: {...paid, number: 1001, provider: 'stripe', lines: [{productId: 'mug', qty: 1, lineTotal: 2500}],
|
|
34
|
+
adjustments: [], subtotal: 2500, refunds: [{at: '2026-10-03T10:00:00Z', amount: 1000}, {at: '2026-10-06T10:00:00Z', amount: 500}], refunded: 1500}};
|
|
35
|
+
const r = salesReport([o], {from: '2026-10-01', to: '2026-10-07', by: 'day', timeZone: 'UTC'});
|
|
36
|
+
assert.deepEqual([r.totals.orders, r.totals.takings, r.totals.refunds, r.totals.refundCount, r.totals.net], [1, 2500, 1500, 2, 1000]);
|
|
37
|
+
const day = (d) => r.periods.find(p => p.period === d);
|
|
38
|
+
assert.equal(day('2026-10-03').refunds, 1000);
|
|
39
|
+
assert.equal(day('2026-10-06').refunds, 500);
|
|
40
|
+
const later = salesReport([o], {from: '2026-10-05', to: '2026-10-07', by: 'day', timeZone: 'UTC'});
|
|
41
|
+
assert.deepEqual([later.totals.orders, later.totals.refunds, later.totals.net], [0, 500, -500]);
|
|
42
|
+
});
|
|
@@ -82,3 +82,61 @@ Site: new replies (`info`), else open requests as an inventory. Manager: request
|
|
|
82
82
|
- `docs/guide.md` added (the Documentation > Plugins page Feedback already had). The manifest entry carries
|
|
83
83
|
`?v=1.1.2` and `V = '1.1.2'` loads the sub-views - bump both with the version. lib/receiver.js header corrected
|
|
84
84
|
(fleet tokens are on disk since 2026-10-04).
|
|
85
|
+
|
|
86
|
+
## 1.1.3 (2026-10-13 Tool sweep - caching)
|
|
87
|
+
|
|
88
|
+
- **`allTickets()` is cached 30 s** (`lib/cache.js`): the manager badge, `/tickets`, every site's `/fleet/mine`
|
|
89
|
+
(nudged each minute by its badge) and `byRef()` read every request. Every write in lib/receiver.js (`openNow`,
|
|
90
|
+
`customerTurnNow`, `dommaTurnNow`, `patchNow`, `removeTicket`) calls `forget()` after the write. A test that
|
|
91
|
+
writes the collection behind the plugin's back calls `forget()` (see "a customer message lost"). Test:
|
|
92
|
+
`tests/api.test.js` "the inbox memory".
|
|
93
|
+
|
|
94
|
+
## 1.1.4 (2026-10-13) - attachments
|
|
95
|
+
|
|
96
|
+
Files on a customer's messages - the opening request and any follow-up - up to 3 per message, 2 MB each, PNG, JPEG,
|
|
97
|
+
GIF, WebP, PDF or plain text. The same design as Feedback 1.1.6 (read its CLAUDE.md note); Support carries its own
|
|
98
|
+
copy of the pure rules (lib/shape.js) and the store (lib/attachments.js) since either plugin can be installed alone.
|
|
99
|
+
|
|
100
|
+
- Not the CMS media API: that folder is public and needs `media.create`. Own upload route (`POST /attachments`,
|
|
101
|
+
multipart, mime whitelist, size cap) into `data/attachments/` (0600/0700), with a sidecar the send routes read
|
|
102
|
+
the file's description from - never the form.
|
|
103
|
+
- A message announces its files (`messages[].attachments[{id, name, type, size, stored}]`, set by `receivedTicket` /
|
|
104
|
+
`addTurn`); `flush()` sends the message, then each file raw to `POST /fleet/attach/:ticketRef/:attId` (token's
|
|
105
|
+
site only, announced file at the announced size; plugin-scoped `application/octet-stream` parser, own throttle).
|
|
106
|
+
The manager saves under `data/attachments/<ticket id>/` and `rx.markStored` (inside `serial()`) sets `stored`.
|
|
107
|
+
- **The outbox keeps a sent item until its files are done** (`filesDone`): `remember()` used to drop an item the
|
|
108
|
+
moment the manager had it, which would have lost files still to send. `outbox.files` lists every file sent (newest
|
|
109
|
+
2000) so `fileIds()` keeps the site's copy - the desk thread shows files from this site's copy
|
|
110
|
+
(`GET /attachments/:id`); the inbox from `GET /tickets/:id/attachments/:att` (`manage`), listing a file still on
|
|
111
|
+
its way as such.
|
|
112
|
+
- Files go with words: a follow-up still needs text, and a close carries none. Domma's own replies cannot carry
|
|
113
|
+
files (not built).
|
|
114
|
+
- Thread: THREAD's row has a nested `{{#each files key=id}}`; `threadRows(messages, mine, {thumbs, available})`
|
|
115
|
+
builds it, `threadFiles()` fetches blob thumbnails into the `thumbs` observable, `fileKeys()` opens one on
|
|
116
|
+
click/Enter. The picker (`filePicker()`, compose and reply box) uploads on choose or paste and is bound on its own
|
|
117
|
+
host after the panel; its `room`/`uploading` are mirrored into the panel's model by an effect.
|
|
118
|
+
- Deleting a request deletes its folder. Tests: shape.test.js (rules, a message's files, outbox keeps an item until
|
|
119
|
+
its files go), api.test.js (request + follow-up files travel and open, refusals, offline wait).
|
|
120
|
+
|
|
121
|
+
## 1.1.5 (2026-10-13) - settings cog
|
|
122
|
+
|
|
123
|
+
- Settings are read when used, per side (`cleanSettings(raw, current, side)`, `settingsErrors`, `SIDE_SETTINGS` in
|
|
124
|
+
lib/shape.js - emails by `EMAIL_RE`, the phone by the contact card's rule, an empty one allowed): the site's
|
|
125
|
+
`syncMinutes`; the manager's card (`email`, `phone`, `hours`, `responseTime`), `notifyEmail`, `perSiteMax`,
|
|
126
|
+
`perSiteMinutes`. A site still falls back to its own card (`allSettings()`) until the manager's arrives.
|
|
127
|
+
`GET/PUT /settings` answers this side only - a site's for `requireAdmin` (`send` without it), the manager's for
|
|
128
|
+
`manage`; 400 with `errors`; the save merges over the stored object. Tests inject `options.readSettings` /
|
|
129
|
+
`options.writeSettings` (tests/api.test.js's `managerSettings` is mutable - the flood test raises the limit live).
|
|
130
|
+
- The sync timer re-arms itself, asking for `syncMinutes` before each wait; a save re-arms it. The throttles
|
|
131
|
+
(`createThrottle()` takes `{max, windowMs}` per call) and the notify email read the live values. kit.js now loads
|
|
132
|
+
plugin-chrome for `addBannerAction`.
|
|
133
|
+
- `admin/lib/settings.js`: the cog, added by `support.js` once the mode is known, only when the silent
|
|
134
|
+
`GET /settings` answers; slideover through kit's `openPanel`, M bindings, data-help per field, discard guard,
|
|
135
|
+
Ctrl+Enter. Test: tests/settings.test.js.
|
|
136
|
+
|
|
137
|
+
## 1.1.6 (2026-10-13) - a request deleted on the manager takes the site's files with it
|
|
138
|
+
The site keeps a copy of each file it sent (`outbox.files`, newest 2000) to draw its own thread, and the prune spared
|
|
139
|
+
every id on that list - so a request Domma deleted left its screenshots on the site for good. `remember(remote)` now
|
|
140
|
+
narrows `files` with `keepFiles(files, remote, items)` (PURE, tested): only ids in a message of a request the manager
|
|
141
|
+
still holds (its `/fleet/mine` is complete for the site) or named by an outbox item. The next prune (older than a day)
|
|
142
|
+
removes the rest. Found on the dcm click-through.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Support - admin styles. Theme tokens only (every one below is defined by every Domma theme). */
|
|
2
2
|
|
|
3
3
|
/* data-bind-hidden sets [hidden]; any display rule below would otherwise beat it. */
|
|
4
|
-
.sp [hidden], .sp-detail [hidden], .sp-form [hidden] { display: none !important; }
|
|
4
|
+
.sp [hidden], .sp-detail [hidden], .sp-form [hidden], .sp-files-host [hidden] { display: none !important; }
|
|
5
5
|
|
|
6
6
|
.sp { max-width: 60rem; display: flex; flex-direction: column; gap: 1rem; }
|
|
7
7
|
.sp-desk { display: flex; flex-direction: column; gap: 1rem; }
|
|
@@ -92,6 +92,21 @@
|
|
|
92
92
|
.sp-msg-body { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: .25rem; }
|
|
93
93
|
.sp-reply, .sp-side { border-top: 1px solid var(--dm-border); padding-top: .8rem; }
|
|
94
94
|
|
|
95
|
+
/* Attached files: a thumbnail or the file's kind, its name and size */
|
|
96
|
+
.sp-attach { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .4rem; }
|
|
97
|
+
.sp-files { list-style: none; margin: .4rem 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: .4rem; }
|
|
98
|
+
.sp-file { display: flex; align-items: center; gap: .55rem; padding: .35rem .5rem; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md);
|
|
99
|
+
background: var(--dm-surface); cursor: pointer; min-width: 0; }
|
|
100
|
+
.sp-file:hover, .sp-file:focus-visible { border-color: var(--dm-primary); outline: none; }
|
|
101
|
+
.sp-thumb { width: 2.6rem; height: 2.6rem; object-fit: cover; border-radius: var(--dm-radius-md); flex: none; }
|
|
102
|
+
.sp-file-ext { width: 2.6rem; height: 2.6rem; display: inline-flex; align-items: center; justify-content: center; flex: none; font-size: var(--dm-font-size-xs);
|
|
103
|
+
font-weight: 700; color: var(--dm-text-secondary); background: var(--dm-hover-bg); border-radius: var(--dm-radius-md); }
|
|
104
|
+
.sp-file-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
105
|
+
.sp-file-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--dm-font-size-sm); }
|
|
106
|
+
.sp-file-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
|
|
107
|
+
.sp-file-x { border: 0; background: none; color: var(--dm-text-secondary); cursor: pointer; font-size: 1.1rem; line-height: 1; padding: .1rem .35rem; border-radius: var(--dm-radius-md); }
|
|
108
|
+
.sp-file-x:hover { background: var(--dm-hover-bg); color: var(--dm-danger); }
|
|
109
|
+
|
|
95
110
|
@media (max-width: 640px) {
|
|
96
111
|
.sp-choices.is-two { grid-template-columns: 1fr; }
|
|
97
112
|
.sp-row { flex-wrap: wrap; }
|
|
@@ -13,20 +13,23 @@ export const BASE = '/api/plugins/support';
|
|
|
13
13
|
|
|
14
14
|
let kit = null;
|
|
15
15
|
|
|
16
|
-
/** tool-kit,
|
|
16
|
+
/** tool-kit, help-popover and plugin-chrome, once, with stand-ins for an older admin. */
|
|
17
17
|
export function loadKit() {
|
|
18
18
|
if (!kit) {
|
|
19
19
|
kit = Promise.all([
|
|
20
20
|
import('/admin/js/lib/tool-kit.js').catch(() => ({})),
|
|
21
|
-
import('/admin/js/lib/help-popover.js').catch(() => ({}))
|
|
22
|
-
|
|
21
|
+
import('/admin/js/lib/help-popover.js').catch(() => ({})),
|
|
22
|
+
import('/admin/js/lib/plugin-chrome.js').catch(() => ({}))
|
|
23
|
+
]).then(([tk, help, chrome]) => ({
|
|
23
24
|
onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
|
|
24
25
|
openMenuAt: tk.openMenuAt || ((el) => {
|
|
25
26
|
const r = el.getBoundingClientRect();
|
|
26
27
|
el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
|
|
27
28
|
}),
|
|
28
29
|
viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
|
|
29
|
-
helpPopovers: help.helpPopovers || (() => () => {})
|
|
30
|
+
helpPopovers: help.helpPopovers || (() => () => {}),
|
|
31
|
+
// The banner's door (the settings cog, 1.1.5); an older admin has none and gets no cog.
|
|
32
|
+
addBannerAction: chrome.addBannerAction || (() => null)
|
|
30
33
|
}));
|
|
31
34
|
}
|
|
32
35
|
return kit;
|
|
@@ -39,7 +42,7 @@ export const api = {
|
|
|
39
42
|
// site
|
|
40
43
|
tickets: () => H.get(`${BASE}/tickets`),
|
|
41
44
|
open: (d) => H.post(`${BASE}/tickets`, d),
|
|
42
|
-
write: (ref, body) => H.post(`${BASE}/tickets/${seg(ref)}/messages`, {body}),
|
|
45
|
+
write: (ref, body, attachments = []) => H.post(`${BASE}/tickets/${seg(ref)}/messages`, {body, ...(attachments.length && {attachments})}),
|
|
43
46
|
close: (ref) => H.post(`${BASE}/tickets/${seg(ref)}/close`, {}),
|
|
44
47
|
seen: (ref, at) => H.put(`${BASE}/tickets/${seg(ref)}/seen`, {at}),
|
|
45
48
|
/** `at`: Domma's latest reply as the screen showed it (omitted for an offline copy, which has none). */
|
|
@@ -48,9 +51,102 @@ export const api = {
|
|
|
48
51
|
ticket: (id) => H.get(`${BASE}/tickets/${seg(id)}`),
|
|
49
52
|
reply: (id, d) => H.post(`${BASE}/tickets/${seg(id)}/reply`, d),
|
|
50
53
|
update: (id, d) => H.put(`${BASE}/tickets/${seg(id)}`, d),
|
|
51
|
-
remove: (id) => H.delete(`${BASE}/tickets/${seg(id)}`)
|
|
54
|
+
remove: (id) => H.delete(`${BASE}/tickets/${seg(id)}`),
|
|
55
|
+
/** Where a file is read from: this site's own copy, or a request's on the manager. */
|
|
56
|
+
ownFileUrl: (id) => `${BASE}/attachments/${seg(id)}`,
|
|
57
|
+
ticketFileUrl: (id, att) => `${BASE}/tickets/${seg(id)}/attachments/${seg(att)}`
|
|
52
58
|
};
|
|
53
59
|
|
|
60
|
+
// ---------------------------------------------------------------------------------
|
|
61
|
+
// Attachments
|
|
62
|
+
// ---------------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const authHeaders = () => { const t = (() => { try { return S.get('auth_token'); } catch { return ''; } })(); return t ? {Authorization: `Bearer ${t}`} : {}; };
|
|
65
|
+
|
|
66
|
+
/** Upload one file for the message being written. H has no multipart helper, so this is the admin's own upload pattern. */
|
|
67
|
+
export async function uploadFile(file) {
|
|
68
|
+
const fd = new FormData();
|
|
69
|
+
fd.append('file', file, file.name);
|
|
70
|
+
const res = await fetch(`${BASE}/attachments`, {method: 'POST', headers: authHeaders(), body: fd});
|
|
71
|
+
const body = await res.json().catch(() => ({}));
|
|
72
|
+
if (!res.ok) throw new Error(body.error || `Upload failed (${res.status}).`);
|
|
73
|
+
return body;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A file behind the session as a blob: URL (an <img src> or a new tab cannot send the token). Revoke it when done. */
|
|
77
|
+
export async function blobUrl(url) {
|
|
78
|
+
const res = await fetch(url, {headers: authHeaders()});
|
|
79
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `Could not open the file (${res.status}).`);
|
|
80
|
+
return URL.createObjectURL(await res.blob());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,application/pdf,text/plain';
|
|
84
|
+
export const isImage = (type) => /^image\//.test(String(type || ''));
|
|
85
|
+
const EXT = {'image/png': 'PNG', 'image/jpeg': 'JPG', 'image/gif': 'GIF', 'image/webp': 'WEBP', 'application/pdf': 'PDF', 'text/plain': 'TXT'};
|
|
86
|
+
|
|
87
|
+
/** 812 B, 14 KB, 1.2 MB. */
|
|
88
|
+
export function fileSize(n) {
|
|
89
|
+
const b = Number(n) || 0;
|
|
90
|
+
if (b < 1024) return `${b} B`;
|
|
91
|
+
if (b < 1024 * 1024) return `${Math.round(b / 1024)} KB`;
|
|
92
|
+
return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One row of a file list (the picker's, or a message's). */
|
|
96
|
+
export function fileRow(a, {thumb = '', note = '', removable = false, key} = {}) {
|
|
97
|
+
return {key: key || a.id, id: a.id || '', name: a.name, type: a.type, ext: EXT[a.type] || 'FILE', thumb: thumb || '',
|
|
98
|
+
meta: [fileSize(a.size), note].filter(Boolean).join(' · '), removable};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The compose panel's picked files, bound to a `files` observable of fileRow()s. Text bindings only, no icons (rows arrive after the icon scan). */
|
|
102
|
+
export const FILES = `
|
|
103
|
+
<ul class="sp-files" data-bind-hidden="!files.value.length" data-each="files.value key=key"><li class="sp-file" data-key="{{key}}" data-bind-title="name">
|
|
104
|
+
<img class="sp-thumb" alt="" data-bind-src="thumb || false" data-bind-hidden="!thumb">
|
|
105
|
+
<span class="sp-file-ext" data-bind-hidden="thumb" data-bind-text="ext"></span>
|
|
106
|
+
<span class="sp-file-main"><strong data-bind-text="name"></strong><small data-bind-text="meta"></small></span>
|
|
107
|
+
<button type="button" class="sp-file-x" data-bind-hidden="!removable" aria-label="Remove this file" title="Remove">×</button>
|
|
108
|
+
</li></ul>`;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Thumbnails and opening for the files in a thread. The thread's computed reads
|
|
112
|
+
* `thumbs.value`, so a thumbnail shows as soon as its blob arrives.
|
|
113
|
+
*
|
|
114
|
+
* @param {(a) => string} urlOf
|
|
115
|
+
* @param {(a) => boolean} [available] - false for a file that has not reached this server yet
|
|
116
|
+
*/
|
|
117
|
+
export function threadFiles(urlOf, available = () => true) {
|
|
118
|
+
const thumbs = M.observable({});
|
|
119
|
+
const urls = [];
|
|
120
|
+
const asked = new Set();
|
|
121
|
+
let live = true;
|
|
122
|
+
return {
|
|
123
|
+
thumbs,
|
|
124
|
+
/** Fetch the thumbnails not yet asked for. */
|
|
125
|
+
load(messages = []) {
|
|
126
|
+
for (const a of messages.flatMap(m => m.attachments || [])) {
|
|
127
|
+
if (!isImage(a.type) || !available(a) || asked.has(a.id)) continue;
|
|
128
|
+
asked.add(a.id);
|
|
129
|
+
blobUrl(urlOf(a)).then((u) => {
|
|
130
|
+
if (!live) { URL.revokeObjectURL(u); return; }
|
|
131
|
+
urls.push(u);
|
|
132
|
+
thumbs.value = {...thumbs.peek(), [a.id]: u};
|
|
133
|
+
}).catch(() => { /* no thumbnail; the row still opens */ });
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
/** Open one in a new tab. */
|
|
137
|
+
async open(a) {
|
|
138
|
+
if (!a) return;
|
|
139
|
+
if (!available(a)) { E.toast(`${esc(a.name)} has not arrived yet - it follows its message.`, {type: 'info'}); return; }
|
|
140
|
+
try {
|
|
141
|
+
const u = await blobUrl(urlOf(a));
|
|
142
|
+
urls.push(u);
|
|
143
|
+
window.open(u, '_blank', 'noopener');
|
|
144
|
+
} catch (err) { E.toast(esc(err.message || String(err)), {type: 'error'}); }
|
|
145
|
+
},
|
|
146
|
+
dispose() { live = false; for (const u of urls.splice(0)) URL.revokeObjectURL(u); }
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
54
150
|
/** Some routes send a bare array, some an object. */
|
|
55
151
|
export const listOf = (res, key) => res?.[key] ?? (Array.isArray(res) ? res : []);
|
|
56
152
|
|
|
@@ -97,24 +193,46 @@ export const TONE = {open: 'warning', answered: 'info', closed: 'success', waiti
|
|
|
97
193
|
* The conversation, oldest first, as rows for THREAD. `mine` says which side is
|
|
98
194
|
* "us" (customer on a site, domma on the manager).
|
|
99
195
|
*/
|
|
100
|
-
export function threadRows(messages = [], mine) {
|
|
196
|
+
export function threadRows(messages = [], mine, {thumbs = {}, available = () => true, missing = 'on its way'} = {}) {
|
|
101
197
|
return messages.map((m, i) => ({
|
|
102
198
|
key: m.id || `m${i}`,
|
|
103
199
|
cls: `${m.from === mine ? 'is-mine' : 'is-theirs'}${m.from === 'domma' ? ' is-domma' : ''}`,
|
|
104
200
|
who: m.from === 'domma' ? `${m.name || 'Domma'} · Domma Support` : (m.name || 'Customer'),
|
|
105
201
|
when: `${stamp(m.at)}${m.pending ? ' · waiting to send' : ''}${m.refused ? ' · not accepted' : ''}`,
|
|
106
|
-
body: m.body || ''
|
|
202
|
+
body: m.body || '',
|
|
203
|
+
files: (m.attachments || []).map(a => fileRow(a, {thumb: thumbs[a.id], note: available(a) ? '' : missing})),
|
|
204
|
+
hasFiles: Boolean((m.attachments || []).length)
|
|
107
205
|
}));
|
|
108
206
|
}
|
|
109
207
|
|
|
208
|
+
/** A message's file, by id - for the thread's click handler. */
|
|
209
|
+
export const findFile = (messages = [], id) => messages.flatMap(m => m.attachments || []).find(a => a.id === id) || null;
|
|
210
|
+
|
|
110
211
|
/** The thread, bound to a `thread` observable of threadRows(). Text bindings only: nothing is parsed as HTML. */
|
|
111
212
|
export const THREAD = `
|
|
112
213
|
<p class="sp-note" data-bind-hidden="thread.value.length">Nothing here yet.</p>
|
|
113
214
|
<ol class="sp-thread" data-each="thread.value key=key"><li class="sp-msg" data-bind-class="cls" data-key="{{key}}" tabindex="-1">
|
|
114
215
|
<div class="sp-msg-head"><strong data-bind-text="who"></strong><small data-bind-text="when"></small></div>
|
|
115
216
|
<div class="sp-msg-body" data-bind-text="body"></div>
|
|
217
|
+
<ul class="sp-files" data-bind-hidden="!hasFiles">{{#each files key=id}}<li class="sp-file" data-att="{{id}}" tabindex="0" role="button" data-bind-title="name">
|
|
218
|
+
<img class="sp-thumb" alt="" data-bind-src="thumb || false" data-bind-hidden="!thumb">
|
|
219
|
+
<span class="sp-file-ext" data-bind-hidden="thumb">{{ext}}</span>
|
|
220
|
+
<span class="sp-file-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
|
|
221
|
+
</li>{{/each}}</ul>
|
|
116
222
|
</li></ol>`;
|
|
117
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Click or Enter on a file in the thread opens it.
|
|
226
|
+
* @returns {Function} removes the listeners
|
|
227
|
+
*/
|
|
228
|
+
export function fileKeys(box, open) {
|
|
229
|
+
const onClick = (e) => { const li = e.target.closest('.sp-file[data-att]'); if (li) { e.stopPropagation(); open(li.dataset.att); } };
|
|
230
|
+
const onKey = (e) => { const li = e.target.closest('.sp-file[data-att]'); if (li && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); e.stopPropagation(); open(li.dataset.att); } };
|
|
231
|
+
box.addEventListener('click', onClick);
|
|
232
|
+
box.addEventListener('keydown', onKey);
|
|
233
|
+
return () => { box.removeEventListener('click', onClick); box.removeEventListener('keydown', onKey); };
|
|
234
|
+
}
|
|
235
|
+
|
|
118
236
|
/** Copy text, with a toast either way. */
|
|
119
237
|
export async function copyText(text, what = 'Copied') {
|
|
120
238
|
try {
|
|
@@ -166,3 +284,69 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
|
|
|
166
284
|
so.open();
|
|
167
285
|
return {so, panel, close: () => so.close()};
|
|
168
286
|
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The picker for files going with a message: chosen with the button or pasted
|
|
290
|
+
* into the text box, uploaded at once (so Send only names them), shown with a
|
|
291
|
+
* thumbnail and a remove button. Bound on its own host, after the panel's own
|
|
292
|
+
* bindings - the panel's model has no `files`.
|
|
293
|
+
*
|
|
294
|
+
* @param {{host: HTMLElement, input: HTMLInputElement, paste?: HTMLElement, caps: {max: number, maxBytes: number}}} o
|
|
295
|
+
*/
|
|
296
|
+
export function filePicker({host, input, paste, caps}) {
|
|
297
|
+
const picked = M.observable([]);
|
|
298
|
+
const room = M.computed(() => picked.value.length < caps.max);
|
|
299
|
+
const uploading = M.computed(() => picked.value.some(x => x.busy));
|
|
300
|
+
const files = M.computed(() => picked.value.map(x => fileRow(x, {key: x.key, thumb: x.thumb, removable: !x.busy, note: x.busy ? 'uploading...' : ''})));
|
|
301
|
+
const localUrls = [];
|
|
302
|
+
host.innerHTML = FILES;
|
|
303
|
+
const bound = M.applyBindings({files}, host);
|
|
304
|
+
const onRemove = (e) => {
|
|
305
|
+
const x = e.target.closest('.sp-file-x');
|
|
306
|
+
if (x) picked.value = picked.peek().filter(r => r.key !== x.closest('.sp-file').dataset.key);
|
|
307
|
+
};
|
|
308
|
+
host.addEventListener('click', onRemove);
|
|
309
|
+
async function add(list) {
|
|
310
|
+
for (const file of list) {
|
|
311
|
+
if (picked.peek().length >= caps.max) { E.toast(`Up to ${caps.max} files a message.`, {type: 'warning'}); break; }
|
|
312
|
+
if (file.size > caps.maxBytes) { E.toast(`${esc(file.name)} is too big - up to ${fileSize(caps.maxBytes)} each.`, {type: 'warning'}); continue; }
|
|
313
|
+
if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${esc(file.name)} cannot be attached - images, PDF or text only.`, {type: 'warning'}); continue; }
|
|
314
|
+
const key = `k${Date.now()}${Math.random().toString(16).slice(2)}`;
|
|
315
|
+
let thumb = '';
|
|
316
|
+
if (isImage(file.type)) { thumb = URL.createObjectURL(file); localUrls.push(thumb); }
|
|
317
|
+
picked.value = [...picked.peek(), {key, name: file.name, type: file.type, size: file.size, thumb, busy: true}];
|
|
318
|
+
try {
|
|
319
|
+
const a = await uploadFile(file);
|
|
320
|
+
picked.value = picked.peek().map(r => (r.key === key ? {...r, ...a, key, busy: false} : r));
|
|
321
|
+
} catch (err) {
|
|
322
|
+
picked.value = picked.peek().filter(r => r.key !== key);
|
|
323
|
+
E.toast(esc(err.message || String(err)), {type: 'error'});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const onChange = () => { const list = [...input.files]; input.value = ''; add(list); };
|
|
328
|
+
// A screenshot pasted into the message is the quickest way there is.
|
|
329
|
+
const onPaste = (e) => {
|
|
330
|
+
const list = [...(e.clipboardData?.files || [])];
|
|
331
|
+
if (!list.length) return;
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
334
|
+
add(list.map(f => (f.name && f.name !== 'image.png' ? f : new File([f], `screenshot-${stamp}.png`, {type: f.type}))));
|
|
335
|
+
};
|
|
336
|
+
input.addEventListener('change', onChange);
|
|
337
|
+
paste?.addEventListener('paste', onPaste);
|
|
338
|
+
return {
|
|
339
|
+
room, uploading,
|
|
340
|
+
/** What Send names: the uploaded ones. */
|
|
341
|
+
attachments: () => picked.peek().filter(x => x.id && !x.busy).map(x => ({id: x.id, name: x.name, type: x.type, size: x.size})),
|
|
342
|
+
clear() { picked.value = []; },
|
|
343
|
+
dispose() {
|
|
344
|
+
host.removeEventListener('click', onRemove);
|
|
345
|
+
input.removeEventListener('change', onChange);
|
|
346
|
+
paste?.removeEventListener('paste', onPaste);
|
|
347
|
+
bound.dispose();
|
|
348
|
+
[room, uploading, files].forEach(c => c.dispose?.());
|
|
349
|
+
for (const u of localUrls.splice(0)) URL.revokeObjectURL(u);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support - the settings cog (1.1.5). Each side shows its own settings only:
|
|
3
|
+
*
|
|
4
|
+
* a site how often it sends what waits and looks for replies (administrators)
|
|
5
|
+
* the manager the contact card every site shows, where new requests are
|
|
6
|
+
* emailed, and how many messages one site may send (`manage`)
|
|
7
|
+
*
|
|
8
|
+
* They used to be reachable only through Marketplace > Configure and were read
|
|
9
|
+
* at boot; the server now reads them when used, so a save here is live - the
|
|
10
|
+
* card reaches each site with its next sync.
|
|
11
|
+
*
|
|
12
|
+
* @module support/admin/lib/settings
|
|
13
|
+
*/
|
|
14
|
+
import {BASE, esc, openPanel} from './kit.js';
|
|
15
|
+
|
|
16
|
+
/** As lib/shape.js checks them; the server checks again. */
|
|
17
|
+
const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
18
|
+
const PHONE_RE = /^[+\d][\d\s().-]{4,}$/;
|
|
19
|
+
const NUM = {syncMinutes: {min: 1, max: 1440}, perSiteMax: {min: 1, max: 1000}, perSiteMinutes: {min: 1, max: 1440}};
|
|
20
|
+
|
|
21
|
+
const FIELDS = {
|
|
22
|
+
sender: [
|
|
23
|
+
{key: 'syncMinutes', kind: 'number', label: 'Check for replies every (minutes)',
|
|
24
|
+
help: 'How often this site sends any message still waiting and looks for Domma Support\'s replies. Opening Support always checks at once; this is the background check. 1 to 1440 (a day).'}
|
|
25
|
+
],
|
|
26
|
+
receiver: [
|
|
27
|
+
{key: 'email', kind: 'email', label: 'Support email', max: 200,
|
|
28
|
+
help: 'Shown on every site\'s contact card, and the address replies to customers come from. Empty hides the line.'},
|
|
29
|
+
{key: 'phone', kind: 'phone', label: 'Support phone', max: 40,
|
|
30
|
+
help: 'Shown on every site\'s contact card as a tap-to-call link. Digits, spaces and + ( ) . - only. Empty hides the line.'},
|
|
31
|
+
{key: 'hours', kind: 'text', label: 'Opening hours', max: 200,
|
|
32
|
+
help: 'One line on the contact card, for example "Monday to Friday, 9am to 5pm (UK time)".'},
|
|
33
|
+
{key: 'responseTime', kind: 'text', label: 'How fast you answer', max: 300,
|
|
34
|
+
help: 'One line on the contact card, for example "We answer within one working day".'},
|
|
35
|
+
{key: 'notifyEmail', kind: 'email', label: 'Email new requests to', max: 200,
|
|
36
|
+
help: 'Every new request and every customer reply is emailed here, as well as appearing in the Inbox. Empty emails nobody.'},
|
|
37
|
+
{key: 'perSiteMax', kind: 'number', label: 'Messages per site',
|
|
38
|
+
help: 'The most messages one site may send within the window below; more are turned away for now and the site sends them later.'},
|
|
39
|
+
{key: 'perSiteMinutes', kind: 'number', label: 'Window (minutes)',
|
|
40
|
+
help: 'The window the limit above counts in, in minutes.'}
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const problem = (kind, v, key) => {
|
|
45
|
+
const s = String(v ?? '').trim();
|
|
46
|
+
if (kind === 'number') { const n = Number(s); const r = NUM[key]; return s !== '' && Number.isInteger(n) && n >= r.min && n <= r.max ? '' : `A whole number from ${r.min} to ${r.max}.`; }
|
|
47
|
+
if (kind === 'email') return !s || EMAIL_RE.test(s) ? '' : 'One email address, or leave it empty.';
|
|
48
|
+
if (kind === 'phone') return !s || PHONE_RE.test(s) ? '' : 'Digits, spaces and + ( ) . - only.';
|
|
49
|
+
return '';
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Add the cog when this user may change this side's settings (asked once, silently). */
|
|
53
|
+
export async function addSettingsCog($container, kit) {
|
|
54
|
+
let got = null;
|
|
55
|
+
try { got = await H.request('GET', `${BASE}/settings`, null, {silent: true}); } catch { return; }
|
|
56
|
+
if (!got?.side) return;
|
|
57
|
+
kit.addBannerAction($container, {id: 'support-settings', icon: 'settings', title: 'Support settings', onClick: () => openSettings()});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function openSettings() {
|
|
61
|
+
let got;
|
|
62
|
+
try { got = await H.get(`${BASE}/settings`); } catch (err) { E.toast(`Settings not loaded: ${esc(err?.message || err)}`, {type: 'error'}); return; }
|
|
63
|
+
const fields = FIELDS[got.side] || [];
|
|
64
|
+
const f = Object.fromEntries(fields.map(x => [x.key, M.observable(String(got.settings?.[x.key] ?? ''))]));
|
|
65
|
+
const values = () => Object.fromEntries(fields.map(x => [x.key, x.kind === 'number' ? Number(f[x.key].peek()) : f[x.key].peek().trim()]));
|
|
66
|
+
const opened = JSON.stringify(values());
|
|
67
|
+
const saving = M.observable(false);
|
|
68
|
+
const errs = Object.fromEntries(fields.map(x => [`${x.key}Err`, M.computed(() => problem(x.kind, f[x.key].value, x.key))]));
|
|
69
|
+
const model = {...f, ...errs, saving, cannot: M.computed(() => saving.value || Object.values(errs).some(e => e.value))};
|
|
70
|
+
const input = (x) => {
|
|
71
|
+
const type = x.kind === 'number' ? `type="number" min="${NUM[x.key].min}" max="${NUM[x.key].max}" step="1"`
|
|
72
|
+
: x.kind === 'email' ? 'type="email"' : x.kind === 'phone' ? 'type="tel"' : 'type="text"';
|
|
73
|
+
return `<input id="sp-set-${x.key}" ${type} class="form-input"${x.max ? ` maxlength="${x.max}"` : ''} data-model="${x.key}.value">`;
|
|
74
|
+
};
|
|
75
|
+
const html = `
|
|
76
|
+
<div class="sp-form">
|
|
77
|
+
${got.side === 'receiver' ? '<p class="sp-note">The contact card reaches every site with its next sync.</p>' : ''}
|
|
78
|
+
${fields.map(x => `
|
|
79
|
+
<label class="form-label" for="sp-set-${x.key}">${x.label} <span data-help="${esc(x.help)}"></span></label>
|
|
80
|
+
${input(x)}
|
|
81
|
+
<small class="sp-note is-bad" data-bind-hidden="!${x.key}Err.value" data-bind-text="${x.key}Err.value"></small>`).join('')}
|
|
82
|
+
<p class="sp-note">Saved settings take effect at once - no restart.</p>
|
|
83
|
+
<div class="sp-actions">
|
|
84
|
+
<span class="sp-spacer"></span>
|
|
85
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
86
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">Save</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>`;
|
|
89
|
+
let panel = null;
|
|
90
|
+
let closed = false;
|
|
91
|
+
let asking = false;
|
|
92
|
+
const methods = {
|
|
93
|
+
cancel: () => panel?.so.close(),
|
|
94
|
+
async save() {
|
|
95
|
+
if (model.cannot.peek()) return;
|
|
96
|
+
saving.value = true;
|
|
97
|
+
try {
|
|
98
|
+
await H.put(`${BASE}/settings`, values());
|
|
99
|
+
E.toast('Support settings saved.', {type: 'success'});
|
|
100
|
+
panel.so.close(true);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
E.toast(`Not saved: ${esc(err?.message || err)}`, {type: 'error'});
|
|
103
|
+
} finally {
|
|
104
|
+
saving.value = false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
panel = await openPanel({title: 'Support settings', size: 'md', html, model, methods});
|
|
109
|
+
// Closing with a change asks first - once (onceClosed calls close() again, argument-less).
|
|
110
|
+
const close = panel.so.close.bind(panel.so);
|
|
111
|
+
panel.so.close = async (force) => {
|
|
112
|
+
if (!closed && force !== true && JSON.stringify(values()) !== opened) {
|
|
113
|
+
if (asking) return undefined;
|
|
114
|
+
asking = true;
|
|
115
|
+
const ok = await E.confirm('Discard what you have entered?');
|
|
116
|
+
asking = false;
|
|
117
|
+
if (!ok) return undefined;
|
|
118
|
+
}
|
|
119
|
+
closed = true;
|
|
120
|
+
return close();
|
|
121
|
+
};
|
|
122
|
+
panel.panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); methods.save(); } });
|
|
123
|
+
}
|