@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,694 @@
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 } from "react/jsx-runtime";
19
+ import { AppLink } from "@aglyn/shared-ui-jsx";
20
+ import { Figure, MoneyFigure, Section } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
21
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
22
+ import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
23
+ import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
24
+ import { Alert, Button, Stack, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
25
+ import { collection, doc, getCountFromServer, getDoc, query, where } from "firebase/firestore";
26
+ import { useCallback, useEffect, useMemo, useState } from "react";
27
+ import { useFirestore } from "@aglyn/tenant-feature-instance";
28
+ import { campaignRevenueAcrossSends, CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, EMAIL_ATTRIBUTION_WINDOW_DAYS } from "@aglyn/shared-ui-email-campaigns/model";
29
+ /**
30
+ * A CAMPAIGN BEYOND ITS MAIL — what it caused, what it earned, and where it
31
+ * sent people.
32
+ *
33
+ * ## Attribution is the mechanism, and there is no other one
34
+ *
35
+ * A campaign owns no screen, no form and no overlay. Nothing in the stored
36
+ * model connects `emailCampaigns/{id}` to a page a visitor lands on:
37
+ * `HostExperiment` names a screen and never a campaign, `HostOverlay` names
38
+ * neither, and a form document carries no campaign field. So a section
39
+ * claiming a campaign CONTAINS a landing page would be inventing an edge, and
40
+ * the containment would be a claim nothing in the data supports.
41
+ *
42
+ * What the data does support is EVIDENCE, of two kinds, and both are already
43
+ * being written:
44
+ *
45
+ * - **Where it sent people.** `campaigns/{sendId}/reports/links` counts
46
+ * clicks per destination URL, so the union across a campaign's emails is
47
+ * the set of pages that campaign drove traffic to — observed rather than
48
+ * declared, which is also why it stays true when a marketer re-points a
49
+ * link.
50
+ * - **What it caused there.** `campaignAttributions` records one row per
51
+ * identify moment — a form submission, a lead, a contact, a booking —
52
+ * carrying the SEND whose link the visitor followed.
53
+ * - **What it earned there.** `campaigns/{sendId}/reports/revenue` buckets
54
+ * the orders credited to that send by currency, so the merge across a
55
+ * campaign's emails is what the campaign was credited with — per currency,
56
+ * because the buckets have units and a total across them would not.
57
+ *
58
+ * All three sections below therefore hang off the campaign's own send ids,
59
+ * which is the only handle any of those collections offers.
60
+ *
61
+ * ## What neither section can say, and says so instead
62
+ *
63
+ * A conversion credited to a WEB touch carries `utm_` labels and no send id.
64
+ * A campaign container has no `utm_` label of its own — a marketer types
65
+ * those into a URL — so there is no join to make, and no amount of matching
66
+ * on the campaign's NAME would be one: two campaigns may share a name, and a
67
+ * label is a string anybody who can vary a query string can mint. The
68
+ * site-wide Conversions section is where the web channel is read, and the
69
+ * copy below points at it rather than quietly leaving it out.
70
+ *
71
+ * A conversion with no touch at all is credited to nobody and is in neither
72
+ * collection. The same holds for an order: one placed by somebody who never
73
+ * clicked is credited to no campaign. That makes a page showing only credited
74
+ * outcomes a partial account by construction, which is why the figures are
75
+ * labelled "credited to this campaign" everywhere they appear.
76
+ */ /** Firestore's cap on the values in one `in` filter. */ const ID_CHUNK = 30;
77
+ /** The ids as one primitive, so an effect depends on the VALUE not the array. */ const idsKey = (sendIds)=>sendIds.join(',');
78
+ /** `['a','b','c']` in runs of at most {@link ID_CHUNK}. */ function chunked(ids) {
79
+ const out = [];
80
+ for(let index = 0; index < ids.length; index += ID_CHUNK){
81
+ out.push(ids.slice(index, index + ID_CHUNK));
82
+ }
83
+ return out;
84
+ }
85
+ /**
86
+ * WHAT THE CAMPAIGN CAUSED, counted rather than listed.
87
+ *
88
+ * Four aggregation counts over `campaignAttributions`, one per kind, each
89
+ * narrowed to this campaign's own sends. An aggregation is billed per
90
+ * thousand index entries rather than per document, so the honest figure costs
91
+ * about as much as a single row — which is what lets this run on mount
92
+ * instead of behind a button. Listing the records would not: that is the
93
+ * per-record read every figure on this page is arranged to avoid, and the
94
+ * site-wide Conversions section already lists them.
95
+ *
96
+ * The composite index the query needs — `kind` then `campaignId` — is already
97
+ * declared for the conversions list's own campaign-scoped view, and an `in`
98
+ * runs as a disjunction of equalities against that same index.
99
+ *
100
+ * ## The kinds are never added together
101
+ *
102
+ * One form submission by a new person writes a submission, a contact AND a
103
+ * lead: three true statements about one visit. A total would count that visit
104
+ * three times and would look exactly like a bigger number, so there is no
105
+ * total here and the note under the figures says why.
106
+ */ export function CampaignConversionsSection(props) {
107
+ const { hostId, sendIds, truncated, basePath } = props;
108
+ const firestore = useFirestore();
109
+ const key = idsKey(sendIds);
110
+ const [counts, setCounts] = useState(null);
111
+ const [failed, setFailed] = useState(false);
112
+ useEffect(()=>{
113
+ const ids = key ? key.split(',') : [];
114
+ setCounts(null);
115
+ setFailed(false);
116
+ if (!ids.length) return;
117
+ let active = true;
118
+ const attributions = collection(firestore, 'hosts', hostId, 'campaignAttributions');
119
+ /*
120
+ * Summed ACROSS chunks, which is safe because a record carries exactly
121
+ * one `campaignId`: the chunks partition the send ids, so no record can
122
+ * be counted by two of them.
123
+ */ void Promise.all(CAMPAIGN_CONVERSION_KINDS.map(async (kind)=>{
124
+ const perChunk = await Promise.all(chunked(ids).map((chunk)=>getCountFromServer(query(attributions, where('kind', '==', kind), where('campaignId', 'in', chunk))).then((snapshot)=>{
125
+ var _snapshot_data_count;
126
+ return Number((_snapshot_data_count = snapshot.data().count) != null ? _snapshot_data_count : 0);
127
+ })));
128
+ return [
129
+ kind,
130
+ perChunk.reduce((sum, value)=>sum + value, 0)
131
+ ];
132
+ })).then((entries)=>{
133
+ if (active) setCounts(Object.fromEntries(entries));
134
+ }).catch(()=>{
135
+ /*
136
+ * WITHHELD, never zeroed. A count that failed and a campaign that
137
+ * caused nothing are opposite facts, and rendering the first as the
138
+ * second is the most flattering wrong answer available here.
139
+ */ if (active) setFailed(true);
140
+ });
141
+ return ()=>{
142
+ active = false;
143
+ };
144
+ }, [
145
+ firestore,
146
+ hostId,
147
+ key
148
+ ]);
149
+ return /*#__PURE__*/ _jsxs(Section, {
150
+ title: "What it caused",
151
+ children: [
152
+ !key ? /*#__PURE__*/ _jsx(Typography, {
153
+ variant: "body2",
154
+ color: "text.secondary",
155
+ children: 'No emails have gone out under this campaign, so nothing can be ' + 'credited to it yet.'
156
+ }) : failed ? /*#__PURE__*/ _jsx(Alert, {
157
+ severity: "warning",
158
+ children: 'The conversions credited to this campaign could not be counted.'
159
+ }) : !counts ? /*#__PURE__*/ _jsx(Typography, {
160
+ variant: "body2",
161
+ color: "text.secondary",
162
+ children: 'Counting what this campaign is credited with…'
163
+ }) : /*#__PURE__*/ _jsxs(Stack, {
164
+ spacing: 1,
165
+ children: [
166
+ /*#__PURE__*/ _jsx(Stack, {
167
+ direction: "row",
168
+ spacing: 3,
169
+ sx: {
170
+ flexWrap: 'wrap'
171
+ },
172
+ children: CAMPAIGN_CONVERSION_KINDS.map((kind)=>{
173
+ var _counts_kind;
174
+ return /*#__PURE__*/ _jsx(Figure, {
175
+ label: CAMPAIGN_CONVERSION_KIND_COPY[kind].label,
176
+ value: (_counts_kind = counts[kind]) != null ? _counts_kind : 0,
177
+ note: CAMPAIGN_CONVERSION_KIND_COPY[kind].note
178
+ }, kind);
179
+ })
180
+ }),
181
+ /*#__PURE__*/ _jsx(Typography, {
182
+ variant: "caption",
183
+ color: "text.secondary",
184
+ children: 'These count different things about the same visits and are ' + 'deliberately not added together — one person filling in one ' + 'form appears as a submission, a contact and a lead.'
185
+ }),
186
+ /*#__PURE__*/ _jsx(Typography, {
187
+ variant: "caption",
188
+ color: "text.secondary",
189
+ children: 'Credited to this campaign’s own emails. Somebody who arrived ' + 'from a link tagged with utm_ parameters is credited to that ' + 'label instead, and somebody who arrived directly is credited ' + 'to nobody — both are counted on the site’s conversions list.'
190
+ }),
191
+ truncated ? /*#__PURE__*/ _jsx(Typography, {
192
+ variant: "caption",
193
+ color: "text.secondary",
194
+ children: 'Over the emails listed below. This campaign has sent more ' + 'than the page holds, and their conversions are not in these ' + 'figures.'
195
+ }) : null
196
+ ]
197
+ }),
198
+ /*#__PURE__*/ _jsx(Stack, {
199
+ direction: "row",
200
+ children: /*#__PURE__*/ _jsx(Button, _extends({
201
+ component: AppLink
202
+ }, {
203
+ componentVariant: 'naked',
204
+ nativeButton: false
205
+ }, {
206
+ href: `${basePath}/conversions`,
207
+ size: "small",
208
+ color: "primary",
209
+ children: 'All conversions'
210
+ }))
211
+ })
212
+ ]
213
+ });
214
+ }
215
+ CampaignConversionsSection.displayName = 'CampaignConversionsSection';
216
+ /**
217
+ * The rule the amounts were credited under, in the sentence a reader can
218
+ * check them against.
219
+ *
220
+ * Plural where the campaign is plural. A container can hold emails credited
221
+ * under different models or windows, and printing one of them as though it
222
+ * governed all of them would be the same lie as printing one currency symbol
223
+ * over two currencies.
224
+ */ function creditedUnder(report) {
225
+ const model = report.models.length === 1 ? report.models[0] : '';
226
+ const rule = model === 'last-click' ? 'to the last campaign whose link the buyer clicked' : model ? `under the ${model} model` : 'under more than one attribution model';
227
+ const window = report.windowDays.length === 1 ? `within ${report.windowDays[0]} days of that click` : `within ${report.windowDays.join(' or ')} days of that click, ` + 'depending on the email';
228
+ return `Credited ${rule}, ${window}.`;
229
+ }
230
+ /**
231
+ * WHAT THE CAMPAIGN EARNED — per currency, and never across them.
232
+ *
233
+ * The same question a campaign of one email answers, answered the same way by
234
+ * a campaign of several: how many messages a container happens to hold is not
235
+ * a fact about whether its revenue is knowable. So this is the same section
236
+ * the single-send report carries — `Revenue`, in the same place in the page's
237
+ * order, drawn by the same money components — over a merge rather than over
238
+ * one document.
239
+ *
240
+ * ## Why it takes a merge, and what the merge may not do
241
+ *
242
+ * `reports/revenue` is written per SEND. There is no container-level rollup
243
+ * and there should not be one: it would have to be kept true against every
244
+ * order and every refund of every email in the campaign, duplicating figures
245
+ * that are already correct one level down. So the campaign's figure is one
246
+ * document per email and an addition — and the addition is the danger.
247
+ *
248
+ * **Currency is to money what kind is to a conversion.** The section above
249
+ * refuses to total form submissions with leads because one visit writes both;
250
+ * this refuses to total USD with EUR because the sum has no unit. Both
251
+ * refusals are structural rather than remembered:
252
+ * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so
253
+ * amounts in different currencies never reach the same addition, and the
254
+ * result carries no combined field for a screen to print.
255
+ *
256
+ * ## One currency reads as one figure; several read as several
257
+ *
258
+ * The common case is one currency, and it is drawn exactly as a single
259
+ * email's report draws it — net, gross, refunded, orders — with no currency
260
+ * heading, because a label saying `USD` over a page that only ever shows
261
+ * dollars is noise. The moment a second currency appears every block gets its
262
+ * code as a heading and its own email count, and a caveat says in words what
263
+ * the headings say in layout: these do not add up, and nothing here converts
264
+ * between them. The rare case pays for itself; the common one does not pay
265
+ * for the rare one.
266
+ *
267
+ * ## Behind a button, and the button says the price
268
+ *
269
+ * One document per email — the per-record read the conversions section above
270
+ * is shaped to avoid. That section can run on mount because an aggregation is
271
+ * billed per thousand index entries, so its four honest figures cost about as
272
+ * much as a single row however large the campaign is. This cannot: fifty
273
+ * emails is fifty document reads, every time the page opens, for a section
274
+ * most readers of a campaign are not opening the page for. So it is asked
275
+ * for, at the same control and in the same words as the destinations section
276
+ * below, which buys its rows the same way.
277
+ *
278
+ * ## Nothing, and nothing readable, are different sentences
279
+ *
280
+ * The rollup is created by the writer on the first order it credits, so an
281
+ * email with no record has never earned. A campaign where NO email has one
282
+ * has not been shown to have earned nothing — it is also every campaign sent
283
+ * before the join existed and every campaign on a site with no store, which
284
+ * is the distinction the single-send report draws in the same two sentences.
285
+ * A read that is REFUSED shows neither: the figures are withheld behind a
286
+ * warning, and any figures already on screen are dropped rather than left
287
+ * standing beside it, because a stale total under a fresh error is the one
288
+ * arrangement that looks authoritative and is not.
289
+ */ export function CampaignRevenueSection(props) {
290
+ const { hostId, sendIds, truncated } = props;
291
+ const firestore = useFirestore();
292
+ const key = idsKey(sendIds);
293
+ const [busy, setBusy] = useState(false);
294
+ const [error, setError] = useState(null);
295
+ const [report, setReport] = useState(null);
296
+ // A merge over one set of emails describes that set. Leaving it on screen
297
+ // after the campaign's emails change labels it as something it is not.
298
+ useEffect(()=>{
299
+ setReport(null);
300
+ setError(null);
301
+ }, [
302
+ key
303
+ ]);
304
+ const load = useCallback(async ()=>{
305
+ if (busy) return;
306
+ const ids = key ? key.split(',') : [];
307
+ if (!ids.length) return;
308
+ setBusy(true);
309
+ setError(null);
310
+ try {
311
+ const snapshots = await Promise.all(ids.map((id)=>getDoc(doc(firestore, 'hosts', hostId, 'campaigns', id, 'reports', 'revenue'))));
312
+ /*
313
+ * `data()` is `undefined` on a document that does not exist, and it is
314
+ * handed on as `undefined` rather than as an empty rollup: the merge
315
+ * counts the records that EXIST to tell a campaign that earned nothing
316
+ * from one whose revenue was never recorded at all.
317
+ */ setReport(campaignRevenueAcrossSends(snapshots.map((snapshot)=>snapshot.data())));
318
+ } catch (caught) {
319
+ console.error(caught);
320
+ /*
321
+ * WITHHELD, never zeroed and never left stale. A refused read and a
322
+ * campaign that earned nothing are opposite facts, and the second is
323
+ * the more flattering of the two.
324
+ */ setReport(null);
325
+ setError('The revenue credited to this campaign could not be read');
326
+ } finally{
327
+ setBusy(false);
328
+ }
329
+ }, [
330
+ busy,
331
+ firestore,
332
+ hostId,
333
+ key
334
+ ]);
335
+ const count = key ? key.split(',').length : 0;
336
+ return /*#__PURE__*/ _jsx(Section, {
337
+ title: "Revenue",
338
+ children: !count ? /*#__PURE__*/ _jsx(Typography, {
339
+ variant: "body2",
340
+ color: "text.secondary",
341
+ children: 'No emails have gone out under this campaign, so nothing can be ' + 'credited to it yet.'
342
+ }) : /*#__PURE__*/ _jsxs(Stack, {
343
+ spacing: 1,
344
+ children: [
345
+ /*#__PURE__*/ _jsx(Typography, {
346
+ variant: "body2",
347
+ color: "text.secondary",
348
+ children: 'What this campaign’s emails were credited with, read from each ' + 'email’s own revenue record and merged by currency.'
349
+ }),
350
+ /*#__PURE__*/ _jsxs(Stack, {
351
+ direction: "row",
352
+ spacing: 1,
353
+ sx: {
354
+ alignItems: 'center'
355
+ },
356
+ children: [
357
+ /*#__PURE__*/ _jsx(Button, {
358
+ size: "small",
359
+ onClick: ()=>void load(),
360
+ disabled: busy,
361
+ children: busy ? 'Reading…' : report ? 'Read again' : 'Show revenue'
362
+ }),
363
+ /*#__PURE__*/ _jsx(Typography, {
364
+ variant: "caption",
365
+ color: "text.secondary",
366
+ children: `Reads one record per email — ${count.toLocaleString()} of them.`
367
+ })
368
+ ]
369
+ }),
370
+ error ? /*#__PURE__*/ _jsx(Alert, {
371
+ severity: "warning",
372
+ children: error
373
+ }) : null,
374
+ report ? report.currencies.length ? /*#__PURE__*/ _jsxs(Stack, {
375
+ spacing: 3,
376
+ children: [
377
+ report.caveats.map((caveat)=>/*#__PURE__*/ _jsx(Alert, {
378
+ severity: "info",
379
+ children: caveat.message
380
+ }, caveat.id)),
381
+ report.currencies.map((entry)=>/*#__PURE__*/ _jsxs(Stack, {
382
+ spacing: 1,
383
+ children: [
384
+ report.multiCurrency ? /*#__PURE__*/ _jsx(Typography, {
385
+ variant: "subtitle2",
386
+ children: entry.currency.toUpperCase()
387
+ }) : null,
388
+ /*#__PURE__*/ _jsxs(Stack, {
389
+ direction: "row",
390
+ spacing: 4,
391
+ useFlexGap: true,
392
+ sx: {
393
+ flexWrap: 'wrap'
394
+ },
395
+ children: [
396
+ /*#__PURE__*/ _jsx(MoneyFigure, {
397
+ label: "Net revenue",
398
+ cents: entry.netCents,
399
+ currency: entry.currency,
400
+ note: "after refunds"
401
+ }),
402
+ /*#__PURE__*/ _jsx(MoneyFigure, {
403
+ label: "Gross revenue",
404
+ cents: entry.grossCents,
405
+ currency: entry.currency,
406
+ note: "as charged"
407
+ }),
408
+ /*#__PURE__*/ _jsx(MoneyFigure, {
409
+ label: "Refunded",
410
+ cents: entry.refundedCents,
411
+ currency: entry.currency,
412
+ note: "handed back"
413
+ }),
414
+ /*#__PURE__*/ _jsx(Figure, {
415
+ label: "Orders",
416
+ value: entry.orders,
417
+ note: "credited to this campaign"
418
+ }),
419
+ /*#__PURE__*/ _jsx(Figure, {
420
+ label: "Fully refunded",
421
+ value: entry.refundedOrders,
422
+ note: "of those orders"
423
+ }),
424
+ report.multiCurrency ? /*#__PURE__*/ _jsx(Figure, {
425
+ label: "Emails",
426
+ value: entry.emails,
427
+ note: `earned in ${entry.currency.toUpperCase()}`
428
+ }) : null
429
+ ]
430
+ })
431
+ ]
432
+ }, entry.currency)),
433
+ /*#__PURE__*/ _jsx(Typography, {
434
+ variant: "caption",
435
+ color: "text.secondary",
436
+ children: `${report.attributedOrders.toLocaleString()} ` + `${report.attributedOrders === 1 ? 'order' : 'orders'} ` + `credited, across ${report.recorded.toLocaleString()} of ` + `this campaign’s ${report.read.toLocaleString()} ` + `${report.read === 1 ? 'email' : 'emails'}. The rest have ` + 'never been credited with a sale.'
437
+ }),
438
+ /*#__PURE__*/ _jsx(Typography, {
439
+ variant: "caption",
440
+ color: "text.secondary",
441
+ children: `${creditedUnder(report)} Clicks only — an open is not ` + 'treated as evidence that anybody read the email. An ' + 'order placed by somebody who never clicked, or who ' + 'checked out without giving an address, is credited to no ' + 'campaign, so this is a floor rather than every sale this ' + 'campaign influenced.'
442
+ })
443
+ ]
444
+ }) : /*#__PURE__*/ _jsx(Typography, {
445
+ variant: "body2",
446
+ color: "text.secondary",
447
+ children: report.recorded ? 'No orders have been credited to this campaign.' : 'No revenue has been attributed to any of this ' + 'campaign’s emails. Orders are credited to the last ' + 'campaign whose link the buyer clicked, within ' + `${EMAIL_ATTRIBUTION_WINDOW_DAYS} days — emails sent ` + 'before that was recorded, or a site with no store, will ' + 'never show a figure here.'
448
+ }) : null,
449
+ report && truncated ? /*#__PURE__*/ _jsx(Typography, {
450
+ variant: "caption",
451
+ color: "text.secondary",
452
+ children: `Across the ${report.read.toLocaleString()} emails this page ` + 'holds. The campaign has sent more.'
453
+ }) : null
454
+ ]
455
+ })
456
+ });
457
+ }
458
+ CampaignRevenueSection.displayName = 'CampaignRevenueSection';
459
+ /**
460
+ * WHERE THE CAMPAIGN SENT PEOPLE — the pages its mail pointed at.
461
+ *
462
+ * The union of `campaigns/{sendId}/reports/links` across the campaign's
463
+ * emails, which is the closest the stored data comes to "the surfaces this
464
+ * campaign runs across". It is observed rather than declared: a page is on
465
+ * this list because a link in this campaign's mail was followed to it.
466
+ *
467
+ * ## Behind a button, and the button says the price
468
+ *
469
+ * One document per email, up to the ceiling the page holds — which is the
470
+ * per-record read the figures above are shaped to avoid, so it is asked for
471
+ * rather than paid on mount. The same bargain the conversions list makes for
472
+ * its landing-page grouping.
473
+ *
474
+ * ## What a row is, and what it is not
475
+ *
476
+ * A destination is the link's address with its query string dropped, because
477
+ * a campaign body is merged per recipient and a personalised query would mint
478
+ * one row per person — and could carry that person's address into an
479
+ * aggregate the whole team reads. The cost is that two links to one page
480
+ * distinguished only by their tracking parameters are one row, which the note
481
+ * under the table states rather than leaving the reader to discover.
482
+ *
483
+ * Clicks are EVENTS. One reader clicking twice counts twice, so a destination
484
+ * total is not a count of people.
485
+ */ export function CampaignDestinationsSection(props) {
486
+ const { hostId, sendIds, truncated } = props;
487
+ const firestore = useFirestore();
488
+ const key = idsKey(sendIds);
489
+ const [busy, setBusy] = useState(false);
490
+ const [error, setError] = useState(null);
491
+ const [result, setResult] = useState(null);
492
+ /*
493
+ * The page is a SLICE of a window this section already holds. One campaign
494
+ * can reach fifty destinations per email over fifty emails, so the row
495
+ * count is bounded by the rollups rather than small — and a table that
496
+ * grows with the campaign gets a footer like every other one.
497
+ */ const [page, setPage] = useState(0);
498
+ const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
499
+ const visibleRows = useMemo(()=>{
500
+ var _ref;
501
+ return ((_ref = result == null ? void 0 : result.rows) != null ? _ref : []).slice(page * pageSize, page * pageSize + pageSize);
502
+ }, [
503
+ result,
504
+ page,
505
+ pageSize
506
+ ]);
507
+ // A grouping read for one set of emails describes that set. Leaving it on
508
+ // screen after the campaign's emails change labels it as something it is not.
509
+ useEffect(()=>{
510
+ setResult(null);
511
+ setError(null);
512
+ setPage(0);
513
+ }, [
514
+ key
515
+ ]);
516
+ const load = useCallback(async ()=>{
517
+ if (busy) return;
518
+ const ids = key ? key.split(',') : [];
519
+ if (!ids.length) return;
520
+ setBusy(true);
521
+ setError(null);
522
+ try {
523
+ const snapshots = await Promise.all(ids.map((id)=>getDoc(doc(firestore, 'hosts', hostId, 'campaigns', id, 'reports', 'links'))));
524
+ const byUrl = new Map();
525
+ let overflowClicks = 0;
526
+ let unattributedClicks = 0;
527
+ snapshots.forEach((snapshot)=>{
528
+ var _rollup_overflowClicks, _rollup_unattributedClicks, _rollup_links;
529
+ const rollup = snapshot.data();
530
+ if (!rollup) return;
531
+ overflowClicks += Number((_rollup_overflowClicks = rollup.overflowClicks) != null ? _rollup_overflowClicks : 0);
532
+ unattributedClicks += Number((_rollup_unattributedClicks = rollup.unattributedClicks) != null ? _rollup_unattributedClicks : 0);
533
+ Object.values((_rollup_links = rollup.links) != null ? _rollup_links : {}).forEach((entry)=>{
534
+ var _ref, _byUrl_get, _ref1;
535
+ const url = String((_ref = entry == null ? void 0 : entry.url) != null ? _ref : '').trim();
536
+ if (!url) return;
537
+ const row = (_byUrl_get = byUrl.get(url)) != null ? _byUrl_get : {
538
+ url,
539
+ clicks: 0,
540
+ emails: 0
541
+ };
542
+ row.clicks += Number((_ref1 = entry == null ? void 0 : entry.clicks) != null ? _ref1 : 0);
543
+ // One rollup holds at most one entry per destination, so this
544
+ // counts EMAILS that linked there rather than link occurrences.
545
+ row.emails += 1;
546
+ byUrl.set(url, row);
547
+ });
548
+ });
549
+ setPage(0);
550
+ setResult({
551
+ rows: [
552
+ ...byUrl.values()
553
+ ].sort((a, b)=>b.clicks - a.clicks || a.url.localeCompare(b.url)),
554
+ read: ids.length,
555
+ overflowClicks,
556
+ unattributedClicks
557
+ });
558
+ } catch (caught) {
559
+ console.error(caught);
560
+ setError('The destinations could not be read');
561
+ } finally{
562
+ setBusy(false);
563
+ }
564
+ }, [
565
+ busy,
566
+ firestore,
567
+ hostId,
568
+ key
569
+ ]);
570
+ const count = key ? key.split(',').length : 0;
571
+ return /*#__PURE__*/ _jsx(Section, {
572
+ title: "Where it sent people",
573
+ children: !count ? /*#__PURE__*/ _jsx(Typography, {
574
+ variant: "body2",
575
+ color: "text.secondary",
576
+ children: 'No emails have gone out under this campaign, so it has sent ' + 'nobody anywhere yet.'
577
+ }) : /*#__PURE__*/ _jsxs(Stack, {
578
+ spacing: 1,
579
+ children: [
580
+ /*#__PURE__*/ _jsx(Typography, {
581
+ variant: "body2",
582
+ color: "text.secondary",
583
+ children: 'The pages this campaign’s links were followed to. A campaign ' + 'does not own a page — this is where its mail actually sent ' + 'people, read from each email’s own click report.'
584
+ }),
585
+ /*#__PURE__*/ _jsxs(Stack, {
586
+ direction: "row",
587
+ spacing: 1,
588
+ sx: {
589
+ alignItems: 'center'
590
+ },
591
+ children: [
592
+ /*#__PURE__*/ _jsx(Button, {
593
+ size: "small",
594
+ onClick: ()=>void load(),
595
+ disabled: busy,
596
+ children: busy ? 'Reading…' : result ? 'Read again' : 'Show destinations'
597
+ }),
598
+ /*#__PURE__*/ _jsx(Typography, {
599
+ variant: "caption",
600
+ color: "text.secondary",
601
+ children: `Reads one record per email — ${count.toLocaleString()} of them.`
602
+ })
603
+ ]
604
+ }),
605
+ error ? /*#__PURE__*/ _jsx(Alert, {
606
+ severity: "warning",
607
+ children: error
608
+ }) : null,
609
+ result ? result.rows.length ? /*#__PURE__*/ _jsxs(Stack, {
610
+ spacing: 0.5,
611
+ children: [
612
+ /*#__PURE__*/ _jsxs(ScrollTable, {
613
+ size: "small",
614
+ children: [
615
+ /*#__PURE__*/ _jsx(TableHead, {
616
+ children: /*#__PURE__*/ _jsxs(TableRow, {
617
+ children: [
618
+ /*#__PURE__*/ _jsx(TableCell, {
619
+ children: 'Destination'
620
+ }),
621
+ /*#__PURE__*/ _jsx(TableCell, {
622
+ align: "right",
623
+ children: 'Emails'
624
+ }),
625
+ /*#__PURE__*/ _jsx(TableCell, {
626
+ align: "right",
627
+ children: 'Clicks'
628
+ })
629
+ ]
630
+ })
631
+ }),
632
+ /*#__PURE__*/ _jsx(TableBody, {
633
+ children: visibleRows.map((row)=>/*#__PURE__*/ _jsxs(TableRow, {
634
+ children: [
635
+ /*#__PURE__*/ _jsx(TableCell, {
636
+ sx: {
637
+ wordBreak: 'break-all'
638
+ },
639
+ children: row.url
640
+ }),
641
+ /*#__PURE__*/ _jsx(TableCell, {
642
+ align: "right",
643
+ children: row.emails.toLocaleString()
644
+ }),
645
+ /*#__PURE__*/ _jsx(TableCell, {
646
+ align: "right",
647
+ children: row.clicks.toLocaleString()
648
+ })
649
+ ]
650
+ }, row.url))
651
+ })
652
+ ]
653
+ }),
654
+ /*#__PURE__*/ _jsx(ListPagination, {
655
+ page: page,
656
+ pageSize: pageSize,
657
+ rowCount: visibleRows.length,
658
+ count: result.rows.length,
659
+ onPageChange: setPage,
660
+ onPageSizeChange: setPageSize
661
+ }),
662
+ /*#__PURE__*/ _jsx(Typography, {
663
+ variant: "caption",
664
+ color: "text.secondary",
665
+ children: 'A destination is the address without its query string, ' + 'so two links to one page that differ only in their ' + 'tracking parameters are one row. Clicks are events — ' + 'one reader clicking twice counts twice.'
666
+ }),
667
+ result.overflowClicks ? /*#__PURE__*/ _jsx(Typography, {
668
+ variant: "caption",
669
+ color: "text.secondary",
670
+ children: `${result.overflowClicks.toLocaleString()} further ` + 'clicks landed on destinations past the per-email cap ' + 'and are not in the table.'
671
+ }) : null,
672
+ result.unattributedClicks ? /*#__PURE__*/ _jsx(Typography, {
673
+ variant: "caption",
674
+ color: "text.secondary",
675
+ children: `${result.unattributedClicks.toLocaleString()} clicks ` + 'arrived naming no destination, so they belong to no ' + 'row.'
676
+ }) : null,
677
+ truncated ? /*#__PURE__*/ _jsx(Typography, {
678
+ variant: "caption",
679
+ color: "text.secondary",
680
+ children: `Across the ${result.read.toLocaleString()} emails this ` + 'page holds. The campaign has sent more.'
681
+ }) : null
682
+ ]
683
+ }) : /*#__PURE__*/ _jsx(Typography, {
684
+ variant: "body2",
685
+ color: "text.secondary",
686
+ children: 'None of this campaign’s emails has recorded a followed ' + 'link yet.'
687
+ }) : null
688
+ ]
689
+ })
690
+ });
691
+ }
692
+ CampaignDestinationsSection.displayName = 'CampaignDestinationsSection';
693
+
694
+ //# sourceMappingURL=campaign-reach-sections.js.map