domma-cms 0.55.1 → 0.66.0

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 (211) hide show
  1. package/CLAUDE.md +130 -2
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. package/plugins/todo/data/todos.json +0 -1
@@ -1,33 +1,75 @@
1
- <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0"><span data-icon="file-text"></span> Invoices</h1>
3
- <button id="btn-new-invoice" class="btn btn-primary">
4
- <span data-icon="plus"></span> New Invoice
5
- </button>
6
- </div>
1
+ <!--
2
+ The KPI numbers, the filter controls and the "n of m" line are bound; the
3
+ table is Domma's T, fed from one M.effect. Nothing on this page is refreshed
4
+ by hand.
7
5
 
8
- <div class="grid grid-cols-4 gap-3 mb-4">
9
- <div class="card"><div class="card-body">
10
- <div class="text-muted small">Total invoices</div>
11
- <div class="h3 mb-0" id="kpi-total">-</div>
6
+ There is no page heading here on purpose - the admin wraps every plugin view
7
+ in its own banner, which carries the name, the version and (from this plugin)
8
+ the settings cog. A second <h1> underneath it would just be the same words
9
+ twice.
10
+ -->
11
+ <div class="grid grid-cols-5 gap-3 mb-4">
12
+ <div class="card stat-card invoice-kpi" data-on-click="showAll"
13
+ data-help="Everything not archived. Click any tile to filter the list by it.">
14
+ <div class="card-body">
15
+ <div class="stat-icon"><span data-icon="file-text"></span></div>
16
+ <div>
17
+ <div class="stat-value" id="kpi-total" data-bind-text="kpiTotal.value">-</div>
18
+ <div class="stat-label">Total invoices</div>
19
+ </div>
12
20
  </div></div>
13
- <div class="card"><div class="card-body">
14
- <div class="text-muted small">Outstanding</div>
15
- <div class="h3 mb-0" id="kpi-outstanding">-</div>
21
+ <div class="card stat-card invoice-kpi" data-on-click="showOutstanding"
22
+ data-help="What you are still owed on invoices you have sent, less anything already paid against them.">
23
+ <div class="card-body">
24
+ <div class="stat-icon stat-icon--warning"><span data-icon="clock"></span></div>
25
+ <div>
26
+ <div class="stat-value" id="kpi-outstanding" data-bind-text="kpiOutstandingText.value">-</div>
27
+ <div class="stat-label">Outstanding</div>
28
+ </div>
16
29
  </div></div>
17
- <div class="card"><div class="card-body">
18
- <div class="text-muted small">Paid</div>
19
- <div class="h3 mb-0" id="kpi-paid">-</div>
30
+ <div class="card stat-card invoice-kpi" data-on-click="showOverdue"
31
+ data-help="Sent invoices past their due date. Worked out on every read, so nothing has to run to keep it true.">
32
+ <div class="card-body">
33
+ <div class="stat-icon stat-icon--danger"><span data-icon="alert-triangle"></span></div>
34
+ <div>
35
+ <div class="stat-value" id="kpi-overdue" data-bind-text="kpiOverdue.value">-</div>
36
+ <div class="stat-label">Overdue</div>
37
+ </div>
20
38
  </div></div>
21
- <div class="card"><div class="card-body">
22
- <div class="text-muted small">Drafts</div>
23
- <div class="h3 mb-0" id="kpi-drafts">-</div>
39
+ <div class="card stat-card invoice-kpi" data-on-click="showPaid"
40
+ data-help="Invoices marked paid, either by hand or by a payment that settled the balance.">
41
+ <div class="card-body">
42
+ <div class="stat-icon stat-icon--success"><span data-icon="check-circle"></span></div>
43
+ <div>
44
+ <div class="stat-value" id="kpi-paid" data-bind-text="kpiPaid.value">-</div>
45
+ <div class="stat-label">Paid</div>
46
+ </div>
47
+ </div></div>
48
+ <div class="card stat-card invoice-kpi" data-on-click="showDrafts"
49
+ data-help="Started but not sent. A draft moves to sent the first time you email it.">
50
+ <div class="card-body">
51
+ <div class="stat-icon"><span data-icon="edit"></span></div>
52
+ <div>
53
+ <div class="stat-value" id="kpi-drafts" data-bind-text="kpiDrafts.value">-</div>
54
+ <div class="stat-label">Drafts</div>
55
+ </div>
24
56
  </div></div>
25
57
  </div>
26
58
 
27
- <div class="card mb-3">
59
+ <div class="card mb-3 invoice-toolbar">
28
60
  <div class="card-body">
29
- <div class="d-flex gap-2 flex-wrap">
30
- <select id="filter-status" class="form-select" style="width:auto;">
61
+
62
+ <div class="invoice-toolbar-row">
63
+ <label class="invoice-search"
64
+ data-help="Matches the invoice number, the company or contact billed, the issuer, the customer reference and the notes.">
65
+ <span data-icon="search" data-icon-size="15"></span>
66
+ <input id="filter-search" type="search" class="form-input"
67
+ data-model="query.value" placeholder="Search number, company or reference…">
68
+ </label>
69
+
70
+ <select id="filter-status" class="form-select invoice-filter-narrow" data-model="status.value"
71
+ aria-label="Filter by status"
72
+ data-help="Overdue is worked out from the due date rather than stored, so it appears here without anything having changed the invoice.">
31
73
  <option value="">All statuses</option>
32
74
  <option value="draft">Draft</option>
33
75
  <option value="sent">Sent</option>
@@ -35,9 +77,97 @@
35
77
  <option value="overdue">Overdue</option>
36
78
  <option value="cancelled">Cancelled</option>
37
79
  </select>
38
- <input id="filter-search" type="search" class="form-control" style="width:240px;" placeholder="Search number or receiver…">
80
+
81
+ <select id="filter-issuer" class="form-select invoice-filter-narrow" data-model="issuerId.value"
82
+ data-options="issuerOptions.value" data-options-value="id" data-options-text="label"
83
+ data-options-caption="'All issuers'" aria-label="Filter by issuer"
84
+ data-help="Which of your own businesses raised the invoice. Manage these under the cog, in Issuers."></select>
85
+
86
+ <select id="sort-by" class="form-select invoice-filter-narrow" data-model="sortBy.value"
87
+ aria-label="Sort by"
88
+ data-help="Clicking a column heading does the same thing, and the two always agree.">
89
+ <option value="issueDate:desc">Newest first</option>
90
+ <option value="issueDate:asc">Oldest first</option>
91
+ <option value="_number:asc">Number A-Z</option>
92
+ <option value="_number:desc">Number Z-A</option>
93
+ <option value="_receiver:asc">Billed to A-Z</option>
94
+ <option value="_total:desc">Largest first</option>
95
+ <option value="_total:asc">Smallest first</option>
96
+ <option value="dueDate:asc">Due soonest</option>
97
+ </select>
98
+
99
+ <button id="btn-new-invoice" class="btn btn-primary" data-on-click="newInvoice"
100
+ data-bind-disabled="cannotCreate.value"
101
+ data-bind-title="newInvoiceTitle.value">
102
+ <span data-icon="plus"></span> New Invoice
103
+ </button>
104
+ </div>
105
+
106
+ <div class="invoice-toolbar-row invoice-toolbar-row--sub">
107
+ <label class="invoice-field">
108
+ <span>Issued from</span>
109
+ <input id="filter-from" type="text" class="form-input" data-model="from.value">
110
+ </label>
111
+ <label class="invoice-field">
112
+ <span>to</span>
113
+ <input id="filter-to" type="text" class="form-input" data-model="to.value">
114
+ </label>
115
+
116
+ <label class="invoice-check"
117
+ data-help="Archived invoices are kept but hidden. This swaps the list over to them - it does not add them to the live list.">
118
+ <input type="checkbox" class="form-checkbox" data-model="showArchived.value">
119
+ <span>Show archived</span>
120
+ </label>
121
+
122
+ <button class="btn btn-sm btn-ghost" data-on-click="clearFilters" data-if="hasFilters.value">
123
+ Clear filters
124
+ </button>
125
+
126
+ <span class="invoice-toolbar-spacer"></span>
127
+
128
+ <span class="text-muted text-sm invoice-matches" data-bind-text="matchesText.value"></span>
129
+
130
+ <button class="btn btn-sm btn-outline" data-on-click="exportCsv"
131
+ data-help="Exports the rows currently shown - the filters apply. For one invoice with its line items, use its row menu instead.">
132
+ CSV
133
+ </button>
134
+ <button class="btn btn-sm btn-outline" data-on-click="exportExcel"
135
+ data-help="The same rows, as a spreadsheet Excel and LibreOffice both open.">
136
+ Excel
137
+ </button>
138
+ <button class="btn btn-sm btn-outline" data-on-click="exportPdf" data-if="canPdf.value"
139
+ data-help="Every invoice currently shown as one PDF, a page each - the documents themselves rather than a summary of them. Large batches take a few seconds to build.">
140
+ PDF
141
+ </button>
142
+ </div>
143
+
144
+ </div>
145
+ </div>
146
+
147
+ <div class="card invoice-setup" data-if="needsSetup.value">
148
+ <div class="card-body">
149
+ <strong data-bind-text="setupTitle.value"></strong>
150
+ <p class="text-sm text-muted" data-bind-text="setupText.value"></p>
151
+ <div class="flex gap-2">
152
+ <button class="btn btn-sm btn-primary" data-if="needsIssuer.value" data-on-click="addIssuer">
153
+ Add an issuer
154
+ </button>
155
+ <button class="btn btn-sm btn-primary" data-if="needsReceiver.value" data-on-click="addReceiver">
156
+ Add a receiver
157
+ </button>
158
+ <button class="btn btn-sm btn-ghost" data-on-click="openHelp">What are these?</button>
39
159
  </div>
40
160
  </div>
41
161
  </div>
42
162
 
163
+ <p class="text-muted text-sm invoice-hint" data-if="hasRows.value">Right-click a row (or use <strong>⋯</strong>) to print, send, export, duplicate or archive it.</p>
164
+
43
165
  <div id="invoices-table"></div>
166
+
167
+ <!--
168
+ Domma's table has no empty-state option - `emptyMessage` was being passed and
169
+ silently ignored, so an empty list rendered a header row, a pager and nothing
170
+ in between, which reads as a screen that has broken rather than one with
171
+ nothing in it.
172
+ -->
173
+ <p class="text-muted invoice-empty" data-if="isEmpty.value" data-bind-text="emptyText.value"></p>
@@ -1,5 +1,2 @@
1
- <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0"><span data-icon="briefcase"></span> Issuers</h1>
3
- <button id="btn-new" class="btn btn-primary"><span data-icon="plus"></span> New Issuer</button>
4
- </div>
5
- <div id="party-table"></div>
1
+ <!-- The banner carries the heading; this view only supplies the panel host. -->
2
+ <div id="party-host"></div>
@@ -1,5 +1,2 @@
1
- <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0"><span data-icon="users"></span> Receivers</h1>
3
- <button id="btn-new" class="btn btn-primary"><span data-icon="plus"></span> New Receiver</button>
4
- </div>
5
- <div id="party-table"></div>
1
+ <!-- The banner carries the heading; this view only supplies the panel host. -->
2
+ <div id="party-host"></div>
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Invoice plugin - the seam to the Contacts plugin.
3
+ *
4
+ * Contacts already holds company name, billing address, VAT number, payment
5
+ * terms and a billing email, and it exposes `GET /contacts/:id/billing` which
6
+ * returns exactly the field names a receiver has. So a receiver is imported
7
+ * rather than retyped, and the mapping lives at the end that owns the data.
8
+ *
9
+ * Everything here is OPTIONAL. Contacts can be disabled, or not licensed, and
10
+ * the invoice screens must not lose a button over it - so `contactsAvailable()`
11
+ * probes once and every caller hides its control when the answer is no.
12
+ */
13
+ const CONTACTS = '/api/plugins/contacts';
14
+
15
+ /** Asked once per page load; enabling a plugin reloads the admin anyway. */
16
+ let available = null;
17
+
18
+ /**
19
+ * @returns {Promise<boolean>}
20
+ */
21
+ export async function contactsAvailable() {
22
+ if (available !== null) return available;
23
+ try {
24
+ await H.get(`${CONTACTS}/groups`, { silent: true });
25
+ available = true;
26
+ } catch {
27
+ available = false;
28
+ }
29
+ return available;
30
+ }
31
+
32
+ /**
33
+ * @returns {Promise<object[]>} contacts, name-sorted, as the plugin sends them
34
+ */
35
+ export async function listContacts() {
36
+ const res = await H.get(`${CONTACTS}/contacts`);
37
+ return res.data ?? (Array.isArray(res) ? res : []);
38
+ }
39
+
40
+ /**
41
+ * @returns {Promise<string[]>} group names
42
+ */
43
+ export async function listGroups() {
44
+ const res = await H.get(`${CONTACTS}/groups`);
45
+ return res.data ?? (Array.isArray(res) ? res : []);
46
+ }
47
+
48
+ /**
49
+ * One contact as a receiver body.
50
+ *
51
+ * @param {string} id
52
+ * @returns {Promise<object>}
53
+ */
54
+ export async function billingProfile(id) {
55
+ const res = await H.get(`${CONTACTS}/contacts/${encodeURIComponent(id)}/billing`);
56
+ return res.data ?? res;
57
+ }
58
+
59
+ /**
60
+ * Every address a contact can be reached at, billing first.
61
+ *
62
+ * @param {object} contact
63
+ * @returns {string[]}
64
+ */
65
+ export function addressesFor(contact) {
66
+ const all = [contact?.billingEmail, contact?.email, ...(contact?.emails ?? [])];
67
+ const seen = new Set();
68
+ const out = [];
69
+ for (const item of all) {
70
+ const address = String(item ?? '').trim();
71
+ if (!address || seen.has(address.toLowerCase())) continue;
72
+ seen.add(address.toLowerCase());
73
+ out.push(address);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * A searchable list of contacts in a slideover.
80
+ *
81
+ * Multi-select mode is what "send to a group" is built out of: the group
82
+ * filter narrows the list, and Select all takes what is showing.
83
+ *
84
+ * @param {{title?: string, multiple?: boolean, onPick: Function}} options
85
+ * @returns {Promise<void>}
86
+ */
87
+ export async function openContactPicker({title = 'Choose a contact', multiple = false, onPick}) {
88
+ let contacts = [];
89
+ let groups = [];
90
+ try {
91
+ [contacts, groups] = await Promise.all([listContacts(), listGroups().catch(() => [])]);
92
+ } catch (err) {
93
+ E.toast(err?.message ?? 'Could not read your contacts.', { type: 'error' });
94
+ return;
95
+ }
96
+
97
+ const query = M.observable('').extend({ rateLimit: 150 });
98
+ const group = M.observable('');
99
+ const selected = M.observable([]);
100
+
101
+ const shown = M.computed(() => {
102
+ const q = String(query.value || '').toLowerCase().trim();
103
+ const g = group.value;
104
+ return contacts.filter((c) => {
105
+ if (g && !(c.groups ?? []).includes(g)) return false;
106
+ if (!q) return true;
107
+ return [c.name, c.company, c.email, c.billingEmail]
108
+ .some((v) => String(v ?? '').toLowerCase().includes(q));
109
+ });
110
+ });
111
+
112
+ const slideover = E.slideover({
113
+ title,
114
+ size: 'md',
115
+ position: 'right',
116
+ onClose: () => setTimeout(() => {
117
+ stop();
118
+ slideover.destroy();
119
+ slideover.element?.remove();
120
+ }, 350)
121
+ });
122
+
123
+ const wrapper = document.createElement('div');
124
+ wrapper.className = 'invoice-party-form';
125
+
126
+ const filters = document.createElement('div');
127
+ filters.className = 'invoice-picker-filters';
128
+
129
+ const search = document.createElement('input');
130
+ search.type = 'search';
131
+ search.className = 'form-input';
132
+ search.placeholder = 'Search name, company or email…';
133
+ search.addEventListener('input', () => { query.value = search.value; });
134
+ filters.appendChild(search);
135
+
136
+ if (groups.length) {
137
+ const groupSelect = document.createElement('select');
138
+ groupSelect.className = 'form-select';
139
+ const any = document.createElement('option');
140
+ any.value = '';
141
+ any.textContent = 'All groups';
142
+ groupSelect.appendChild(any);
143
+ for (const name of groups) {
144
+ const option = document.createElement('option');
145
+ option.value = name;
146
+ option.textContent = name;
147
+ groupSelect.appendChild(option);
148
+ }
149
+ groupSelect.addEventListener('change', () => { group.value = groupSelect.value; });
150
+ filters.appendChild(groupSelect);
151
+ }
152
+
153
+ wrapper.appendChild(filters);
154
+
155
+ const list = document.createElement('div');
156
+ list.className = 'invoice-picker-list';
157
+ wrapper.appendChild(list);
158
+
159
+ const actions = document.createElement('div');
160
+ actions.className = 'invoice-party-actions';
161
+ const count = document.createElement('span');
162
+ count.className = 'text-sm text-muted invoice-picker-count';
163
+ actions.appendChild(count);
164
+
165
+ const cancelBtn = document.createElement('button');
166
+ cancelBtn.type = 'button';
167
+ cancelBtn.className = 'btn btn-ghost';
168
+ cancelBtn.textContent = 'Cancel';
169
+ cancelBtn.addEventListener('click', () => slideover.close());
170
+ actions.appendChild(cancelBtn);
171
+
172
+ let selectAllBtn = null;
173
+ let useBtn = null;
174
+ if (multiple) {
175
+ selectAllBtn = document.createElement('button');
176
+ selectAllBtn.type = 'button';
177
+ selectAllBtn.className = 'btn btn-outline';
178
+ selectAllBtn.textContent = 'Select all shown';
179
+ selectAllBtn.addEventListener('click', () => {
180
+ selected.value = shown.value.map((c) => c.id);
181
+ });
182
+ actions.appendChild(selectAllBtn);
183
+
184
+ useBtn = document.createElement('button');
185
+ useBtn.type = 'button';
186
+ useBtn.className = 'btn btn-primary';
187
+ useBtn.textContent = 'Use these';
188
+ useBtn.addEventListener('click', () => {
189
+ const chosen = contacts.filter((c) => selected.value.includes(c.id));
190
+ slideover.close();
191
+ onPick(chosen);
192
+ });
193
+ actions.appendChild(useBtn);
194
+ }
195
+
196
+ wrapper.appendChild(actions);
197
+ slideover.element.appendChild(wrapper);
198
+
199
+ /**
200
+ * The list is rebuilt rather than bound, because each row is a plain
201
+ * button and there is no per-row state worth a binding - the only thing
202
+ * that moves is which ids are in `selected`.
203
+ *
204
+ * @returns {void}
205
+ */
206
+ function paint() {
207
+ const rows = shown.value;
208
+ const chosen = new Set(selected.value);
209
+
210
+ list.textContent = '';
211
+ if (!rows.length) {
212
+ const empty = document.createElement('p');
213
+ empty.className = 'text-sm text-muted';
214
+ empty.textContent = contacts.length
215
+ ? 'No contact matches that.'
216
+ : 'You have no contacts yet.';
217
+ list.appendChild(empty);
218
+ }
219
+
220
+ for (const contact of rows) {
221
+ const row = document.createElement('button');
222
+ row.type = 'button';
223
+ row.className = 'invoice-picker-row' + (chosen.has(contact.id) ? ' is-chosen' : '');
224
+
225
+ const name = document.createElement('span');
226
+ name.className = 'invoice-picker-name';
227
+ name.textContent = contact.company
228
+ ? `${contact.name} · ${contact.company}`
229
+ : contact.name;
230
+ row.appendChild(name);
231
+
232
+ const mail = document.createElement('span');
233
+ mail.className = 'invoice-picker-mail';
234
+ mail.textContent = addressesFor(contact)[0] || 'no email';
235
+ row.appendChild(mail);
236
+
237
+ row.addEventListener('click', () => {
238
+ if (!multiple) {
239
+ slideover.close();
240
+ onPick(contact);
241
+ return;
242
+ }
243
+ selected.value = chosen.has(contact.id)
244
+ ? selected.value.filter((id) => id !== contact.id)
245
+ : [...selected.value, contact.id];
246
+ });
247
+
248
+ list.appendChild(row);
249
+ }
250
+
251
+ count.textContent = multiple
252
+ ? `${selected.value.length} selected`
253
+ : `${rows.length} of ${contacts.length}`;
254
+ if (useBtn) useBtn.disabled = selected.value.length === 0;
255
+ }
256
+
257
+ const stop = M.effect(() => {
258
+ // Tracked reads first, then the DOM work untracked, so the rebuild's
259
+ // own reads never become dependencies of this effect.
260
+ const deps = [shown.value, selected.value];
261
+ M.untracked(() => { void deps; paint(); });
262
+ });
263
+
264
+ slideover.open();
265
+ search.focus();
266
+ }