domma-cms 0.31.0 → 0.32.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/js/lib/themes.js +1 -1
- package/admin/js/views/settings.js +1 -1
- package/config/site.json +1 -0
- package/package.json +2 -2
- package/plugins/surveys/admin/templates/audience.html +47 -0
- package/plugins/surveys/admin/templates/results.html +58 -0
- package/plugins/surveys/admin/templates/survey-editor.html +141 -0
- package/plugins/surveys/admin/templates/surveys.html +25 -0
- package/plugins/surveys/admin/views/audience.js +301 -0
- package/plugins/surveys/admin/views/results.js +172 -0
- package/plugins/surveys/admin/views/survey-editor.js +211 -0
- package/plugins/surveys/admin/views/surveys.js +161 -0
- package/plugins/surveys/collections/survey-contacts/schema.json +13 -0
- package/plugins/surveys/collections/survey-groups/schema.json +11 -0
- package/plugins/surveys/collections/survey-invites/schema.json +16 -0
- package/plugins/surveys/collections/surveys/schema.json +23 -0
- package/plugins/surveys/config.js +8 -0
- package/plugins/surveys/plugin.js +174 -0
- package/plugins/surveys/plugin.json +65 -0
- package/plugins/surveys/public/dist-shared.mjs +1 -0
- package/plugins/surveys/public/survey.css +1 -0
- package/plugins/surveys/public/survey.mjs +1 -0
- package/plugins/surveys/templates/survey-page.html +63 -0
- package/server/server.js +3 -1
|
@@ -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, '&')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
.replace(/"/g, '"');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
};
|