domma-cms 0.30.0 → 0.32.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.
Files changed (43) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +4 -4
  3. package/admin/js/lib/themes.js +1 -1
  4. package/admin/js/templates/effects.html +752 -752
  5. package/admin/js/templates/forms.html +17 -17
  6. package/admin/js/templates/my-profile.html +17 -17
  7. package/admin/js/templates/role-editor.html +70 -70
  8. package/admin/js/templates/roles.html +10 -10
  9. package/admin/js/views/settings.js +1 -1
  10. package/bin/lib/config-merge.js +44 -44
  11. package/config/site.json +1 -0
  12. package/package.json +2 -2
  13. package/plugins/surveys/admin/templates/audience.html +47 -0
  14. package/plugins/surveys/admin/templates/results.html +58 -0
  15. package/plugins/surveys/admin/templates/survey-editor.html +141 -0
  16. package/plugins/surveys/admin/templates/surveys.html +25 -0
  17. package/plugins/surveys/admin/views/audience.js +301 -0
  18. package/plugins/surveys/admin/views/results.js +172 -0
  19. package/plugins/surveys/admin/views/survey-editor.js +211 -0
  20. package/plugins/surveys/admin/views/surveys.js +161 -0
  21. package/plugins/surveys/collections/survey-contacts/schema.json +13 -0
  22. package/plugins/surveys/collections/survey-groups/schema.json +11 -0
  23. package/plugins/surveys/collections/survey-invites/schema.json +16 -0
  24. package/plugins/surveys/collections/surveys/schema.json +23 -0
  25. package/plugins/surveys/config.js +8 -0
  26. package/plugins/surveys/plugin.js +169 -0
  27. package/plugins/surveys/plugin.json +65 -0
  28. package/plugins/surveys/public/dist-shared.mjs +1 -0
  29. package/plugins/surveys/public/survey.css +1 -0
  30. package/plugins/surveys/public/survey.mjs +1 -0
  31. package/plugins/surveys/templates/survey-page.html +63 -0
  32. package/server/middleware/auth.js +253 -253
  33. package/server/routes/api/auth.js +309 -309
  34. package/server/routes/api/navigation.js +42 -42
  35. package/server/routes/api/settings.js +141 -141
  36. package/server/routes/docs-public.js +43 -0
  37. package/server/routes/public.js +2 -2
  38. package/server/server.js +13 -1
  39. package/{scripts/migrate-docs.js → server/services/docs.js} +204 -126
  40. package/server/services/email.js +167 -167
  41. package/server/services/userProfiles.js +199 -199
  42. package/server/services/users.js +302 -302
  43. package/config/connections.json.bak +0 -9
@@ -0,0 +1,141 @@
1
+ <div class="survey-editor-plugin">
2
+
3
+ <style>
4
+ /* Layout only — colours/borders come from Domma's themed .card and
5
+ .form-input, so the active theme is always respected. */
6
+ .survey-editor-plugin .se-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem 1.25rem; }
7
+ .survey-editor-plugin .se-span-2 { grid-column: 1 / -1; }
8
+ .survey-editor-plugin .se-field { display: flex; flex-direction: column; gap: 0.35rem; }
9
+ .survey-editor-plugin .se-field label { font-weight: 600; font-size: 0.85rem; }
10
+ .survey-editor-plugin .se-field .se-hint { font-size: 0.78rem; color: var(--dm-text-muted); }
11
+ .survey-editor-plugin .se-field .form-input { width: 100%; }
12
+ .survey-editor-plugin .se-field textarea.form-input { min-height: 90px; resize: vertical; }
13
+ .survey-editor-plugin .se-field select[multiple].form-input { min-height: 120px; }
14
+ @media (max-width: 640px) { .survey-editor-plugin .se-grid { grid-template-columns: 1fr; } }
15
+ </style>
16
+
17
+ <div class="toolbar mb-3" style="display:flex;align-items:center;gap:0.5rem;">
18
+ <h2 id="editor-title" style="margin:0;flex:1;">New Survey</h2>
19
+ <button id="back-btn" class="btn btn-outline"><span data-icon="arrow-left"></span> Back</button>
20
+ </div>
21
+
22
+ <div class="card">
23
+ <div class="card-header"><strong>Basics</strong></div>
24
+ <div class="card-body">
25
+ <div class="se-grid">
26
+ <div class="se-field se-span-2">
27
+ <label for="f-title">Title</label>
28
+ <input id="f-title" name="title" type="text" class="form-input" placeholder="Customer satisfaction Q3">
29
+ </div>
30
+ <div class="se-field">
31
+ <label for="f-slug">Slug</label>
32
+ <input id="f-slug" name="slug" type="text" class="form-input" placeholder="customer-sat-q3">
33
+ <span class="se-hint" id="slug-hint">URL-safe identifier.</span>
34
+ </div>
35
+ <div class="se-field">
36
+ <label for="f-formSlug">Form</label>
37
+ <select id="f-formSlug" name="formSlug" class="form-input"><option value="">Select a form…</option></select>
38
+ <span class="se-hint">The Forms definition respondents complete.</span>
39
+ </div>
40
+ <div class="se-field se-span-2">
41
+ <label for="f-description">Description</label>
42
+ <textarea id="f-description" name="description" class="form-input" placeholder="Optional internal description…"></textarea>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="card mt-3">
49
+ <div class="card-header"><strong>Setup</strong></div>
50
+ <div class="card-body">
51
+ <div class="se-grid">
52
+ <div class="se-field">
53
+ <label for="f-status">Status</label>
54
+ <select id="f-status" name="status" class="form-input">
55
+ <option value="draft">Draft</option>
56
+ <option value="open">Open</option>
57
+ <option value="closed">Closed</option>
58
+ </select>
59
+ </div>
60
+ <div class="se-field">
61
+ <label for="f-mode">Mode</label>
62
+ <select id="f-mode" name="mode" class="form-input">
63
+ <option value="identified">Identified</option>
64
+ <option value="anonymous">Anonymous</option>
65
+ </select>
66
+ <span class="se-hint">Anonymous de-links responses from contacts.</span>
67
+ </div>
68
+ <div class="se-field se-span-2">
69
+ <label for="f-groupIds">Audience groups</label>
70
+ <select id="f-groupIds" name="groupIds" class="form-input" multiple></select>
71
+ <span class="se-hint">Hold Ctrl/Cmd to select multiple groups.</span>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="card mt-3">
78
+ <div class="card-header"><strong>Schedule</strong></div>
79
+ <div class="card-body">
80
+ <div class="se-grid">
81
+ <div class="se-field"><label for="f-opensAt">Opens at</label><input id="f-opensAt" name="opensAt" type="date" class="form-input"></div>
82
+ <div class="se-field"><label for="f-closesAt">Closes at</label><input id="f-closesAt" name="closesAt" type="date" class="form-input"></div>
83
+ <div class="se-field"><label for="f-reminderAfterDays">Reminder after (days)</label><input id="f-reminderAfterDays" name="reminderAfterDays" type="number" min="0" value="7" class="form-input"></div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+
88
+ <div class="card mt-3">
89
+ <div class="card-header"><strong>Survey page content</strong></div>
90
+ <div class="card-body">
91
+ <div class="se-grid">
92
+ <div class="se-field se-span-2">
93
+ <label for="f-intro">Intro (Markdown — shown above the form)</label>
94
+ <textarea id="f-intro" name="intro" class="form-input" placeholder="## Welcome&#10;Thanks for taking a moment to help us improve."></textarea>
95
+ </div>
96
+ <div class="se-field se-span-2">
97
+ <label for="f-footer">Footer (Markdown — shown below the form)</label>
98
+ <textarea id="f-footer" name="footer" class="form-input" placeholder="Questions? Contact us at hello@example.com"></textarea>
99
+ </div>
100
+ <div class="se-field se-span-2">
101
+ <label for="f-thankYou">Thank-you message (shown after submitting)</label>
102
+ <textarea id="f-thankYou" name="thankYou" class="form-input" placeholder="Thank you — your response has been recorded."></textarea>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <div class="card mt-3">
109
+ <div class="card-header"><strong>Invitation email</strong></div>
110
+ <div class="card-body">
111
+ <div class="se-grid">
112
+ <div class="se-field se-span-2">
113
+ <label for="f-inviteSubject">Subject</label>
114
+ <input id="f-inviteSubject" name="inviteSubject" type="text" class="form-input" placeholder="We would love your feedback">
115
+ </div>
116
+ <div class="se-field se-span-2">
117
+ <label for="f-inviteBody">Body</label>
118
+ <textarea id="f-inviteBody" name="inviteBody" class="form-input" placeholder="Hi {{name}}, please complete our survey: {{link}}"></textarea>
119
+ <span class="se-hint">{{name}} and {{link}} are substituted per recipient; {{link}} becomes the button.</span>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <div class="card mt-3">
126
+ <div class="card-body" style="display:flex;gap:0.5rem;flex-wrap:wrap;">
127
+ <button id="save-btn" class="btn btn-primary">Save Survey</button>
128
+ <button id="cancel-btn" class="btn btn-outline">Cancel</button>
129
+ </div>
130
+ </div>
131
+
132
+ <div id="send-panel" class="card mt-3" style="display:none;">
133
+ <div class="card-header"><strong>Invitations</strong></div>
134
+ <div class="card-body" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
135
+ <button id="send-all-btn" class="btn btn-primary"><span data-icon="send"></span> Send invitations</button>
136
+ <button id="send-reminder-btn" class="btn btn-outline"><span data-icon="bell"></span> Send reminder to non-responders</button>
137
+ <span id="send-hint" class="text-muted" style="font-size:0.85rem;"></span>
138
+ </div>
139
+ </div>
140
+
141
+ </div>
@@ -0,0 +1,25 @@
1
+ <div class="surveys-plugin">
2
+
3
+ <!-- Toolbar -->
4
+ <div class="toolbar mb-3" style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
5
+ <h2 style="margin:0;flex:1;">Surveys</h2>
6
+ <button id="manage-audience" class="btn btn-outline">
7
+ <span data-icon="users"></span>
8
+ Audience
9
+ </button>
10
+ <button id="new-survey" class="btn btn-primary">
11
+ <span data-icon="plus"></span>
12
+ New Survey
13
+ </button>
14
+ </div>
15
+
16
+ <!-- Surveys table -->
17
+ <div id="surveys-table"></div>
18
+
19
+ <!-- Empty state -->
20
+ <div id="surveys-empty" style="display:none;text-align:center;padding:3rem 1rem;color:var(--dm-text-muted);">
21
+ <span data-icon="clipboard-list" style="font-size:3rem;display:block;margin-bottom:1rem;"></span>
22
+ <p>No surveys yet. Click <strong>New Survey</strong> to create your first campaign.</p>
23
+ </div>
24
+
25
+ </div>
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Surveys plugin — shared audience view.
3
+ *
4
+ * Manages survey contacts and audience groups, plus an import action that pulls
5
+ * the audience from the Contacts plugin.
6
+ *
7
+ * @module surveys/admin/views/audience
8
+ */
9
+
10
+ const BASE = '/api/plugins/surveys';
11
+
12
+ /**
13
+ * Authenticated JSON fetch helper. Mirrors plugins/contacts/admin/views/contacts.js.
14
+ *
15
+ * @param {string} url
16
+ * @param {string} [method]
17
+ * @param {object} [body]
18
+ * @returns {Promise<*>}
19
+ */
20
+ async function api(url, method = 'GET', body) {
21
+ const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
22
+ if (body !== undefined) {
23
+ opts.headers['Content-Type'] = 'application/json';
24
+ opts.body = JSON.stringify(body);
25
+ }
26
+ const res = await fetch(url, opts);
27
+ if (!res.ok) {
28
+ const err = await res.json().catch(() => ({error: res.statusText}));
29
+ throw new Error(err.error || res.statusText);
30
+ }
31
+ const text = await res.text();
32
+ return text ? JSON.parse(text) : {};
33
+ }
34
+
35
+ /**
36
+ * Escape HTML special characters to prevent XSS in render strings.
37
+ *
38
+ * @param {*} str
39
+ * @returns {string}
40
+ */
41
+ function esc(str) {
42
+ return String(str ?? '')
43
+ .replace(/&/g, '&amp;')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ .replace(/"/g, '&quot;');
47
+ }
48
+
49
+ export const audienceView = {
50
+ templateUrl: '/plugins/surveys/admin/templates/audience.html',
51
+
52
+ /**
53
+ * Mount the audience view.
54
+ *
55
+ * @param {object} $container - Domma-wrapped container element
56
+ * @returns {Promise<void>}
57
+ */
58
+ async onMount($container) {
59
+ let contacts = [];
60
+ let groups = [];
61
+ let table = null;
62
+
63
+ async function load() {
64
+ const [c, g] = await Promise.all([
65
+ api(`${BASE}/contacts`).catch(() => []),
66
+ api(`${BASE}/groups`).catch(() => [])
67
+ ]);
68
+ contacts = Array.isArray(c) ? c : [];
69
+ groups = Array.isArray(g) ? g : [];
70
+ }
71
+
72
+ // ---- Contacts table ----------------------------------------------
73
+ function buildTable() {
74
+ const tableEl = $container.find('#audience-table').get(0);
75
+ const emptyEl = $container.find('#audience-empty').get(0);
76
+
77
+ if (!contacts.length) {
78
+ if (tableEl) tableEl.style.display = 'none';
79
+ if (emptyEl) emptyEl.style.display = 'block';
80
+ return;
81
+ }
82
+ if (emptyEl) emptyEl.style.display = 'none';
83
+ if (tableEl) tableEl.style.display = '';
84
+
85
+ if (table) {
86
+ table.setData(contacts);
87
+ } else {
88
+ // Render functions return HTML strings; delete uses delegated
89
+ // listener below (not inline onclick) so we can await E.confirm.
90
+ table = T.create($container.find('#audience-table').get(0), {
91
+ data: contacts,
92
+ columns: [
93
+ {key: 'name', title: 'Name', sortable: true, render: (v) => esc(v) || '—'},
94
+ {
95
+ key: 'email', title: 'Email', sortable: true,
96
+ render: (v) => v ? `<a href="mailto:${esc(v)}">${esc(v)}</a>` : '—'
97
+ },
98
+ {
99
+ key: 'groups', title: 'Groups', sortable: false,
100
+ render: (v) => {
101
+ if (!Array.isArray(v) || !v.length) return '—';
102
+ return v.map((g) => `<span class="badge badge-primary" style="margin-right:2px;">${esc(g)}</span>`).join('');
103
+ }
104
+ },
105
+ {
106
+ key: 'id', title: 'Actions', sortable: false,
107
+ render: (id) =>
108
+ `<button class="btn btn-sm btn-danger contact-del" data-id="${esc(id)}">` +
109
+ `<span data-icon="trash" data-icon-size="14"></span> Delete</button>`
110
+ }
111
+ ],
112
+ emptyMessage: 'No contacts.'
113
+ });
114
+
115
+ const container = $container.find('#audience-table').get(0);
116
+ if (container) {
117
+ container.addEventListener('click', async (e) => {
118
+ const btn = e.target.closest('.contact-del');
119
+ if (!btn) return;
120
+ const id = btn.dataset.id;
121
+ const contact = contacts.find((c) => c.id === id);
122
+ const ok = await E.confirm(`Delete "${contact?.name || contact?.email || 'this contact'}"?`);
123
+ if (!ok) return;
124
+ try {
125
+ await api(`${BASE}/contacts/${encodeURIComponent(id)}`, 'DELETE');
126
+ E.toast('Contact deleted.', {type: 'success'});
127
+ await refresh();
128
+ } catch (err) {
129
+ E.toast(err?.message ?? 'Failed to delete contact.', {type: 'error'});
130
+ }
131
+ });
132
+ }
133
+ }
134
+ Domma.icons.scan();
135
+ }
136
+
137
+ // ---- Groups panel -------------------------------------------------
138
+ function buildGroups() {
139
+ const listEl = $container.find('#groups-list').get(0);
140
+ if (!listEl) return;
141
+ while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
142
+
143
+ if (!groups.length) {
144
+ const p = document.createElement('p');
145
+ p.className = 'text-muted';
146
+ p.textContent = 'No groups yet.';
147
+ listEl.appendChild(p);
148
+ return;
149
+ }
150
+ groups.forEach((g) => {
151
+ const row = document.createElement('div');
152
+ row.style.cssText = 'padding:0.4rem 0;border-bottom:1px solid var(--dm-border);';
153
+ const badge = document.createElement('span');
154
+ badge.className = 'badge badge-primary';
155
+ badge.textContent = g.name; // textContent — safe
156
+ row.appendChild(badge);
157
+ if (g.description) {
158
+ const desc = document.createElement('div');
159
+ desc.className = 'text-muted';
160
+ desc.style.fontSize = '0.8rem';
161
+ desc.textContent = g.description;
162
+ row.appendChild(desc);
163
+ }
164
+ listEl.appendChild(row);
165
+ });
166
+ }
167
+
168
+ // ---- Add-contact modal (light-DOM projection) ---------------------
169
+ function openContactModal() {
170
+ const modal = E.modal({title: 'Add Contact', size: 'md'});
171
+
172
+ const wrapper = document.createElement('div');
173
+ wrapper.style.cssText = 'padding:0.25rem 0 0.5rem;';
174
+
175
+ const formContainer = document.createElement('div');
176
+ wrapper.appendChild(formContainer);
177
+
178
+ // Canonical F.create: (blueprintObject, defaultsObject, opts).renderTo(el).
179
+ // Blueprint is keyed by field name; placeholder/hint live under
180
+ // formConfig (proven shape — collection-entries.js). We supply our own
181
+ // buttons (below) and read values from the DOM on save, so the modal's
182
+ // HTML sanitiser never strips an interactive submit button.
183
+ F.create(
184
+ {
185
+ name: {type: 'string', label: 'Name', formConfig: {placeholder: 'Full name'}},
186
+ email: {
187
+ type: 'string', label: 'Email', required: true,
188
+ formConfig: {placeholder: 'email@example.com'}
189
+ },
190
+ groups: {
191
+ type: 'multiselect', label: 'Groups',
192
+ options: groups.map((g) => ({value: g.name, label: g.name})),
193
+ formConfig: {hint: 'Hold Ctrl/Cmd to select multiple.'}
194
+ }
195
+ },
196
+ {},
197
+ {layout: 'stacked', columns: 1, showSubmitButton: false}
198
+ ).renderTo(formContainer);
199
+
200
+ const btnWrap = document.createElement('div');
201
+ btnWrap.style.cssText = 'display:flex;justify-content:flex-end;gap:0.5rem;margin-top:0.75rem;';
202
+
203
+ const cancelBtn = document.createElement('button');
204
+ cancelBtn.className = 'btn btn-outline';
205
+ cancelBtn.textContent = 'Cancel';
206
+ cancelBtn.addEventListener('click', () => modal.close());
207
+
208
+ const saveBtn = document.createElement('button');
209
+ saveBtn.className = 'btn btn-primary';
210
+ saveBtn.textContent = 'Save contact';
211
+ saveBtn.addEventListener('click', async () => {
212
+ const nameEl = formContainer.querySelector('[name="name"]');
213
+ const emailEl = formContainer.querySelector('[name="email"]');
214
+ const groupsEl = formContainer.querySelector('[name="groups"]');
215
+ const email = (emailEl?.value ?? '').trim();
216
+ if (!email) {
217
+ E.toast('Email is required.', {type: 'error'});
218
+ return;
219
+ }
220
+ const selectedGroups = groupsEl
221
+ ? Array.from(groupsEl.selectedOptions).map((o) => o.value)
222
+ : [];
223
+ try {
224
+ await api(`${BASE}/contacts`, 'POST', {
225
+ name: (nameEl?.value ?? '').trim(),
226
+ email,
227
+ groups: selectedGroups
228
+ });
229
+ E.toast('Contact added.', {type: 'success'});
230
+ modal.close();
231
+ await refresh();
232
+ } catch (err) {
233
+ E.toast(err?.message ?? 'Failed to add contact.', {type: 'error'});
234
+ }
235
+ });
236
+
237
+ btnWrap.appendChild(cancelBtn);
238
+ btnWrap.appendChild(saveBtn);
239
+ wrapper.appendChild(btnWrap);
240
+
241
+ // Project the assembled DOM into the modal's light DOM so the
242
+ // interactive buttons survive (E.modal().setContent strips them).
243
+ modal.element.appendChild(wrapper);
244
+ modal.open();
245
+ Domma.icons.scan();
246
+ }
247
+
248
+ // ---- Refresh ------------------------------------------------------
249
+ async function refresh() {
250
+ await load();
251
+ buildTable();
252
+ buildGroups();
253
+ }
254
+
255
+ // ---- Wiring -------------------------------------------------------
256
+ const addContactBtn = $container.find('#add-contact').get(0);
257
+ if (addContactBtn) addContactBtn.addEventListener('click', openContactModal);
258
+
259
+ const backBtn = $container.find('#back-surveys').get(0);
260
+ if (backBtn) backBtn.addEventListener('click', () => { location.hash = '#/plugins/surveys'; });
261
+
262
+ const importBtn = $container.find('#import-contacts').get(0);
263
+ if (importBtn) {
264
+ importBtn.addEventListener('click', async () => {
265
+ const ok = await E.confirm('Import all contacts from the Contacts plugin?');
266
+ if (!ok) return;
267
+ try {
268
+ const res = await api(`${BASE}/import-from-contacts`, 'POST', {});
269
+ E.toast(`Imported ${res?.imported ?? 0} contact(s).`, {type: 'success'});
270
+ await refresh();
271
+ } catch (err) {
272
+ E.toast(err?.message ?? 'Import failed.', {type: 'error'});
273
+ }
274
+ });
275
+ }
276
+
277
+ const addGroupBtn = $container.find('#add-group').get(0);
278
+ const newGroupInput = $container.find('#new-group-name').get(0);
279
+ async function addGroup() {
280
+ const name = (newGroupInput?.value ?? '').trim();
281
+ if (!name) return;
282
+ try {
283
+ await api(`${BASE}/groups`, 'POST', {name});
284
+ if (newGroupInput) newGroupInput.value = '';
285
+ E.toast('Group added.', {type: 'success'});
286
+ await refresh();
287
+ } catch (err) {
288
+ E.toast(err?.message ?? 'Failed to add group.', {type: 'error'});
289
+ }
290
+ }
291
+ if (addGroupBtn) addGroupBtn.addEventListener('click', addGroup);
292
+ if (newGroupInput) {
293
+ newGroupInput.addEventListener('keydown', (e) => {
294
+ if (e.key === 'Enter') addGroup();
295
+ });
296
+ }
297
+
298
+ await refresh();
299
+ Domma.icons.scan();
300
+ }
301
+ };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Surveys plugin — results / statistics dashboard.
3
+ *
4
+ * Renders a response-rate summary, then per-question cards: distribution bars
5
+ * for choice/rating fields and a tidy answer list for free-text fields. Authed
6
+ * CSV/JSON export.
7
+ *
8
+ * @module surveys/admin/views/results
9
+ */
10
+
11
+ import {renderDistribution} from '/plugins/surveys/public/dist-shared.mjs';
12
+
13
+ const BASE = '/api/plugins/surveys';
14
+
15
+ /** Authenticated JSON fetch helper. Mirrors plugins/contacts/admin/views/contacts.js. */
16
+ async function api(url, method = 'GET', body) {
17
+ const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
18
+ if (body !== undefined) {
19
+ opts.headers['Content-Type'] = 'application/json';
20
+ opts.body = JSON.stringify(body);
21
+ }
22
+ const res = await fetch(url, opts);
23
+ if (!res.ok) {
24
+ const err = await res.json().catch(() => ({error: res.statusText}));
25
+ throw new Error(err.error || res.statusText);
26
+ }
27
+ const text = await res.text();
28
+ return text ? JSON.parse(text) : {};
29
+ }
30
+
31
+ /** Escape HTML special characters. */
32
+ function esc(str) {
33
+ return String(str ?? '')
34
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
35
+ }
36
+
37
+ const TYPE_LABEL = {choice: 'Multiple choice', rating: 'Rating', text: 'Free text'};
38
+
39
+ /** Total answers represented by a field's aggregation. */
40
+ function answerCount(f) {
41
+ if (f.type === 'text') return Array.isArray(f.responses) ? f.responses.length : 0;
42
+ return Array.isArray(f.items) ? f.items.reduce((n, i) => n + (i.value || 0), 0) : 0;
43
+ }
44
+
45
+ export const resultsView = {
46
+ templateUrl: '/plugins/surveys/admin/templates/results.html',
47
+
48
+ async onMount($container) {
49
+ const root = $container.find('.survey-results-plugin').get(0) || $container.get(0);
50
+ const hashMatch = location.hash.match(/^#\/plugins\/surveys\/results\/(.+)$/);
51
+ const slug = hashMatch ? decodeURIComponent(hashMatch[1]) : null;
52
+ if (!slug) {
53
+ E.toast('No survey specified.', {type: 'error'});
54
+ location.hash = '#/plugins/surveys';
55
+ return;
56
+ }
57
+
58
+ // ---- Load survey (for title/status) + stats -----------------------
59
+ let survey = null;
60
+ let stats = null;
61
+ try {
62
+ [survey, stats] = await Promise.all([
63
+ api(`${BASE}/surveys/${encodeURIComponent(slug)}`).catch(() => null),
64
+ api(`${BASE}/surveys/${encodeURIComponent(slug)}/stats`)
65
+ ]);
66
+ } catch (err) {
67
+ E.toast(err?.message ?? 'Failed to load results.', {type: 'error'});
68
+ return;
69
+ }
70
+
71
+ const health = stats?.health ?? {invited: 0, completed: 0, responseRate: 0};
72
+ const invited = health.invited ?? 0;
73
+ const completed = health.completed ?? 0;
74
+ const rate = health.responseRate ?? 0;
75
+ const outstanding = Math.max(0, invited - completed);
76
+
77
+ // ---- Header -------------------------------------------------------
78
+ const titleEl = root.querySelector('#results-title');
79
+ if (titleEl) titleEl.textContent = `Results — ${survey?.title || slug}`;
80
+ const subEl = root.querySelector('#results-sub');
81
+ if (subEl) {
82
+ const statusBadge = survey?.status ? ` · ${survey.status}` : '';
83
+ const modeBadge = survey?.mode === 'anonymous' ? ' · anonymous' : '';
84
+ subEl.textContent = `${completed} of ${invited} invited have responded${statusBadge}${modeBadge}.`;
85
+ }
86
+
87
+ // ---- Summary cards ------------------------------------------------
88
+ const kpi = (num, label) =>
89
+ `<div class="card"><div class="card-body"><div class="sr-kpi-num">${esc(num)}</div><div class="sr-kpi-lbl">${esc(label)}</div></div></div>`;
90
+ $container.find('#summary').html(
91
+ `<div class="card"><div class="card-body">
92
+ <div class="sr-rate-num">${esc(rate)}%</div>
93
+ <div class="sr-kpi-lbl">Response rate</div>
94
+ <div class="sr-rate-bar"><div class="sr-rate-fill" data-pct="${Math.min(100, rate)}" style="width:${Math.min(100, rate)}%"></div></div>
95
+ </div></div>` +
96
+ kpi(invited, 'Invited') +
97
+ kpi(completed, 'Completed') +
98
+ kpi(outstanding, 'Awaiting response')
99
+ );
100
+
101
+ // ---- Per-question cards -------------------------------------------
102
+ const fields = Array.isArray(stats?.fields) ? stats.fields : [];
103
+ let questionsHtml;
104
+
105
+ if (!completed && !fields.some((f) => answerCount(f) > 0)) {
106
+ questionsHtml = `<div class="card"><div class="card-body"><p class="sr-empty">No responses yet — once people complete the survey, their answers appear here.</p></div></div>`;
107
+ } else {
108
+ questionsHtml = fields.map((f, idx) => {
109
+ const n = answerCount(f);
110
+ const meta = `${TYPE_LABEL[f.type] || f.type} · ${n} answer${n === 1 ? '' : 's'}`;
111
+ let body;
112
+ if (f.type === 'choice' || f.type === 'rating') {
113
+ const items = Array.isArray(f.items) ? f.items : [];
114
+ const bars = items.length ? renderDistribution(items) : `<p class="sr-empty">No answers.</p>`;
115
+ const avg = (f.type === 'rating' && typeof f.average !== 'undefined')
116
+ ? `<p class="sr-avg">Average <strong>${esc(f.average)}</strong></p>` : '';
117
+ body = avg + bars;
118
+ } else {
119
+ const responses = (Array.isArray(f.responses) ? f.responses : [])
120
+ .map((r) => (Array.isArray(r) ? r.join(', ') : r))
121
+ .filter((r) => String(r ?? '').trim() !== '');
122
+ body = responses.length
123
+ ? `<div class="sr-quotes">` + responses.map((r) => `<div class="sr-quote">${esc(r)}</div>`).join('') + `</div>`
124
+ : `<p class="sr-empty">No answers.</p>`;
125
+ }
126
+ return `<div class="card mb-3"><div class="card-body">
127
+ <div class="sr-q-head">
128
+ <span class="sr-q-num">${idx + 1}</span>
129
+ <span class="sr-q-label">${esc(f.label || f.field)}</span>
130
+ <span class="sr-q-meta">${esc(meta)}</span>
131
+ </div>
132
+ ${body}
133
+ </div></div>`;
134
+ }).join('');
135
+ }
136
+ $container.find('#questions').html(questionsHtml);
137
+
138
+ // Bar widths: set from data-pct via JS so they survive even if the admin's
139
+ // HTML sanitiser strips the inline width style.
140
+ root.querySelectorAll('[data-pct]').forEach((el) => {
141
+ const p = Number(el.getAttribute('data-pct'));
142
+ el.style.width = `${Math.max(0, Math.min(100, Number.isFinite(p) ? p : 0))}%`;
143
+ });
144
+
145
+ // ---- Authed blob export -------------------------------------------
146
+ async function exportAs(format) {
147
+ try {
148
+ const res = await fetch(`${BASE}/surveys/${encodeURIComponent(slug)}/export?format=${format}`, {
149
+ headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
150
+ });
151
+ if (!res.ok) throw new Error(res.statusText);
152
+ const blob = await res.blob();
153
+ const url = URL.createObjectURL(blob);
154
+ const a = document.createElement('a');
155
+ a.href = url;
156
+ a.download = `${slug}.${format}`;
157
+ document.body.appendChild(a);
158
+ a.click();
159
+ document.body.removeChild(a);
160
+ URL.revokeObjectURL(url);
161
+ } catch (err) {
162
+ E.toast(err?.message ?? 'Export failed.', {type: 'error'});
163
+ }
164
+ }
165
+
166
+ root.querySelector('#export-csv')?.addEventListener('click', () => exportAs('csv'));
167
+ root.querySelector('#export-json')?.addEventListener('click', () => exportAs('json'));
168
+ root.querySelector('#back-surveys')?.addEventListener('click', () => { location.hash = '#/plugins/surveys'; });
169
+
170
+ if (typeof Domma !== 'undefined' && Domma.icons) Domma.icons.scan();
171
+ }
172
+ };