@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,642 @@
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 { pluginDocsHelp } from "@aglyn/aglyn";
20
+ import { mdiEyeOutline, mdiMapMarkerOutline } from "@aglyn/shared-data-mdi";
21
+ import { AppLink, CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
22
+ import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
24
+ import { Figure, Section } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
25
+ import { Alert, Button, Chip, Divider, Stack, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
26
+ import { collection, documentId, getCountFromServer, getDocs, query, where } from "firebase/firestore";
27
+ import { useCallback, useEffect, useMemo, useState } from "react";
28
+ import { useRouter } from "next/navigation";
29
+ import { useFirestore, useOrgDataScope, usePagedCollection } from "@aglyn/tenant-feature-instance";
30
+ /*
31
+ * The MODULE, not the barrel, for the pure query helpers — a spec that mocks
32
+ * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks
33
+ * would otherwise lose them, and neither is a hook.
34
+ */ import { ceilingedWindow, collectionCeiling, collectionPage } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
35
+ import { CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, campaignConversionsCoverage, campaignTouchLabel } from "@aglyn/shared-ui-email-campaigns/model/campaign-conversions";
36
+ /**
37
+ * WHAT THE CAMPAIGNS CAUSED, from the conversions' end.
38
+ *
39
+ * A campaign's own report says what that campaign caused. This is the list
40
+ * the report cannot be: every credited conversion on the site, the ones
41
+ * credited to a tagged web link rather than to a campaign document, and — the
42
+ * half a report of successes can never show — how many were credited to
43
+ * nothing at all.
44
+ *
45
+ * ## ONE KIND AT A TIME, and that is the whole design
46
+ *
47
+ * A form submission by a new person writes a submission record, a contact and
48
+ * a lead. Three true statements about one visit, and a screen that showed
49
+ * them together would invite the reader to add them and treble every figure.
50
+ * So the kind is a MODE rather than a column: the query carries
51
+ * `where('kind','==', …)`, only one kind's rows are ever on screen, and there
52
+ * is no view in which two kinds could be totalled because there is no view in
53
+ * which two kinds are both present.
54
+ *
55
+ * ## THE TWO CHANNELS ARE TWO LISTS, and never one
56
+ *
57
+ * An email touch names a campaign document — a real entity with a report to
58
+ * open. A web touch names `utm_` text a marketer typed into a URL: no
59
+ * document, no id, no bound on how many distinct values exist. Merging them
60
+ * would put a linkable record and an unlinkable label in one column and imply
61
+ * they are the same kind of thing, and grouping the web side into a rollup
62
+ * would build a map anybody who can vary a query string can grow. So there
63
+ * are two tables, the web one is read as records, and neither is summed into
64
+ * the other.
65
+ *
66
+ * ## The read cost
67
+ *
68
+ * Two paged listeners bounded by the page size, and two aggregation counts.
69
+ * Nothing scans a collection, no row triggers a read of its own, and the
70
+ * landing-page join — the only expensive thing here — is behind a button and
71
+ * says what it will cost before it runs.
72
+ */ /**
73
+ * How many attribution records the landing-page join reads.
74
+ *
75
+ * A CEILING rather than a page size: grouping is over a window and a window
76
+ * sliced ten at a time cannot be grouped at all. Kept low because this is the
77
+ * one read on the surface that is measured in hundreds — see the button that
78
+ * asks for it.
79
+ */ const LANDING_PAGE_CEILING = 100;
80
+ /** Firestore's cap on the values in one `in` filter. */ const ID_CHUNK = 30;
81
+ const conversionsDocsHelp = pluginDocsHelp('emailCampaigns', {
82
+ anchor: '#the-campaign-report',
83
+ excerpt: 'A conversion is credited to the last campaign whose link the visitor ' + 'followed. The four kinds count different things about the same visits ' + 'and are never added together.'
84
+ });
85
+ const day = (ms)=>{
86
+ const value = Number(ms != null ? ms : 0);
87
+ return Number.isFinite(value) && value > 0 ? new Date(value).toLocaleString() : '—';
88
+ };
89
+ export function CampaignConversionsCard(props) {
90
+ const { hostId, basePath, campaignId } = props;
91
+ const firestore = useFirestore();
92
+ const router = useRouter();
93
+ const { scope: dataScope } = useOrgDataScope({
94
+ hostId
95
+ });
96
+ const [kind, setKind] = useState('form');
97
+ const attributions = useCallback(()=>collection(firestore, 'hosts', hostId, 'campaignAttributions'), [
98
+ firestore,
99
+ hostId
100
+ ]);
101
+ /*==========================================
102
+ * THE TWO LISTS.
103
+ *
104
+ * Both walk the collection in DOCUMENT-NAME order through `collectionPage`,
105
+ * which is the console's one ordering decision: `orderBy` on a field
106
+ * matches only documents that HAVE it, so ordering on a date would hide
107
+ * rows rather than merely mis-order them, and an unordered `limit()` is a
108
+ * pseudo-random sample whose gaps leave nothing to notice. The walk is
109
+ * total — every row is reachable by paging — and it is deliberately NOT
110
+ * newest-first, which the note under each table says out loud.
111
+ *
112
+ * `campaignId` narrows the email list when the reader arrived from one
113
+ * campaign. It is never applied to the web list: a `utm_` label is not a
114
+ * campaign id and a query pretending otherwise would return nothing while
115
+ * reading as "this campaign caused no web conversions".
116
+ *=========================================*/ const emailList = usePagedCollection((pageLimit)=>collectionPage(campaignId ? query(attributions(), where('kind', '==', kind), where('campaignId', '==', campaignId)) : query(attributions(), where('kind', '==', kind), where('channel', '==', 'email')), pageLimit), [
117
+ firestore,
118
+ hostId,
119
+ kind,
120
+ campaignId
121
+ ], {
122
+ idField: '$id'
123
+ });
124
+ const webList = usePagedCollection((pageLimit)=>campaignId ? null : collectionPage(query(attributions(), where('kind', '==', kind), where('channel', '==', 'web')), pageLimit), [
125
+ firestore,
126
+ hostId,
127
+ kind,
128
+ campaignId
129
+ ], {
130
+ idField: '$id'
131
+ });
132
+ /*==========================================
133
+ * THE UNCREDITED HALF.
134
+ *
135
+ * Two aggregation counts, and they are on the page by DEFAULT rather than
136
+ * behind a button, because the tables above them are a list of the
137
+ * successes: showing only those renders "we credited nine of these" as
138
+ * "nine of these happened", which is the conclusion this surface exists to
139
+ * stop a reader drawing. An aggregation is billed per thousand index
140
+ * entries rather than per document, so the honest figure costs about as
141
+ * much as one row.
142
+ *
143
+ * Withheld entirely when either count fails. Defaulting the total to the
144
+ * attributed figure would render every conversion as attributed, which is
145
+ * the most flattering wrong answer available here.
146
+ *=========================================*/ /*
147
+ * A PATH STRING and a BOOLEAN, not an object.
148
+ *
149
+ * Both are effect dependencies, and an object rebuilt on every render
150
+ * reopens the effect on every render — which here means paying for two
151
+ * aggregation counts per render rather than per kind. Primitives compare by
152
+ * value, so the reads happen when the question changes and not when React
153
+ * re-runs the component.
154
+ */ const totalCollectionPath = useMemo(()=>{
155
+ switch(kind){
156
+ case 'form':
157
+ return `hosts/${hostId}/formSubmissions`;
158
+ case 'lead':
159
+ return `hosts/${hostId}/leads`;
160
+ case 'booking':
161
+ return `hosts/${hostId}/bookings`;
162
+ case 'contact':
163
+ /*
164
+ * ORG-SCOPED, and the coverage model is told so below. A contact is
165
+ * shared across every site in the org while an attribution belongs to
166
+ * one host, so this total counts contacts created on another site
167
+ * that could never have been credited here.
168
+ */ return dataScope ? `${dataScope[0]}/${dataScope[1]}/contacts` : null;
169
+ default:
170
+ return null;
171
+ }
172
+ }, [
173
+ kind,
174
+ hostId,
175
+ dataScope
176
+ ]);
177
+ /** The kind's records live outside this host, so the total over-counts. */ const totalCrossesHosts = kind === 'contact';
178
+ const [attributedCount, setAttributedCount] = useState(null);
179
+ const [totalCount, setTotalCount] = useState(null);
180
+ useEffect(()=>{
181
+ // A campaign-scoped view has no uncredited figure to compute — see the
182
+ // prop's docblock — so it does not pay for one either.
183
+ if (campaignId || !totalCollectionPath) {
184
+ setAttributedCount(null);
185
+ setTotalCount(null);
186
+ return;
187
+ }
188
+ let active = true;
189
+ setAttributedCount(null);
190
+ setTotalCount(null);
191
+ void getCountFromServer(query(attributions(), where('kind', '==', kind))).then((snapshot)=>{
192
+ if (active) setAttributedCount(snapshot.data().count);
193
+ }).catch(()=>{
194
+ // Left null, which withholds the split. See the block comment.
195
+ });
196
+ const segments = totalCollectionPath.split('/');
197
+ void getCountFromServer(collection(firestore, segments[0], ...segments.slice(1))).then((snapshot)=>{
198
+ if (active) setTotalCount(snapshot.data().count);
199
+ }).catch(()=>{
200
+ // Left null, which withholds the split. See the block comment.
201
+ });
202
+ return ()=>{
203
+ active = false;
204
+ };
205
+ }, [
206
+ attributions,
207
+ firestore,
208
+ kind,
209
+ campaignId,
210
+ totalCollectionPath
211
+ ]);
212
+ const coverage = campaignConversionsCoverage({
213
+ kind,
214
+ attributed: attributedCount,
215
+ total: totalCount,
216
+ crossHostTotal: totalCrossesHosts
217
+ });
218
+ /*==========================================
219
+ * LANDING PAGES — a join, and an EXPENSIVE one, so it is asked for.
220
+ *
221
+ * The attribution record carries `refId`; a form submission carries the
222
+ * `path` it was submitted from. Joining the two is the only landing-page
223
+ * answer the stored data supports, and the notice below says plainly what
224
+ * it is not: `path` is where the FORM was, not where the campaign link
225
+ * landed, so a visitor who arrived on one page and submitted on another is
226
+ * grouped under the second.
227
+ *
228
+ * Two reads deep and both bounded: a ceiling over the attributions, then
229
+ * their submissions by document name in chunks of thirty. `getDocs` rather
230
+ * than a listener — a grouping nobody is watching does not need to be kept
231
+ * live, and a listener would go on costing after the reader looked away.
232
+ *=========================================*/ const [landingBusy, setLandingBusy] = useState(false);
233
+ const [landingError, setLandingError] = useState(null);
234
+ const [landing, setLanding] = useState(null);
235
+ // A grouping computed for one kind describes that kind. Switching the mode
236
+ // leaves it on screen labelled as something it is not.
237
+ useEffect(()=>{
238
+ setLanding(null);
239
+ setLandingError(null);
240
+ }, [
241
+ kind,
242
+ campaignId
243
+ ]);
244
+ const groupByLandingPage = useCallback(async ()=>{
245
+ if (landingBusy) return;
246
+ setLandingBusy(true);
247
+ setLandingError(null);
248
+ try {
249
+ const credited = await getDocs(collectionCeiling(campaignId ? query(attributions(), where('kind', '==', 'form'), where('campaignId', '==', campaignId)) : query(attributions(), where('kind', '==', 'form')), LANDING_PAGE_CEILING));
250
+ const { rows: credits, truncated } = ceilingedWindow(credited.docs.map((entry)=>entry.data()), LANDING_PAGE_CEILING);
251
+ const refIds = Array.from(new Set(credits.map((record)=>{
252
+ var _record_refId;
253
+ return String((_record_refId = record.refId) != null ? _record_refId : '');
254
+ }).filter((id)=>id.length > 0)));
255
+ const submissions = collection(firestore, 'hosts', hostId, 'formSubmissions');
256
+ const paths = new Map();
257
+ for(let index = 0; index < refIds.length; index += ID_CHUNK){
258
+ const chunk = refIds.slice(index, index + ID_CHUNK);
259
+ const found = await getDocs(query(submissions, where(documentId(), 'in', chunk)));
260
+ found.docs.forEach((entry)=>{
261
+ var _ref;
262
+ var _entry_data;
263
+ const path = String((_ref = (_entry_data = entry.data()) == null ? void 0 : _entry_data.path) != null ? _ref : '').trim();
264
+ if (path) paths.set(entry.id, path);
265
+ });
266
+ }
267
+ const counts = new Map();
268
+ let missing = 0;
269
+ refIds.forEach((refId)=>{
270
+ var _counts_get;
271
+ const path = paths.get(refId);
272
+ if (!path) {
273
+ /*
274
+ * The submission was deleted, or it carries no path — an older one,
275
+ * or a form posted by an integration that sent none. Counted and
276
+ * REPORTED rather than filed under a placeholder page: a bar
277
+ * labelled "/" that is really "we do not know" is the kind of row a
278
+ * reader acts on.
279
+ */ missing += 1;
280
+ return;
281
+ }
282
+ counts.set(path, ((_counts_get = counts.get(path)) != null ? _counts_get : 0) + 1);
283
+ });
284
+ setLanding({
285
+ rows: Array.from(counts.entries()).map(([path, conversions])=>({
286
+ $id: path,
287
+ path,
288
+ conversions
289
+ })).sort((a, b)=>b.conversions - a.conversions || a.path.localeCompare(b.path)),
290
+ read: refIds.length,
291
+ truncated,
292
+ missing
293
+ });
294
+ } catch (error) {
295
+ console.error(error);
296
+ setLandingError('The landing pages could not be read');
297
+ } finally{
298
+ setLandingBusy(false);
299
+ }
300
+ }, [
301
+ attributions,
302
+ campaignId,
303
+ firestore,
304
+ hostId,
305
+ landingBusy
306
+ ]);
307
+ const campaignHref = (row)=>row.campaignId ? `${basePath}/campaigns/${row.campaignId}` : undefined;
308
+ const columns = useMemo(()=>[
309
+ {
310
+ field: 'refId',
311
+ headerName: 'Record',
312
+ flex: 1,
313
+ minWidth: 200,
314
+ renderCell: ({ row })=>{
315
+ var _row_refId;
316
+ return /*#__PURE__*/ _jsxs(Stack, {
317
+ sx: {
318
+ minWidth: 0
319
+ },
320
+ children: [
321
+ /*#__PURE__*/ _jsx(Typography, {
322
+ variant: "body2",
323
+ noWrap: true,
324
+ children: String((_row_refId = row.refId) != null ? _row_refId : row.$id)
325
+ }),
326
+ /*#__PURE__*/ _jsx(Typography, {
327
+ variant: "caption",
328
+ color: "text.secondary",
329
+ noWrap: true,
330
+ children: CAMPAIGN_CONVERSION_KIND_COPY[kind].label
331
+ })
332
+ ]
333
+ });
334
+ }
335
+ },
336
+ {
337
+ field: 'campaign',
338
+ headerName: 'Credited to',
339
+ flex: 1,
340
+ minWidth: 180,
341
+ renderCell: ({ row })=>{
342
+ const label = campaignTouchLabel(row) || '—';
343
+ const href = campaignHref(row);
344
+ return href ? /*#__PURE__*/ _jsx(AppLink, {
345
+ href: href,
346
+ onClick: (event)=>event.stopPropagation(),
347
+ children: label
348
+ }) : /*#__PURE__*/ _jsx(Typography, {
349
+ variant: "body2",
350
+ noWrap: true,
351
+ children: label
352
+ });
353
+ }
354
+ },
355
+ {
356
+ field: 'convertedAtMs',
357
+ headerName: 'Converted',
358
+ width: 190,
359
+ renderCell: ({ row })=>/*#__PURE__*/ _jsx(Typography, {
360
+ variant: "body2",
361
+ noWrap: true,
362
+ children: day(row.convertedAtMs)
363
+ })
364
+ },
365
+ listActionsColumn((row)=>{
366
+ var _row_refId;
367
+ return /*#__PURE__*/ _jsx(ListRowActions, {
368
+ label: String((_row_refId = row.refId) != null ? _row_refId : row.$id),
369
+ items: [
370
+ {
371
+ key: 'campaign',
372
+ label: 'Open campaign',
373
+ icon: /*#__PURE__*/ _jsx(MdiIcon, {
374
+ path: mdiEyeOutline.path,
375
+ size: 0.8
376
+ }),
377
+ href: campaignHref(row),
378
+ disabled: !campaignHref(row),
379
+ /*
380
+ * Shown disabled with the reason rather than hidden. A control
381
+ * that vanishes and one that does not apply look identical from
382
+ * the outside, and only one of them is honest — there is no
383
+ * campaign document behind a `utm_` label to open.
384
+ */ disabledReason: 'This came from a tagged link rather than a campaign email, ' + 'so there is no campaign to open'
385
+ }
386
+ ]
387
+ });
388
+ })
389
+ ], // eslint-disable-next-line react-hooks/exhaustive-deps
390
+ [
391
+ kind,
392
+ basePath
393
+ ]);
394
+ const landingColumns = useMemo(()=>[
395
+ {
396
+ field: 'path',
397
+ headerName: 'Page the form was on',
398
+ flex: 1,
399
+ minWidth: 240,
400
+ renderCell: ({ row })=>/*#__PURE__*/ _jsx(Typography, {
401
+ variant: "body2",
402
+ noWrap: true,
403
+ children: row.path
404
+ })
405
+ },
406
+ {
407
+ field: 'conversions',
408
+ headerName: 'Credited submissions',
409
+ width: 190,
410
+ align: 'right',
411
+ headerAlign: 'right',
412
+ renderCell: ({ row })=>/*#__PURE__*/ _jsx(Typography, {
413
+ variant: "body2",
414
+ children: Number(row.conversions).toLocaleString()
415
+ })
416
+ }
417
+ ], []);
418
+ const kindCopy = CAMPAIGN_CONVERSION_KIND_COPY[kind];
419
+ return /*#__PURE__*/ _jsx(CardDisplay, {
420
+ header: "Conversions",
421
+ subheader: campaignId ? 'Credited to this campaign' : 'What the campaigns caused, one kind at a time',
422
+ help: conversionsDocsHelp,
423
+ HeaderProps: {
424
+ action: campaignId ? /*#__PURE__*/ _jsx(Button, _extends({
425
+ component: AppLink
426
+ }, {
427
+ componentVariant: 'naked',
428
+ nativeButton: false
429
+ }, {
430
+ href: `${basePath}/conversions`,
431
+ size: "small",
432
+ color: "primary",
433
+ children: 'All conversions'
434
+ })) : undefined
435
+ },
436
+ contentGutterX: true,
437
+ contentGutterY: true,
438
+ children: /*#__PURE__*/ _jsxs(Stack, {
439
+ spacing: 2,
440
+ children: [
441
+ /*#__PURE__*/ _jsxs(Stack, {
442
+ spacing: 1,
443
+ children: [
444
+ /*#__PURE__*/ _jsx(ToggleButtonGroup, {
445
+ exclusive: true,
446
+ size: "small",
447
+ color: "primary",
448
+ value: kind,
449
+ onChange: (_event, next)=>{
450
+ if (next) setKind(next);
451
+ },
452
+ "aria-label": "Conversion kind",
453
+ children: CAMPAIGN_CONVERSION_KINDS.map((one)=>/*#__PURE__*/ _jsx(ToggleButton, {
454
+ value: one,
455
+ children: CAMPAIGN_CONVERSION_KIND_COPY[one].label
456
+ }, one))
457
+ }),
458
+ /*#__PURE__*/ _jsx(Typography, {
459
+ variant: "caption",
460
+ color: "text.secondary",
461
+ children: 'One kind at a time. The four count different things about the ' + 'same visits — one person filling in one form appears as a ' + 'submission, a contact and a lead — so they are never shown ' + 'together and never added.'
462
+ })
463
+ ]
464
+ }),
465
+ /*#__PURE__*/ _jsx(Divider, {}),
466
+ campaignId ? /*#__PURE__*/ _jsx(Alert, {
467
+ severity: "info",
468
+ children: 'This is one campaign’s share. Conversions credited to no ' + 'campaign belong to no campaign, so they are not counted here ' + '— open all conversions for the site-wide figure.'
469
+ }) : coverage ? /*#__PURE__*/ _jsxs(Section, {
470
+ title: `${kindCopy.label} on this site`,
471
+ children: [
472
+ /*#__PURE__*/ _jsxs(Stack, {
473
+ direction: "row",
474
+ spacing: 4,
475
+ useFlexGap: true,
476
+ sx: {
477
+ flexWrap: 'wrap'
478
+ },
479
+ children: [
480
+ /*#__PURE__*/ _jsx(Figure, {
481
+ label: "Credited to a campaign",
482
+ value: coverage.attributed,
483
+ note: "from a campaign email or a tagged link"
484
+ }),
485
+ /*#__PURE__*/ _jsx(Figure, {
486
+ label: "Not credited",
487
+ value: coverage.unattributed,
488
+ note: "arrived directly, or predate attribution"
489
+ }),
490
+ /*#__PURE__*/ _jsx(Figure, {
491
+ label: `${kindCopy.label} in total`,
492
+ value: coverage.total,
493
+ note: coverage.exact ? 'on this site' : 'on this site, all time'
494
+ })
495
+ ]
496
+ }),
497
+ coverage.caveats.map((caveat)=>/*#__PURE__*/ _jsx(Alert, {
498
+ severity: "info",
499
+ children: caveat.message
500
+ }, caveat.id))
501
+ ]
502
+ }) : /*#__PURE__*/ _jsx(Alert, {
503
+ severity: "info",
504
+ children: 'How many of these were credited to no campaign could not be ' + 'counted, so it is not shown. The lists below are the credited ' + 'ones only and are not a count of everything that happened.'
505
+ }),
506
+ /*#__PURE__*/ _jsx(Divider, {}),
507
+ /*#__PURE__*/ _jsxs(Section, {
508
+ title: "From campaign emails",
509
+ children: [
510
+ /*#__PURE__*/ _jsx(ListTable, {
511
+ rows: emailList.rows,
512
+ columns: columns,
513
+ hideFooter: true,
514
+ onOpen: (_id, row)=>{
515
+ const href = campaignHref(row);
516
+ if (href) router.push(href);
517
+ },
518
+ noRowsLabel: "Nothing credited to a campaign email",
519
+ noRowsDescription: 'A conversion is credited when the visitor followed a link in ' + 'a campaign email before converting.'
520
+ }),
521
+ /*#__PURE__*/ _jsx(ListPagination, {
522
+ page: emailList.page,
523
+ pageSize: emailList.pageSize,
524
+ rowCount: emailList.rows.length,
525
+ hasMore: emailList.hasMore,
526
+ onPageChange: emailList.setPage,
527
+ onPageSizeChange: emailList.setPageSize
528
+ }),
529
+ /*#__PURE__*/ _jsx(Typography, {
530
+ variant: "caption",
531
+ color: "text.secondary",
532
+ children: 'Listed in the order they are stored, which is not the order ' + 'they happened in — every row is reachable by paging, but the ' + 'first page is not the most recent conversions.'
533
+ })
534
+ ]
535
+ }),
536
+ campaignId ? null : /*#__PURE__*/ _jsxs(Section, {
537
+ title: "From tagged web links",
538
+ children: [
539
+ /*#__PURE__*/ _jsx(Typography, {
540
+ variant: "body2",
541
+ color: "text.secondary",
542
+ children: 'Credited to the utm_ label on the link the visitor followed. ' + 'These are listed one by one rather than grouped: the label ' + 'is free text with no campaign behind it, so there is ' + 'nothing to open and no fixed set to total.'
543
+ }),
544
+ /*#__PURE__*/ _jsx(ListTable, {
545
+ rows: webList.rows,
546
+ columns: columns,
547
+ hideFooter: true,
548
+ noRowsLabel: "Nothing credited to a tagged link",
549
+ noRowsDescription: 'A conversion lands here when the visitor arrived on a URL ' + 'carrying utm_ parameters.'
550
+ }),
551
+ /*#__PURE__*/ _jsx(ListPagination, {
552
+ page: webList.page,
553
+ pageSize: webList.pageSize,
554
+ rowCount: webList.rows.length,
555
+ hasMore: webList.hasMore,
556
+ onPageChange: webList.setPage,
557
+ onPageSizeChange: webList.setPageSize
558
+ })
559
+ ]
560
+ }),
561
+ kind === 'form' ? /*#__PURE__*/ _jsxs(_Fragment, {
562
+ children: [
563
+ /*#__PURE__*/ _jsx(Divider, {}),
564
+ /*#__PURE__*/ _jsxs(Section, {
565
+ title: "Landing pages",
566
+ children: [
567
+ /*#__PURE__*/ _jsx(Typography, {
568
+ variant: "body2",
569
+ color: "text.secondary",
570
+ children: 'Groups credited form submissions by the page the form was ' + 'on. This reads up to ' + `${LANDING_PAGE_CEILING.toLocaleString()} attribution ` + 'records and their submissions, so it runs when you ask ' + 'for it rather than on every visit.'
571
+ }),
572
+ /*#__PURE__*/ _jsx(Stack, {
573
+ direction: "row",
574
+ spacing: 1,
575
+ children: /*#__PURE__*/ _jsx(Button, {
576
+ size: "small",
577
+ variant: "contained",
578
+ disabled: landingBusy,
579
+ startIcon: /*#__PURE__*/ _jsx(MdiIcon, {
580
+ path: mdiMapMarkerOutline.path,
581
+ size: 0.8
582
+ }),
583
+ onClick: ()=>void groupByLandingPage(),
584
+ children: landingBusy ? 'Reading…' : 'Group by landing page'
585
+ })
586
+ }),
587
+ landingError ? /*#__PURE__*/ _jsx(Alert, {
588
+ severity: "warning",
589
+ children: landingError
590
+ }) : null,
591
+ landing ? /*#__PURE__*/ _jsxs(Stack, {
592
+ spacing: 1,
593
+ children: [
594
+ /*#__PURE__*/ _jsx(Alert, {
595
+ severity: "info",
596
+ children: 'This is the page each form sat on, not the page the ' + 'campaign link landed on — a visitor who arrived on ' + 'one page and submitted on another is counted under ' + 'the second. There is no conversion RATE here either: ' + 'the record carries no count of visits to a page from ' + 'a campaign, so there is no denominator to divide by. ' + 'Only form submissions carry a page at all, so leads, ' + 'contacts and bookings have no landing page.'
597
+ }),
598
+ landing.truncated ? /*#__PURE__*/ _jsx(Alert, {
599
+ severity: "warning",
600
+ children: `More than ${LANDING_PAGE_CEILING.toLocaleString()} ` + 'credited submissions exist. This groups the ' + `${landing.read.toLocaleString()} that were read, ` + 'which are not the most recent — they are the ones ' + 'that came first in storage order.'
601
+ }) : null,
602
+ landing.missing ? /*#__PURE__*/ _jsx(Alert, {
603
+ severity: "info",
604
+ children: `${landing.missing.toLocaleString()} of the ` + `${landing.read.toLocaleString()} credited ` + 'submissions are not grouped: the submission has ' + 'been deleted, or it recorded no page. They are ' + 'reported here rather than filed under a page they ' + 'may not have come from.'
605
+ }) : null,
606
+ /*#__PURE__*/ _jsx(ListTable, {
607
+ rows: landing.rows,
608
+ columns: landingColumns,
609
+ hideFooter: true,
610
+ noRowsLabel: "No pages to group",
611
+ noRowsDescription: "None of the credited submissions recorded a page."
612
+ }),
613
+ /*#__PURE__*/ _jsxs(Stack, {
614
+ direction: "row",
615
+ spacing: 1,
616
+ children: [
617
+ /*#__PURE__*/ _jsx(Chip, {
618
+ size: "small",
619
+ variant: "outlined",
620
+ label: `${landing.read.toLocaleString()} credited submissions read`
621
+ }),
622
+ /*#__PURE__*/ _jsx(Chip, {
623
+ size: "small",
624
+ variant: "outlined",
625
+ label: `${landing.rows.length.toLocaleString()} pages`
626
+ })
627
+ ]
628
+ })
629
+ ]
630
+ }) : null
631
+ ]
632
+ })
633
+ ]
634
+ }) : null
635
+ ]
636
+ })
637
+ });
638
+ }
639
+ CampaignConversionsCard.displayName = 'CampaignConversionsCard';
640
+ export default CampaignConversionsCard;
641
+
642
+ //# sourceMappingURL=campaign-conversions-card.js.map