domma-cms 0.116.0 → 0.116.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +16 -16
- package/plugins/support/CLAUDE.md +19 -0
- package/plugins/support/admin/css/index.css +4 -0
- package/plugins/support/admin/lib/kit.js +60 -11
- package/plugins/support/admin/views/desk.js +158 -74
- package/plugins/support/admin/views/inbox.js +146 -56
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/collections/support-tickets.json +1 -0
- package/plugins/support/lib/receiver.js +2 -1
- package/plugins/support/lib/sender.js +68 -4
- package/plugins/support/lib/shape.js +10 -1
- package/plugins/support/plugin.js +38 -6
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +94 -0
- package/plugins/support/tests/shape.test.js +68 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@149aff8",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.4",
|
|
@@ -224,25 +224,25 @@
|
|
|
224
224
|
}
|
|
225
225
|
},
|
|
226
226
|
"support": {
|
|
227
|
-
"version": "1.0
|
|
227
|
+
"version": "1.1.0",
|
|
228
228
|
"files": {
|
|
229
|
-
"CLAUDE.md": "
|
|
230
|
-
"admin/css/index.css": "
|
|
231
|
-
"admin/lib/kit.js": "
|
|
229
|
+
"CLAUDE.md": "ff42515f210dfdbeb1b14992012177ad8513323a340a0d4fe69a6ec13773db5a",
|
|
230
|
+
"admin/css/index.css": "bad02a487ebb131b64ee55e2a4533c8b79d6ada80fffd34b0952bf3b68204a36",
|
|
231
|
+
"admin/lib/kit.js": "22f02768625bcad9d9c8e64bf0a8fe95f7c5ff6dcf8abb189df8203362a7a4e1",
|
|
232
232
|
"admin/templates/support.html": "2bb69d0443a367a402e6160fe68f4af3e8983b578cfff9575b4df9a534fcb480",
|
|
233
|
-
"admin/views/desk.js": "
|
|
234
|
-
"admin/views/inbox.js": "
|
|
235
|
-
"admin/views/support.js": "
|
|
236
|
-
"collections/support-tickets.json": "
|
|
233
|
+
"admin/views/desk.js": "ac043630ad08c8f2053f3f908be18586d9a4798976d20427cf5215ee042c807c",
|
|
234
|
+
"admin/views/inbox.js": "f570595d7b2c831d151e0aaf85ff903e6e2b7066124b3f9d23c2c24f5b1734cf",
|
|
235
|
+
"admin/views/support.js": "360a50ff54b81f93fcd6fb6949710e0cff5d342bb8573408415ef3faf3fd29ff",
|
|
236
|
+
"collections/support-tickets.json": "55a0856bacd66c2e8f1e3c85bdf3638e5ff826560943cda854a8f2d336a88905",
|
|
237
237
|
"config.js": "eb7ccc1169fa8b2be52a213536825492fedaec5fa73fd749cba9f498dd54b503",
|
|
238
238
|
"lib/mail.js": "d14aae3a59aad6af61451863cdaaf94fc4a87810b16e1dcb9c655dda6c803b3b",
|
|
239
|
-
"lib/receiver.js": "
|
|
240
|
-
"lib/sender.js": "
|
|
241
|
-
"lib/shape.js": "
|
|
242
|
-
"plugin.js": "
|
|
243
|
-
"plugin.json": "
|
|
244
|
-
"tests/api.test.js": "
|
|
245
|
-
"tests/shape.test.js": "
|
|
239
|
+
"lib/receiver.js": "b95c569932c261a48849f0a6b7005f649d9c2a50d319b098a47d02da9363050c",
|
|
240
|
+
"lib/sender.js": "b20aaee34ade89814a897f68467f9902693843fa6b5c8eb0090c4059193c8227",
|
|
241
|
+
"lib/shape.js": "04d840b589d20700ccb2cfa4c72072f8d9f8e03f0573e2ea1177a7a83a383bea",
|
|
242
|
+
"plugin.js": "e3c50a05fa99cb24375782bbb3ca8c58cf5ddc3036937e6115f4d963715c120e",
|
|
243
|
+
"plugin.json": "e6efaf06f7f2c99b196909e56f0a0b6d65b438ffd6d616aa4207769861f9bd66",
|
|
244
|
+
"tests/api.test.js": "1fa87f6ae12c48fc34a5fb2b5bbeed6cfaa36329610674d4fae05d64734dbb15",
|
|
245
|
+
"tests/shape.test.js": "364c6b7680699f59fcd449cd448184486488838300f155135daea4c05387cf74"
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
}
|
|
@@ -39,6 +39,25 @@ registration by where it runs.
|
|
|
39
39
|
- **The CMS version comes from the manager's registry** (`sites.json` `cmsVersion`), not the payload: a
|
|
40
40
|
child site's package.json is the manager's, so its own answer is wrong.
|
|
41
41
|
|
|
42
|
+
## Archive, remove and the row menus (1.1.0)
|
|
43
|
+
|
|
44
|
+
Both screens are domma-reactive throughout (lists AND the request panel: the thread is a `data-each` over
|
|
45
|
+
`threadRows()`, status/urgent/archived are computeds over the list's copy, so a move from a row menu shows in
|
|
46
|
+
an open panel at once). Every row has ONE menu definition (`menuItems`) reached by right-click, the ⋮ kebab
|
|
47
|
+
and Shift+F10 (`rowKeys()` in kit.js); click/Enter opens. The panel's thread has its own menu (copy; on the
|
|
48
|
+
manager also "Quote in reply").
|
|
49
|
+
|
|
50
|
+
- **Manager archive** = `data.archived` (PUT `/tickets/:id {archived}`), Domma's own filing: the site never
|
|
51
|
+
sees it, `/count` ignores archived requests, and `addTurn()` takes a request OUT of the archive when the
|
|
52
|
+
customer writes again (a close does not).
|
|
53
|
+
- **Site shelf** = the outbox's `shelf` (`{ref: {state: archived|removed, dommaAt}}`), this site's own tidying
|
|
54
|
+
- Domma keeps every request. `applyShelf()` (pure) marks archived and drops removed, and either comes back
|
|
55
|
+
the moment `lastDommaAt` moves past the recorded `dommaAt`: a reply is never hidden. `remember()` prunes
|
|
56
|
+
entries Domma deleted or has written to since. Shelving marks the reply read. Removing a request that
|
|
57
|
+
never reached Domma (`discard()`) cancels it - it and its follow-ups leave the outbox. Writing to an
|
|
58
|
+
archived request unshelves it.
|
|
59
|
+
- Manager **Delete** stays a real delete (with `deleted.json`); the confirm suggests archiving instead.
|
|
60
|
+
|
|
42
61
|
## Files
|
|
43
62
|
|
|
44
63
|
- `plugin.js` - both route sets (see its header); `options.mode` / `mail` / `fetch` / `env` /
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
.sp-seg button { border: 1px solid var(--dm-border); background: none; color: var(--dm-text-secondary); border-radius: var(--dm-radius-full);
|
|
37
37
|
padding: .2rem .7rem; font-size: var(--dm-font-size-sm); cursor: pointer; }
|
|
38
38
|
.sp-seg button b { font-weight: 600; margin-left: .2rem; }
|
|
39
|
+
.sp-seg button:focus-visible { outline: 2px solid var(--dm-primary); outline-offset: 1px; }
|
|
39
40
|
.sp-seg button.is-on { background: var(--dm-primary-light); color: var(--dm-text); border-color: var(--dm-primary); }
|
|
40
41
|
.sp-search { flex: 1; min-width: 12rem; max-width: 20rem; }
|
|
41
42
|
|
|
@@ -48,6 +49,8 @@
|
|
|
48
49
|
.sp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
49
50
|
.sp-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
50
51
|
.sp-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
|
|
52
|
+
.sp-kebab { border: 0; background: none; color: var(--dm-text-secondary); cursor: pointer; font-size: 1.1rem; line-height: 1; padding: .2rem .4rem; border-radius: var(--dm-radius-md); flex: none; }
|
|
53
|
+
.sp-kebab:hover, .sp-kebab:focus-visible { background: var(--dm-hover-bg); color: var(--dm-text); outline: none; }
|
|
51
54
|
.sp-dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--dm-primary); flex: none; }
|
|
52
55
|
.sp-urgent { font-size: var(--dm-font-size-xs); font-weight: 700; color: var(--dm-danger); text-transform: uppercase; letter-spacing: .03em; }
|
|
53
56
|
.sp-chip { font-size: var(--dm-font-size-xs); font-weight: 600; padding: .12rem .55rem; border-radius: var(--dm-radius-full); white-space: nowrap;
|
|
@@ -55,6 +58,7 @@
|
|
|
55
58
|
.sp-chip.is-info { background: color-mix(in srgb, var(--dm-info) 18%, transparent); }
|
|
56
59
|
.sp-chip.is-warning { background: color-mix(in srgb, var(--dm-warning) 22%, transparent); }
|
|
57
60
|
.sp-chip.is-success { background: color-mix(in srgb, var(--dm-success) 20%, transparent); }
|
|
61
|
+
.sp-chip.is-muted { color: var(--dm-text-secondary); }
|
|
58
62
|
.sp-chip.is-bad { background: color-mix(in srgb, var(--dm-danger) 20%, transparent); }
|
|
59
63
|
|
|
60
64
|
/* Forms */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Support admin - what both screens share: the admin's helpers (loaded at run
|
|
3
3
|
* time; a plugin never imports admin/ statically - the updater replaces it),
|
|
4
|
-
* the API, escaping, dates, slideovers and the conversation thread.
|
|
4
|
+
* the API, escaping, dates, slideovers, the row menus and the conversation thread.
|
|
5
5
|
*
|
|
6
6
|
* @module support/admin/lib/kit
|
|
7
7
|
*/
|
|
@@ -21,6 +21,10 @@ export function loadKit() {
|
|
|
21
21
|
import('/admin/js/lib/help-popover.js').catch(() => ({}))
|
|
22
22
|
]).then(([tk, help]) => ({
|
|
23
23
|
onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
|
|
24
|
+
openMenuAt: tk.openMenuAt || ((el) => {
|
|
25
|
+
const r = el.getBoundingClientRect();
|
|
26
|
+
el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
|
|
27
|
+
}),
|
|
24
28
|
viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
|
|
25
29
|
helpPopovers: help.helpPopovers || (() => () => {})
|
|
26
30
|
}));
|
|
@@ -38,6 +42,8 @@ export const api = {
|
|
|
38
42
|
write: (ref, body) => H.post(`${BASE}/tickets/${seg(ref)}/messages`, {body}),
|
|
39
43
|
close: (ref) => H.post(`${BASE}/tickets/${seg(ref)}/close`, {}),
|
|
40
44
|
seen: (ref, at) => H.put(`${BASE}/tickets/${seg(ref)}/seen`, {at}),
|
|
45
|
+
/** `at`: Domma's latest reply as the screen showed it (omitted for an offline copy, which has none). */
|
|
46
|
+
shelf: (ref, state, at) => H.put(`${BASE}/tickets/${seg(ref)}/shelf`, {state, ...(at !== undefined && {at})}),
|
|
41
47
|
// manager
|
|
42
48
|
ticket: (id) => H.get(`${BASE}/tickets/${seg(id)}`),
|
|
43
49
|
reply: (id, d) => H.post(`${BASE}/tickets/${seg(id)}/reply`, d),
|
|
@@ -88,17 +94,60 @@ export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g,
|
|
|
88
94
|
export const TONE = {open: 'warning', answered: 'info', closed: 'success', waiting: 'warning', refused: 'bad'};
|
|
89
95
|
|
|
90
96
|
/**
|
|
91
|
-
* The conversation, oldest first, as
|
|
92
|
-
*
|
|
97
|
+
* The conversation, oldest first, as rows for THREAD. `mine` says which side is
|
|
98
|
+
* "us" (customer on a site, domma on the manager).
|
|
93
99
|
*/
|
|
94
|
-
export function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
export function threadRows(messages = [], mine) {
|
|
101
|
+
return messages.map((m, i) => ({
|
|
102
|
+
key: m.id || `m${i}`,
|
|
103
|
+
cls: `${m.from === mine ? 'is-mine' : 'is-theirs'}${m.from === 'domma' ? ' is-domma' : ''}`,
|
|
104
|
+
who: m.from === 'domma' ? `${m.name || 'Domma'} · Domma Support` : (m.name || 'Customer'),
|
|
105
|
+
when: `${stamp(m.at)}${m.pending ? ' · waiting to send' : ''}${m.refused ? ' · not accepted' : ''}`,
|
|
106
|
+
body: m.body || ''
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The thread, bound to a `thread` observable of threadRows(). Text bindings only: nothing is parsed as HTML. */
|
|
111
|
+
export const THREAD = `
|
|
112
|
+
<p class="sp-note" data-bind-hidden="thread.value.length">Nothing here yet.</p>
|
|
113
|
+
<ol class="sp-thread" data-each="thread.value key=key"><li class="sp-msg" data-bind-class="cls" data-key="{{key}}" tabindex="-1">
|
|
114
|
+
<div class="sp-msg-head"><strong data-bind-text="who"></strong><small data-bind-text="when"></small></div>
|
|
115
|
+
<div class="sp-msg-body" data-bind-text="body"></div>
|
|
116
|
+
</li></ol>`;
|
|
117
|
+
|
|
118
|
+
/** Copy text, with a toast either way. */
|
|
119
|
+
export async function copyText(text, what = 'Copied') {
|
|
120
|
+
try {
|
|
121
|
+
await navigator.clipboard.writeText(String(text ?? ''));
|
|
122
|
+
E.toast(`${what}.`, {type: 'success'});
|
|
123
|
+
} catch {
|
|
124
|
+
E.toast('Your browser would not let us copy that.', {type: 'warning'});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The one way into a list's row menu besides right-click: the row's ⋮ button
|
|
130
|
+
* and Shift+F10. Click (or Enter) on the row itself does `primary`.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Function} removes the listeners
|
|
133
|
+
*/
|
|
134
|
+
export function rowKeys(listEl, {rowSel, kebabSel, openMenuAt, primary}) {
|
|
135
|
+
const onClick = (e) => {
|
|
136
|
+
const row = e.target.closest(rowSel);
|
|
137
|
+
if (!row) return;
|
|
138
|
+
const kebab = e.target.closest(kebabSel);
|
|
139
|
+
if (kebab) { e.stopPropagation(); openMenuAt(kebab); return; }
|
|
140
|
+
primary(row.dataset.id);
|
|
141
|
+
};
|
|
142
|
+
const onKey = (e) => {
|
|
143
|
+
const row = e.target.closest(rowSel);
|
|
144
|
+
if (!row) return;
|
|
145
|
+
if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') { e.preventDefault(); openMenuAt(row); }
|
|
146
|
+
else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) { e.preventDefault(); primary(row.dataset.id); }
|
|
147
|
+
};
|
|
148
|
+
listEl.addEventListener('click', onClick);
|
|
149
|
+
listEl.addEventListener('keydown', onKey);
|
|
150
|
+
return () => { listEl.removeEventListener('click', onClick); listEl.removeEventListener('keydown', onKey); };
|
|
102
151
|
}
|
|
103
152
|
|
|
104
153
|
/**
|
|
@@ -6,19 +6,33 @@
|
|
|
6
6
|
* that is restarting only delays it ("waiting to send"); opening this screen
|
|
7
7
|
* tries again. A standalone site has the contact card and nothing else - it has
|
|
8
8
|
* nowhere to send a conversation, so it says to email instead.
|
|
9
|
+
*
|
|
10
|
+
* Right-click a request (or its ⋮, or Shift+F10) to reply, mark it resolved or
|
|
11
|
+
* read, archive it or remove it from this site's list. Archive and remove are
|
|
12
|
+
* this site's own tidying: Domma keeps the conversation, and a new reply from
|
|
13
|
+
* Domma brings it straight back. Removing one that never reached Domma cancels it.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
|
-
import {TONE, api, esc, listOf, openPanel, refreshBadge, stamp, telHref,
|
|
16
|
+
import {THREAD, TONE, api, copyText, esc, listOf, openPanel, refreshBadge, rowKeys, stamp, telHref, threadRows, when} from '../lib/kit.js';
|
|
12
17
|
|
|
13
18
|
const ROW = `
|
|
14
|
-
<div class="sp-row" data-id="{{key}}" tabindex="0" role="button">
|
|
19
|
+
<div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
|
|
15
20
|
<span class="sp-cat" data-bind-title="categoryLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
|
|
16
21
|
<span class="sp-main"><strong data-bind-text="subject"></strong><small data-bind-text="meta"></small></span>
|
|
17
22
|
<span class="sp-urgent" data-bind-hidden="!urgent">Urgent</span>
|
|
18
23
|
<span class="sp-dot" data-bind-hidden="!unread" title="New reply from Domma"></span>
|
|
19
24
|
<span class="sp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
|
|
25
|
+
<button type="button" class="sp-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
20
26
|
</div>`;
|
|
21
27
|
|
|
28
|
+
const FILTERS = [
|
|
29
|
+
{key: 'active', label: 'Requests'},
|
|
30
|
+
{key: 'archived', label: 'Archived'}
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** The newest Domma message's time - what "read" is measured against. */
|
|
34
|
+
const lastDomma = (t) => [...(t?.messages || [])].reverse().find(m => m.from === 'domma')?.at || '';
|
|
35
|
+
|
|
22
36
|
export async function mount(root, ctx) {
|
|
23
37
|
const {kit, scope, here, meta, id} = ctx;
|
|
24
38
|
const categories = meta.categories || [];
|
|
@@ -33,9 +47,13 @@ export async function mount(root, ctx) {
|
|
|
33
47
|
const loading = M.observable(Boolean(ctx.mode.connected));
|
|
34
48
|
const reachable = M.observable(true);
|
|
35
49
|
const error = M.observable('');
|
|
50
|
+
const filter = M.observable('active');
|
|
36
51
|
const rows = M.observable([]);
|
|
52
|
+
const counts = M.observable({});
|
|
37
53
|
scope(M.effect(() => {
|
|
38
|
-
|
|
54
|
+
const all = tickets.value;
|
|
55
|
+
counts.value = {active: all.filter(t => !t.archived).length, archived: all.filter(t => t.archived).length};
|
|
56
|
+
rows.value = all.filter(t => (filter.value === 'archived') === Boolean(t.archived)).map(t => ({
|
|
39
57
|
key: t.ref,
|
|
40
58
|
subject: t.subject,
|
|
41
59
|
icon: iconOf(t.category),
|
|
@@ -43,10 +61,15 @@ export async function mount(root, ctx) {
|
|
|
43
61
|
meta: [when(t.lastAt || t.createdAt), t.userName, plural((t.messages || []).length, 'message')].filter(Boolean).join(' · '),
|
|
44
62
|
statusLabel: t.statusLabel || t.status,
|
|
45
63
|
tone: `is-${TONE[t.status] || 'muted'}`,
|
|
46
|
-
urgent: t.priority === 'urgent',
|
|
64
|
+
urgent: t.priority === 'urgent' && t.status !== 'closed',
|
|
47
65
|
unread: Boolean(t.unread)
|
|
48
66
|
}));
|
|
49
67
|
}));
|
|
68
|
+
// Nothing archived any more: back to the list, rather than an empty tab with no way to see it.
|
|
69
|
+
scope(M.effect(() => { if (filter.value === 'archived' && !counts.value.archived) filter.value = 'active'; }));
|
|
70
|
+
const hasArchive = M.computed(() => Boolean(counts.value.archived) || filter.value === 'archived');
|
|
71
|
+
const emptyText = M.computed(() => (filter.value === 'archived' ? 'Nothing archived.'
|
|
72
|
+
: 'No requests yet. Stuck on something, or something not working? Ask for help - a person reads every one.'));
|
|
50
73
|
const waitingCount = M.computed(() => tickets.value.filter(t => t.status === 'waiting'
|
|
51
74
|
|| (t.messages || []).some(m => m.pending)).length);
|
|
52
75
|
|
|
@@ -83,7 +106,11 @@ export async function mount(root, ctx) {
|
|
|
83
106
|
<section class="sp-card" data-bind-hidden="!connected.value">
|
|
84
107
|
<div class="sp-bar">
|
|
85
108
|
<div class="sp-intro"><strong>Your requests</strong><span>Every conversation this site has had with Domma Support, whoever started it.</span></div>
|
|
86
|
-
<span
|
|
109
|
+
<span class="sp-spacer"></span>
|
|
110
|
+
<div class="sp-seg" role="tablist" aria-label="Show" data-bind-hidden="!hasArchive.value">
|
|
111
|
+
${FILTERS.map(f => `<button type="button" role="tab" data-filter="${f.key}" data-bind-class="filter.value === '${f.key}' && 'is-on'">${esc(f.label)} <b data-bind-text="counts.value['${f.key}'] || 0"></b></button>`).join('')}
|
|
112
|
+
</div>
|
|
113
|
+
<span data-help-title="Your requests" data-help="A request is a conversation with the Domma team. It shows Waiting on Domma until we answer, Domma replied when it is your turn, and Resolved when either of us marks it done - writing again reopens it. Right-click a request (or use its ⋮ button, or Shift+F10) to archive it or remove it from this list - Domma keeps its copy either way, and a new reply from Domma brings it back. Replies also arrive by email, and the sidebar badge counts the ones you have not opened."></span>
|
|
87
114
|
</div>
|
|
88
115
|
<p class="sp-note is-warn" data-bind-hidden="!waitingCount.value">
|
|
89
116
|
<span data-icon="clock" data-icon-size="14"></span> Something is waiting to send - it goes as soon as the Domma server answers.
|
|
@@ -92,7 +119,7 @@ export async function mount(root, ctx) {
|
|
|
92
119
|
<p class="sp-note is-warn" data-bind-hidden="reachable.value || loading.value">Domma could not be reached just now, so what you see may be out of date.</p>
|
|
93
120
|
<div class="sp-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading...</div>
|
|
94
121
|
<p class="sp-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
95
|
-
<p class="sp-note" data-bind-hidden="loading.value || rows.value.length"
|
|
122
|
+
<p class="sp-note" data-bind-hidden="loading.value || rows.value.length" data-bind-text="emptyText.value"></p>
|
|
96
123
|
<div class="sp-list" data-each="rows.value key=key"></div>
|
|
97
124
|
</section>`;
|
|
98
125
|
root.appendChild(view);
|
|
@@ -128,6 +155,60 @@ export async function mount(root, ctx) {
|
|
|
128
155
|
}
|
|
129
156
|
|
|
130
157
|
const byRef = (ref) => tickets.peek().find(t => t.ref === ref);
|
|
158
|
+
const patchLocal = (ref, change) => { tickets.value = tickets.peek().map(x => (x.ref === ref ? {...x, ...change} : x)); };
|
|
159
|
+
const fail = (err) => E.toast(err.message || String(err), {type: 'error'});
|
|
160
|
+
|
|
161
|
+
// ---- The moves: one definition each, for the row menu and the panel alike ----
|
|
162
|
+
|
|
163
|
+
const actions = {
|
|
164
|
+
async seen(t) {
|
|
165
|
+
const at = lastDomma(t);
|
|
166
|
+
if (!t.unread || !at) return;
|
|
167
|
+
await api.seen(t.ref, at);
|
|
168
|
+
patchLocal(t.ref, {unread: false});
|
|
169
|
+
refreshBadge();
|
|
170
|
+
},
|
|
171
|
+
async resolve(t) {
|
|
172
|
+
const r = await api.close(t.ref);
|
|
173
|
+
E.toast(r.message || 'Marked as resolved.', {type: r.state === 'sent' ? 'success' : 'warning'});
|
|
174
|
+
await load();
|
|
175
|
+
},
|
|
176
|
+
async archive(t, on) {
|
|
177
|
+
await api.shelf(t.ref, on ? 'archived' : null, t.stale ? undefined : lastDomma(t));
|
|
178
|
+
patchLocal(t.ref, {archived: on, ...(on && {unread: false})});
|
|
179
|
+
refreshBadge();
|
|
180
|
+
E.toast(on ? `Archived "${t.subject}". A new reply from Domma brings it back.` : `"${t.subject}" is back in your requests.`, {type: 'success'});
|
|
181
|
+
},
|
|
182
|
+
async remove(t) {
|
|
183
|
+
const local = t.status === 'waiting' || t.status === 'refused';
|
|
184
|
+
const ask = local
|
|
185
|
+
? `Remove "${esc(t.subject)}"? If it has not gone to Domma yet, it will not be sent.`
|
|
186
|
+
: `Remove "${esc(t.subject)}" from this site's list? Domma keeps the conversation, and it comes back here if Domma replies again.`;
|
|
187
|
+
if (!await E.confirm(ask)) return false;
|
|
188
|
+
const r = await api.shelf(t.ref, 'removed', t.stale ? undefined : lastDomma(t));
|
|
189
|
+
tickets.value = tickets.peek().filter(x => x.ref !== t.ref);
|
|
190
|
+
refreshBadge();
|
|
191
|
+
E.toast(r.state === 'discarded' ? 'Removed - it will not be sent.'
|
|
192
|
+
: local ? 'Removed from this list - it had already started on its way, so Domma has it.' : 'Removed from this list.', {type: 'success'});
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const act = (name, ...args) => async (t) => { try { return await actions[name](t, ...args); } catch (err) { fail(err); return false; } };
|
|
197
|
+
|
|
198
|
+
function menuItems(t) {
|
|
199
|
+
const local = t.status === 'waiting' || t.status === 'refused';
|
|
200
|
+
return [
|
|
201
|
+
{label: local ? 'Open' : 'Open and reply', icon: 'message-square', action: () => open(t.ref)},
|
|
202
|
+
...(t.unread ? [{label: 'Mark as read', icon: 'check', action: () => act('seen')(t)}] : []),
|
|
203
|
+
...(!local && t.status !== 'closed' && !t.stale ? [{label: 'Mark resolved', icon: 'check-circle', action: () => act('resolve')(t)}] : []),
|
|
204
|
+
{type: 'divider'},
|
|
205
|
+
t.archived ? {label: 'Back to your requests', icon: 'inbox', action: () => act('archive', false)(t)}
|
|
206
|
+
: {label: 'Archive', icon: 'archive', action: () => act('archive', true)(t)},
|
|
207
|
+
{label: 'Copy reference', icon: 'copy', action: () => copyText(t.ref, 'Reference copied')},
|
|
208
|
+
{type: 'divider'},
|
|
209
|
+
{label: local ? 'Remove (do not send)' : 'Remove from this list', icon: 'trash', danger: true, action: () => act('remove')(t)}
|
|
210
|
+
];
|
|
211
|
+
}
|
|
131
212
|
|
|
132
213
|
function compose() {
|
|
133
214
|
const f = {category: M.observable('help'), priority: M.observable('normal'), subject: M.observable(''), body: M.observable(''),
|
|
@@ -138,16 +219,16 @@ export async function mount(root, ctx) {
|
|
|
138
219
|
model: f,
|
|
139
220
|
html: `<div class="sp-form">
|
|
140
221
|
<label class="form-label">What is it about?</label>
|
|
141
|
-
<div class="sp-choices"
|
|
142
|
-
${categories.map(c => `<button type="button" role="radio" data-
|
|
222
|
+
<div class="sp-choices" role="radiogroup" aria-label="Category">
|
|
223
|
+
${categories.map(c => `<button type="button" role="radio" data-on-click="pick('category', '${esc(c.key)}')" data-bind-class="category.value === '${esc(c.key)}' && 'is-on'" data-bind-aria-checked="category.value === '${esc(c.key)}' ? 'true' : 'false'"><span data-icon="${esc(c.icon)}" data-icon-size="16"></span>${esc(c.label)}</button>`).join('')}
|
|
143
224
|
</div>
|
|
144
225
|
<label class="form-label" for="sp-subject">Subject</label>
|
|
145
226
|
<input id="sp-subject" class="form-input" maxlength="140" data-model="subject.value" placeholder="One line - e.g. My contact form stopped sending">
|
|
146
227
|
<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>
|
|
147
228
|
<textarea id="sp-body" class="form-textarea" rows="8" maxlength="8000" data-model="body.value"></textarea>
|
|
148
229
|
<label class="form-label">How urgent is it?</label>
|
|
149
|
-
<div class="sp-choices is-two"
|
|
150
|
-
${priorities.map(p => `<button type="button" role="radio" data-value="${esc(p.key)}" data-bind-class="priority.value === '${esc(p.key)}' && 'is-on'"><strong>${esc(p.label)}</strong><small>${esc(p.hint)}</small></button>`).join('')}
|
|
230
|
+
<div class="sp-choices is-two" role="radiogroup" aria-label="Priority">
|
|
231
|
+
${priorities.map(p => `<button type="button" role="radio" data-value="${esc(p.key)}" data-on-click="pick('priority', '${esc(p.key)}')" data-bind-class="priority.value === '${esc(p.key)}' && 'is-on'" data-bind-aria-checked="priority.value === '${esc(p.key)}' ? 'true' : 'false'"><strong>${esc(p.label)}</strong><small>${esc(p.hint)}</small></button>`).join('')}
|
|
151
232
|
</div>
|
|
152
233
|
<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>
|
|
153
234
|
<input id="sp-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
|
|
@@ -158,6 +239,7 @@ export async function mount(root, ctx) {
|
|
|
158
239
|
</div>
|
|
159
240
|
</div>`,
|
|
160
241
|
methods: {
|
|
242
|
+
pick: (group, value) => { if (f[group]) f[group].value = value; },
|
|
161
243
|
cancel: () => panel?.close(),
|
|
162
244
|
async send() {
|
|
163
245
|
if (f.busy.peek()) return;
|
|
@@ -168,9 +250,10 @@ export async function mount(root, ctx) {
|
|
|
168
250
|
E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
|
|
169
251
|
panel?.close();
|
|
170
252
|
refreshBadge();
|
|
253
|
+
filter.value = 'active';
|
|
171
254
|
await load();
|
|
172
255
|
} catch (err) {
|
|
173
|
-
|
|
256
|
+
fail(err);
|
|
174
257
|
} finally {
|
|
175
258
|
f.busy.value = false;
|
|
176
259
|
}
|
|
@@ -178,11 +261,6 @@ export async function mount(root, ctx) {
|
|
|
178
261
|
}
|
|
179
262
|
}).then(p => {
|
|
180
263
|
panel = p;
|
|
181
|
-
p.panel.addEventListener('click', (e) => {
|
|
182
|
-
const b = e.target.closest('[data-value]');
|
|
183
|
-
const group = b?.closest('[data-group]')?.dataset.group;
|
|
184
|
-
if (group) f[group].value = b.dataset.value;
|
|
185
|
-
});
|
|
186
264
|
p.panel.querySelector('#sp-subject')?.focus();
|
|
187
265
|
});
|
|
188
266
|
}
|
|
@@ -190,100 +268,106 @@ export async function mount(root, ctx) {
|
|
|
190
268
|
async function open(ref) {
|
|
191
269
|
const t0 = byRef(ref);
|
|
192
270
|
if (!t0) return;
|
|
271
|
+
// The panel follows the list's copy, so a reload or a move from the row menu shows here at once.
|
|
272
|
+
const cur = M.computed(() => tickets.value.find(x => x.ref === ref) || t0);
|
|
273
|
+
const thread = M.computed(() => threadRows(cur.value.messages || [], 'customer'));
|
|
274
|
+
const status = M.computed(() => cur.value.status);
|
|
275
|
+
const statusLabel = M.computed(() => cur.value.statusLabel || cur.value.status);
|
|
276
|
+
const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
|
|
277
|
+
const archived = M.computed(() => Boolean(cur.value.archived));
|
|
193
278
|
const draft = M.observable('');
|
|
279
|
+
// Bindings may not call methods (.trim()), so the test is a computed.
|
|
280
|
+
const hasDraft = M.computed(() => Boolean(draft.value.trim()));
|
|
194
281
|
const busy = M.observable(false);
|
|
195
|
-
const status = M.observable(t0.status);
|
|
196
282
|
const canWrite = !['waiting', 'refused'].includes(t0.status);
|
|
197
283
|
let panel = null;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (chip) { chip.textContent = t.statusLabel || t.status; chip.className = `sp-chip is-${TONE[t.status] || 'muted'}`; }
|
|
205
|
-
};
|
|
206
|
-
const markSeen = async () => {
|
|
207
|
-
const t = byRef(ref);
|
|
208
|
-
const at = [...(t?.messages || [])].reverse().find(m => m.from === 'domma')?.at;
|
|
209
|
-
if (t?.unread && at) {
|
|
210
|
-
try { await api.seen(ref, at); tickets.value = tickets.peek().map(x => (x.ref === ref ? {...x, unread: false} : x)); refreshBadge(); }
|
|
211
|
-
catch { /* the badge catches up next time */ }
|
|
212
|
-
}
|
|
284
|
+
let stopScroll = () => {};
|
|
285
|
+
let menu = null;
|
|
286
|
+
const run = async (fn) => {
|
|
287
|
+
if (busy.peek()) return;
|
|
288
|
+
busy.value = true;
|
|
289
|
+
try { await fn(); } catch (err) { fail(err); } finally { busy.value = false; }
|
|
213
290
|
};
|
|
214
291
|
panel = await openPanel({
|
|
215
292
|
title: t0.subject,
|
|
216
293
|
size: 'lg',
|
|
217
|
-
model: {draft,
|
|
294
|
+
model: {thread, status, statusLabel, tone, archived, draft, hasDraft, busy},
|
|
295
|
+
cleanup: () => { stopScroll(); menu?.destroy?.(); [cur, thread, status, statusLabel, tone, archived, hasDraft].forEach(c => c.dispose?.()); },
|
|
218
296
|
html: `<div class="sp-detail">
|
|
219
297
|
<dl class="sp-facts">
|
|
220
|
-
<div><dt>Status</dt><dd><span
|
|
298
|
+
<div><dt>Status</dt><dd><span class="sp-chip" data-bind-class="tone.value" data-bind-text="statusLabel.value"></span>
|
|
299
|
+
<span class="sp-chip is-muted" data-bind-hidden="!archived.value">Archived</span></dd></div>
|
|
221
300
|
<div><dt>About</dt><dd>${esc(labelOf(t0.category))}${t0.priority === 'urgent' ? ' · <span class="sp-urgent">Urgent</span>' : ''}</dd></div>
|
|
222
301
|
<div><dt>Opened</dt><dd>${esc(stamp(t0.createdAt))}${t0.userName ? ` by ${esc(t0.userName)}` : ''}</dd></div>
|
|
223
302
|
${t0.where ? `<div><dt>Where</dt><dd>${esc(t0.where)}</dd></div>` : ''}
|
|
224
303
|
${t0.lastError ? `<div><dt>Last try</dt><dd>${esc(t0.lastError)}</dd></div>` : ''}
|
|
225
304
|
</dl>
|
|
226
|
-
<div class="sp-thread-host"
|
|
305
|
+
<div class="sp-thread-host">${THREAD}</div>
|
|
227
306
|
${canWrite ? `<div class="sp-reply">
|
|
228
307
|
<label class="form-label" for="sp-write" data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></label>
|
|
229
308
|
<textarea id="sp-write" class="form-textarea" rows="4" maxlength="8000" data-model="draft.value" placeholder="Write to Domma Support"></textarea>
|
|
230
309
|
<div class="sp-actions">
|
|
231
310
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="resolve" data-bind-hidden="status.value === 'closed'" data-bind-disabled="busy.value"><span data-icon="check-circle" data-icon-size="13"></span> Mark resolved</button>
|
|
311
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="archive" data-bind-disabled="busy.value">
|
|
312
|
+
<span data-icon="archive" data-icon-size="13"></span> <span data-bind-text="archived.value ? 'Back to your requests' : 'Archive'"></span></button>
|
|
232
313
|
<span class="sp-spacer"></span>
|
|
233
|
-
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !
|
|
314
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !hasDraft.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
|
|
234
315
|
</div>
|
|
235
316
|
</div>` : '<p class="sp-note">You can write again once this request reaches Domma.</p>'}
|
|
236
317
|
</div>`,
|
|
237
318
|
methods: {
|
|
238
|
-
async
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
render();
|
|
261
|
-
} catch (err) {
|
|
262
|
-
E.toast(err.message || String(err), {type: 'error'});
|
|
263
|
-
} finally {
|
|
264
|
-
busy.value = false;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
319
|
+
send: () => run(async () => {
|
|
320
|
+
if (!draft.peek().trim()) return;
|
|
321
|
+
const r = await api.write(ref, draft.peek());
|
|
322
|
+
if (r.state !== 'sent') E.toast(r.message, {type: 'warning'});
|
|
323
|
+
draft.value = '';
|
|
324
|
+
await load();
|
|
325
|
+
}),
|
|
326
|
+
resolve: () => run(() => actions.resolve(cur.peek())),
|
|
327
|
+
archive: () => run(async () => {
|
|
328
|
+
const on = !archived.peek();
|
|
329
|
+
await actions.archive(cur.peek(), on);
|
|
330
|
+
if (on) panel?.close();
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
const box = panel.panel.querySelector('.sp-thread-host');
|
|
335
|
+
stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
|
|
336
|
+
menu = E.contextMenu(box, {
|
|
337
|
+
cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
|
|
338
|
+
items: (li) => {
|
|
339
|
+
const m = thread.peek().find(x => x.key === li.dataset.key);
|
|
340
|
+
return m ? [{label: 'Copy message', icon: 'copy', action: () => copyText(m.body, 'Message copied')}] : [];
|
|
267
341
|
}
|
|
268
342
|
});
|
|
269
|
-
|
|
270
|
-
await markSeen();
|
|
343
|
+
await act('seen')(cur.peek());
|
|
271
344
|
}
|
|
272
345
|
|
|
273
|
-
const bindings = M.applyBindings({connected, contact, loading, reachable, error, rows, waitingCount}, view, {
|
|
346
|
+
const bindings = M.applyBindings({connected, contact, loading, reachable, error, filter, rows, counts, hasArchive, emptyText, waitingCount}, view, {
|
|
274
347
|
methods: {compose, reload: () => load()}
|
|
275
348
|
});
|
|
276
349
|
scope(() => bindings.dispose());
|
|
277
350
|
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
351
|
+
const seg = view.querySelector('.sp-seg');
|
|
352
|
+
const onFilter = (e) => { const b = e.target.closest('[data-filter]'); if (b) filter.value = b.dataset.filter; };
|
|
353
|
+
seg.addEventListener('click', onFilter);
|
|
354
|
+
scope(() => seg.removeEventListener('click', onFilter));
|
|
355
|
+
|
|
356
|
+
// Rows: click or Enter opens; right-click, the ⋮ or Shift+F10 = the menu.
|
|
357
|
+
const rowMenu = E.contextMenu(listEl, {
|
|
358
|
+
cursor: 'glyph', match: '.sp-row', inherit: false, density: 'compact',
|
|
359
|
+
items: (row) => { const t = byRef(row.dataset.id); return t ? menuItems(t) : []; }
|
|
360
|
+
});
|
|
361
|
+
scope(() => rowMenu?.destroy?.());
|
|
362
|
+
scope(rowKeys(listEl, {rowSel: '.sp-row', kebabSel: '.sp-kebab', openMenuAt: kit.openMenuAt, primary: open}));
|
|
283
363
|
|
|
284
364
|
I.scan(view);
|
|
285
365
|
if (connected.peek()) {
|
|
286
366
|
await load();
|
|
287
|
-
if (id && here())
|
|
367
|
+
if (id && here()) {
|
|
368
|
+
// A link to an archived request shows it where it is.
|
|
369
|
+
if (byRef(id)?.archived) filter.value = 'archived';
|
|
370
|
+
open(id);
|
|
371
|
+
}
|
|
288
372
|
}
|
|
289
373
|
}
|