domma-cms 0.66.0 → 0.69.4

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 +94 -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 +310 -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,130 +0,0 @@
1
- /**
2
- * Invoice plugin - opening the printable invoice.
3
- *
4
- * `authenticate` is Bearer-only (see server/middleware/auth.js), so a plain
5
- * <a href target="_blank"> to /invoices/:id/print arrives with no Authorization
6
- * header and comes back 401 every time. The markup is fetched through H, which
7
- * the admin's http-interceptor tokenises, and written into a window opened
8
- * SYNCHRONOUSLY on the click - a window opened after an await counts as
9
- * unsolicited and the pop-up blocker eats it.
10
- */
11
- const BASE = '/api/plugins/invoice';
12
-
13
- const PLACEHOLDER = '<!doctype html><html><head><title>Invoice</title></head>' +
14
- '<body style="font:14px system-ui,sans-serif;padding:2rem;color:#555;">Preparing invoice…</body></html>';
15
-
16
- /**
17
- * @param {string} id invoice id
18
- * @param {boolean} [auto] have the printout raise the print dialog itself
19
- * @returns {Promise<void>}
20
- */
21
- export async function openPrintWindow(id, auto = false) {
22
- const win = window.open('', '_blank');
23
- if (!win) {
24
- E.toast('Allow pop-ups for this site to print an invoice.', { type: 'error' });
25
- return;
26
- }
27
- win.document.write(PLACEHOLDER);
28
-
29
- try {
30
- const res = await H.get(`${BASE}/invoices/${id}/print-html${auto ? '?print=1' : ''}`);
31
- const html = (res?.data ?? res)?.html;
32
- if (!html) throw new Error('The server returned an empty invoice.');
33
- win.document.open();
34
- win.document.write(html);
35
- win.document.close();
36
- } catch (err) {
37
- win.close();
38
- E.toast(err?.message ?? 'Could not open the printout.', { type: 'error' });
39
- }
40
- }
41
-
42
- /**
43
- * Does this host have a renderer? Asked once and remembered - the answer cannot
44
- * change without a server restart, and both views ask on every mount.
45
- *
46
- * @returns {Promise<boolean>}
47
- */
48
- let pdfAvailable = null;
49
-
50
- export async function canDownloadPdf() {
51
- if (pdfAvailable !== null) return pdfAvailable;
52
- try {
53
- const res = await H.get(`${BASE}/pdf/status`);
54
- pdfAvailable = Boolean((res?.data ?? res)?.available);
55
- } catch {
56
- pdfAvailable = false;
57
- }
58
- return pdfAvailable;
59
- }
60
-
61
- /**
62
- * Download the server-rendered PDF.
63
- *
64
- * A download is a NAVIGATION, so it cannot carry the admin's bearer token - the
65
- * same reason the Print button could not link straight at /print. The server
66
- * mints a two-minute token bound to this one invoice and puts it in the query
67
- * string, and an <a download> click is what turns that URL into a file without
68
- * taking the SPA off its route.
69
- *
70
- * @param {string} id
71
- * @returns {Promise<void>}
72
- */
73
- export async function downloadPdf(id) {
74
- try {
75
- const res = await H.post(`${BASE}/invoices/${id}/pdf-link`, {});
76
- const link = res?.data ?? res;
77
- if (!link?.url) throw new Error(link?.error || 'No download link was returned.');
78
-
79
- const a = document.createElement('a');
80
- a.href = link.url;
81
- a.download = link.filename || 'invoice.pdf';
82
- a.rel = 'noopener';
83
- document.body.appendChild(a);
84
- a.click();
85
- a.remove();
86
- E.toast('Preparing your PDF…', { type: 'info' });
87
- } catch (err) {
88
- E.toast(err?.message ?? 'Could not build the PDF.', { type: 'error' });
89
- }
90
- }
91
-
92
- /**
93
- * Download one PDF holding every invoice given, a page each.
94
- *
95
- * Same two-minute token dance as the single download, for the same reason - a
96
- * download is a navigation and carries no bearer token. The ids are POSTed
97
- * rather than put in the query string: the server keeps the list and the URL
98
- * carries only a claim ticket, so the link stays short whether it is three
99
- * invoices or a hundred.
100
- *
101
- * The toast is not decoration. Chrome lays out every sheet before the first
102
- * byte arrives, so a large batch is several seconds during which an <a> click
103
- * looks exactly like a button that did nothing.
104
- *
105
- * @param {string[]} ids
106
- * @returns {Promise<void>}
107
- */
108
- export async function downloadPdfBatch(ids) {
109
- if (!ids.length) {
110
- E.toast('There are no invoices to export.', { type: 'info' });
111
- return;
112
- }
113
-
114
- try {
115
- E.toast(`Building a PDF of ${ids.length} invoice${ids.length === 1 ? '' : 's'}…`, { type: 'info' });
116
- const res = await H.post(`${BASE}/invoices/batch-pdf-link`, { ids });
117
- const link = res?.data ?? res;
118
- if (!link?.url) throw new Error(link?.error || 'No download link was returned.');
119
-
120
- const a = document.createElement('a');
121
- a.href = link.url;
122
- a.download = link.filename || 'invoices.pdf';
123
- a.rel = 'noopener';
124
- document.body.appendChild(a);
125
- a.click();
126
- a.remove();
127
- } catch (err) {
128
- E.toast(err?.message ?? 'Could not build the PDF.', { type: 'error' });
129
- }
130
- }
@@ -1,55 +0,0 @@
1
- /**
2
- * Invoice plugin - receivers screen.
3
- *
4
- * The same panel the settings slideover puts in its Receivers tab, plus the
5
- * Contacts import - a receiver is a contact's billing side, and Contacts
6
- * already holds the company name, the billing address and the VAT number.
7
- */
8
- import {RECEIVER_FIELDS, mountPartyPanel} from './party-view.js?v=8';
9
- import {billingProfile, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
10
- import {disposeOnLeave} from './view-lifecycle.js?v=8';
11
-
12
- export const receiversView = {
13
- templateUrl: '/plugins/invoice/admin/templates/receivers.html',
14
-
15
- async onMount($container) {
16
- const host = $container.find('#party-host').get(0);
17
- if (!host) return;
18
-
19
- const panel = mountPartyPanel(host, {
20
- kind: 'receiver',
21
- endpoint: '/api/plugins/invoice/receivers',
22
- label: 'Receiver',
23
- fields: RECEIVER_FIELDS
24
- });
25
-
26
- disposeOnLeave(host, panel.dispose);
27
-
28
- if (await contactsAvailable()) {
29
- const importBtn = document.createElement('button');
30
- importBtn.type = 'button';
31
- importBtn.className = 'btn btn-sm btn-outline';
32
- importBtn.textContent = 'Import from Contacts';
33
- importBtn.addEventListener('click', () => openContactPicker({
34
- title: 'Import a receiver',
35
- onPick: async (contact) => {
36
- try {
37
- // The billing profile uses the receiver's own field
38
- // names, which is the point of that endpoint - it goes
39
- // straight to POST /receivers with nothing to translate.
40
- const profile = await billingProfile(contact.id);
41
- const res = await H.post('/api/plugins/invoice/receivers', profile);
42
- if (res?.error) throw new Error(res.error);
43
- E.toast(`${profile.company || profile.name} imported.`, { type: 'success' });
44
- await panel.reload();
45
- } catch (err) {
46
- E.toast(err?.message ?? 'Could not import that contact.', { type: 'error' });
47
- }
48
- }
49
- }));
50
- host.querySelector('.invoice-panel-head')?.prepend(importBtn);
51
- }
52
-
53
- Domma.icons.scan($container.get(0));
54
- }
55
- };
@@ -1,212 +0,0 @@
1
- /**
2
- * Invoice plugin - the Send dialog.
3
- *
4
- * The defaults (recipient, subject, covering note, whether a PDF can be
5
- * attached) come from the server rather than being templated here, so the
6
- * settings that shape the email have one reader.
7
- *
8
- * Recipients can also come from Contacts - one contact, or everybody in a
9
- * group. They are appended to the To field as text rather than held in a
10
- * parallel list, because the field has to stay typeable: half the time the
11
- * address someone wants is an accounts inbox that is in nobody's contacts.
12
- *
13
- * The form is F.create - schema first, then .renderTo() - and the panel is
14
- * projected into the slideover's light DOM, because setContent() sanitises and
15
- * would strip the inputs.
16
- */
17
- import {addressesFor, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
18
-
19
- const BASE = '/api/plugins/invoice';
20
-
21
- /**
22
- * Merge new addresses into a comma-separated To field, without duplicates.
23
- *
24
- * @param {string} current
25
- * @param {string[]} extra
26
- * @returns {string}
27
- */
28
- export function mergeRecipients(current, extra) {
29
- const seen = new Set();
30
- const out = [];
31
- for (const item of [...String(current ?? '').split(/[,;]/), ...extra]) {
32
- const address = String(item ?? '').trim();
33
- if (!address || seen.has(address.toLowerCase())) continue;
34
- seen.add(address.toLowerCase());
35
- out.push(address);
36
- }
37
- return out.join(', ');
38
- }
39
-
40
- /**
41
- * @param {string} id invoice id
42
- * @param {Function} [onSent] called with the updated invoice
43
- * @returns {Promise<void>}
44
- */
45
- export async function openSendDialog(id, onSent) {
46
- let defaults;
47
- try {
48
- const res = await H.get(`${BASE}/invoices/${id}/send-defaults`);
49
- defaults = res?.data ?? res;
50
- if (defaults?.error) throw new Error(defaults.error);
51
- } catch (err) {
52
- E.toast(err?.message ?? 'Could not prepare the email.', { type: 'error' });
53
- return;
54
- }
55
-
56
- const haveContacts = await contactsAvailable();
57
-
58
- const schema = {
59
- to: { type: 'string', label: 'To', required: true,
60
- formConfig: { placeholder: 'name@example.com',
61
- hint: defaults.to
62
- ? 'Separate several addresses with commas.'
63
- : 'This receiver has no email address on file.' } },
64
- subject: { type: 'string', label: 'Subject', required: true },
65
- message: { type: 'textarea', label: 'Covering note',
66
- formConfig: { rows: 3, hint: 'Sent above the invoice summary.' } }
67
- };
68
- if (defaults.canAttachPdf) {
69
- schema.attachPdf = { type: 'boolean', label: 'Attach a PDF',
70
- formConfig: { hint: 'Without it the invoice is sent inline instead.' } };
71
- }
72
-
73
- const form = F.create(schema, {
74
- to: defaults.to || '',
75
- subject: defaults.subject || '',
76
- message: defaults.message || '',
77
- attachPdf: Boolean(defaults.attachPdf)
78
- }, { showSubmitButton: false });
79
-
80
- const wrapper = document.createElement('div');
81
- wrapper.className = 'invoice-party-form';
82
-
83
- if (defaults.sendCount) {
84
- const trail = document.createElement('p');
85
- trail.className = 'text-muted text-sm mb-0';
86
- const when = defaults.sentAt ? D(defaults.sentAt).format('DD MMM YYYY HH:mm') : 'previously';
87
- trail.textContent = `Last sent ${when} (${defaults.sendCount} ${defaults.sendCount === 1 ? 'time' : 'times'}).`;
88
- wrapper.appendChild(trail);
89
- }
90
-
91
- const fields = document.createElement('div');
92
- form.renderTo(fields);
93
- wrapper.appendChild(fields);
94
-
95
- // Contacts, appended to whatever is already in To. The value goes back
96
- // through the MODEL as well as the input: Forma reads getData() off the
97
- // model, so an input written behind its back would send the old address.
98
- if (haveContacts) {
99
- const toInput = fields.querySelector('[name="to"]');
100
- if (toInput) {
101
- const row = document.createElement('div');
102
- row.className = 'invoice-recipient-actions';
103
-
104
- /**
105
- * @param {object[]} contacts
106
- * @returns {void}
107
- */
108
- const add = (contacts) => {
109
- const found = contacts.flatMap((c) => addressesFor(c).slice(0, 1));
110
- const missing = contacts.filter((c) => addressesFor(c).length === 0);
111
- if (!found.length) {
112
- E.toast('None of those contacts has an email address.', { type: 'error' });
113
- return;
114
- }
115
- const merged = mergeRecipients(form.getData().to, found);
116
- form.setData({ to: merged });
117
- toInput.value = merged;
118
- if (missing.length) {
119
- E.toast(`${missing.length} contact(s) had no email address and were skipped.`,
120
- { type: 'warning' });
121
- }
122
- };
123
-
124
- const one = document.createElement('button');
125
- one.type = 'button';
126
- one.className = 'btn btn-sm btn-outline';
127
- one.textContent = 'Add a contact';
128
- one.addEventListener('click', () => openContactPicker({
129
- title: 'Add a recipient',
130
- onPick: (contact) => add([contact])
131
- }));
132
- row.appendChild(one);
133
-
134
- const many = document.createElement('button');
135
- many.type = 'button';
136
- many.className = 'btn btn-sm btn-outline';
137
- many.textContent = 'Add a group';
138
- many.title = 'Filter by group, then Select all shown';
139
- many.addEventListener('click', () => openContactPicker({
140
- title: 'Add recipients',
141
- multiple: true,
142
- onPick: (contacts) => add(contacts)
143
- }));
144
- row.appendChild(many);
145
-
146
- // Under the field it belongs to, whatever Forma wrapped it in.
147
- (toInput.closest('.domma-form-field') || toInput.parentElement || wrapper).appendChild(row);
148
- }
149
- }
150
-
151
- const actions = document.createElement('div');
152
- actions.className = 'invoice-party-actions';
153
- const cancelBtn = document.createElement('button');
154
- cancelBtn.type = 'button';
155
- cancelBtn.className = 'btn btn-ghost';
156
- cancelBtn.textContent = 'Cancel';
157
- const sendBtn = document.createElement('button');
158
- sendBtn.type = 'button';
159
- sendBtn.className = 'btn btn-primary';
160
- sendBtn.textContent = 'Send';
161
- actions.appendChild(cancelBtn);
162
- actions.appendChild(sendBtn);
163
- wrapper.appendChild(actions);
164
-
165
- const slideover = E.slideover({
166
- title: 'Send Invoice',
167
- size: 'md',
168
- position: 'right',
169
- onClose: () => setTimeout(() => {
170
- slideover.destroy();
171
- slideover.element?.remove();
172
- }, 350)
173
- });
174
- slideover.element.appendChild(wrapper);
175
-
176
- cancelBtn.addEventListener('click', () => slideover.close());
177
-
178
- sendBtn.addEventListener('click', async () => {
179
- if (!form.validate()) return;
180
- const values = form.getData();
181
-
182
- // Sending is slow (rendering the PDF is about a second) and it is not
183
- // repeatable - say so, and make a second click impossible.
184
- sendBtn.disabled = true;
185
- cancelBtn.disabled = true;
186
- const original = sendBtn.textContent;
187
- sendBtn.textContent = 'Sending…';
188
-
189
- try {
190
- const res = await H.post(`${BASE}/invoices/${id}/send`, {
191
- to: values.to,
192
- subject: values.subject,
193
- message: values.message,
194
- attachPdf: Boolean(values.attachPdf)
195
- });
196
- const out = res?.data ?? res;
197
- if (out?.error) throw new Error(out.error);
198
- slideover.close();
199
- E.toast(out.attached ? `Sent to ${out.to} with the PDF attached.` : `Sent to ${out.to}.`,
200
- { type: 'success' });
201
- if (typeof onSent === 'function') onSent(out.invoice);
202
- } catch (err) {
203
- E.toast(err?.message ?? 'The invoice could not be sent.', { type: 'error' });
204
- sendBtn.disabled = false;
205
- cancelBtn.disabled = false;
206
- sendBtn.textContent = original;
207
- }
208
- });
209
-
210
- slideover.open();
211
- Domma.icons.scan(wrapper);
212
- }