domma-cms 0.113.0 → 0.114.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.113.0",
3
+ "version": "0.114.1",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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@ef8c865",
3
+ "source": "dcms-marketplace@26a9062",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.4",
@@ -222,6 +222,28 @@
222
222
  "tests/api.test.js": "04105c5d4f7bf4fd7b9e4dea5e8b8de4a4ebbba0a8795b47c166b0a6798fd089",
223
223
  "tests/pure.test.js": "f00f27ee8c941d6268eb780520007b7413b0872e2590487f1b460ce5ea09dbb1"
224
224
  }
225
+ },
226
+ "support": {
227
+ "version": "1.0.0",
228
+ "files": {
229
+ "CLAUDE.md": "a54f3af90bc273622e67d0da0502da39a9e0962633d43d82eebf1228cf996f79",
230
+ "admin/css/index.css": "43a792dbc08acafc9f79757ac96c3675a49abb39aab05a553c6871f6f2050a1e",
231
+ "admin/lib/kit.js": "bc52d8dfe031b470b49c51e7170d46689a6b2892871e53ec0d6a44f37a91afc1",
232
+ "admin/templates/support.html": "2bb69d0443a367a402e6160fe68f4af3e8983b578cfff9575b4df9a534fcb480",
233
+ "admin/views/desk.js": "f0770c750dd340b83438c8f41b222b814c0937081201cb06b851a3fdea2952fd",
234
+ "admin/views/inbox.js": "fac961363a827ce5139689464a6b9a65c685752b0771f4bf956fb1dcd8d95ccb",
235
+ "admin/views/support.js": "448595a7087f7cff633bf5844755a931d1ca461af1c418f275dadb97673502dc",
236
+ "collections/support-tickets.json": "aa1e6b4814fdfbbcd46d097edd93dfb838cc49f5232a86ba76b54c9b29d82ac5",
237
+ "config.js": "07a72a5c7900d5eb3e2464f7f58b316fd2217f41f57fdad43ce917fe2da80b82",
238
+ "lib/mail.js": "4365511b2c3ab93bbe6e2635e4e2ebe39eb841f968a836507331b426ab6f4c1c",
239
+ "lib/receiver.js": "ef958be2a8fadcec10169ff76f10404d395f9c6b401a51642ede9218547d6262",
240
+ "lib/sender.js": "71e9146729e443df401a1b84e9778e9243d057012462e48a05e32a73f7309aa2",
241
+ "lib/shape.js": "7bef95c823ee4c6d4041152a58fdb2279dbe6797710a89e1339f8aca60cd0f89",
242
+ "plugin.js": "50db3f804ad987490d322c265be0d00e11202af265c5067b20816defa6ff990d",
243
+ "plugin.json": "75d1eaf11416b855e5255ae565781f2f7c144b42384362cd0f00f7da85f046ab",
244
+ "tests/api.test.js": "57a00bd19ba3569246f0841d8fb8f3d488fcf420c4c0556bfe07331576d74cf5",
245
+ "tests/shape.test.js": "cd7c44c8e2f676734c90044f25acc0583ab6074c1661cce05103fd0cbe307796"
246
+ }
225
247
  }
226
248
  }
227
249
  }
@@ -0,0 +1,59 @@
1
+ # support - AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ A customer gets help from Domma without leaving their admin: the contact card (email, phone, hours) is
6
+ always on screen, and a request is a two-way CONVERSATION with the Domma team. **Every site gets it,
7
+ whatever its tier** (Darryl, 2026-10-04): `tier: "free"`, shipped in the CMS by `make sync-free-tier`,
8
+ and switched on (and locked) fleet-wide from the manager's Tool switches.
9
+
10
+ Built on Feedback's transport (read `plugins/feedback/CLAUDE.md`): ONE plugin, TWO sides, chosen at
11
+ registration by where it runs.
12
+
13
+ - **site (sender)** - any site. Connected when the manager started it (`MANAGER_URL` + `FLEET_TOKEN`,
14
+ set by site-manager's processManager): contact card + conversations. A standalone install gets the
15
+ contact card only, and `POST /tickets` answers 409 ("email us instead").
16
+ - **manager (receiver)** - dcm, detected by `plugins/site-manager/services/fleetTokens.js` beside this
17
+ plugin. The inbox of every site's requests.
18
+
19
+ ## How it differs from Feedback
20
+
21
+ - **Conversations, not report + reply.** `data.messages[]` (`{id, from: customer|domma, name, body, at}`),
22
+ appended by `addTurn()` (pure, in `lib/shape.js`), which also moves the status: `open` (waiting on
23
+ Domma) / `answered` (Domma replied - the customer's turn) / `closed` (resolved; writing again reopens).
24
+ A turn whose id is already there is a retry and changes nothing.
25
+ - **A site knows its requests by `ref`** (made on the site), never the manager's entry id - a waiting
26
+ request has no id yet. Follow-ups carry `ticketRef`, wait behind their request in the outbox, and are
27
+ refused with it. The manager looks a ref up in THAT site's requests only (another site's ref is 404).
28
+ - **Email both ways** (`lib/mail.js`, the Waypoint pattern: no SMTP = not sent, never Ethereal, never
29
+ throws). New request / customer reply -> `settings.notifyEmail`, Reply-To the customer, `[URGENT]`
30
+ in the subject. Domma reply or resolve -> the customer, linking to
31
+ `https://<site primaryDomain>/admin/#/plugins/support/<ref>`. The inbox link is
32
+ `<manager site.json url>/admin/#/plugins/support/<id>`.
33
+ - **The contact card comes from the MANAGER.** `/fleet/mine` returns `contactCard(settings)`, the site
34
+ keeps it in its outbox and shows it from then on, so hours/phone are changed once, on dcm. The site's
35
+ own settings are the fallback (and all a standalone install has). `contactCard()` lets through only a
36
+ real email and a phone of phone characters - the links are built from it.
37
+ - **Faster sync.** `syncMinutes` 5 (Feedback: 60), plus `/count` nudges a background sync when the last
38
+ is over a minute old - the badge itself never waits on the manager.
39
+ - **The CMS version comes from the manager's registry** (`sites.json` `cmsVersion`), not the payload: a
40
+ child site's package.json is the manager's, so its own answer is wrong.
41
+
42
+ ## Files
43
+
44
+ - `plugin.js` - both route sets (see its header); `options.mode` / `mail` / `fetch` / `env` /
45
+ `slugFromToken` / `siteDirectory` / `timer: false` are test seams
46
+ - `lib/shape.js` - PURE: categories, priorities, statuses, cleaning, `addTurn`, `senderView` (the
47
+ whitelist - no notes, no history), `contactCard`, throttle, both emails
48
+ - `lib/sender.js` - outbox (`data/outbox.json`: items, seen, last remote view, contact), `managerApi`,
49
+ `flush`, `mergeTickets`
50
+ - `lib/receiver.js` - the `support-tickets` collection (API off on all verbs), token -> slug, site
51
+ directory, `open` / `customerTurn` / `dommaTurn` / `patch`
52
+ - `admin/views/support.js` (shell) -> `desk.js` (site) or `inbox.js` (manager); `admin/lib/kit.js`
53
+ - `tests/shape.test.js` (pure), `tests/api.test.js` (site apps wired into a manager app via inject;
54
+ mail into an array)
55
+
56
+ ## Badges
57
+
58
+ Site: new replies (`info`), else open requests as an inventory. Manager: requests waiting on Domma,
59
+ `danger` while any is urgent, `attention` otherwise; nothing waiting = every request, inventory.
@@ -0,0 +1,95 @@
1
+ /* Support - admin styles. Theme tokens only (every one below is defined by every Domma theme). */
2
+
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; }
5
+
6
+ .sp { max-width: 60rem; display: flex; flex-direction: column; gap: 1rem; }
7
+ .sp-desk { display: flex; flex-direction: column; gap: 1rem; }
8
+ .sp-card { background: var(--dm-surface); border: 1px solid var(--dm-border); border-radius: var(--dm-radius-lg); padding: 1rem 1.1rem; }
9
+ .sp-bar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .8rem; }
10
+ .sp-spacer { flex: 1; }
11
+ .sp-intro { display: flex; flex-direction: column; gap: .15rem; }
12
+ .sp-intro span { color: var(--dm-text-secondary); font-size: var(--dm-font-size-sm); }
13
+ .sp-note { margin: .5rem 0; color: var(--dm-text-secondary); font-size: var(--dm-font-size-sm); display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
14
+ .sp-note.is-bad { color: var(--dm-danger); }
15
+ .sp-note.is-warn { color: var(--dm-text); background: color-mix(in srgb, var(--dm-warning) 14%, transparent); border-radius: var(--dm-radius-md); padding: .45rem .7rem; }
16
+ .sp-small { font-size: var(--dm-font-size-xs); margin-top: 0; }
17
+ .sp-hint { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
18
+
19
+ /* The contact card */
20
+ .sp-contact { border-color: color-mix(in srgb, var(--dm-primary) 40%, var(--dm-border)); }
21
+ .sp-contact-lead { display: flex; gap: .9rem; align-items: center; }
22
+ .sp-contact-lead h3 { margin: 0; }
23
+ .sp-contact-lead p { margin: .15rem 0 0; color: var(--dm-text-secondary); }
24
+ .sp-contact-icon { color: var(--dm-primary); display: inline-flex; padding: .6rem; border-radius: 50%; background: var(--dm-primary-light); }
25
+ .sp-ways { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: .6rem; margin-top: 1rem; }
26
+ .sp-way { display: flex; gap: .6rem; align-items: center; padding: .6rem .75rem; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md);
27
+ color: var(--dm-text); text-decoration: none; min-width: 0; }
28
+ .sp-way:not(.is-static):hover { background: var(--dm-hover-bg); border-color: var(--dm-primary); }
29
+ .sp-way > span:last-child { display: flex; flex-direction: column; min-width: 0; }
30
+ .sp-way small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
31
+ .sp-way strong { overflow-wrap: anywhere; }
32
+ .sp-contact-actions { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-top: 1rem; }
33
+
34
+ /* Filters */
35
+ .sp-seg { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
36
+ .sp-seg button { border: 1px solid var(--dm-border); background: none; color: var(--dm-text-secondary); border-radius: var(--dm-radius-full);
37
+ padding: .2rem .7rem; font-size: var(--dm-font-size-sm); cursor: pointer; }
38
+ .sp-seg button b { font-weight: 600; margin-left: .2rem; }
39
+ .sp-seg button.is-on { background: var(--dm-primary-light); color: var(--dm-text); border-color: var(--dm-primary); }
40
+ .sp-search { flex: 1; min-width: 12rem; max-width: 20rem; }
41
+
42
+ /* Rows */
43
+ .sp-list { display: flex; flex-direction: column; }
44
+ .sp-row { display: flex; align-items: center; gap: .75rem; padding: .6rem .5rem; border-top: 1px solid var(--dm-border); cursor: pointer; border-radius: var(--dm-radius-md); }
45
+ .sp-row:first-child { border-top: 0; }
46
+ .sp-row:hover, .sp-row:focus-visible { background: var(--dm-hover-bg); outline: none; }
47
+ .sp-cat { display: inline-flex; color: var(--dm-text-secondary); }
48
+ .sp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
49
+ .sp-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
50
+ .sp-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
51
+ .sp-dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--dm-primary); flex: none; }
52
+ .sp-urgent { font-size: var(--dm-font-size-xs); font-weight: 700; color: var(--dm-danger); text-transform: uppercase; letter-spacing: .03em; }
53
+ .sp-chip { font-size: var(--dm-font-size-xs); font-weight: 600; padding: .12rem .55rem; border-radius: var(--dm-radius-full); white-space: nowrap;
54
+ background: color-mix(in srgb, var(--dm-text-secondary) 14%, transparent); color: var(--dm-text); }
55
+ .sp-chip.is-info { background: color-mix(in srgb, var(--dm-info) 18%, transparent); }
56
+ .sp-chip.is-warning { background: color-mix(in srgb, var(--dm-warning) 22%, transparent); }
57
+ .sp-chip.is-success { background: color-mix(in srgb, var(--dm-success) 20%, transparent); }
58
+ .sp-chip.is-bad { background: color-mix(in srgb, var(--dm-danger) 20%, transparent); }
59
+
60
+ /* Forms */
61
+ .sp-form { display: flex; flex-direction: column; gap: .35rem; }
62
+ .sp-form .form-label { margin-top: .5rem; }
63
+ .sp-choices { display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: .4rem; }
64
+ .sp-choices.is-two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
65
+ .sp-choices button { display: flex; align-items: center; gap: .45rem; border: 1px solid var(--dm-border); background: none; color: var(--dm-text);
66
+ border-radius: var(--dm-radius-md); padding: .5rem .6rem; cursor: pointer; text-align: left; font-size: var(--dm-font-size-sm); }
67
+ .sp-choices.is-two button { flex-direction: column; align-items: flex-start; gap: .1rem; }
68
+ .sp-choices.is-two small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
69
+ .sp-choices button:hover { background: var(--dm-hover-bg); }
70
+ .sp-choices button.is-on { border-color: var(--dm-primary); background: var(--dm-primary-light); }
71
+ .sp-choices [data-value="urgent"].is-on { border-color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 12%, transparent); }
72
+ .sp-actions { display: flex; gap: .5rem; align-items: center; margin-top: .6rem; flex-wrap: wrap; }
73
+ .sp-inline { display: inline-flex; align-items: center; gap: .4rem; font-size: var(--dm-font-size-sm); }
74
+ .sp-inline .form-select { width: auto; }
75
+ .sp-danger { color: var(--dm-danger); }
76
+
77
+ /* A request */
78
+ .sp-detail { display: flex; flex-direction: column; gap: 1rem; }
79
+ .sp-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: .5rem 1rem; margin: 0; }
80
+ .sp-facts dt { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
81
+ .sp-facts dd { margin: 0; overflow-wrap: anywhere; }
82
+ .sp-thread-host { max-height: 50vh; overflow-y: auto; }
83
+ .sp-thread { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
84
+ .sp-msg { max-width: 88%; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-lg); padding: .55rem .75rem; background: var(--dm-background); }
85
+ .sp-msg.is-mine { align-self: flex-end; background: var(--dm-primary-light); border-color: color-mix(in srgb, var(--dm-primary) 35%, var(--dm-border)); }
86
+ .sp-msg-head { display: flex; gap: .6rem; justify-content: space-between; align-items: baseline; font-size: var(--dm-font-size-sm); }
87
+ .sp-msg-head small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); white-space: nowrap; }
88
+ .sp-msg-body { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: .25rem; }
89
+ .sp-reply, .sp-side { border-top: 1px solid var(--dm-border); padding-top: .8rem; }
90
+
91
+ @media (max-width: 640px) {
92
+ .sp-choices.is-two { grid-template-columns: 1fr; }
93
+ .sp-row { flex-wrap: wrap; }
94
+ .sp-msg { max-width: 100%; }
95
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Support admin - what both screens share: the admin's helpers (loaded at run
3
+ * time; a plugin never imports admin/ statically - the updater replaces it),
4
+ * the API, escaping, dates, slideovers and the conversation thread.
5
+ *
6
+ * @module support/admin/lib/kit
7
+ */
8
+
9
+ import {fmtDate, fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
10
+
11
+ export const SIDEBAR_URL = '#/plugins/support';
12
+ export const BASE = '/api/plugins/support';
13
+
14
+ let kit = null;
15
+
16
+ /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
17
+ export function loadKit() {
18
+ if (!kit) {
19
+ kit = Promise.all([
20
+ import('/admin/js/lib/tool-kit.js').catch(() => ({})),
21
+ import('/admin/js/lib/help-popover.js').catch(() => ({}))
22
+ ]).then(([tk, help]) => ({
23
+ onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
24
+ viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
25
+ helpPopovers: help.helpPopovers || (() => () => {})
26
+ }));
27
+ }
28
+ return kit;
29
+ }
30
+
31
+ const seg = (id) => encodeURIComponent(String(id));
32
+ export const api = {
33
+ mode: () => H.get(`${BASE}/mode`),
34
+ meta: () => H.get(`${BASE}/meta`),
35
+ // site
36
+ tickets: () => H.get(`${BASE}/tickets`),
37
+ open: (d) => H.post(`${BASE}/tickets`, d),
38
+ write: (ref, body) => H.post(`${BASE}/tickets/${seg(ref)}/messages`, {body}),
39
+ close: (ref) => H.post(`${BASE}/tickets/${seg(ref)}/close`, {}),
40
+ seen: (ref, at) => H.put(`${BASE}/tickets/${seg(ref)}/seen`, {at}),
41
+ // manager
42
+ ticket: (id) => H.get(`${BASE}/tickets/${seg(id)}`),
43
+ reply: (id, d) => H.post(`${BASE}/tickets/${seg(id)}/reply`, d),
44
+ update: (id, d) => H.put(`${BASE}/tickets/${seg(id)}`, d),
45
+ remove: (id) => H.delete(`${BASE}/tickets/${seg(id)}`)
46
+ };
47
+
48
+ /** Some routes send a bare array, some an object. */
49
+ export const listOf = (res, key) => res?.[key] ?? (Array.isArray(res) ? res : []);
50
+
51
+ /** Slideover titles and E.confirm render HTML: user text goes in escaped. */
52
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
53
+
54
+ /** A small DOM tree from an HTML string that holds NO unescaped user data. */
55
+ export function fragment(html) {
56
+ const t = document.createElement('template');
57
+ t.innerHTML = html.trim();
58
+ return t.content.firstElementChild;
59
+ }
60
+
61
+ export const store = {
62
+ get(k) { try { return S.get(k); } catch { return null; } },
63
+ set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
64
+ };
65
+
66
+ /** The sidebar re-asks the Support badge. */
67
+ export function refreshBadge() {
68
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
69
+ }
70
+
71
+ /** "3 min ago", "26/09/2026". */
72
+ export function when(iso) {
73
+ const t = new Date(iso).getTime();
74
+ if (!Number.isFinite(t)) return '';
75
+ const s = (Date.now() - t) / 1000;
76
+ if (s < 60) return 'just now';
77
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
78
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
79
+ if (s < 86400 * 6) return `${Math.round(s / 86400)} d ago`;
80
+ return fmtDate(t);
81
+ }
82
+
83
+ export const stamp = (iso) => (Number.isFinite(new Date(iso ?? '').getTime()) ? fmtDateTime(iso) : '');
84
+
85
+ /** The tel: form of a phone number (shape.js has the same; admin code cannot import lib/). */
86
+ export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g, '')}`;
87
+
88
+ export const TONE = {open: 'warning', answered: 'info', closed: 'success', waiting: 'warning', refused: 'bad'};
89
+
90
+ /**
91
+ * The conversation, oldest first, as HTML with every user string escaped.
92
+ * `mine` says which side is "us" (customer on a site, domma on the manager).
93
+ */
94
+ export function threadHtml(messages = [], mine) {
95
+ if (!messages.length) return '<p class="sp-note">Nothing here yet.</p>';
96
+ return `<ol class="sp-thread">${messages.map(m => `
97
+ <li class="sp-msg ${m.from === mine ? 'is-mine' : 'is-theirs'}${m.from === 'domma' ? ' is-domma' : ''}">
98
+ <div class="sp-msg-head"><strong>${esc(m.from === 'domma' ? `${m.name || 'Domma'} · Domma Support` : (m.name || 'Customer'))}</strong>
99
+ <small>${esc(stamp(m.at))}${m.pending ? ' · waiting to send' : ''}${m.refused ? ' · not accepted' : ''}</small></div>
100
+ <div class="sp-msg-body">${esc(m.body)}</div>
101
+ </li>`).join('')}</ol>`;
102
+ }
103
+
104
+ /**
105
+ * A slideover holding `html`, bound to `model`. Everything is disposed and the
106
+ * element removed on close.
107
+ */
108
+ export async function openPanel({title, size = 'md', html, model = {}, methods = {}, cleanup = () => {}}) {
109
+ const {onceClosed, helpPopovers} = await loadKit();
110
+ const so = E.slideover({title: esc(title), size, position: 'right'});
111
+ const panel = fragment(html);
112
+ so.element.appendChild(panel);
113
+ const disposeHelp = helpPopovers(panel, {position: 'bottom'});
114
+ const handle = M.applyBindings(model, panel, {methods});
115
+ onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
116
+ I.scan(panel);
117
+ so.open();
118
+ return {so, panel, close: () => so.close()};
119
+ }
@@ -0,0 +1 @@
1
+ <div class="sp" data-support-admin></div>
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Support on a site: how to reach Domma (always), and this site's conversations
3
+ * with Domma Support (when the manager started the site).
4
+ *
5
+ * Everything written is kept on this site before it is sent, so a Domma server
6
+ * that is restarting only delays it ("waiting to send"); opening this screen
7
+ * tries again. A standalone site has the contact card and nothing else - it has
8
+ * nowhere to send a conversation, so it says to email instead.
9
+ */
10
+
11
+ import {TONE, api, esc, listOf, openPanel, refreshBadge, stamp, telHref, threadHtml, when} from '../lib/kit.js';
12
+
13
+ const ROW = `
14
+ <div class="sp-row" data-id="{{key}}" tabindex="0" role="button">
15
+ <span class="sp-cat" data-bind-title="categoryLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
16
+ <span class="sp-main"><strong data-bind-text="subject"></strong><small data-bind-text="meta"></small></span>
17
+ <span class="sp-urgent" data-bind-hidden="!urgent">Urgent</span>
18
+ <span class="sp-dot" data-bind-hidden="!unread" title="New reply from Domma"></span>
19
+ <span class="sp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
20
+ </div>`;
21
+
22
+ export async function mount(root, ctx) {
23
+ const {kit, scope, here, meta, id} = ctx;
24
+ const categories = meta.categories || [];
25
+ const priorities = meta.priorities || [];
26
+ const iconOf = (c) => categories.find(x => x.key === c)?.icon || 'message-square';
27
+ const labelOf = (c) => categories.find(x => x.key === c)?.short || c;
28
+ const plural = (n, w) => (n ? `${n} ${w}${n === 1 ? '' : 's'}` : '');
29
+
30
+ const connected = M.observable(Boolean(ctx.mode.connected));
31
+ const contact = M.observable(ctx.mode.contact || {});
32
+ const tickets = M.observable([]);
33
+ const loading = M.observable(Boolean(ctx.mode.connected));
34
+ const reachable = M.observable(true);
35
+ const error = M.observable('');
36
+ const rows = M.observable([]);
37
+ scope(M.effect(() => {
38
+ rows.value = tickets.value.map(t => ({
39
+ key: t.ref,
40
+ subject: t.subject,
41
+ icon: iconOf(t.category),
42
+ categoryLabel: labelOf(t.category),
43
+ meta: [when(t.lastAt || t.createdAt), t.userName, plural((t.messages || []).length, 'message')].filter(Boolean).join(' · '),
44
+ statusLabel: t.statusLabel || t.status,
45
+ tone: `is-${TONE[t.status] || 'muted'}`,
46
+ urgent: t.priority === 'urgent',
47
+ unread: Boolean(t.unread)
48
+ }));
49
+ }));
50
+ const waitingCount = M.computed(() => tickets.value.filter(t => t.status === 'waiting'
51
+ || (t.messages || []).some(m => m.pending)).length);
52
+
53
+ root.innerHTML = '';
54
+ const view = document.createElement('div');
55
+ view.className = 'sp-desk';
56
+ view.innerHTML = `
57
+ <section class="sp-card sp-contact" aria-label="Contact Domma">
58
+ <div class="sp-contact-lead">
59
+ <span class="sp-contact-icon"><span data-icon="headphones" data-icon-size="28"></span></span>
60
+ <div>
61
+ <h3>We are here to help</h3>
62
+ <p data-bind-text="contact.value.responseTime"></p>
63
+ </div>
64
+ </div>
65
+ <div class="sp-ways">
66
+ <a class="sp-way" data-bind-hidden="!contact.value.email" data-ref="mail">
67
+ <span data-icon="mail" data-icon-size="18"></span><span><small>Email</small><strong data-bind-text="contact.value.email"></strong></span></a>
68
+ <a class="sp-way" data-bind-hidden="!contact.value.phone" data-ref="tel">
69
+ <span data-icon="phone" data-icon-size="18"></span><span><small>Phone</small><strong data-bind-text="contact.value.phone"></strong></span></a>
70
+ <div class="sp-way is-static" data-bind-hidden="!contact.value.hours">
71
+ <span data-icon="clock" data-icon-size="18"></span><span><small>Hours</small><strong data-bind-text="contact.value.hours"></strong></span></div>
72
+ </div>
73
+ <div class="sp-contact-actions" data-bind-hidden="!connected.value">
74
+ <button type="button" class="btn btn-primary" data-on-click="compose"><span data-icon="send" data-icon-size="14"></span> Ask for help</button>
75
+ <span class="sp-hint">Fastest: it reaches the Domma team at once, with your site attached. Mark it urgent if your site is down.</span>
76
+ </div>
77
+ </section>
78
+
79
+ <section class="sp-card" data-bind-hidden="connected.value">
80
+ <p class="sp-note"><span data-icon="cloud-off" data-icon-size="16"></span> This site is not connected to the Domma server, so requests cannot be sent from here - email us and we will pick it up from there.</p>
81
+ </section>
82
+
83
+ <section class="sp-card" data-bind-hidden="!connected.value">
84
+ <div class="sp-bar">
85
+ <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 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. Replies also arrive by email, and the sidebar badge counts the ones you have not opened."></span>
87
+ </div>
88
+ <p class="sp-note is-warn" data-bind-hidden="!waitingCount.value">
89
+ <span data-icon="clock" data-icon-size="14"></span> Something is waiting to send - it goes as soon as the Domma server answers.
90
+ <button type="button" class="btn btn-ghost btn-xs" data-on-click="reload">Try now</button>
91
+ </p>
92
+ <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
+ <div class="sp-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading...</div>
94
+ <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">No requests yet. Stuck on something, or something not working? Ask for help - a person reads every one.</p>
96
+ <div class="sp-list" data-each="rows.value key=key"></div>
97
+ </section>`;
98
+ root.appendChild(view);
99
+ scope(kit.helpPopovers(view, {position: 'bottom'}));
100
+ const listEl = view.querySelector('.sp-list');
101
+ listEl.innerHTML = ROW;
102
+ scope(M.effect(() => { rows.value; Promise.resolve().then(() => { M.flush?.(); I.scan(listEl); }); }));
103
+ // The links are set here, from the cleaned card (the server lets through only a real email and phone).
104
+ const mailLink = view.querySelector('[data-ref="mail"]');
105
+ const tel = view.querySelector('[data-ref="tel"]');
106
+ scope(M.effect(() => {
107
+ const c = contact.value || {};
108
+ if (c.email) mailLink.setAttribute('href', `mailto:${c.email}`);
109
+ if (c.phone) tel.setAttribute('href', telHref(c.phone));
110
+ }));
111
+
112
+ async function load() {
113
+ loading.value = true;
114
+ try {
115
+ const res = await api.tickets();
116
+ if (!here()) return;
117
+ connected.value = Boolean(res.connected);
118
+ reachable.value = Boolean(res.reachable) || !res.connected;
119
+ if (res.contact) contact.value = res.contact;
120
+ tickets.value = listOf(res, 'tickets');
121
+ error.value = '';
122
+ } catch (err) {
123
+ error.value = `Could not load your requests: ${err.message || err}`;
124
+ } finally {
125
+ loading.value = false;
126
+ I.scan(view);
127
+ }
128
+ }
129
+
130
+ const byRef = (ref) => tickets.peek().find(t => t.ref === ref);
131
+
132
+ function compose() {
133
+ const f = {category: M.observable('help'), priority: M.observable('normal'), subject: M.observable(''), body: M.observable(''),
134
+ where: M.observable(''), busy: M.observable(false)};
135
+ let panel = null;
136
+ openPanel({
137
+ title: 'Ask Domma for help',
138
+ model: f,
139
+ html: `<div class="sp-form">
140
+ <label class="form-label">What is it about?</label>
141
+ <div class="sp-choices" data-group="category" role="radiogroup" aria-label="Category">
142
+ ${categories.map(c => `<button type="button" role="radio" data-value="${esc(c.key)}" data-bind-class="category.value === '${esc(c.key)}' && 'is-on'"><span data-icon="${esc(c.icon)}" data-icon-size="16"></span>${esc(c.label)}</button>`).join('')}
143
+ </div>
144
+ <label class="form-label" for="sp-subject">Subject</label>
145
+ <input id="sp-subject" class="form-input" maxlength="140" data-model="subject.value" placeholder="One line - e.g. My contact form stopped sending">
146
+ <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
+ <textarea id="sp-body" class="form-textarea" rows="8" maxlength="8000" data-model="body.value"></textarea>
148
+ <label class="form-label">How urgent is it?</label>
149
+ <div class="sp-choices is-two" data-group="priority" role="radiogroup" aria-label="Priority">
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('')}
151
+ </div>
152
+ <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
+ <input id="sp-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
154
+ <p class="sp-hint">Sent with your name, email and this site, so we can reply here and by email.</p>
155
+ <div class="sp-actions"><span class="sp-spacer"></span>
156
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
157
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
158
+ </div>
159
+ </div>`,
160
+ methods: {
161
+ cancel: () => panel?.close(),
162
+ async send() {
163
+ if (f.busy.peek()) return;
164
+ f.busy.value = true;
165
+ try {
166
+ const r = await api.open({category: f.category.peek(), priority: f.priority.peek(), subject: f.subject.peek(),
167
+ body: f.body.peek(), where: f.where.peek()});
168
+ E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
169
+ panel?.close();
170
+ refreshBadge();
171
+ await load();
172
+ } catch (err) {
173
+ E.toast(err.message || String(err), {type: 'error'});
174
+ } finally {
175
+ f.busy.value = false;
176
+ }
177
+ }
178
+ }
179
+ }).then(p => {
180
+ 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
+ p.panel.querySelector('#sp-subject')?.focus();
187
+ });
188
+ }
189
+
190
+ async function open(ref) {
191
+ const t0 = byRef(ref);
192
+ if (!t0) return;
193
+ const draft = M.observable('');
194
+ const busy = M.observable(false);
195
+ const status = M.observable(t0.status);
196
+ const canWrite = !['waiting', 'refused'].includes(t0.status);
197
+ let panel = null;
198
+ const render = () => {
199
+ const t = byRef(ref) || t0;
200
+ status.value = t.status;
201
+ const box = panel?.panel.querySelector('.sp-thread-host');
202
+ if (box) { box.innerHTML = threadHtml(t.messages, 'customer'); box.scrollTop = box.scrollHeight; }
203
+ const chip = panel?.panel.querySelector('[data-ref="status"]');
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
+ }
213
+ };
214
+ panel = await openPanel({
215
+ title: t0.subject,
216
+ size: 'lg',
217
+ model: {draft, busy, status},
218
+ html: `<div class="sp-detail">
219
+ <dl class="sp-facts">
220
+ <div><dt>Status</dt><dd><span data-ref="status" class="sp-chip"></span></dd></div>
221
+ <div><dt>About</dt><dd>${esc(labelOf(t0.category))}${t0.priority === 'urgent' ? ' · <span class="sp-urgent">Urgent</span>' : ''}</dd></div>
222
+ <div><dt>Opened</dt><dd>${esc(stamp(t0.createdAt))}${t0.userName ? ` by ${esc(t0.userName)}` : ''}</dd></div>
223
+ ${t0.where ? `<div><dt>Where</dt><dd>${esc(t0.where)}</dd></div>` : ''}
224
+ ${t0.lastError ? `<div><dt>Last try</dt><dd>${esc(t0.lastError)}</dd></div>` : ''}
225
+ </dl>
226
+ <div class="sp-thread-host"></div>
227
+ ${canWrite ? `<div class="sp-reply">
228
+ <label class="form-label" for="sp-write" data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></label>
229
+ <textarea id="sp-write" class="form-textarea" rows="4" maxlength="8000" data-model="draft.value" placeholder="Write to Domma Support"></textarea>
230
+ <div class="sp-actions">
231
+ <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>
232
+ <span class="sp-spacer"></span>
233
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !draft.value.trim()"><span data-icon="send" data-icon-size="13"></span> Send</button>
234
+ </div>
235
+ </div>` : '<p class="sp-note">You can write again once this request reaches Domma.</p>'}
236
+ </div>`,
237
+ methods: {
238
+ async send() {
239
+ if (busy.peek() || !draft.peek().trim()) return;
240
+ busy.value = true;
241
+ try {
242
+ const r = await api.write(ref, draft.peek());
243
+ if (r.state !== 'sent') E.toast(r.message, {type: 'warning'});
244
+ draft.value = '';
245
+ await load();
246
+ render();
247
+ } catch (err) {
248
+ E.toast(err.message || String(err), {type: 'error'});
249
+ } finally {
250
+ busy.value = false;
251
+ }
252
+ },
253
+ async resolve() {
254
+ if (busy.peek()) return;
255
+ busy.value = true;
256
+ try {
257
+ const r = await api.close(ref);
258
+ E.toast(r.message || 'Marked as resolved.', {type: r.state === 'sent' ? 'success' : 'warning'});
259
+ await load();
260
+ render();
261
+ } catch (err) {
262
+ E.toast(err.message || String(err), {type: 'error'});
263
+ } finally {
264
+ busy.value = false;
265
+ }
266
+ }
267
+ }
268
+ });
269
+ render();
270
+ await markSeen();
271
+ }
272
+
273
+ const bindings = M.applyBindings({connected, contact, loading, reachable, error, rows, waitingCount}, view, {
274
+ methods: {compose, reload: () => load()}
275
+ });
276
+ scope(() => bindings.dispose());
277
+
278
+ const onClick = (e) => { const row = e.target.closest('.sp-row'); if (row) open(row.dataset.id); };
279
+ const onKey = (e) => { const row = e.target.closest('.sp-row'); if (row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); open(row.dataset.id); } };
280
+ listEl.addEventListener('click', onClick);
281
+ listEl.addEventListener('keydown', onKey);
282
+ scope(() => { listEl.removeEventListener('click', onClick); listEl.removeEventListener('keydown', onKey); });
283
+
284
+ I.scan(view);
285
+ if (connected.peek()) {
286
+ await load();
287
+ if (id && here()) open(id);
288
+ }
289
+ }