domma-cms 1.6.56 → 1.6.57
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/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
FILE_TYPES, THREAD, TONE, api, copyText, esc, fileKeys, filePicker, fileSize, findFile,
|
|
18
|
-
threadFiles, threadRows, when
|
|
17
|
+
FILE_TYPES, THREAD, TONE, api, copyText, esc, fileKeys, filePicker, fileSize, findFile, guardPanel, listOf, msgKeys, offerDraft, openPanel, panelSet,
|
|
18
|
+
refreshBadge, rowKeys, stamp, store, telHref, threadFiles, threadRows, when
|
|
19
19
|
} from '../lib/kit.js';
|
|
20
|
+
import {DRAFT_KEYS, afterSend, draftQuestion, draftText, draftValues, newDraftQuestion, ticketPrint} from '../lib/drafts.js';
|
|
20
21
|
|
|
21
22
|
const ROW = `
|
|
22
23
|
<div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
|
|
@@ -62,6 +63,8 @@ export async function mount(root, ctx) {
|
|
|
62
63
|
const connected = M.observable(Boolean(ctx.mode.connected));
|
|
63
64
|
const contact = M.observable(ctx.mode.contact || {});
|
|
64
65
|
const tickets = M.observable([]);
|
|
66
|
+
const panels = panelSet();
|
|
67
|
+
scope(() => panels.leave());
|
|
65
68
|
const loading = M.observable(Boolean(ctx.mode.connected));
|
|
66
69
|
const reachable = M.observable(true);
|
|
67
70
|
const error = M.observable('');
|
|
@@ -155,21 +158,24 @@ export async function mount(root, ctx) {
|
|
|
155
158
|
if (c.phone) tel.setAttribute('href', telHref(c.phone));
|
|
156
159
|
}));
|
|
157
160
|
|
|
161
|
+
// The first load shows a skeleton in the slot; a reload keeps the list on screen.
|
|
162
|
+
let loaded = false;
|
|
158
163
|
async function load() {
|
|
159
|
-
|
|
164
|
+
const first = !loaded;
|
|
160
165
|
try {
|
|
161
|
-
const
|
|
166
|
+
const asked = api.tickets();
|
|
167
|
+
const res = first ? await kit.whileLoading(view.querySelector('.sp-loading'), asked, kit.SHAPES.iconRows) : await asked;
|
|
162
168
|
if (!here()) return;
|
|
163
169
|
connected.value = Boolean(res.connected);
|
|
164
170
|
reachable.value = Boolean(res.reachable) || !res.connected;
|
|
165
171
|
if (res.contact) contact.value = res.contact;
|
|
166
172
|
tickets.value = listOf(res, 'tickets');
|
|
167
173
|
error.value = '';
|
|
174
|
+
loaded = true;
|
|
168
175
|
} catch (err) {
|
|
169
|
-
error.value = `Could not load your requests: ${err.message || err}`;
|
|
176
|
+
if (here()) error.value = `Could not load your requests: ${err.message || err}`;
|
|
170
177
|
} finally {
|
|
171
|
-
loading.value = false;
|
|
172
|
-
I.scan(view);
|
|
178
|
+
if (here()) { loading.value = false; I.scan(view); }
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
181
|
|
|
@@ -190,13 +196,14 @@ export async function mount(root, ctx) {
|
|
|
190
196
|
async resolve(t) {
|
|
191
197
|
const r = await api.close(t.ref);
|
|
192
198
|
E.toast(r.message || 'Marked as resolved.', {type: r.state === 'sent' ? 'success' : 'warning'});
|
|
193
|
-
|
|
199
|
+
refreshBadge();
|
|
200
|
+
if (here()) await load();
|
|
194
201
|
},
|
|
195
202
|
async archive(t, on) {
|
|
196
203
|
await api.shelf(t.ref, on ? 'archived' : null, t.stale ? undefined : lastDomma(t));
|
|
197
204
|
patchLocal(t.ref, {archived: on, ...(on && {unread: false})});
|
|
198
205
|
refreshBadge();
|
|
199
|
-
E.toast(on ? `Archived
|
|
206
|
+
E.toast(on ? `Archived “${t.subject}”. A new reply from Domma brings it back.` : `“${t.subject}” is back in your requests.`, {type: 'success'});
|
|
200
207
|
},
|
|
201
208
|
async remove(t) {
|
|
202
209
|
const local = t.status === 'waiting' || t.status === 'refused';
|
|
@@ -205,6 +212,11 @@ export async function mount(root, ctx) {
|
|
|
205
212
|
: `Remove "${esc(t.subject)}" from this site's list? Domma keeps the conversation, and it comes back here if Domma replies again.`;
|
|
206
213
|
if (!await E.confirm(ask)) return false;
|
|
207
214
|
const r = await api.shelf(t.ref, 'removed', t.stale ? undefined : lastDomma(t));
|
|
215
|
+
// Removed on purpose: its panel goes without asking, and so does a message half-written on it.
|
|
216
|
+
const showing = panels.get('detail');
|
|
217
|
+
if (showing?.key === t.ref) showing.close?.(true);
|
|
218
|
+
store.remove(DRAFT_KEYS.reply(t.ref));
|
|
219
|
+
if (!here()) return true;
|
|
208
220
|
tickets.value = tickets.peek().filter(x => x.ref !== t.ref);
|
|
209
221
|
refreshBadge();
|
|
210
222
|
E.toast(r.state === 'discarded' ? 'Removed - it will not be sent.'
|
|
@@ -229,12 +241,25 @@ export async function mount(root, ctx) {
|
|
|
229
241
|
];
|
|
230
242
|
}
|
|
231
243
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
244
|
+
const FORM = {category: 'help', priority: 'normal', subject: '', body: '', where: ''};
|
|
245
|
+
|
|
246
|
+
async function compose() {
|
|
247
|
+
if (!panels.claim('compose')) return;
|
|
248
|
+
let opened = false;
|
|
249
|
+
try {
|
|
250
|
+
// A request left unsent last time is offered back first; it goes only on "Throw away".
|
|
251
|
+
const offer = await offerDraft(DRAFT_KEYS.compose, {here, question: (d) => newDraftQuestion(d, 'the request')});
|
|
252
|
+
if (offer.state !== 'gone') opened = await openCompose(offer.state === 'keep' ? draftValues(offer.draft, FORM) : {...FORM});
|
|
253
|
+
} catch (err) { fail(err); }
|
|
254
|
+
if (!opened) panels.release('compose');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function openCompose(start) {
|
|
258
|
+
const f = {category: M.observable(start.category), priority: M.observable(start.priority), subject: M.observable(start.subject),
|
|
259
|
+
body: M.observable(start.body), where: M.observable(start.where), busy: M.observable(false), room: M.observable(true), uploading: M.observable(false)};
|
|
260
|
+
let guard = null;
|
|
236
261
|
let wired = null;
|
|
237
|
-
openPanel({
|
|
262
|
+
const p = await openPanel({
|
|
238
263
|
title: 'Ask Domma for help',
|
|
239
264
|
model: f,
|
|
240
265
|
cleanup: () => wired?.dispose(),
|
|
@@ -245,7 +270,7 @@ export async function mount(root, ctx) {
|
|
|
245
270
|
</div>
|
|
246
271
|
<label class="form-label" for="sp-subject">Subject</label>
|
|
247
272
|
<input id="sp-subject" class="form-input" maxlength="140" data-model="subject.value" placeholder="One line - e.g. My contact form stopped sending">
|
|
248
|
-
<label class="form-label" for="sp-body">How can we help? <span data-help="What you were doing, what you expected and what happened instead. A page address or the screen you were on helps us get there faster."></span></label>
|
|
273
|
+
<label class="form-label" for="sp-body">How can we help? <span data-help="What you were doing, what you expected and what happened instead. A page address or the screen you were on helps us get there faster. If you leave before sending, what you typed is kept and offered back next time."></span></label>
|
|
249
274
|
<textarea id="sp-body" class="form-textarea" rows="8" maxlength="8000" data-model="body.value"></textarea>
|
|
250
275
|
<label class="form-label">How urgent is it?</label>
|
|
251
276
|
<div class="sp-choices is-two" role="radiogroup" aria-label="Priority">
|
|
@@ -253,7 +278,7 @@ export async function mount(root, ctx) {
|
|
|
253
278
|
</div>
|
|
254
279
|
<label class="form-label" for="sp-where">Where <span data-help="The page or admin screen it is about, if there is one - e.g. /contact, or Pages. Optional."></span></label>
|
|
255
280
|
<input id="sp-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
|
|
256
|
-
<label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps us most. Only the Domma team sees them.`)}"></span></label>
|
|
281
|
+
<label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps us most. Only the Domma team sees them. Files are not kept if you leave before sending - attach them again.`)}"></span></label>
|
|
257
282
|
${attachUi('Or paste a screenshot into the message.')}
|
|
258
283
|
<p class="sp-hint">Sent with your name, email and this site, so we can reply here and by email.</p>
|
|
259
284
|
<div class="sp-actions"><span class="sp-spacer"></span>
|
|
@@ -263,36 +288,71 @@ export async function mount(root, ctx) {
|
|
|
263
288
|
</div>`,
|
|
264
289
|
methods: {
|
|
265
290
|
pick: (group, value) => { if (f[group]) f[group].value = value; },
|
|
266
|
-
cancel: () =>
|
|
267
|
-
pickFiles: () =>
|
|
291
|
+
cancel: () => guard?.close(),
|
|
292
|
+
pickFiles: () => p?.panel.querySelector('.sp-file-input')?.click(),
|
|
268
293
|
async send() {
|
|
269
294
|
if (f.busy.peek() || f.uploading.peek()) return;
|
|
270
295
|
f.busy.value = true;
|
|
296
|
+
// The panel closes once sent: locked meanwhile, so nothing typed during the request is lost.
|
|
297
|
+
guard?.lock(true);
|
|
271
298
|
try {
|
|
272
299
|
const r = await api.open({category: f.category.peek(), priority: f.priority.peek(), subject: f.subject.peek(),
|
|
273
300
|
body: f.body.peek(), where: f.where.peek(), attachments: wired?.picker.attachments() || []});
|
|
274
301
|
E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
|
|
275
|
-
|
|
302
|
+
guard?.settle();
|
|
303
|
+
guard?.close(true);
|
|
276
304
|
refreshBadge();
|
|
305
|
+
if (!here()) return;
|
|
277
306
|
filter.value = 'active';
|
|
278
307
|
await load();
|
|
279
308
|
} catch (err) {
|
|
309
|
+
guard?.lock(false);
|
|
280
310
|
fail(err);
|
|
281
311
|
} finally {
|
|
282
312
|
f.busy.value = false;
|
|
283
313
|
}
|
|
284
314
|
}
|
|
285
315
|
}
|
|
286
|
-
}).then(p => {
|
|
287
|
-
panel = p;
|
|
288
|
-
wired = wirePicker(p, '#sp-body', f.room, f.uploading);
|
|
289
|
-
p.panel.querySelector('#sp-subject')?.focus();
|
|
290
316
|
});
|
|
317
|
+
wired = wirePicker(p, '#sp-body', f.room, f.uploading);
|
|
318
|
+
guard = guardPanel(p, {
|
|
319
|
+
key: DRAFT_KEYS.compose,
|
|
320
|
+
values: () => ({category: f.category.value, priority: f.priority.value, subject: f.subject.value, body: f.body.value, where: f.where.value}),
|
|
321
|
+
baseline: {...FORM},
|
|
322
|
+
extraDirty: () => (wired?.picker.attachments() || []).length > 0 || f.uploading.peek(),
|
|
323
|
+
onClosed: () => panels.release('compose')
|
|
324
|
+
});
|
|
325
|
+
panels.set('compose', {close: (how) => guard.close(how)});
|
|
326
|
+
p.panel.querySelector('#sp-subject')?.focus();
|
|
327
|
+
return true;
|
|
291
328
|
}
|
|
292
329
|
|
|
330
|
+
/** One request open at a time: opening another closes (and may ask about) the one showing. */
|
|
293
331
|
async function open(ref) {
|
|
332
|
+
const showing = panels.get('detail');
|
|
333
|
+
if (showing?.key === ref) return;
|
|
334
|
+
if (showing) { await showing.close?.(); if (panels.get('detail')) return; }
|
|
335
|
+
if (!here() || !panels.claim('detail')) return;
|
|
336
|
+
let opened = false;
|
|
337
|
+
try { opened = await openTicket(ref); } catch (err) { fail(err); }
|
|
338
|
+
if (!opened) panels.release('detail');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function openTicket(ref) {
|
|
342
|
+
const key = DRAFT_KEYS.reply(ref);
|
|
294
343
|
const t0 = byRef(ref);
|
|
295
|
-
if (!t0)
|
|
344
|
+
if (!t0) {
|
|
345
|
+
// Gone from Domma (or removed here) with a message half-written on it: offer the words back.
|
|
346
|
+
const offer = await offerDraft(key, {here, question: (d) => draftQuestion(d, null, {what: 'a request'})});
|
|
347
|
+
if (offer.state === 'keep' && await copyText(draftText(offer.draft), 'Your unsent message is copied')) store.remove(key);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
const canWrite = !['waiting', 'refused'].includes(t0.status);
|
|
351
|
+
const offer = canWrite
|
|
352
|
+
? await offerDraft(key, {here, question: (d) => draftQuestion(d, ticketPrint(t0), {what: `your reply to “${t0.subject}”`,
|
|
353
|
+
changed: 'Domma has written since', changedAt: lastDomma(t0)})})
|
|
354
|
+
: {state: 'none'};
|
|
355
|
+
if (offer.state === 'gone') return false;
|
|
296
356
|
// The panel follows the list's copy, so a reload or a move from the row menu shows here at once.
|
|
297
357
|
const cur = M.computed(() => tickets.value.find(x => x.ref === ref) || t0);
|
|
298
358
|
// Every file in this site's requests was attached here, so this site's own copy shows them -
|
|
@@ -307,12 +367,12 @@ export async function mount(root, ctx) {
|
|
|
307
367
|
const statusLabel = M.computed(() => cur.value.statusLabel || cur.value.status);
|
|
308
368
|
const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
|
|
309
369
|
const archived = M.computed(() => Boolean(cur.value.archived));
|
|
310
|
-
const draft = M.observable('');
|
|
370
|
+
const draft = M.observable(offer.state === 'keep' ? draftValues(offer.draft, {text: ''}).text : '');
|
|
311
371
|
// Bindings may not call methods (.trim()), so the test is a computed.
|
|
312
372
|
const hasDraft = M.computed(() => Boolean(draft.value.trim()));
|
|
313
373
|
const busy = M.observable(false);
|
|
314
|
-
const canWrite = !['waiting', 'refused'].includes(t0.status);
|
|
315
374
|
let panel = null;
|
|
375
|
+
let guard = null;
|
|
316
376
|
let stopScroll = () => {};
|
|
317
377
|
let menu = null;
|
|
318
378
|
const run = async (fn) => {
|
|
@@ -327,6 +387,7 @@ export async function mount(root, ctx) {
|
|
|
327
387
|
cleanup: () => {
|
|
328
388
|
stopScroll(); stopFiles(); menu?.destroy?.(); wired?.dispose(); shown.dispose();
|
|
329
389
|
[cur, thread, status, statusLabel, tone, archived, hasDraft].forEach(c => c.dispose?.());
|
|
390
|
+
panels.release('detail');
|
|
330
391
|
},
|
|
331
392
|
html: `<div class="sp-detail">
|
|
332
393
|
<dl class="sp-facts">
|
|
@@ -339,7 +400,7 @@ export async function mount(root, ctx) {
|
|
|
339
400
|
</dl>
|
|
340
401
|
<div class="sp-thread-host">${THREAD}</div>
|
|
341
402
|
${canWrite ? `<div class="sp-reply">
|
|
342
|
-
<label class="form-label" for="sp-write" data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></label>
|
|
403
|
+
<label class="form-label" for="sp-write"><span data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></span> <span data-help="Goes to the Domma team with your name. If you leave before sending, what you typed is kept and offered back when you open this request again. Right-click a message (or its ⋮, or Shift+F10) to copy it."></span></label>
|
|
343
404
|
<textarea id="sp-write" class="form-textarea" rows="4" maxlength="8000" data-model="draft.value" placeholder="Write to Domma Support"></textarea>
|
|
344
405
|
${attachUi('Files go with a message - paste a screenshot into it, or attach one.')}
|
|
345
406
|
<div class="sp-actions">
|
|
@@ -353,29 +414,48 @@ export async function mount(root, ctx) {
|
|
|
353
414
|
</div>`,
|
|
354
415
|
methods: {
|
|
355
416
|
pickFiles: () => panel?.panel.querySelector('.sp-file-input')?.click(),
|
|
417
|
+
// The panel stays open: what is typed while it sends stays in the box, unsent and kept.
|
|
356
418
|
send: () => run(async () => {
|
|
357
419
|
if (!draft.peek().trim() || uploading.peek()) return;
|
|
358
|
-
const
|
|
420
|
+
const sent = draft.peek();
|
|
421
|
+
const files = wired?.picker.attachments() || [];
|
|
422
|
+
const r = await api.write(ref, sent, files);
|
|
359
423
|
if (r.state !== 'sent') E.toast(r.message, {type: 'warning'});
|
|
360
|
-
draft.value =
|
|
361
|
-
|
|
424
|
+
draft.value = afterSend(draft.peek(), sent);
|
|
425
|
+
// Only the files that went: any picked while it was sending stay for the next message.
|
|
426
|
+
wired?.picker.drop(files.map(a => a.id));
|
|
427
|
+
// What is left unsent is kept (or the draft goes) even if the panel was left meanwhile;
|
|
428
|
+
// our own message is not "Domma has written since", so `was` moves on.
|
|
429
|
+
guard?.rebase({text: ''}, ticketPrint({messages: [...(cur.peek().messages || []), {id: r.ref}]}));
|
|
430
|
+
refreshBadge();
|
|
431
|
+
if (!here()) return;
|
|
362
432
|
await load();
|
|
433
|
+
guard?.rebase({text: ''}, ticketPrint(cur.peek()));
|
|
363
434
|
}),
|
|
364
435
|
resolve: () => run(() => actions.resolve(cur.peek())),
|
|
365
436
|
archive: () => run(async () => {
|
|
366
437
|
const on = !archived.peek();
|
|
367
438
|
await actions.archive(cur.peek(), on);
|
|
368
|
-
if (on)
|
|
439
|
+
if (on) guard?.close();
|
|
369
440
|
})
|
|
370
441
|
}
|
|
371
442
|
});
|
|
443
|
+
if (canWrite) wired = wirePicker(panel, '#sp-write', room, uploading);
|
|
444
|
+
guard = guardPanel(panel, {
|
|
445
|
+
key,
|
|
446
|
+
values: () => ({text: draft.value}),
|
|
447
|
+
baseline: {text: ''},
|
|
448
|
+
was: offer.state === 'keep' ? offer.draft.was : ticketPrint(t0),
|
|
449
|
+
extraDirty: () => (wired?.picker.attachments() || []).length > 0 || uploading.peek()
|
|
450
|
+
});
|
|
451
|
+
panels.set('detail', {key: ref, close: (how) => guard.close(how)});
|
|
372
452
|
const box = panel.panel.querySelector('.sp-thread-host');
|
|
373
453
|
stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
|
|
374
|
-
if (canWrite) wired = wirePicker(panel, '#sp-write', room, uploading);
|
|
375
454
|
// Thumbnails for the images in the thread, as messages come; click a file to open it.
|
|
376
455
|
const fileStop = M.effect(() => { shown.load(cur.value.messages || []); });
|
|
377
456
|
const keysOff = fileKeys(box, (id) => shown.open(findFile(cur.peek().messages, id)));
|
|
378
|
-
|
|
457
|
+
const msgOff = msgKeys(box, kit.openMenuAt);
|
|
458
|
+
stopFiles = () => { fileStop(); keysOff(); msgOff(); };
|
|
379
459
|
menu = E.contextMenu(box, {
|
|
380
460
|
cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
|
|
381
461
|
items: (li) => {
|
|
@@ -384,6 +464,7 @@ export async function mount(root, ctx) {
|
|
|
384
464
|
}
|
|
385
465
|
});
|
|
386
466
|
await act('seen')(cur.peek());
|
|
467
|
+
return true;
|
|
387
468
|
}
|
|
388
469
|
|
|
389
470
|
const bindings = M.applyBindings({connected, contact, loading, reachable, error, filter, rows, counts, hasArchive, emptyText, waitingCount}, view, {
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
-
THREAD, TONE, api, copyText, esc, fileKeys, findFile, listOf, openPanel, refreshBadge, rowKeys, stamp, store,
|
|
12
|
+
THREAD, TONE, api, copyText, esc, fileKeys, findFile, guardPanel, listOf, msgKeys, offerDraft, openPanel, panelSet, refreshBadge, rowKeys, stamp, store,
|
|
13
|
+
threadFiles, threadRows, when
|
|
13
14
|
} from '../lib/kit.js';
|
|
15
|
+
import {DRAFT_KEYS, afterSend, draftQuestion, draftText, draftValues, ticketPrint} from '../lib/drafts.js';
|
|
14
16
|
|
|
15
17
|
const ROW = `
|
|
16
18
|
<div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
|
|
@@ -40,6 +42,8 @@ export async function mount(root, ctx) {
|
|
|
40
42
|
const inboxLabel = (s) => statuses.find(x => x.key === s)?.inboxLabel || s;
|
|
41
43
|
|
|
42
44
|
const tickets = M.observable([]);
|
|
45
|
+
const panels = panelSet();
|
|
46
|
+
scope(() => panels.leave());
|
|
43
47
|
const loading = M.observable(true);
|
|
44
48
|
const error = M.observable('');
|
|
45
49
|
const stored = store.get('support.filter');
|
|
@@ -100,21 +104,20 @@ export async function mount(root, ctx) {
|
|
|
100
104
|
scope(M.effect(() => { rows.value; Promise.resolve().then(() => { M.flush?.(); I.scan(listEl); }); }));
|
|
101
105
|
|
|
102
106
|
async function load() {
|
|
103
|
-
loading.value = true;
|
|
104
107
|
try {
|
|
105
|
-
const res = await api.tickets();
|
|
108
|
+
const res = await kit.whileLoading(view.querySelector('.sp-loading'), api.tickets(), kit.SHAPES.iconRows);
|
|
106
109
|
if (!here()) return;
|
|
107
110
|
tickets.value = listOf(res, 'tickets');
|
|
108
111
|
error.value = '';
|
|
109
112
|
} catch (err) {
|
|
110
|
-
error.value = `Could not load requests: ${err.message || err}`;
|
|
113
|
+
if (here()) error.value = `Could not load requests: ${err.message || err}`;
|
|
111
114
|
} finally {
|
|
112
|
-
loading.value = false;
|
|
115
|
+
if (here()) loading.value = false;
|
|
113
116
|
}
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
const byId = (tid) => tickets.peek().find(t => t.id === tid);
|
|
117
|
-
const replace = (t) => { tickets.value = tickets.peek().map(x => (x.id === t.id ? {...x, ...t} : x)); refreshBadge(); return t; };
|
|
120
|
+
const replace = (t) => { if (here()) tickets.value = tickets.peek().map(x => (x.id === t.id ? {...x, ...t} : x)); refreshBadge(); return t; };
|
|
118
121
|
const fail = (err) => E.toast(err.message || String(err), {type: 'error'});
|
|
119
122
|
|
|
120
123
|
// ---- The moves: one definition each, for the row menu and the panel alike ----
|
|
@@ -126,20 +129,22 @@ export async function mount(root, ctx) {
|
|
|
126
129
|
async urgent(t, on) { replace(await api.update(t.id, {priority: on ? 'urgent' : 'normal'})); },
|
|
127
130
|
async archive(t, on) {
|
|
128
131
|
replace(await api.update(t.id, {archived: on}));
|
|
129
|
-
E.toast(on ? `Archived
|
|
132
|
+
E.toast(on ? `Archived “${t.subject}”.` : `“${t.subject}” is back in the inbox.`, {type: 'success'});
|
|
130
133
|
},
|
|
131
134
|
async remove(t) {
|
|
132
135
|
if (!await E.confirm(`Delete "${esc(t.subject)}" for good? It goes from ${esc(t.siteName)}'s admin too. Archive it instead to keep it.`)) return false;
|
|
133
136
|
await api.remove(t.id);
|
|
134
|
-
|
|
137
|
+
// Deleted on purpose: its panel goes without asking, and so does a reply half-written on it.
|
|
138
|
+
const showing = panels.get('detail');
|
|
139
|
+
if (showing?.key === t.id) showing.close?.(true);
|
|
140
|
+
store.remove(DRAFT_KEYS.answer(t.id));
|
|
141
|
+
if (!here()) return true;
|
|
135
142
|
tickets.value = tickets.peek().filter(x => x.id !== t.id);
|
|
136
143
|
refreshBadge();
|
|
137
144
|
E.toast('Deleted.', {type: 'success'});
|
|
138
145
|
return true;
|
|
139
146
|
}
|
|
140
147
|
};
|
|
141
|
-
/** The request open in a panel, so a delete from its row menu closes it. */
|
|
142
|
-
let openPanel_ = null;
|
|
143
148
|
const act = (name, ...args) => async (t) => { try { return await actions[name](t, ...args); } catch (err) { fail(err); return false; } };
|
|
144
149
|
|
|
145
150
|
function menuItems(t) {
|
|
@@ -164,12 +169,41 @@ export async function mount(root, ctx) {
|
|
|
164
169
|
|
|
165
170
|
// ---- A request, in a panel ----
|
|
166
171
|
|
|
172
|
+
/** One request open at a time: opening another closes (and may ask about) the one showing. */
|
|
167
173
|
async function open(tid) {
|
|
174
|
+
const showing = panels.get('detail');
|
|
175
|
+
if (showing?.key === tid) return;
|
|
176
|
+
if (showing) { await showing.close?.(); if (panels.get('detail')) return; }
|
|
177
|
+
if (!here() || !panels.claim('detail')) return;
|
|
178
|
+
let opened = false;
|
|
179
|
+
try { opened = await openTicket(tid); } catch (err) { fail(err); }
|
|
180
|
+
if (!opened) panels.release('detail');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function openTicket(tid) {
|
|
184
|
+
const key = DRAFT_KEYS.answer(tid);
|
|
168
185
|
// Read fresh: the panel shows where a reply will be emailed, which only the detail route works out.
|
|
169
186
|
let t;
|
|
170
|
-
|
|
171
|
-
|
|
187
|
+
let gone = false;
|
|
188
|
+
try { t = await api.ticket(tid); } catch (err) {
|
|
189
|
+
gone = err?.status === 404 || /no such/i.test(err?.message || '');
|
|
190
|
+
if (!here()) return false;
|
|
191
|
+
// Only a 404 is a deletion. A slow or failing server says so, and any draft stays for next time.
|
|
192
|
+
if (!gone) { E.toast(`Could not open the request: ${err?.message || err}`, {type: 'error'}); return false; }
|
|
193
|
+
}
|
|
194
|
+
if (!here()) return false;
|
|
195
|
+
if (!t) {
|
|
196
|
+
// Deleted - with a reply or notes half-written on it: say so, and offer the words back.
|
|
197
|
+
const offer = await offerDraft(key, {here, question: (d) => draftQuestion(d, null, {what: 'a request'})});
|
|
198
|
+
if (offer.state === 'keep' && await copyText(draftText(offer.draft), 'Your unsent reply and notes are copied')) store.remove(key);
|
|
199
|
+
if (offer.state === 'none') E.toast('That request is no longer here.', {type: 'warning'});
|
|
200
|
+
if (gone && here()) tickets.value = tickets.peek().filter(x => x.id !== tid);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
172
203
|
if (!byId(tid)) tickets.value = [t, ...tickets.peek()];
|
|
204
|
+
const offer = await offerDraft(key, {here, question: (d) => draftQuestion(d, ticketPrint(t, {notes: true}), {what: `the request “${t.subject}”`,
|
|
205
|
+
changed: 'The customer has written, or the notes have changed, since', changedAt: t.lastFrom === 'customer' ? t.lastAt : t.updatedAt})});
|
|
206
|
+
if (offer.state === 'gone') return false;
|
|
173
207
|
const rt = t.replyTo || {email: t.userEmail || '', source: t.userEmail ? 'profile' : 'none'};
|
|
174
208
|
const replyNote = rt.source === 'registration' ? `Replies are emailed to ${rt.email} - the address the site was bought with.`
|
|
175
209
|
: rt.source === 'profile' ? `Replies are emailed to ${rt.email} - from the requester's profile on their site (this site was not bought through Customers).`
|
|
@@ -188,13 +222,17 @@ export async function mount(root, ctx) {
|
|
|
188
222
|
const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
|
|
189
223
|
const urgent = M.computed(() => cur.value.priority === 'urgent');
|
|
190
224
|
const archived = M.computed(() => Boolean(cur.value.archived));
|
|
191
|
-
const
|
|
225
|
+
const saved = {reply: '', notes: String(t.notes || '')};
|
|
226
|
+
const start = offer.state === 'keep' ? draftValues(offer.draft, saved) : saved;
|
|
227
|
+
const draft = M.observable(start.reply);
|
|
192
228
|
// Bindings may not call methods (.trim()), so the test is a computed.
|
|
193
229
|
const hasDraft = M.computed(() => Boolean(draft.value.trim()));
|
|
194
230
|
const after = M.observable('answered');
|
|
195
|
-
const notes = M.observable(
|
|
231
|
+
const notes = M.observable(start.notes);
|
|
196
232
|
const busy = M.observable(false);
|
|
197
233
|
let panel = null;
|
|
234
|
+
let guard = null;
|
|
235
|
+
let savedNotes = saved.notes;
|
|
198
236
|
let stopScroll = () => {};
|
|
199
237
|
let menu = null;
|
|
200
238
|
|
|
@@ -203,6 +241,8 @@ export async function mount(root, ctx) {
|
|
|
203
241
|
busy.value = true;
|
|
204
242
|
try { await fn(); } catch (err) { fail(err); } finally { busy.value = false; }
|
|
205
243
|
};
|
|
244
|
+
/** Our own change is not "changed since": the draft measures from here on. */
|
|
245
|
+
const ours = (next) => guard?.rebase({reply: '', notes: savedNotes}, ticketPrint(next, {notes: true}));
|
|
206
246
|
const siteUrl = t.siteHost ? `https://${t.siteHost}/admin/` : '';
|
|
207
247
|
panel = await openPanel({
|
|
208
248
|
title: t.subject,
|
|
@@ -211,7 +251,7 @@ export async function mount(root, ctx) {
|
|
|
211
251
|
cleanup: () => {
|
|
212
252
|
stopScroll(); stopFiles(); shown.dispose(); menu?.destroy?.();
|
|
213
253
|
[cur, thread, status, statusLabel, tone, urgent, archived, hasDraft].forEach(c => c.dispose?.());
|
|
214
|
-
|
|
254
|
+
panels.release('detail');
|
|
215
255
|
},
|
|
216
256
|
html: `<div class="sp-detail">
|
|
217
257
|
<dl class="sp-facts">
|
|
@@ -226,11 +266,12 @@ export async function mount(root, ctx) {
|
|
|
226
266
|
</dl>
|
|
227
267
|
<div class="sp-thread-host">${THREAD}</div>
|
|
228
268
|
<div class="sp-reply">
|
|
229
|
-
<label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email. Right-click a message to copy or quote it."></span></label>
|
|
269
|
+
<label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email. Right-click a message (or its ⋮, or Shift+F10) to copy or quote it. A reply you leave unsent is kept, and offered back when you open this request again."></span></label>
|
|
230
270
|
<p class="sp-note sp-small">${esc(replyNote)}</p>
|
|
231
271
|
<textarea id="sp-answer" class="form-textarea" rows="5" maxlength="8000" data-model="draft.value" placeholder="Write to ${esc(t.userName || 'the customer')}"></textarea>
|
|
232
272
|
<div class="sp-actions">
|
|
233
|
-
<label class="sp-inline">Then <select class="form-select" data-model="after.value"><option value="answered">wait for them</option><option value="closed">mark resolved</option></select
|
|
273
|
+
<label class="sp-inline">Then <select class="form-select" data-model="after.value"><option value="answered">wait for them</option><option value="closed">mark resolved</option></select>
|
|
274
|
+
<span data-help="What the request becomes once this reply goes: Answered (the customer's turn - it leaves Waiting on Domma), or Resolved (the customer is told it is done; writing again reopens it)."></span></label>
|
|
234
275
|
<span class="sp-spacer"></span>
|
|
235
276
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="resolve" data-bind-hidden="status.value === 'closed'" data-bind-disabled="busy.value">Resolve without reply</button>
|
|
236
277
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="reopen" data-bind-hidden="status.value !== 'closed'" data-bind-disabled="busy.value">Reopen</button>
|
|
@@ -251,13 +292,16 @@ export async function mount(root, ctx) {
|
|
|
251
292
|
</div>
|
|
252
293
|
</div>`,
|
|
253
294
|
methods: {
|
|
295
|
+
// The panel stays open: what is typed while it sends stays in the box, unsent and kept.
|
|
254
296
|
send: () => run(async () => {
|
|
255
|
-
|
|
256
|
-
|
|
297
|
+
const sent = draft.peek();
|
|
298
|
+
const next = replace(await api.reply(tid, {body: sent, status: after.peek()}));
|
|
299
|
+
draft.value = afterSend(draft.peek(), sent);
|
|
300
|
+
ours(next);
|
|
257
301
|
E.toast(rt.email ? `Sent - emailed to ${rt.email}.` : 'Sent - it shows in their admin.', {type: 'success'});
|
|
258
302
|
}),
|
|
259
|
-
resolve: () => run(() => actions.resolve(cur.peek())),
|
|
260
|
-
reopen: () => run(() => actions.reopen(cur.peek())),
|
|
303
|
+
resolve: () => run(async () => { await actions.resolve(cur.peek()); ours(cur.peek()); }),
|
|
304
|
+
reopen: () => run(async () => { await actions.reopen(cur.peek()); ours(cur.peek()); }),
|
|
261
305
|
toggleUrgent: (e) => run(async () => {
|
|
262
306
|
// A failed save puts the box back: the binding only re-runs when the value changes.
|
|
263
307
|
try { await actions.urgent(cur.peek(), Boolean(e.target.checked)); } finally { e.target.checked = urgent.peek(); }
|
|
@@ -265,20 +309,34 @@ export async function mount(root, ctx) {
|
|
|
265
309
|
archive: () => run(async () => {
|
|
266
310
|
const on = !archived.peek();
|
|
267
311
|
await actions.archive(cur.peek(), on);
|
|
268
|
-
if (on)
|
|
312
|
+
if (on) guard?.close();
|
|
313
|
+
}),
|
|
314
|
+
// Notes typed while they save stay unsaved (the baseline is what was sent, not what is there now).
|
|
315
|
+
saveNotes: () => run(async () => {
|
|
316
|
+
const sent = notes.peek();
|
|
317
|
+
const next = replace(await api.update(tid, {notes: sent}));
|
|
318
|
+
savedNotes = sent;
|
|
319
|
+
ours(next);
|
|
320
|
+
E.toast('Notes saved.', {type: 'success'});
|
|
269
321
|
}),
|
|
270
|
-
|
|
271
|
-
remove: () => run(async () => { if (await actions.remove(cur.peek())) panel?.close(); })
|
|
322
|
+
remove: () => run(async () => { await actions.remove(cur.peek()); })
|
|
272
323
|
}
|
|
273
324
|
});
|
|
274
|
-
|
|
325
|
+
guard = guardPanel(panel, {
|
|
326
|
+
key,
|
|
327
|
+
values: () => ({reply: draft.value, notes: notes.value}),
|
|
328
|
+
baseline: saved,
|
|
329
|
+
was: offer.state === 'keep' ? offer.draft.was : ticketPrint(t, {notes: true})
|
|
330
|
+
});
|
|
331
|
+
panels.set('detail', {key: tid, close: (how) => guard.close(how)});
|
|
275
332
|
// Keep the newest message in view as the thread grows.
|
|
276
333
|
const box = panel.panel.querySelector('.sp-thread-host');
|
|
277
334
|
stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
|
|
278
335
|
// Thumbnails for the images that have arrived; click a file to open it in a new tab.
|
|
279
336
|
const fileStop = M.effect(() => { shown.load(cur.value.messages || []); });
|
|
280
337
|
const keysOff = fileKeys(box, (fid) => shown.open(findFile(cur.peek().messages, fid)));
|
|
281
|
-
|
|
338
|
+
const msgOff = msgKeys(box, kit.openMenuAt);
|
|
339
|
+
stopFiles = () => { fileStop(); keysOff(); msgOff(); };
|
|
282
340
|
// A message: copy it, or quote it into the reply.
|
|
283
341
|
menu = E.contextMenu(box, {
|
|
284
342
|
cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
|
|
@@ -295,6 +353,7 @@ export async function mount(root, ctx) {
|
|
|
295
353
|
];
|
|
296
354
|
}
|
|
297
355
|
});
|
|
356
|
+
return true;
|
|
298
357
|
}
|
|
299
358
|
|
|
300
359
|
const bindings = M.applyBindings({tickets, loading, error, filter, query, rows, counts, emptyText}, view, {methods: {}});
|
|
@@ -52,7 +52,7 @@ export const supportView = {
|
|
|
52
52
|
}
|
|
53
53
|
if (!here()) return;
|
|
54
54
|
// The cog (1.1.5) - this side's settings, for those who may change them. Never blocks the screen.
|
|
55
|
-
addSettingsCog($container, kit).catch(() => {});
|
|
55
|
+
addSettingsCog($container, kit, {scope, here}).catch(() => {});
|
|
56
56
|
try {
|
|
57
57
|
const screen = mode.mode === 'receiver' ? 'inbox' : 'desk';
|
|
58
58
|
const mod = await import(`/plugins/support/admin/views/${screen}.js?v=${V}`);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "support",
|
|
3
3
|
"displayName": "Support",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Get help from Domma straight from your admin: our email, phone and hours always to hand, and a conversation with the Domma team for anything you need - urgent requests first. Right-click a request to archive, resolve or remove it.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
8
|
+
"date": "2026-10-15",
|
|
9
9
|
"icon": "headphones",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
],
|
|
62
62
|
"views": {
|
|
63
63
|
"plugin-support": {
|
|
64
|
-
"entry": "support/admin/views/support.js?v=1.
|
|
64
|
+
"entry": "support/admin/views/support.js?v=1.2.0",
|
|
65
65
|
"exportName": "supportView"
|
|
66
66
|
}
|
|
67
67
|
}
|