@aglyn/plugins-marketing 1.0.0-beta.143

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 (183) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +56 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
  8. package/src/lib/components/announcement-bar-card.component.js +292 -0
  9. package/src/lib/components/announcement-bar-card.component.js.map +1 -0
  10. package/src/lib/components/campaign-composer.d.ts +84 -0
  11. package/src/lib/components/campaign-composer.js +1643 -0
  12. package/src/lib/components/campaign-composer.js.map +1 -0
  13. package/src/lib/components/campaign-conversions-card.d.ts +22 -0
  14. package/src/lib/components/campaign-conversions-card.js +642 -0
  15. package/src/lib/components/campaign-conversions-card.js.map +1 -0
  16. package/src/lib/components/campaign-detail-card.d.ts +55 -0
  17. package/src/lib/components/campaign-detail-card.js +771 -0
  18. package/src/lib/components/campaign-detail-card.js.map +1 -0
  19. package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
  20. package/src/lib/components/campaign-edit-drawer.js +252 -0
  21. package/src/lib/components/campaign-edit-drawer.js.map +1 -0
  22. package/src/lib/components/campaign-email-zones.d.ts +111 -0
  23. package/src/lib/components/campaign-email-zones.js +54 -0
  24. package/src/lib/components/campaign-email-zones.js.map +1 -0
  25. package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
  26. package/src/lib/components/campaign-glance-card.component.js +151 -0
  27. package/src/lib/components/campaign-glance-card.component.js.map +1 -0
  28. package/src/lib/components/campaign-members-section.d.ts +87 -0
  29. package/src/lib/components/campaign-members-section.js +499 -0
  30. package/src/lib/components/campaign-members-section.js.map +1 -0
  31. package/src/lib/components/campaign-reach-sections.d.ts +137 -0
  32. package/src/lib/components/campaign-reach-sections.js +694 -0
  33. package/src/lib/components/campaign-reach-sections.js.map +1 -0
  34. package/src/lib/components/campaign-report-card.d.ts +91 -0
  35. package/src/lib/components/campaign-report-card.js +667 -0
  36. package/src/lib/components/campaign-report-card.js.map +1 -0
  37. package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
  38. package/src/lib/components/campaign-test-send-drawer.js +238 -0
  39. package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
  40. package/src/lib/components/campaigns-card.d.ts +31 -0
  41. package/src/lib/components/campaigns-card.js +667 -0
  42. package/src/lib/components/campaigns-card.js.map +1 -0
  43. package/src/lib/components/conversion-attribution.component.d.ts +62 -0
  44. package/src/lib/components/conversion-attribution.component.js +127 -0
  45. package/src/lib/components/conversion-attribution.component.js.map +1 -0
  46. package/src/lib/components/email-compose-card.d.ts +12 -0
  47. package/src/lib/components/email-compose-card.js +157 -0
  48. package/src/lib/components/email-compose-card.js.map +1 -0
  49. package/src/lib/components/email-detail.d.ts +40 -0
  50. package/src/lib/components/email-detail.js +1180 -0
  51. package/src/lib/components/email-detail.js.map +1 -0
  52. package/src/lib/components/email-edit-drawer.d.ts +54 -0
  53. package/src/lib/components/email-edit-drawer.js +172 -0
  54. package/src/lib/components/email-edit-drawer.js.map +1 -0
  55. package/src/lib/components/email-messages-widget.d.ts +22 -0
  56. package/src/lib/components/email-messages-widget.js +49 -0
  57. package/src/lib/components/email-messages-widget.js.map +1 -0
  58. package/src/lib/components/email-recipients-card.d.ts +50 -0
  59. package/src/lib/components/email-recipients-card.js +338 -0
  60. package/src/lib/components/email-recipients-card.js.map +1 -0
  61. package/src/lib/components/emails-list-card.d.ts +36 -0
  62. package/src/lib/components/emails-list-card.js +544 -0
  63. package/src/lib/components/emails-list-card.js.map +1 -0
  64. package/src/lib/components/experiment-zones.d.ts +78 -0
  65. package/src/lib/components/experiment-zones.js +65 -0
  66. package/src/lib/components/experiment-zones.js.map +1 -0
  67. package/src/lib/components/host-experiments-card.component.d.ts +17 -0
  68. package/src/lib/components/host-experiments-card.component.js +887 -0
  69. package/src/lib/components/host-experiments-card.component.js.map +1 -0
  70. package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
  71. package/src/lib/components/host-marketing-summary-card.component.js +161 -0
  72. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
  73. package/src/lib/components/host-overlays-card.component.d.ts +19 -0
  74. package/src/lib/components/host-overlays-card.component.js +783 -0
  75. package/src/lib/components/host-overlays-card.component.js.map +1 -0
  76. package/src/lib/components/marketing-console-page.d.ts +28 -0
  77. package/src/lib/components/marketing-console-page.js +166 -0
  78. package/src/lib/components/marketing-console-page.js.map +1 -0
  79. package/src/lib/components/marketing-console-sections.d.ts +34 -0
  80. package/src/lib/components/marketing-console-sections.js +77 -0
  81. package/src/lib/components/marketing-console-sections.js.map +1 -0
  82. package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
  83. package/src/lib/components/overlay-stats-row.component.js +78 -0
  84. package/src/lib/components/overlay-stats-row.component.js.map +1 -0
  85. package/src/lib/components/popup-card.component.d.ts +18 -0
  86. package/src/lib/components/popup-card.component.js +462 -0
  87. package/src/lib/components/popup-card.component.js.map +1 -0
  88. package/src/lib/components/record-attribution-widget.d.ts +35 -0
  89. package/src/lib/components/record-attribution-widget.js +33 -0
  90. package/src/lib/components/record-attribution-widget.js.map +1 -0
  91. package/src/lib/components/site-runtime.d.ts +9 -0
  92. package/src/lib/components/site-runtime.js +1081 -0
  93. package/src/lib/components/site-runtime.js.map +1 -0
  94. package/src/lib/components/use-campaign-send-api.d.ts +35 -0
  95. package/src/lib/components/use-campaign-send-api.js +99 -0
  96. package/src/lib/components/use-campaign-send-api.js.map +1 -0
  97. package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
  98. package/src/lib/components/use-campaign-topic-options.js +44 -0
  99. package/src/lib/components/use-campaign-topic-options.js.map +1 -0
  100. package/src/lib/components/use-emails-hub-path.d.ts +39 -0
  101. package/src/lib/components/use-emails-hub-path.js +70 -0
  102. package/src/lib/components/use-emails-hub-path.js.map +1 -0
  103. package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
  104. package/src/lib/components/use-marketing-hub-path.js +50 -0
  105. package/src/lib/components/use-marketing-hub-path.js.map +1 -0
  106. package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
  107. package/src/lib/components/use-overlay-copy-editor.js +110 -0
  108. package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
  109. package/src/lib/constants/bundle-common.d.ts +8 -0
  110. package/src/lib/constants/bundle-common.js +9 -0
  111. package/src/lib/constants/bundle-common.js.map +1 -0
  112. package/src/lib/model/campaign-membership-figures.d.ts +109 -0
  113. package/src/lib/model/campaign-membership-figures.js +113 -0
  114. package/src/lib/model/campaign-membership-figures.js.map +1 -0
  115. package/src/lib/model/compile-client-automations.d.ts +65 -0
  116. package/src/lib/model/compile-client-automations.js +95 -0
  117. package/src/lib/model/compile-client-automations.js.map +1 -0
  118. package/src/lib/model/experiments.d.ts +130 -0
  119. package/src/lib/model/experiments.js +186 -0
  120. package/src/lib/model/experiments.js.map +1 -0
  121. package/src/lib/model/index.d.ts +26 -0
  122. package/src/lib/model/index.js +26 -0
  123. package/src/lib/model/index.js.map +1 -0
  124. package/src/lib/model/marketing-record-routes.d.ts +6 -0
  125. package/src/lib/model/marketing-record-routes.js +46 -0
  126. package/src/lib/model/marketing-record-routes.js.map +1 -0
  127. package/src/lib/model/overlay-copy.d.ts +62 -0
  128. package/src/lib/model/overlay-copy.js +78 -0
  129. package/src/lib/model/overlay-copy.js.map +1 -0
  130. package/src/lib/model/overlays.d.ts +113 -0
  131. package/src/lib/model/overlays.js +93 -0
  132. package/src/lib/model/overlays.js.map +1 -0
  133. package/src/lib/model/site-contract.d.ts +100 -0
  134. package/src/lib/model/site-contract.js +23 -0
  135. package/src/lib/model/site-contract.js.map +1 -0
  136. package/src/lib/plugin.d.ts +27 -0
  137. package/src/lib/plugin.js +168 -0
  138. package/src/lib/plugin.js.map +1 -0
  139. package/src/lib/preview-props.d.ts +31 -0
  140. package/src/lib/preview-props.js +54 -0
  141. package/src/lib/preview-props.js.map +1 -0
  142. package/src/lib/server/campaign-manage.d.ts +62 -0
  143. package/src/lib/server/campaign-manage.js +829 -0
  144. package/src/lib/server/campaign-manage.js.map +1 -0
  145. package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
  146. package/src/lib/server/campaign-process-scheduled.js +198 -0
  147. package/src/lib/server/campaign-process-scheduled.js.map +1 -0
  148. package/src/lib/server/campaign-recipients.d.ts +55 -0
  149. package/src/lib/server/campaign-recipients.js +219 -0
  150. package/src/lib/server/campaign-recipients.js.map +1 -0
  151. package/src/lib/server/campaign-send.d.ts +487 -0
  152. package/src/lib/server/campaign-send.js +3157 -0
  153. package/src/lib/server/campaign-send.js.map +1 -0
  154. package/src/lib/server/email-campaign-reach.d.ts +174 -0
  155. package/src/lib/server/email-campaign-reach.js +254 -0
  156. package/src/lib/server/email-campaign-reach.js.map +1 -0
  157. package/src/lib/server/email-events.d.ts +27 -0
  158. package/src/lib/server/email-events.js +714 -0
  159. package/src/lib/server/email-events.js.map +1 -0
  160. package/src/lib/server/get-client-automations.d.ts +60 -0
  161. package/src/lib/server/get-client-automations.js +87 -0
  162. package/src/lib/server/get-client-automations.js.map +1 -0
  163. package/src/lib/server/get-overlays.d.ts +28 -0
  164. package/src/lib/server/get-overlays.js +65 -0
  165. package/src/lib/server/get-overlays.js.map +1 -0
  166. package/src/lib/server/get-screen-experiments.d.ts +42 -0
  167. package/src/lib/server/get-screen-experiments.js +82 -0
  168. package/src/lib/server/get-screen-experiments.js.map +1 -0
  169. package/src/lib/server/lists-materialize.d.ts +18 -0
  170. package/src/lib/server/lists-materialize.js +130 -0
  171. package/src/lib/server/lists-materialize.js.map +1 -0
  172. package/src/lib/server/marketing-figures.d.ts +37 -0
  173. package/src/lib/server/marketing-figures.js +248 -0
  174. package/src/lib/server/marketing-figures.js.map +1 -0
  175. package/src/lib/server/site-page-enricher.d.ts +26 -0
  176. package/src/lib/server/site-page-enricher.js +210 -0
  177. package/src/lib/server/site-page-enricher.js.map +1 -0
  178. package/src/lib/server.d.ts +20 -0
  179. package/src/lib/server.js +152 -0
  180. package/src/lib/server.js.map +1 -0
  181. package/src/lib/site.d.ts +22 -0
  182. package/src/lib/site.js +48 -0
  183. package/src/lib/site.js.map +1 -0
@@ -0,0 +1,771 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
+ import { mdiDeleteOutline, mdiEyeOutline, mdiPaletteOutline, mdiPencilOutline } from "@aglyn/shared-data-mdi";
20
+ import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
21
+ import { Figure, RateRow, Section } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
22
+ import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
+ import RowActionsMenu from "@aglyn/shared-ui-jsx/components/row-actions-menu.component";
24
+ import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
25
+ import { ceilingedWindow } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
26
+ import { PageHeaderRecord, pluginDocsHelp } from "@aglyn/aglyn";
27
+ import { Alert, Box, Button, Chip, Divider, Stack, Typography } from "@mui/material";
28
+ import { collection, deleteField, doc, documentId, limit, orderBy, query, updateDoc, where } from "firebase/firestore";
29
+ import { useCallback, useMemo, useState } from "react";
30
+ import { useRouter } from "next/navigation";
31
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
32
+ import { useFirestore, useFirestoreCollection, useFirestoreDoc, useOrgDataScope } from "@aglyn/tenant-feature-instance";
33
+ import { campaignRollup, campaignSendDisplay, campaignWindowState, emailListTimeMs } from "@aglyn/shared-ui-email-campaigns/model";
34
+ import CampaignComposer from "./campaign-composer.js";
35
+ import CampaignEditDrawer from "./campaign-edit-drawer.js";
36
+ import { CampaignConversionsSection, CampaignDestinationsSection, CampaignRevenueSection } from "./campaign-reach-sections.js";
37
+ import CampaignMembersSection from "./campaign-members-section.js";
38
+ import CampaignReportCard from "./campaign-report-card.js";
39
+ import { useCampaignManageApi } from "./use-campaign-send-api.js";
40
+ import { useEmailsHubPath } from "./use-emails-hub-path.js";
41
+ import { useCampaignTopicOptions } from "./use-campaign-topic-options.js";
42
+ /** How many of a campaign's emails the detail page enumerates. */ const CAMPAIGN_EMAIL_CEILING = 50;
43
+ const detailDocsHelp = pluginDocsHelp('emailCampaigns', {
44
+ anchor: '#the-campaign-report',
45
+ excerpt: 'A campaign groups the emails sent to its lists. The figures here are ' + 'the sum across those emails, and each one keeps its own report.'
46
+ });
47
+ /**
48
+ * A rolled-up figure, in the shared `Figure`'s shape.
49
+ *
50
+ * The note is where an aggregate differs from a single send's count: a total
51
+ * measured over fewer emails than the campaign holds says which part of the
52
+ * campaign it describes, rather than presenting a partial sum as a complete
53
+ * one. `null` still renders as an em dash and says "not recorded", which is
54
+ * the property the shared component already owns.
55
+ */ const rolled = (value)=>({
56
+ value: value.value,
57
+ note: value.value !== null && value.recorded < value.sends ? `across ${value.recorded} of ${value.sends} emails` : `across ${value.sends} email${value.sends === 1 ? '' : 's'}`
58
+ });
59
+ /**
60
+ * ONE CAMPAIGN: its lists, what it caused, what it earned, where it sent
61
+ * people, and the emails it did all of that with.
62
+ *
63
+ * ## Why the emails are a section rather than the page
64
+ *
65
+ * A campaign reaches people by email, which is not the same as a campaign
66
+ * BEING a list of emails. It runs over a window, against a set of audiences,
67
+ * and the pages its links land on, the money credited to it and the
68
+ * conversions credited to it are facts about the campaign that no single
69
+ * message in it holds. So the body is sectioned — what it caused, what it
70
+ * earned, where it sent people, then the mail's own figures, then its emails
71
+ * — and each section names the population it describes.
72
+ *
73
+ * The ORDER is the part that says which of those the page is about. Delivery,
74
+ * engagement and rates are mechanics of a message; a page that met the reader
75
+ * with them and reached the outcomes only after scrolling was a mail report
76
+ * whatever its headings claimed. `campaign-report-card.tsx` — the page a
77
+ * campaign of one email gets — carries the same two outcome headings first,
78
+ * for the same reason and in the same words.
79
+ *
80
+ * The three middle sections live in `campaign-reach-sections.tsx`, which is
81
+ * also where the reason a campaign cannot simply DECLARE the screens and
82
+ * forms it runs across is written down: no such edge is stored, attribution
83
+ * is the mechanism, and all three sections join on this campaign's send ids.
84
+ *
85
+ * ## Why this resolves two kinds of id
86
+ *
87
+ * The id in `campaigns/{id}` may name a campaign CONTAINER or a single SEND,
88
+ * and which it is is answered by reading: a container at `emailCampaigns/{id}`
89
+ * renders this page, and anything else falls through to that send's own
90
+ * report.
91
+ *
92
+ * Not every send belongs to a container. One written before campaigns grouped
93
+ * their emails names none, its own id is the only id the URL can carry, and
94
+ * that id is also inside the HMAC of every unsubscribe footer already
95
+ * delivered — so a send id has to go on addressing a page rather than a 404.
96
+ * The fall-through is what makes the container additive: no send document is
97
+ * rewritten and no id is reassigned.
98
+ *
99
+ * The extra read is one document, and it buys the guarantee that a pasted
100
+ * report link resolves whichever kind of id it carries.
101
+ */ export function CampaignDetailCard(props) {
102
+ var _campaign_listIds;
103
+ const { hostId, campaignId, basePath } = props;
104
+ const firestore = useFirestore();
105
+ // The sibling hub, for the two records this page links to but does not own:
106
+ // each message's report and the template it was built from.
107
+ const emailsHub = useEmailsHubPath();
108
+ const router = useRouter();
109
+ const { scope: dataScope } = useOrgDataScope({
110
+ hostId
111
+ });
112
+ const { confirm } = useConfirmationContext();
113
+ const { enqueueSnackbar } = useSnackbar();
114
+ const manageApi = useCampaignManageApi(hostId);
115
+ const [composing, setComposing] = useState(false);
116
+ const [editing, setEditing] = useState(false);
117
+ const [saving, setSaving] = useState(false);
118
+ const [saveError, setSaveError] = useState(null);
119
+ const [deleting, setDeleting] = useState(false);
120
+ const { data: campaign, status } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId), [
121
+ firestore,
122
+ hostId,
123
+ campaignId
124
+ ]);
125
+ /*
126
+ * ONLY WHEN THE ID NAMES A CAMPAIGN.
127
+ *
128
+ * Both queries below are gated on the container having been found, because
129
+ * the other half of this component is the single-send report — and that
130
+ * screen's whole design is that it reads two documents whatever the size of
131
+ * the audience. Listening unconditionally would add two collection reads to
132
+ * every legacy report URL, which is the cost the report route was split out
133
+ * to avoid. Not listening is what a null builder means.
134
+ *
135
+ * The campaign's emails come back by the field each send carries: equality
136
+ * on one field ordered by DOCUMENT NAME, which Firestore's automatic
137
+ * single-field index serves without a composite one. Ordering on a date
138
+ * would need that index AND would drop every send missing that particular
139
+ * date — a sent send carries `sentAt`, a scheduled one `sendAtMs`, and
140
+ * neither is on both. Sorted by date below, over a window this page holds
141
+ * whole.
142
+ *
143
+ * One document past the ceiling, so "this campaign has more emails than are
144
+ * listed" is a fact rather than a guess.
145
+ */ const { data: sendDocs } = useFirestoreCollection(()=>campaign ? query(collection(firestore, 'hosts', hostId, 'campaigns'), where('emailCampaignId', '==', campaignId), orderBy(documentId()), limit(CAMPAIGN_EMAIL_CEILING + 1)) : null, [
146
+ firestore,
147
+ hostId,
148
+ campaignId,
149
+ Boolean(campaign)
150
+ ], {
151
+ idField: '$id'
152
+ });
153
+ const { data: listDocs } = useFirestoreCollection(()=>campaign && dataScope ? query(collection(firestore, dataScope[0], dataScope[1], 'lists'), limit(50)) : null, [
154
+ firestore,
155
+ dataScope,
156
+ Boolean(campaign)
157
+ ], {
158
+ idField: '$id'
159
+ });
160
+ /*
161
+ * The org's topic catalog, read only while the EDIT DRAWER is open.
162
+ *
163
+ * It fills one picker in that drawer and is drawn nowhere else on this
164
+ * page, so reading it on mount would charge every reader who came for the
165
+ * campaign's numbers for a field they are not looking at. The composer
166
+ * carries its own read of the same catalog, behind its own button, for the
167
+ * same reason.
168
+ */ const { topics, source: topicSource } = useCampaignTopicOptions(hostId, {
169
+ enabled: editing
170
+ });
171
+ const topicOptions = useMemo(()=>topics.map((topic)=>({
172
+ value: topic.id,
173
+ label: topic.name
174
+ })), [
175
+ topics
176
+ ]);
177
+ const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow(sendDocs, CAMPAIGN_EMAIL_CEILING);
178
+ /*
179
+ * Newest first on the time each email SITS at — its send time where it has
180
+ * one, its creation where it does not. A draft has neither `sentAt` nor
181
+ * `sendAtMs`, so ordering on the send time alone gave every draft the key 0
182
+ * and filed the email a merchant is in the middle of writing below mail
183
+ * sent years ago.
184
+ */ const sends = useMemo(()=>[
185
+ ...readSends
186
+ ].sort((a, b)=>emailListTimeMs(b) - emailListTimeMs(a)), [
187
+ readSends
188
+ ]);
189
+ const rollup = useMemo(()=>campaignRollup(sends), [
190
+ sends
191
+ ]);
192
+ // The ids the two sections beneath the figures join on. Derived from the
193
+ // window this card already holds, so neither of them reads the send list
194
+ // again to find out which emails the campaign has.
195
+ const sendIds = useMemo(()=>sends.map((send)=>String(send.$id)).filter(Boolean), [
196
+ sends
197
+ ]);
198
+ /*==========================================
199
+ * SAVING THE CONTAINER, WITH THE CLIENT SDK.
200
+ *
201
+ * The same door the create drawer already uses. `emailCampaigns` is
202
+ * deliberately outside the security rules' server-only exclusion list — a
203
+ * container holds no counter, no consent record and no entitlement input,
204
+ * so an editor who writes one cannot buy themselves a send — and giving the
205
+ * edit a route of its own would be a second writer to keep in step with the
206
+ * create.
207
+ *
208
+ * `updateDoc` rather than a merge-set, because this is an edit of a
209
+ * document that exists: a set would CREATE a campaign at this id for
210
+ * somebody who deleted it in another tab, which is the shape the delivery
211
+ * webhook is careful about on the send collection for the same reason.
212
+ *
213
+ * A cleared date is written as `null` — the absence the model already
214
+ * spells — and a cleared topic REMOVES the field, because the model has no
215
+ * null topic and `campaignTopicId` is handed to the composer as one.
216
+ *=========================================*/ const handleSave = useCallback(async (values)=>{
217
+ if (saving) return;
218
+ setSaving(true);
219
+ setSaveError(null);
220
+ try {
221
+ await updateDoc(doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId), {
222
+ name: values.name,
223
+ startAtMs: values.startAtMs,
224
+ endAtMs: values.endAtMs,
225
+ listIds: values.listIds,
226
+ topicId: values.topicId ? values.topicId : deleteField()
227
+ });
228
+ setEditing(false);
229
+ enqueueSnackbar('Campaign updated', {
230
+ variant: 'success',
231
+ persist: false
232
+ });
233
+ } catch (error) {
234
+ console.error(error);
235
+ setSaveError('The campaign could not be saved');
236
+ } finally{
237
+ setSaving(false);
238
+ }
239
+ }, [
240
+ campaignId,
241
+ enqueueSnackbar,
242
+ firestore,
243
+ hostId,
244
+ saving
245
+ ]);
246
+ /*==========================================
247
+ * DELETING THE CONTAINER, WHICH IS NOT DELETING ITS MAIL.
248
+ *
249
+ * The route detaches every email first and then removes the campaign, so
250
+ * what a merchant is agreeing to is the grouping going away — the emails
251
+ * stay, keep their ids, keep their reports, and read afterwards as single
252
+ * sends. Their unsubscribe links carry those ids inside an HMAC and go on
253
+ * working, which is why the container can never take them with it.
254
+ *
255
+ * The confirmation says the count and says what a delete does NOT do. A
256
+ * scheduled email inside the campaign still goes out at its time — stopping
257
+ * somebody's mail is `cancel`, on that email's own page — and a merchant
258
+ * who reads "delete campaign" as "stop the campaign" has to be told
259
+ * otherwise before they press it, not after.
260
+ *=========================================*/ const handleDelete = useCallback(async ()=>{
261
+ if (deleting) return;
262
+ /*
263
+ * Emails with mail still to go: the ones waiting for their time AND the
264
+ * ones part way through an audience larger than one batch. Both are what
265
+ * a merchant needs warning about, and counting only `scheduled` would
266
+ * miss the case that needs it most — a campaign that is delivering right
267
+ * now is stored as `scheduled` too, but `rollup.scheduled` is the
268
+ * narrower reading that excludes it.
269
+ */ const unfinished = rollup.scheduled + rollup.sending;
270
+ const agreed = await confirm({
271
+ title: 'Delete this campaign?',
272
+ description: (sends.length ? `The ${sends.length.toLocaleString()} ` + `${sends.length === 1 ? 'email' : 'emails'} in it are kept — ` + 'each keeps its own report and its unsubscribe links, and they ' + 'appear in the campaigns list as single sends. ' : 'It holds no emails. ') + (unfinished ? `${unfinished.toLocaleString()} of them ${unfinished === 1 ? 'is' : 'are'} still going out or still due, and deleting the campaign does ` + 'not stop that — the sender picks each one up from its own ' + 'record. Stop a send from its own page. ' : '') + 'Any screens, forms and contacts assigned to it come out of it and ' + 'are otherwise untouched. Only the campaign itself goes.',
273
+ confirmationText: 'Delete campaign'
274
+ }).then(()=>true).catch(()=>false);
275
+ if (!agreed) return;
276
+ setDeleting(true);
277
+ try {
278
+ const { response, payload } = await manageApi({
279
+ action: 'deleteCampaign',
280
+ campaignId
281
+ });
282
+ if (!response.ok) {
283
+ var _ref;
284
+ return void enqueueSnackbar((_ref = payload == null ? void 0 : payload.error) != null ? _ref : 'The campaign could not be deleted', {
285
+ variant: 'warning',
286
+ allowDuplicate: true
287
+ });
288
+ }
289
+ enqueueSnackbar('Campaign deleted', {
290
+ variant: 'success',
291
+ persist: false
292
+ });
293
+ router.push(`${basePath}/campaigns`);
294
+ } catch (error) {
295
+ console.error(error);
296
+ enqueueSnackbar('The campaign could not be deleted', {
297
+ variant: 'error',
298
+ allowDuplicate: true
299
+ });
300
+ } finally{
301
+ setDeleting(false);
302
+ }
303
+ }, [
304
+ basePath,
305
+ campaignId,
306
+ confirm,
307
+ deleting,
308
+ enqueueSnackbar,
309
+ manageApi,
310
+ rollup.scheduled,
311
+ rollup.sending,
312
+ router,
313
+ sends.length
314
+ ]);
315
+ // Still settling. Falling through to the send report here would flash "this
316
+ // campaign could not be loaded" on every open of a campaign that exists.
317
+ if (!campaign && status === 'loading') return null;
318
+ /*
319
+ * NOT A CONTAINER — so it is a send id, and the send's own report is what
320
+ * this URL has always meant.
321
+ */ if (!campaign) {
322
+ return /*#__PURE__*/ _jsx(CampaignReportCard, {
323
+ hostId: hostId,
324
+ campaignId: campaignId,
325
+ basePath: basePath
326
+ });
327
+ }
328
+ const listIds = (_campaign_listIds = campaign.listIds) != null ? _campaign_listIds : [];
329
+ const names = new Map((listDocs != null ? listDocs : []).map((list)=>{
330
+ var _list_name;
331
+ return [
332
+ String(list.$id),
333
+ String((_list_name = list.name) != null ? _list_name : list.$id)
334
+ ];
335
+ }));
336
+ /*==========================================
337
+ * ONE EMAIL'S PAGE — the same one every other route to that record opens.
338
+ *
339
+ * The Emails hub's `emails/{sendId}`, which is where the messages list and
340
+ * a template's messages table also send a reader. One record has one page:
341
+ * a link that resolved somewhere else depending on which list it was
342
+ * clicked in would give the same message two pages, and the reader would
343
+ * have no way to tell which one they were on.
344
+ *
345
+ * `campaigns/{sendId}` on THIS hub goes on resolving as well, through the
346
+ * fall-through at the top of this file. That is not a convenience — every
347
+ * unsubscribe footer already delivered carries `cid={sendId}`, those
348
+ * messages sit in inboxes forever, and merchants paste report URLs into
349
+ * their own mail. Which link the console GENERATES and which URLs ANSWER
350
+ * are two separate questions, and only the first is decided here.
351
+ *
352
+ * Nor is a redirect the answer to the second. A redirect would be another
353
+ * thing to be wrong about an id that is inside an HMAC; a page that keeps
354
+ * working has nothing to get wrong, and it costs nothing to leave standing.
355
+ *=========================================*/ const sendHref = (send)=>emailsHub ? `${emailsHub}/messages/${send.$id}` : undefined;
356
+ /**
357
+ * What one of this campaign's emails can be opened into.
358
+ *
359
+ * The same two the Emails hub offers, less the campaign — this page IS the
360
+ * campaign. Both destinations are records the Emails console owns, so they
361
+ * are built from {@link useEmailsHubPath} rather than from this surface's
362
+ * own `basePath`.
363
+ *
364
+ * A message composed inline was built from no template, so that entry is
365
+ * shown DISABLED with the reason rather than hidden: an absent control and
366
+ * an inapplicable one look identical, and only one is honest.
367
+ */ const sendActions = (send)=>{
368
+ var _send_templateScreenId;
369
+ const templateScreenId = String((_send_templateScreenId = send.templateScreenId) != null ? _send_templateScreenId : '');
370
+ return [
371
+ {
372
+ key: 'details',
373
+ label: 'Open report',
374
+ icon: /*#__PURE__*/ _jsx(MdiIcon, {
375
+ path: mdiEyeOutline.path,
376
+ size: 0.8
377
+ }),
378
+ href: sendHref(send),
379
+ disabled: !sendHref(send),
380
+ disabledReason: 'This site’s console URL has not resolved yet'
381
+ },
382
+ {
383
+ key: 'template',
384
+ label: 'Open its template',
385
+ icon: /*#__PURE__*/ _jsx(MdiIcon, {
386
+ path: mdiPaletteOutline.path,
387
+ size: 0.8
388
+ }),
389
+ href: templateScreenId && emailsHub ? `${emailsHub}/templates/${templateScreenId}` : undefined,
390
+ disabled: !templateScreenId || !emailsHub,
391
+ disabledReason: templateScreenId ? 'This site’s console URL has not resolved yet' : 'This message was not built from a template'
392
+ }
393
+ ];
394
+ };
395
+ /*
396
+ * The campaign's emails, on the surface's own row grammar: the row opens
397
+ * the email's report, its subject is also a real link so it can be
398
+ * middle-clicked and copied, and the trailing cluster holds the actions.
399
+ * The window is one the card already holds, so the grid pages it.
400
+ */ const sendColumns = [
401
+ {
402
+ field: 'subject',
403
+ headerName: 'Subject',
404
+ flex: 1,
405
+ minWidth: 220,
406
+ valueGetter: (_value, send)=>send.subject || send.$id,
407
+ /*
408
+ Plain text until the Emails hub resolves: a link with no destination
409
+ is worse than none, and this cell is the message's subject either way.
410
+ */ renderCell: ({ row: send, value })=>{
411
+ const href = sendHref(send);
412
+ return href ? /*#__PURE__*/ _jsx(AppLink, {
413
+ href: href,
414
+ // The row's own handler would fire too and push the same route
415
+ // twice — one history entry per back press.
416
+ onClick: (event)=>event.stopPropagation(),
417
+ children: value
418
+ }) : value;
419
+ }
420
+ },
421
+ {
422
+ /*
423
+ WHAT THE EMAIL IS DOING, not the field it stores.
424
+
425
+ This branched on the status, which reads "Scheduled" about an email
426
+ that has delivered five hundred messages and is between batches — the
427
+ stored state the processor claims to resume it. The derivation reads
428
+ the counters beside the status; the due date is appended only where
429
+ there is genuinely nothing delivered yet, which is the one case a time
430
+ answers.
431
+ */ field: 'state',
432
+ headerName: 'State',
433
+ width: 220,
434
+ valueGetter: (_value, send)=>{
435
+ const display = campaignSendDisplay(send);
436
+ return display.state === 'pending' && send.sendAtMs ? `${display.label} · ${new Date(send.sendAtMs).toLocaleString()}` : display.label;
437
+ },
438
+ renderCell: ({ row: send, value })=>{
439
+ const state = campaignSendDisplay(send).state;
440
+ return /*#__PURE__*/ _jsx(Chip, {
441
+ size: "small",
442
+ color: state === 'sending' ? 'info' : state === 'stopped' ? 'warning' : undefined,
443
+ label: value
444
+ });
445
+ }
446
+ },
447
+ {
448
+ /*
449
+ Sent over addressed, on one line: the two figures only mean anything
450
+ beside each other, and the gap between them is the suppression list
451
+ doing its work.
452
+ */ field: 'sent',
453
+ headerName: 'Sent',
454
+ type: 'number',
455
+ align: 'right',
456
+ headerAlign: 'right',
457
+ width: 110,
458
+ valueGetter: (_value, send)=>{
459
+ var _ref;
460
+ var _send_stats;
461
+ return Number((_ref = (_send_stats = send.stats) == null ? void 0 : _send_stats.sent) != null ? _ref : 0);
462
+ },
463
+ renderCell: ({ row: send })=>{
464
+ var _ref, _ref1;
465
+ var _send_stats, _send_stats1;
466
+ return `${(_ref = (_send_stats = send.stats) == null ? void 0 : _send_stats.sent) != null ? _ref : 0}/${(_ref1 = (_send_stats1 = send.stats) == null ? void 0 : _send_stats1.recipients) != null ? _ref1 : 0}`;
467
+ }
468
+ },
469
+ {
470
+ field: 'opens',
471
+ headerName: 'Opens',
472
+ type: 'number',
473
+ align: 'right',
474
+ headerAlign: 'right',
475
+ width: 100,
476
+ valueGetter: (_value, send)=>{
477
+ var _ref;
478
+ var _send_stats;
479
+ return Number((_ref = (_send_stats = send.stats) == null ? void 0 : _send_stats.opens) != null ? _ref : 0);
480
+ },
481
+ valueFormatter: (value)=>value.toLocaleString()
482
+ },
483
+ {
484
+ field: 'clicks',
485
+ headerName: 'Clicks',
486
+ type: 'number',
487
+ align: 'right',
488
+ headerAlign: 'right',
489
+ width: 100,
490
+ valueGetter: (_value, send)=>{
491
+ var _ref;
492
+ var _send_stats;
493
+ return Number((_ref = (_send_stats = send.stats) == null ? void 0 : _send_stats.clicks) != null ? _ref : 0);
494
+ },
495
+ valueFormatter: (value)=>value.toLocaleString()
496
+ },
497
+ listActionsColumn((send)=>/*#__PURE__*/ _jsx(ListRowActions, {
498
+ label: String(send.subject || send.$id),
499
+ items: sendActions(send)
500
+ }), {
501
+ width: 72
502
+ })
503
+ ];
504
+ const windowState = campaignWindowState(campaign, Date.now());
505
+ const start = campaign.startAtMs ? new Date(campaign.startAtMs).toLocaleDateString() : '';
506
+ const end = campaign.endAtMs ? new Date(campaign.endAtMs).toLocaleDateString() : '';
507
+ /* The card, named so the page chrome above it is a plain list of
508
+ what this surface publishes upward. */ const card = /*#__PURE__*/ _jsxs(CardDisplay, {
509
+ header: 'Campaign',
510
+ subheader: start || end ? `${start || 'Open'} – ${end || 'open-ended'}` : 'No campaign dates',
511
+ help: detailDocsHelp,
512
+ HeaderProps: {
513
+ /*
514
+ NAVIGATION, THEN THE OVERFLOW.
515
+
516
+ Editing and deleting a campaign live here rather than on the
517
+ campaigns table, because a record is edited on its own page in this
518
+ console. `RowActionsMenu` is named for table rows and its rendering
519
+ is not — a kebab and a menu whose items carry `destructive` and
520
+ `disabled` — so reusing it is what keeps this overflow behaving like
521
+ every other one on the surface, including the email page's.
522
+ */ action: /*#__PURE__*/ _jsxs(Stack, {
523
+ direction: "row",
524
+ spacing: 1,
525
+ sx: {
526
+ alignItems: 'center'
527
+ },
528
+ children: [
529
+ /*#__PURE__*/ _jsx(Button, _extends({
530
+ component: AppLink
531
+ }, {
532
+ componentVariant: 'naked',
533
+ nativeButton: false
534
+ }, {
535
+ href: `${basePath}/campaigns`,
536
+ size: "small",
537
+ color: "primary",
538
+ children: 'All campaigns'
539
+ })),
540
+ /*#__PURE__*/ _jsx(RowActionsMenu, {
541
+ label: campaign.name || 'Campaign',
542
+ items: [
543
+ {
544
+ key: 'edit',
545
+ label: 'Edit campaign',
546
+ icon: /*#__PURE__*/ _jsx(MdiIcon, {
547
+ path: mdiPencilOutline.path,
548
+ size: 0.8
549
+ }),
550
+ onClick: ()=>{
551
+ setSaveError(null);
552
+ setEditing(true);
553
+ }
554
+ },
555
+ {
556
+ key: 'delete',
557
+ label: 'Delete campaign',
558
+ icon: /*#__PURE__*/ _jsx(MdiIcon, {
559
+ path: mdiDeleteOutline.path,
560
+ size: 0.8
561
+ }),
562
+ destructive: true,
563
+ disabled: deleting,
564
+ disabledReason: 'This campaign is being deleted',
565
+ onClick: ()=>void handleDelete()
566
+ }
567
+ ]
568
+ })
569
+ ]
570
+ })
571
+ },
572
+ contentGutterX: true,
573
+ contentGutterY: true,
574
+ children: [
575
+ /*#__PURE__*/ _jsxs(Stack, {
576
+ spacing: 2,
577
+ children: [
578
+ /*#__PURE__*/ _jsxs(Stack, {
579
+ direction: "row",
580
+ spacing: 1,
581
+ sx: {
582
+ alignItems: 'center'
583
+ },
584
+ children: [
585
+ /*#__PURE__*/ _jsx(Chip, {
586
+ size: "small",
587
+ label: windowState
588
+ }),
589
+ listIds.length ? listIds.map((id)=>{
590
+ var _names_get;
591
+ return /*#__PURE__*/ _jsx(Chip, {
592
+ size: "small",
593
+ variant: "outlined",
594
+ label: (_names_get = names.get(id)) != null ? _names_get : id
595
+ }, id);
596
+ }) : /*#__PURE__*/ _jsx(Typography, {
597
+ variant: "caption",
598
+ color: "text.secondary",
599
+ children: 'No lists assigned — its emails pick their own audience'
600
+ })
601
+ ]
602
+ }),
603
+ /*#__PURE__*/ _jsx(Divider, {}),
604
+ /*#__PURE__*/ _jsx(CampaignConversionsSection, {
605
+ hostId: hostId,
606
+ sendIds: sendIds,
607
+ truncated: sendsTruncated,
608
+ basePath: basePath
609
+ }),
610
+ /*#__PURE__*/ _jsx(Divider, {}),
611
+ /*#__PURE__*/ _jsx(CampaignRevenueSection, {
612
+ hostId: hostId,
613
+ sendIds: sendIds,
614
+ truncated: sendsTruncated
615
+ }),
616
+ /*#__PURE__*/ _jsx(Divider, {}),
617
+ /*#__PURE__*/ _jsx(CampaignDestinationsSection, {
618
+ hostId: hostId,
619
+ sendIds: sendIds,
620
+ truncated: sendsTruncated
621
+ }),
622
+ /*#__PURE__*/ _jsx(Divider, {}),
623
+ /*#__PURE__*/ _jsx(CampaignMembersSection, {
624
+ hostId: hostId,
625
+ campaignId: campaignId,
626
+ startAtMs: campaign.startAtMs,
627
+ endAtMs: campaign.endAtMs
628
+ }),
629
+ /*#__PURE__*/ _jsx(Divider, {}),
630
+ /*#__PURE__*/ _jsx(Section, {
631
+ title: "The mail, across this campaign",
632
+ children: /*#__PURE__*/ _jsxs(Stack, {
633
+ direction: "row",
634
+ spacing: 3,
635
+ sx: {
636
+ flexWrap: 'wrap'
637
+ },
638
+ children: [
639
+ /*#__PURE__*/ _jsx(Figure, _extends({
640
+ label: "Addressed"
641
+ }, rolled(rollup.addressed))),
642
+ /*#__PURE__*/ _jsx(Figure, _extends({
643
+ label: "Sent"
644
+ }, rolled(rollup.sent))),
645
+ /*#__PURE__*/ _jsx(Figure, _extends({
646
+ label: "Delivered"
647
+ }, rolled(rollup.delivered))),
648
+ /*#__PURE__*/ _jsx(Figure, _extends({
649
+ label: "Opens"
650
+ }, rolled(rollup.opens))),
651
+ /*#__PURE__*/ _jsx(Figure, _extends({
652
+ label: "Clicks"
653
+ }, rolled(rollup.clicks))),
654
+ /*#__PURE__*/ _jsx(Figure, _extends({
655
+ label: "Unsubscribed"
656
+ }, rolled(rollup.unsubscribes)))
657
+ ]
658
+ })
659
+ }),
660
+ /*#__PURE__*/ _jsxs(Stack, {
661
+ spacing: 0.5,
662
+ children: [
663
+ /*#__PURE__*/ _jsx(RateRow, {
664
+ label: "Open rate",
665
+ rate: rollup.openRate
666
+ }),
667
+ /*#__PURE__*/ _jsx(RateRow, {
668
+ label: "Click rate",
669
+ rate: rollup.clickRate
670
+ }),
671
+ /*#__PURE__*/ _jsx(RateRow, {
672
+ label: "Unsubscribe rate",
673
+ rate: rollup.unsubscribeRate
674
+ })
675
+ ]
676
+ }),
677
+ /*#__PURE__*/ _jsx(Divider, {}),
678
+ /*#__PURE__*/ _jsxs(Stack, {
679
+ direction: "row",
680
+ spacing: 1,
681
+ sx: {
682
+ alignItems: 'center',
683
+ justifyContent: 'space-between'
684
+ },
685
+ children: [
686
+ /*#__PURE__*/ _jsx(Typography, {
687
+ variant: "overline",
688
+ color: "text.secondary",
689
+ children: `Emails (${sends.length})`
690
+ }),
691
+ /*#__PURE__*/ _jsx(Button, {
692
+ size: "small",
693
+ variant: composing ? 'text' : 'contained',
694
+ onClick: ()=>setComposing((open)=>!open),
695
+ children: composing ? 'Close composer' : 'Write an email'
696
+ })
697
+ ]
698
+ }),
699
+ sends.length ? /*#__PURE__*/ _jsxs(Stack, {
700
+ spacing: 0.5,
701
+ children: [
702
+ /*#__PURE__*/ _jsx(ListTable, {
703
+ "aria-label": "The campaign's emails",
704
+ rows: sends,
705
+ columns: sendColumns,
706
+ rowHeight: TABLE_ROW_HEIGHT,
707
+ onOpen: (_id, send)=>{
708
+ const href = sendHref(send);
709
+ if (href) router.push(href);
710
+ }
711
+ }),
712
+ sendsTruncated ? /*#__PURE__*/ _jsx(Alert, {
713
+ severity: "info",
714
+ children: `Showing ${CAMPAIGN_EMAIL_CEILING} of this campaign's ` + 'emails. It has sent more — the ones listed are not ' + 'necessarily the most recent, because a send carries no ' + 'date field that every writer stamps, and the figures above ' + 'cover the emails listed.'
715
+ }) : null
716
+ ]
717
+ }) : /*#__PURE__*/ _jsx(Typography, {
718
+ variant: "body2",
719
+ color: "text.secondary",
720
+ children: 'No emails in this campaign yet.'
721
+ }),
722
+ composing ? /*#__PURE__*/ _jsxs(Box, {
723
+ children: [
724
+ /*#__PURE__*/ _jsx(Divider, {
725
+ sx: {
726
+ mb: 2
727
+ }
728
+ }),
729
+ /*#__PURE__*/ _jsx(CampaignComposer, {
730
+ hostId: hostId,
731
+ emailCampaignId: campaignId,
732
+ campaignListIds: listIds,
733
+ campaignTopicId: campaign.topicId,
734
+ onSent: ()=>setComposing(false)
735
+ })
736
+ ]
737
+ }) : null
738
+ ]
739
+ }),
740
+ topicSource,
741
+ /*#__PURE__*/ _jsx(CampaignEditDrawer, {
742
+ open: editing,
743
+ onClose: ()=>setEditing(false),
744
+ campaign: campaign,
745
+ lists: (listDocs != null ? listDocs : []).map((list)=>{
746
+ var _list_name;
747
+ return {
748
+ value: String(list.$id),
749
+ label: String((_list_name = list.name) != null ? _list_name : list.$id)
750
+ };
751
+ }),
752
+ topics: topicOptions,
753
+ busy: saving,
754
+ error: saveError,
755
+ onSubmit: (values)=>void handleSave(values)
756
+ })
757
+ ]
758
+ });
759
+ return /*#__PURE__*/ _jsxs(_Fragment, {
760
+ children: [
761
+ /*#__PURE__*/ _jsx(PageHeaderRecord, {
762
+ title: campaign.name || campaignId
763
+ }),
764
+ card
765
+ ]
766
+ });
767
+ }
768
+ CampaignDetailCard.displayName = 'CampaignDetailCard';
769
+ export default CampaignDetailCard;
770
+
771
+ //# sourceMappingURL=campaign-detail-card.js.map