domma-cms 0.66.0 → 0.69.3

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +92 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +307 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1,172 +0,0 @@
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
- };
@@ -1,211 +0,0 @@
1
- /**
2
- * Surveys plugin - campaign create/edit view.
3
- *
4
- * The form is hand-built in the template as native inputs laid out in an
5
- * explicit CSS grid (guaranteed multi-column), so this view only populates the
6
- * dynamic options (forms, groups), seeds values on edit, collects the payload,
7
- * and POST/PUTs to the admin API. On an open survey it also exposes the
8
- * send / reminder actions.
9
- *
10
- * @module surveys/admin/views/survey-editor
11
- */
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
- export const surveyEditorView = {
32
- templateUrl: '/plugins/surveys/admin/templates/survey-editor.html',
33
-
34
- async onMount($container) {
35
- const root = $container.find('.survey-editor-plugin').get(0) || $container.get(0);
36
- if (!root) return;
37
-
38
- const hashMatch = location.hash.match(/^#\/plugins\/surveys\/edit\/(.+)$/);
39
- const editSlug = hashMatch ? decodeURIComponent(hashMatch[1]) : null;
40
- const isEdit = !!editSlug;
41
-
42
- const titleEl = root.querySelector('#editor-title');
43
- if (titleEl) titleEl.textContent = isEdit ? 'Edit Survey' : 'New Survey';
44
-
45
- // ---- Helpers to read/write the native inputs by field name ----------
46
- const field = (name) => root.querySelector(`[name="${name}"]`);
47
-
48
- function setValue(name, value) {
49
- const el = field(name);
50
- if (!el) return;
51
- if (el.multiple) {
52
- const set = new Set(Array.isArray(value) ? value : []);
53
- Array.from(el.options).forEach((o) => { o.selected = set.has(o.value); });
54
- } else {
55
- el.value = value ?? '';
56
- }
57
- }
58
-
59
- function readField(name) {
60
- const el = field(name);
61
- if (!el) return undefined;
62
- if (el.multiple) return Array.from(el.selectedOptions).map((o) => o.value);
63
- if (el.type === 'number') return el.value === '' ? '' : Number(el.value);
64
- return el.value;
65
- }
66
-
67
- // ---- Load reference data + (on edit) the survey ---------------------
68
- let survey = null;
69
- let forms = [];
70
- let groups = [];
71
- try { forms = await api('/api/forms'); } catch { forms = []; }
72
- try { groups = await api(`${BASE}/groups`); } catch { groups = []; }
73
- if (isEdit) {
74
- try {
75
- survey = await api(`${BASE}/surveys/${encodeURIComponent(editSlug)}`);
76
- } catch (err) {
77
- E.toast(err?.message ?? 'Failed to load survey.', {type: 'error'});
78
- location.hash = '#/plugins/surveys';
79
- return;
80
- }
81
- }
82
-
83
- // ---- Populate dynamic <select> options ------------------------------
84
- const formSelect = field('formSlug');
85
- if (formSelect) {
86
- forms.forEach((f) => {
87
- const opt = document.createElement('option');
88
- opt.value = f.slug;
89
- opt.textContent = f.title || f.slug;
90
- formSelect.appendChild(opt);
91
- });
92
- }
93
- // Audience uses group NAMES (sending.js reads groupIds as names).
94
- const groupSelect = field('groupIds');
95
- if (groupSelect) {
96
- groups.forEach((g) => {
97
- const opt = document.createElement('option');
98
- opt.value = g.name;
99
- opt.textContent = g.name;
100
- groupSelect.appendChild(opt);
101
- });
102
- }
103
-
104
- // ---- Seed values ----------------------------------------------------
105
- const invite = survey?.inviteEmail ?? {};
106
- setValue('title', survey?.title ?? '');
107
- setValue('slug', survey?.slug ?? '');
108
- setValue('description', survey?.description ?? '');
109
- setValue('formSlug', survey?.formSlug ?? '');
110
- setValue('mode', survey?.mode ?? 'identified');
111
- setValue('status', survey?.status ?? 'draft');
112
- setValue('groupIds', Array.isArray(survey?.groupIds) ? survey.groupIds : []);
113
- setValue('opensAt', survey?.opensAt ?? '');
114
- setValue('closesAt', survey?.closesAt ?? '');
115
- setValue('reminderAfterDays', survey?.reminderAfterDays ?? 7);
116
- setValue('intro', survey?.intro ?? '');
117
- setValue('footer', survey?.footer ?? '');
118
- setValue('thankYou', survey?.thankYou ?? '');
119
- setValue('inviteSubject', invite.subject ?? '');
120
- setValue('inviteBody', invite.body ?? '');
121
-
122
- // Slug is immutable after creation.
123
- if (isEdit) {
124
- const slugEl = field('slug');
125
- if (slugEl) slugEl.disabled = true;
126
- const hint = root.querySelector('#slug-hint');
127
- if (hint) hint.textContent = 'Slug cannot be changed after creation.';
128
- }
129
-
130
- // ---- Collect + save -------------------------------------------------
131
- function collect() {
132
- return {
133
- title: (readField('title') ?? '').trim(),
134
- slug: (readField('slug') ?? '').trim(),
135
- description: (readField('description') ?? '').trim(),
136
- formSlug: readField('formSlug') ?? '',
137
- mode: readField('mode') ?? 'identified',
138
- status: readField('status') ?? 'draft',
139
- groupIds: readField('groupIds') ?? [],
140
- opensAt: readField('opensAt') ?? '',
141
- closesAt: readField('closesAt') ?? '',
142
- reminderAfterDays: Number(readField('reminderAfterDays')) || 0,
143
- intro: (readField('intro') ?? '').trim(),
144
- footer: (readField('footer') ?? '').trim(),
145
- thankYou: (readField('thankYou') ?? '').trim(),
146
- inviteEmail: {
147
- subject: (readField('inviteSubject') ?? '').trim(),
148
- body: (readField('inviteBody') ?? '').trim()
149
- }
150
- };
151
- }
152
-
153
- async function save() {
154
- const body = collect();
155
- if (!body.title) { E.toast('Title is required.', {type: 'error'}); return; }
156
- if (!isEdit && !body.slug) { E.toast('Slug is required.', {type: 'error'}); return; }
157
- try {
158
- if (isEdit) {
159
- const {slug, ...patch} = body; // slug immutable
160
- await api(`${BASE}/surveys/${encodeURIComponent(editSlug)}`, 'PUT', patch);
161
- E.toast('Survey updated.', {type: 'success'});
162
- } else {
163
- await api(`${BASE}/surveys`, 'POST', body);
164
- E.toast('Survey created.', {type: 'success'});
165
- }
166
- location.hash = '#/plugins/surveys';
167
- } catch (err) {
168
- E.toast(err?.message ?? 'Failed to save survey.', {type: 'error'});
169
- }
170
- }
171
-
172
- const saveBtn = root.querySelector('#save-btn');
173
- if (saveBtn) {
174
- saveBtn.textContent = isEdit ? 'Update Survey' : 'Create Survey';
175
- saveBtn.addEventListener('click', save);
176
- }
177
- const goBack = () => { location.hash = '#/plugins/surveys'; };
178
- root.querySelector('#cancel-btn')?.addEventListener('click', goBack);
179
- root.querySelector('#back-btn')?.addEventListener('click', goBack);
180
-
181
- // ---- Send / reminder (edit + open only) -----------------------------
182
- const sendPanel = root.querySelector('#send-panel');
183
- const sendHint = root.querySelector('#send-hint');
184
- if (sendPanel && isEdit) {
185
- sendPanel.style.display = '';
186
- if (sendHint) {
187
- sendHint.textContent = survey?.status === 'open'
188
- ? ''
189
- : 'Survey must be Open before invitations are meaningful.';
190
- }
191
- }
192
-
193
- async function send(audience, label) {
194
- const ok = await E.confirm(`${label}?`);
195
- if (!ok) return;
196
- try {
197
- const res = await api(`${BASE}/surveys/${encodeURIComponent(editSlug)}/send`, 'POST', {audience});
198
- const results = Array.isArray(res?.results) ? res.results : [];
199
- const sent = results.filter((r) => r.ok).length;
200
- E.toast(`${sent} of ${results.length} invitation(s) sent.`, {type: 'success'});
201
- } catch (err) {
202
- E.toast(err?.message ?? 'Send failed.', {type: 'error'});
203
- }
204
- }
205
-
206
- root.querySelector('#send-all-btn')?.addEventListener('click', () => send('all', 'Send invitations to the whole audience'));
207
- root.querySelector('#send-reminder-btn')?.addEventListener('click', () => send('non-responders', 'Send a reminder to non-responders'));
208
-
209
- if (typeof Domma !== 'undefined' && Domma.icons) Domma.icons.scan();
210
- }
211
- };
@@ -1,161 +0,0 @@
1
- /**
2
- * Surveys plugin - campaign list view.
3
- *
4
- * Lists survey campaigns in a Domma table with status/mode badges and
5
- * row actions that navigate to the editor and results views.
6
- *
7
- * @module surveys/admin/views/surveys
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
- const STATUS_VARIANT = {draft: 'badge-secondary', open: 'badge-success', closed: 'badge-warning'};
50
-
51
- export const surveysView = {
52
- templateUrl: '/plugins/surveys/admin/templates/surveys.html',
53
-
54
- /**
55
- * Mount the surveys list view.
56
- *
57
- * @param {object} $container - Domma-wrapped container element
58
- * @returns {Promise<void>}
59
- */
60
- async onMount($container) {
61
- let surveys = [];
62
- let table = null;
63
-
64
- async function load() {
65
- try {
66
- surveys = await api(`${BASE}/surveys`);
67
- } catch (err) {
68
- surveys = [];
69
- E.toast(err?.message ?? 'Failed to load surveys.', {type: 'error'});
70
- }
71
- }
72
-
73
- function build() {
74
- const tableEl = $container.find('#surveys-table').get(0);
75
- const emptyEl = $container.find('#surveys-empty').get(0);
76
-
77
- if (!surveys.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(surveys);
87
- Domma.icons.scan();
88
- return;
89
- }
90
-
91
- // Render functions MUST return HTML strings (T.create contract).
92
- // Action buttons carry data-slug + a class; DOMPurify (run by the
93
- // table renderer) strips inline onclick, so clicks are wired via
94
- // unnamespaced delegation on $container after the table mounts.
95
- table = T.create($container.find('#surveys-table').get(0), {
96
- data: surveys,
97
- columns: [
98
- {
99
- key: 'title',
100
- title: 'Title',
101
- sortable: true,
102
- render: (v) => `<strong>${esc(v)}</strong>`
103
- },
104
- {
105
- key: 'status',
106
- title: 'Status',
107
- sortable: true,
108
- render: (v) => {
109
- const variant = STATUS_VARIANT[v] || 'badge-secondary';
110
- return `<span class="badge ${variant}">${esc(v || 'draft')}</span>`;
111
- }
112
- },
113
- {
114
- key: 'mode',
115
- title: 'Mode',
116
- sortable: true,
117
- render: (v) => esc(v || 'identified')
118
- },
119
- {
120
- key: 'slug',
121
- title: 'Actions',
122
- sortable: false,
123
- render: (slug) => {
124
- const s = esc(slug);
125
- return `<button class="btn btn-sm btn-outline btn-edit-survey" ` +
126
- `data-slug="${s}" style="margin-right:4px;">` +
127
- `<span data-icon="edit" data-icon-size="14"></span> Edit</button>` +
128
- `<button class="btn btn-sm btn-outline btn-results-survey" ` +
129
- `data-slug="${s}">` +
130
- `<span data-icon="bar-chart" data-icon-size="14"></span> Results</button>`;
131
- }
132
- }
133
- ],
134
- emptyMessage: 'No surveys yet.'
135
- });
136
-
137
- Domma.icons.scan();
138
- }
139
-
140
- // Toolbar wiring
141
- const newBtn = $container.find('#new-survey').get(0);
142
- if (newBtn) newBtn.addEventListener('click', () => { location.hash = '#/plugins/surveys/new'; });
143
-
144
- const audienceBtn = $container.find('#manage-audience').get(0);
145
- if (audienceBtn) audienceBtn.addEventListener('click', () => { location.hash = '#/plugins/surveys/audience'; });
146
-
147
- // Row-action delegation (proven pattern - see admin/js/views/api-tokens.js).
148
- // Unnamespaced + idempotent via .off()/.on(); namespaced delegation is
149
- // broken in this codebase.
150
- $container.off('click', '.btn-edit-survey').on('click', '.btn-edit-survey', function () {
151
- location.hash = '#/plugins/surveys/edit/' + this.getAttribute('data-slug');
152
- });
153
- $container.off('click', '.btn-results-survey').on('click', '.btn-results-survey', function () {
154
- location.hash = '#/plugins/surveys/results/' + this.getAttribute('data-slug');
155
- });
156
-
157
- await load();
158
- build();
159
- Domma.icons.scan();
160
- }
161
- };
@@ -1,13 +0,0 @@
1
- {
2
- "slug": "surveys-contacts",
3
- "title": "Survey Contacts",
4
- "description": "Shared survey audience contacts.",
5
- "plugin": "surveys",
6
- "storage": {"adapter": "mongodb", "connection": "default"},
7
- "fields": [
8
- {"name": "name", "label": "Name", "type": "text", "required": false},
9
- {"name": "email", "label": "Email", "type": "text", "required": true},
10
- {"name": "groups","label": "Groups","type": "text", "required": false},
11
- {"name": "meta", "label": "Meta", "type": "textarea", "required": false}
12
- ]
13
- }
@@ -1,11 +0,0 @@
1
- {
2
- "slug": "surveys-groups",
3
- "title": "Survey Groups",
4
- "description": "Named survey audience groups.",
5
- "plugin": "surveys",
6
- "storage": {"adapter": "mongodb", "connection": "default"},
7
- "fields": [
8
- {"name": "name", "label": "Name", "type": "text", "required": true},
9
- {"name": "description", "label": "Description", "type": "textarea", "required": false}
10
- ]
11
- }
@@ -1,16 +0,0 @@
1
- {
2
- "slug": "surveys-invites",
3
- "title": "Survey Invites",
4
- "description": "Per-recipient invite ledger (token hashes + status).",
5
- "plugin": "surveys",
6
- "storage": {"adapter": "mongodb", "connection": "default"},
7
- "fields": [
8
- {"name": "surveyId", "label": "Survey", "type": "text", "required": true},
9
- {"name": "contactId", "label": "Contact", "type": "text", "required": true},
10
- {"name": "tokenHash", "label": "Token Hash", "type": "text", "required": true},
11
- {"name": "status", "label": "Status", "type": "text", "required": true},
12
- {"name": "sentAt", "label": "Sent At", "type": "text", "required": false},
13
- {"name": "completedAt", "label": "Completed At","type": "text", "required": false},
14
- {"name": "responseId", "label": "Response", "type": "text", "required": false}
15
- ]
16
- }
@@ -1,23 +0,0 @@
1
- {
2
- "slug": "surveys-surveys",
3
- "title": "Surveys",
4
- "description": "Survey campaigns.",
5
- "plugin": "surveys",
6
- "storage": {"adapter": "mongodb", "connection": "default"},
7
- "fields": [
8
- {"name": "slug", "label": "Slug", "type": "text", "required": true},
9
- {"name": "title", "label": "Title", "type": "text", "required": true},
10
- {"name": "description", "label": "Description", "type": "textarea", "required": false},
11
- {"name": "formSlug", "label": "Form", "type": "text", "required": true},
12
- {"name": "groupIds", "label": "Groups", "type": "text", "required": false},
13
- {"name": "mode", "label": "Mode", "type": "text", "required": true},
14
- {"name": "status", "label": "Status", "type": "text", "required": true},
15
- {"name": "opensAt", "label": "Opens At", "type": "text", "required": false},
16
- {"name": "closesAt", "label": "Closes At", "type": "text", "required": false},
17
- {"name": "reminderAfterDays", "label": "Reminder (days)", "type": "text", "required": false},
18
- {"name": "intro", "label": "Intro (Markdown)", "type": "textarea", "required": false},
19
- {"name": "footer", "label": "Footer (Markdown)", "type": "textarea", "required": false},
20
- {"name": "thankYou", "label": "Thank-you message", "type": "textarea", "required": false},
21
- {"name": "inviteEmail", "label": "Invite Email", "type": "textarea", "required": false}
22
- ]
23
- }
@@ -1,8 +0,0 @@
1
- export default {
2
- // Pro: Mongo when config/connections.json exists; degrades to file otherwise.
3
- storage: {adapter: 'mongodb', connection: 'default'},
4
- // Service token (dcms_...) the external scheduler presents to /due-work + /run.
5
- // Empty disables the scheduler endpoints (403). Set in config/plugins.json.
6
- schedulerToken: '',
7
- fromName: 'Surveys'
8
- };
@@ -1,37 +0,0 @@
1
- const C = 'surveys-contacts';
2
-
3
- function parseGroups(raw) {
4
- if (Array.isArray(raw)) return raw;
5
- if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return raw ? [raw] : []; } }
6
- return [];
7
- }
8
-
9
- export function createAudience(col) {
10
- async function listContacts() {
11
- const {entries} = await col.listEntries(C, {limit: 100000});
12
- return entries.map(e => ({id: e.id, name: e.data.name ?? '', email: e.data.email ?? '', groups: parseGroups(e.data.groups)}));
13
- }
14
-
15
- async function contactIdsForGroups(groupNames) {
16
- const want = new Set(groupNames);
17
- const contacts = await listContacts();
18
- return contacts.filter(c => c.groups.some(g => want.has(g))).map(c => c.id);
19
- }
20
-
21
- async function importFromContacts(sourceContacts) {
22
- let imported = 0;
23
- for (const c of sourceContacts) {
24
- const email = (c.email ?? '').trim();
25
- if (!email) continue;
26
- await col.createEntry(C, {
27
- name: (c.name ?? '').trim(),
28
- email,
29
- groups: parseGroups(c.groups)
30
- }, {source: 'import-contacts'});
31
- imported++;
32
- }
33
- return {imported};
34
- }
35
-
36
- return {listContacts, contactIdsForGroups, importFromContacts};
37
- }