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,173 +0,0 @@
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.
5
-
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>
20
- </div></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>
29
- </div></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>
38
- </div></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>
56
- </div></div>
57
- </div>
58
-
59
- <div class="card mb-3 invoice-toolbar">
60
- <div class="card-body">
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.">
73
- <option value="">All statuses</option>
74
- <option value="draft">Draft</option>
75
- <option value="sent">Sent</option>
76
- <option value="paid">Paid</option>
77
- <option value="overdue">Overdue</option>
78
- <option value="cancelled">Cancelled</option>
79
- </select>
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>
159
- </div>
160
- </div>
161
- </div>
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
-
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,2 +0,0 @@
1
- <!-- The banner carries the heading; this view only supplies the panel host. -->
2
- <div id="party-host"></div>
@@ -1,2 +0,0 @@
1
- <!-- The banner carries the heading; this view only supplies the panel host. -->
2
- <div id="party-host"></div>
@@ -1,266 +0,0 @@
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
- }