@aglyn/plugins-marketing 1.0.0-beta.221 → 1.0.0-beta.222

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 (106) hide show
  1. package/README.md +4 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-conversions-card.js +53 -64
  4. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  5. package/src/lib/components/campaign-detail-card.js +2 -1
  6. package/src/lib/components/campaign-detail-card.js.map +1 -1
  7. package/src/lib/components/campaign-edit-drawer.js +1 -1
  8. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  9. package/src/lib/components/campaign-queries.js +1 -1
  10. package/src/lib/components/campaign-queries.js.map +1 -1
  11. package/src/lib/components/campaign-reach-sections.js +1 -1
  12. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  13. package/src/lib/components/campaign-report-card.js +5 -4
  14. package/src/lib/components/campaign-report-card.js.map +1 -1
  15. package/src/lib/components/campaigns-card.js +1 -1
  16. package/src/lib/components/campaigns-card.js.map +1 -1
  17. package/src/lib/components/email-compose-card.js +1 -1
  18. package/src/lib/components/email-compose-card.js.map +1 -1
  19. package/src/lib/components/email-detail.js +5 -4
  20. package/src/lib/components/email-detail.js.map +1 -1
  21. package/src/lib/components/email-template-report-card.d.ts +53 -0
  22. package/src/lib/components/email-template-report-card.js +469 -0
  23. package/src/lib/components/email-template-report-card.js.map +1 -0
  24. package/src/lib/components/emails-list-card.js +2 -2
  25. package/src/lib/components/emails-list-card.js.map +1 -1
  26. package/src/lib/components/host-experiments-card.component.js +3 -2
  27. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  28. package/src/lib/components/site-runtime.js +3 -1
  29. package/src/lib/components/site-runtime.js.map +1 -1
  30. package/src/lib/declarations.server.d.ts +9 -0
  31. package/src/lib/declarations.server.js +62 -0
  32. package/src/lib/declarations.server.js.map +1 -1
  33. package/src/lib/model/campaign-container.d.ts +503 -0
  34. package/src/lib/model/campaign-container.js +498 -0
  35. package/src/lib/model/campaign-container.js.map +1 -0
  36. package/src/lib/model/campaign-conversions.d.ts +8 -1
  37. package/src/lib/model/campaign-conversions.js +11 -0
  38. package/src/lib/model/campaign-conversions.js.map +1 -1
  39. package/src/lib/model/campaign-report.d.ts +158 -0
  40. package/src/lib/model/campaign-report.js +268 -0
  41. package/src/lib/model/campaign-report.js.map +1 -0
  42. package/src/lib/model/campaign-revenue.d.ts +3 -3
  43. package/src/lib/model/campaign-revenue.js +3 -3
  44. package/src/lib/model/campaign-revenue.js.map +1 -1
  45. package/src/lib/model/campaign-send-time.js +2 -2
  46. package/src/lib/model/campaign-send-time.js.map +1 -1
  47. package/src/lib/model/compile-client-automations.d.ts +2 -2
  48. package/src/lib/model/compile-client-automations.js +3 -3
  49. package/src/lib/model/compile-client-automations.js.map +1 -1
  50. package/src/lib/model/email-record.d.ts +175 -0
  51. package/src/lib/model/email-record.js +206 -0
  52. package/src/lib/model/email-record.js.map +1 -0
  53. package/src/lib/model/site-contract.d.ts +2 -2
  54. package/src/lib/model/site-contract.js.map +1 -1
  55. package/src/lib/model/template-report.d.ts +158 -0
  56. package/src/lib/model/template-report.js +249 -0
  57. package/src/lib/model/template-report.js.map +1 -0
  58. package/src/lib/plugin.js +16 -0
  59. package/src/lib/plugin.js.map +1 -1
  60. package/src/lib/preview-props.js.map +1 -1
  61. package/src/lib/server/campaign-attribution-store.d.ts +55 -0
  62. package/src/lib/server/campaign-attribution-store.js +98 -0
  63. package/src/lib/server/campaign-attribution-store.js.map +1 -0
  64. package/src/lib/server/campaign-conversion-attribution.d.ts +283 -0
  65. package/src/lib/server/campaign-conversion-attribution.js +447 -0
  66. package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
  67. package/src/lib/server/campaign-manage.js +1 -1
  68. package/src/lib/server/campaign-manage.js.map +1 -1
  69. package/src/lib/server/campaign-org-refs.js +1 -1
  70. package/src/lib/server/campaign-org-refs.js.map +1 -1
  71. package/src/lib/server/campaign-recipients.js +7 -7
  72. package/src/lib/server/campaign-recipients.js.map +1 -1
  73. package/src/lib/server/campaign-send.js +25 -19
  74. package/src/lib/server/campaign-send.js.map +1 -1
  75. package/src/lib/server/conversion-creditor.d.ts +18 -0
  76. package/src/lib/server/conversion-creditor.js +131 -0
  77. package/src/lib/server/conversion-creditor.js.map +1 -0
  78. package/src/lib/server/email-campaign-touch.d.ts +81 -0
  79. package/src/lib/server/email-campaign-touch.js +227 -0
  80. package/src/lib/server/email-campaign-touch.js.map +1 -0
  81. package/src/lib/server/email-events.js +8 -6
  82. package/src/lib/server/email-events.js.map +1 -1
  83. package/src/lib/server/email-revenue-attribution.d.ts +186 -0
  84. package/src/lib/server/email-revenue-attribution.js +322 -0
  85. package/src/lib/server/email-revenue-attribution.js.map +1 -0
  86. package/src/lib/server/email-send-record-index.d.ts +34 -0
  87. package/src/lib/server/email-send-record-index.js +90 -0
  88. package/src/lib/server/email-send-record-index.js.map +1 -0
  89. package/src/lib/server/get-client-automations.js.map +1 -1
  90. package/src/lib/server/marketing-figures.js +3 -3
  91. package/src/lib/server/marketing-figures.js.map +1 -1
  92. package/src/lib/server/send-tally.d.ts +32 -0
  93. package/src/lib/server/send-tally.js +54 -0
  94. package/src/lib/server/send-tally.js.map +1 -0
  95. package/src/lib/testing/stand-in-person-records.d.ts +35 -0
  96. package/src/lib/testing/stand-in-person-records.js +67 -0
  97. package/src/lib/testing/stand-in-person-records.js.map +1 -0
  98. package/src/lib/testing/stand-in-record-counts.d.ts +26 -0
  99. package/src/lib/testing/stand-in-record-counts.js +70 -0
  100. package/src/lib/testing/stand-in-record-counts.js.map +1 -0
  101. package/src/lib/transfer/campaigns-package.d.ts +72 -0
  102. package/src/lib/transfer/campaigns-package.js +145 -0
  103. package/src/lib/transfer/campaigns-package.js.map +1 -0
  104. package/src/lib/transfer/campaigns-package.server.d.ts +33 -0
  105. package/src/lib/transfer/campaigns-package.server.js +305 -0
  106. package/src/lib/transfer/campaigns-package.server.js.map +1 -0
package/README.md CHANGED
@@ -22,7 +22,7 @@ The plugin declares four registrars in `plugins.config.json`, plus a `site` modu
22
22
 
23
23
  - A `Marketing` nav item at `/marketing` with the sections Overview, Campaigns, Conversions, Overlays and A/B testing. Each section is a route; the page is code-split.
24
24
  - The same page at the organization level, `/[orgSlug]/marketing`, declared in `orgNavItems` with the site's sections in the site's order. Campaigns and their sends belong to the organization (`orgs/{orgId}/emailCampaigns`, `orgs/{orgId}/campaigns`): the org hub lists every one, places a campaign on every site or on chosen sites (`visibleTo`), and asks which site an email is sent as before writing it; a site's hub lists the campaigns placed on that site. Overlays, A/B tests and conversions are each one site's, so the org hub reads them per site and bounded: Overview sums the org's sends and each site's overlay and test figures as server aggregates, for a capped number of sites; Overlays and A/B testing list every site's, one page of sites at a time, switch an overlay on or off, and send every edit to the site's own hub; Conversions shows the site picked under "Conversions on". Overlays and A/B testing carry their plan flags on the org sections, so an organization without the plan reads nothing per site. Both levels share the `release_marketing` flag. The organization's messages are its Emails page's; the old `/[orgSlug]/marketing/emails/…` addresses redirect there (`apps/console/next.config.js`).
25
- - Campaigns end to end. The plugin owns the campaign composer, test sends, scheduling, reports and recipients. The individual messages are listed on the Emails page, but that page belongs to the email plugin, which hosts an `emailMessages` zone; this plugin fills it with the message list, one message's page and its composer. It also fills `emailTemplateRecipients` (who received the sends built from a template).
25
+ - Campaigns end to end. The plugin owns the campaign composer, test sends, scheduling, reports and recipients. The individual messages are listed on the Emails page, but that page belongs to the email plugin, which hosts an `emailMessages` zone; this plugin fills it with the message list, one message's page and its composer. It also fills `emailTemplateReport` (what the sends built from a template did) and `emailTemplateRecipients` (who received them). The campaign's model — its container, a send's report, the message record and a design's report — is this plugin's (`src/lib/model/`); the rate math every bulk sender divides by is `@aglyn/shared-ui-email-campaigns`.
26
26
  - Widgets in other hosts' slots: the "Last campaign" card on the site dashboard (`hostDashboard`), the campaigns card in the Inbox (`inboxCampaigns`), campaign attribution on a record's page (`crmRecordAttribution`, `inboxRecordAttribution`), and the site's section experiments, reported to the besigner's Interactions section (`besignerInteractions`), which badges an element with its experiment and starts a draft on a page's element.
27
27
  - Zones this plugin hosts for other plugins to fill, registered with `registerPluginZone`: two on the A/B testing card (beside the variants, and below a test's results), and five bare zones in the campaign composer and on a message's page for whichever plugin keeps the mail itself: the topic picker, the topic options, the add-a-sender editor, the design creator and the sent-design preview.
28
28
  - A record route for `campaign`, at both the site and the organization level, so another plugin can link to a campaign without knowing this plugin's URLs.
@@ -32,6 +32,9 @@ The plugin declares four registrars in `plugins.config.json`, plus a `site` modu
32
32
 
33
33
  - `registerMarketingApi` (the `tenantApi` registrar): the `experiments/track` beacon, which counts exposures and conversions for a running experiment and can finish a test whose auto-winner rule is met, and a site page enricher (`registerSitePageEnricher`) that adds overlays, automations and experiments to a page's data.
34
34
  - From its server declarations, the site collector's `overlay` beacons (`registerPluginSiteBeacon`): each bar and popup event the site runtime reports is counted into the site's day document and the overlay's own counters, after the collector's host and lockdown gates.
35
+ - From its server declarations, the platform's conversion creditor (`registerPluginConversionCreditor`, `plugin-conversion-credit`): every door that produces an outcome — a form submission, a booking, a member sign-up, a newsletter opt-in, the contact and the lead the record system files for them, an order and its refund, and what a sequence's mail led to — asks it where the visitor arrived from and which campaign to credit. It answers with the last-touch join over the visitor's UTM labels and the campaign mail they clicked (`server/campaign-conversion-attribution.ts`), the order revenue join (`server/email-revenue-attribution.ts`), the per-site click touch kept on the person's delivery document (`server/email-campaign-touch.ts`), the sequence outcomes counted under a campaign, and the person's erasure of all of it.
36
+ - From its server declarations, the send tally the site's unsubscribe page tells when a recipient leaves from a campaign send's mail (`registerPluginSendTally`, `plugin-send-tallies`), which counts it on the send.
37
+ - From its server declarations, the `emailSend` record index (`plugin-record-index`): the organization's sends, named, which the CRM names the campaign mail on a person's timeline by.
35
38
  - `registerMarketingConsoleApi` (the `consoleApi` registrar): `campaigns/send`, `campaigns/manage`, `campaigns/recipients`, `campaigns/process-scheduled`, `lists/materialize`, and the delivery events webhook at `email/events`. It also registers the campaign draft writer on the core's resource-drafts seam and figure readers for campaign and experiment results.
36
39
 
37
40
  **Exports from `.`**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-marketing",
3
- "version": "1.0.0-beta.221",
3
+ "version": "1.0.0-beta.222",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,20 +25,20 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.221",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.221",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.221",
31
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.221",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.221",
33
- "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.221",
34
- "@aglyn/shared-ui-next": "1.0.0-beta.221",
35
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.221",
36
- "@aglyn/shared-util-email": "1.0.0-beta.221",
37
- "@aglyn/shared-util-http": "1.0.0-beta.221",
38
- "@aglyn/shared-util-timestamp": "1.0.0-beta.221",
39
- "@aglyn/tenant-data-admin": "1.0.0-beta.221",
40
- "@aglyn/tenant-feature-instance": "1.0.0-beta.221",
41
- "@aglyn/tenant-runtime": "1.0.0-beta.221",
28
+ "@aglyn/aglyn": "1.0.0-beta.222",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.222",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.222",
31
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.222",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.222",
33
+ "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.222",
34
+ "@aglyn/shared-ui-next": "1.0.0-beta.222",
35
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.222",
36
+ "@aglyn/shared-util-email": "1.0.0-beta.222",
37
+ "@aglyn/shared-util-http": "1.0.0-beta.222",
38
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.222",
39
+ "@aglyn/tenant-data-admin": "1.0.0-beta.222",
40
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.222",
41
+ "@aglyn/tenant-runtime": "1.0.0-beta.222",
42
42
  "@swc/helpers": "0.5.23"
43
43
  },
44
44
  "peerDependencies": {
@@ -16,14 +16,16 @@
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
- import { CAPTURED_BY_HOST_FIELD, pluginDocsHelp } from "@aglyn/aglyn";
19
+ import { pluginDocsHelp } from "@aglyn/aglyn";
20
+ import { pluginRecordCountSource } from "@aglyn/aglyn/plugin-manager/plugin-record-counts";
21
+ import { PLUGIN_RECORD_LIST_IDS_MAX, pluginRecordListByIdsQuery, pluginRecordsFromRows } from "@aglyn/aglyn/plugin-manager/plugin-record-lists";
20
22
  import { mdiEyeOutline, mdiMapMarkerOutline } from "@aglyn/shared-data-mdi";
21
23
  import { AppLink, CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
22
24
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
25
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
24
26
  import { Figure, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
25
27
  import { Alert, Button, Chip, Divider, Stack, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
26
- import { collection, documentId, getCountFromServer, getDocs, query, where } from "firebase/firestore";
28
+ import { collection, getCountFromServer, getDocs, query, where } from "firebase/firestore";
27
29
  import { useCallback, useEffect, useMemo, useState } from "react";
28
30
  import { useRouter } from "next/navigation";
29
31
  import { useFirestore, useOrgDataScope, usePagedCollection } from "@aglyn/tenant-feature-instance";
@@ -32,7 +34,7 @@ import { useFirestore, useOrgDataScope, usePagedCollection } from "@aglyn/tenant
32
34
  * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks
33
35
  * would otherwise lose them, and neither is a hook.
34
36
  */ 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 "../model/campaign-conversions.js";
37
+ import { CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, CAMPAIGN_CONVERSION_RECORD_KINDS, campaignConversionsCoverage, campaignTouchLabel } from "../model/campaign-conversions.js";
36
38
  /**
37
39
  * WHAT THE CAMPAIGNS CAUSED, from the conversions' end.
38
40
  *
@@ -77,7 +79,8 @@ import { CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, campaignConve
77
79
  * one read on the surface that is measured in hundreds — see the button that
78
80
  * asks for it.
79
81
  */ const LANDING_PAGE_CEILING = 100;
80
- /** Firestore's cap on the values in one `in` filter. */ const ID_CHUNK = 30;
82
+ /** The most submissions one by-name read asks for — Firestore's `in` cap. */ const ID_CHUNK = PLUGIN_RECORD_LIST_IDS_MAX;
83
+ /** A credited form conversion's `refId` names a submission of this kind. */ const SUBMISSION_RECORD_KIND = CAMPAIGN_CONVERSION_RECORD_KINDS.form;
81
84
  const conversionsDocsHelp = pluginDocsHelp('emailCampaigns', {
82
85
  anchor: '#the-campaign-report',
83
86
  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.'
@@ -144,58 +147,36 @@ export function CampaignConversionsCard(props) {
144
147
  * attributed figure would render every conversion as attributed, which is
145
148
  * the most flattering wrong answer available here.
146
149
  *=========================================*/ /*
147
- * A PATH STRING and a BOOLEAN, not an object.
150
+ * THE TOTAL IS COUNTED BY THE PLUGIN THAT KEEPS THE RECORDS
151
+ * (`plugin-record-counts`, AGL-3080): the forms plugin's submissions, the
152
+ * bookings plugin's bookings, the record system's leads and contacts. The
153
+ * owner says which of its records this site produced — its own
154
+ * submissions and bookings, the leads it CAPTURED (AGL-3275; not the ones
155
+ * a consent group merely shares with it), and for contacts, which the
156
+ * organization holds once for every site, all of them, which the owner
157
+ * marks as a count that crosses sites. With no owner in this console the
158
+ * total is withheld exactly as a failed count is.
148
159
  *
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
- /*
160
- * The ORG's leads (AGL-3275), narrowed below to the ones this site
161
- * CAPTURED. A lead is one document per person, stamped in
162
- * `capturedByHostIds` with every site that met them, and its
163
- * attribution is written by the site that captured it — so this
164
- * site's captures are exactly the population its lead attributions
165
- * are drawn from, and the total does not cross hosts.
166
- *
167
- * Not `visibleTo`: that also names the sites a consent group shares a
168
- * person with, and would count leads a sibling brand captured that
169
- * could never have been credited here. The price is that a
170
- * collaborator scoped to one site cannot run the count — the rules
171
- * prove a lead read by `visibleTo` — and is shown the withheld split,
172
- * as they already are for contacts.
173
- */ return dataScope ? `${dataScope[0]}/${dataScope[1]}/leads` : null;
174
- case 'booking':
175
- return `hosts/${hostId}/bookings`;
176
- case 'contact':
177
- /*
178
- * ORG-SCOPED, and the coverage model is told so below. A contact is
179
- * shared across every site in the org while an attribution belongs to
180
- * one host, so this total counts contacts created on another site
181
- * that could never have been credited here.
182
- */ return dataScope ? `${dataScope[0]}/${dataScope[1]}/contacts` : null;
183
- default:
184
- return null;
185
- }
186
- }, [
187
- kind,
188
- hostId,
189
- dataScope
160
+ * The source is resolved per kind and the query built inside the effect,
161
+ * from primitives: a query object rebuilt on every render as a dependency
162
+ * would reopen the effect, and pay for two aggregation counts, on every
163
+ * render rather than per kind.
164
+ */ const orgId = dataScope ? dataScope[1] : null;
165
+ const countSource = useMemo(()=>pluginRecordCountSource(CAMPAIGN_CONVERSION_RECORD_KINDS[kind]), [
166
+ kind
190
167
  ]);
191
- /** The total counts only the org records THIS site captured. */ const totalCapturedHere = kind === 'lead';
192
- /** The kind's records live outside this host, so the total over-counts. */ const totalCrossesHosts = kind === 'contact';
168
+ /** The kind's records live outside this host, so the total over-counts. */ const totalCrossesHosts = (countSource == null ? void 0 : countSource.crossesSites) === true;
193
169
  const [attributedCount, setAttributedCount] = useState(null);
194
170
  const [totalCount, setTotalCount] = useState(null);
195
171
  useEffect(()=>{
172
+ var _ref;
196
173
  // A campaign-scoped view has no uncredited figure to compute — see the
197
174
  // prop's docblock — so it does not pay for one either.
198
- if (campaignId || !totalCollectionPath) {
175
+ const totalQuery = campaignId ? null : (_ref = countSource == null ? void 0 : countSource.query(firestore, {
176
+ hostId,
177
+ orgId
178
+ })) != null ? _ref : null;
179
+ if (!totalQuery) {
199
180
  setAttributedCount(null);
200
181
  setTotalCount(null);
201
182
  return;
@@ -208,9 +189,7 @@ export function CampaignConversionsCard(props) {
208
189
  }).catch(()=>{
209
190
  // Left null, which withholds the split. See the block comment.
210
191
  });
211
- const segments = totalCollectionPath.split('/');
212
- const records = collection(firestore, segments[0], ...segments.slice(1));
213
- void getCountFromServer(totalCapturedHere ? query(records, where(CAPTURED_BY_HOST_FIELD, 'array-contains', hostId)) : records).then((snapshot)=>{
192
+ void getCountFromServer(totalQuery).then((snapshot)=>{
214
193
  if (active) setTotalCount(snapshot.data().count);
215
194
  }).catch(()=>{
216
195
  // Left null, which withholds the split. See the block comment.
@@ -223,9 +202,9 @@ export function CampaignConversionsCard(props) {
223
202
  firestore,
224
203
  kind,
225
204
  campaignId,
226
- totalCollectionPath,
227
- totalCapturedHere,
228
- hostId
205
+ countSource,
206
+ hostId,
207
+ orgId
229
208
  ]);
230
209
  const coverage = campaignConversionsCoverage({
231
210
  kind,
@@ -270,17 +249,27 @@ export function CampaignConversionsCard(props) {
270
249
  var _record_refId;
271
250
  return String((_record_refId = record.refId) != null ? _record_refId : '');
272
251
  }).filter((id)=>id.length > 0)));
273
- const submissions = collection(firestore, 'hosts', hostId, 'formSubmissions');
274
- const paths = new Map();
252
+ /*
253
+ * The page each credited submission was sent from, asked of the plugin
254
+ * that keeps submissions (`plugin-record-lists`, by name), a chunk at a
255
+ * time. With no such plugin here every one reads as not found, and is
256
+ * counted as missing below rather than grouped under a guess.
257
+ */ const paths = new Map();
275
258
  for(let index = 0; index < refIds.length; index += ID_CHUNK){
276
- const chunk = refIds.slice(index, index + ID_CHUNK);
277
- const found = await getDocs(query(submissions, where(documentId(), 'in', chunk)));
278
- found.docs.forEach((entry)=>{
279
- var _ref;
280
- var _entry_data;
281
- const path = String((_ref = (_entry_data = entry.data()) == null ? void 0 : _entry_data.path) != null ? _ref : '').trim();
282
- if (path) paths.set(entry.id, path);
259
+ const named = pluginRecordListByIdsQuery(SUBMISSION_RECORD_KIND, firestore, {
260
+ hostId,
261
+ ids: refIds.slice(index, index + ID_CHUNK)
283
262
  });
263
+ if (!named) continue;
264
+ const found = await getDocs(named);
265
+ const records = pluginRecordsFromRows(SUBMISSION_RECORD_KIND, found.docs.map((entry)=>_extends({}, entry.data(), {
266
+ $id: entry.id
267
+ })));
268
+ for (const record of records){
269
+ var _record_facts_path;
270
+ const path = String((_record_facts_path = record.facts['path']) != null ? _record_facts_path : '').trim();
271
+ if (path) paths.set(record.id, path);
272
+ }
284
273
  }
285
274
  const counts = new Map();
286
275
  let missing = 0;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-conversions-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CAPTURED_BY_HOST_FIELD, pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiEyeOutline, mdiMapMarkerOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Figure, Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n documentId,\n getCountFromServer,\n getDocs,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useFirestore,\n useOrgDataScope,\n usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\n/*\n * The MODULE, not the barrel, for the pure query helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n collectionPage,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n campaignConversionsCoverage,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '../model/campaign-conversions'\n\n/**\n * WHAT THE CAMPAIGNS CAUSED, from the conversions' end.\n *\n * A campaign's own report says what that campaign caused. This is the list\n * the report cannot be: every credited conversion on the site, the ones\n * credited to a tagged web link rather than to a campaign document, and — the\n * half a report of successes can never show — how many were credited to\n * nothing at all.\n *\n * ## ONE KIND AT A TIME, and that is the whole design\n *\n * A form submission by a new person writes a submission record, a contact and\n * a lead. Three true statements about one visit, and a screen that showed\n * them together would invite the reader to add them and treble every figure.\n * So the kind is a MODE rather than a column: the query carries\n * `where('kind','==', …)`, only one kind's rows are ever on screen, and there\n * is no view in which two kinds could be totalled because there is no view in\n * which two kinds are both present.\n *\n * ## THE TWO CHANNELS ARE TWO LISTS, and never one\n *\n * An email touch names a campaign document — a real entity with a report to\n * open. A web touch names `utm_` text a marketer typed into a URL: no\n * document, no id, no bound on how many distinct values exist. Merging them\n * would put a linkable record and an unlinkable label in one column and imply\n * they are the same kind of thing, and grouping the web side into a rollup\n * would build a map anybody who can vary a query string can grow. So there\n * are two tables, the web one is read as records, and neither is summed into\n * the other.\n *\n * ## The read cost\n *\n * Two paged listeners bounded by the page size, and two aggregation counts.\n * Nothing scans a collection, no row triggers a read of its own, and the\n * landing-page join — the only expensive thing here — is behind a button and\n * says what it will cost before it runs.\n */\n\n/**\n * How many attribution records the landing-page join reads.\n *\n * A CEILING rather than a page size: grouping is over a window and a window\n * sliced ten at a time cannot be grouped at all. Kept low because this is the\n * one read on the surface that is measured in hundreds — see the button that\n * asks for it.\n */\nconst LANDING_PAGE_CEILING = 100\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nconst conversionsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A conversion is credited to the last campaign whose link the visitor ' +\n 'followed. The four kinds count different things about the same visits ' +\n 'and are never added together.',\n})\n\n/** The stored record plus the document name the reader listed it under. */\ntype ConversionRow = CampaignConversionRecord & { $id: string }\n\nconst day = (ms: unknown): string => {\n const value = Number(ms ?? 0)\n return Number.isFinite(value) && value > 0\n ? new Date(value).toLocaleString()\n : '—'\n}\n\nexport interface CampaignConversionsCardProps {\n hostId: string\n /** The marketing hub URL, for the link to each credited campaign. */\n basePath: string\n /**\n * One campaign's conversions rather than the whole site's.\n *\n * Set from `/marketing/conversions/{campaignId}`. It narrows the EMAIL list\n * only: a web-channel record carries no campaign id, so there is nothing\n * for it to be narrowed by — and the uncredited figure is withheld\n * entirely, because a conversion credited to nobody belongs to no campaign\n * and attributing the site's whole direct traffic to whichever campaign the\n * reader happens to be looking at is the exact inference this join refuses\n * to make.\n */\n campaignId?: string\n}\n\nexport function CampaignConversionsCard(props: CampaignConversionsCardProps) {\n const { hostId, basePath, campaignId } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const [kind, setKind] = useState<CampaignConversionKind>('form')\n\n const attributions = useCallback(\n () => collection(firestore, 'hosts', hostId, 'campaignAttributions'),\n [firestore, hostId],\n )\n\n /*==========================================\n * THE TWO LISTS.\n *\n * Both walk the collection in DOCUMENT-NAME order through `collectionPage`,\n * which is the console's one ordering decision: `orderBy` on a field\n * matches only documents that HAVE it, so ordering on a date would hide\n * rows rather than merely mis-order them, and an unordered `limit()` is a\n * pseudo-random sample whose gaps leave nothing to notice. The walk is\n * total — every row is reachable by paging — and it is deliberately NOT\n * newest-first, which the note under each table says out loud.\n *\n * `campaignId` narrows the email list when the reader arrived from one\n * campaign. It is never applied to the web list: a `utm_` label is not a\n * campaign id and a query pretending otherwise would return nothing while\n * reading as \"this campaign caused no web conversions\".\n *=========================================*/\n const emailList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n collectionPage(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', kind),\n where('campaignId', '==', campaignId),\n )\n : query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'email'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n const webList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n campaignId\n ? null\n : collectionPage(\n query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'web'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE UNCREDITED HALF.\n *\n * Two aggregation counts, and they are on the page by DEFAULT rather than\n * behind a button, because the tables above them are a list of the\n * successes: showing only those renders \"we credited nine of these\" as\n * \"nine of these happened\", which is the conclusion this surface exists to\n * stop a reader drawing. An aggregation is billed per thousand index\n * entries rather than per document, so the honest figure costs about as\n * much as one row.\n *\n * Withheld entirely when either count fails. Defaulting the total to the\n * attributed figure would render every conversion as attributed, which is\n * the most flattering wrong answer available here.\n *=========================================*/\n /*\n * A PATH STRING and a BOOLEAN, not an object.\n *\n * Both are effect dependencies, and an object rebuilt on every render\n * reopens the effect on every render — which here means paying for two\n * aggregation counts per render rather than per kind. Primitives compare by\n * value, so the reads happen when the question changes and not when React\n * re-runs the component.\n */\n const totalCollectionPath = useMemo((): string | null => {\n switch (kind) {\n case 'form':\n return `hosts/${hostId}/formSubmissions`\n case 'lead':\n /*\n * The ORG's leads (AGL-3275), narrowed below to the ones this site\n * CAPTURED. A lead is one document per person, stamped in\n * `capturedByHostIds` with every site that met them, and its\n * attribution is written by the site that captured it — so this\n * site's captures are exactly the population its lead attributions\n * are drawn from, and the total does not cross hosts.\n *\n * Not `visibleTo`: that also names the sites a consent group shares a\n * person with, and would count leads a sibling brand captured that\n * could never have been credited here. The price is that a\n * collaborator scoped to one site cannot run the count — the rules\n * prove a lead read by `visibleTo` — and is shown the withheld split,\n * as they already are for contacts.\n */\n return dataScope ? `${dataScope[0]}/${dataScope[1]}/leads` : null\n case 'booking':\n return `hosts/${hostId}/bookings`\n case 'contact':\n /*\n * ORG-SCOPED, and the coverage model is told so below. A contact is\n * shared across every site in the org while an attribution belongs to\n * one host, so this total counts contacts created on another site\n * that could never have been credited here.\n */\n return dataScope ? `${dataScope[0]}/${dataScope[1]}/contacts` : null\n default:\n return null\n }\n }, [kind, hostId, dataScope])\n\n /** The total counts only the org records THIS site captured. */\n const totalCapturedHere = kind === 'lead'\n\n /** The kind's records live outside this host, so the total over-counts. */\n const totalCrossesHosts = kind === 'contact'\n\n const [attributedCount, setAttributedCount] = useState<number | null>(null)\n const [totalCount, setTotalCount] = useState<number | null>(null)\n\n useEffect(() => {\n // A campaign-scoped view has no uncredited figure to compute — see the\n // prop's docblock — so it does not pay for one either.\n if (campaignId || !totalCollectionPath) {\n setAttributedCount(null)\n setTotalCount(null)\n return\n }\n let active = true\n setAttributedCount(null)\n setTotalCount(null)\n void getCountFromServer(query(attributions(), where('kind', '==', kind)))\n .then((snapshot) => {\n if (active) setAttributedCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n const segments = totalCollectionPath.split('/')\n const records = collection(firestore, segments[0], ...segments.slice(1))\n void getCountFromServer(\n totalCapturedHere\n ? query(\n records,\n where(CAPTURED_BY_HOST_FIELD, 'array-contains', hostId),\n )\n : records,\n )\n .then((snapshot) => {\n if (active) setTotalCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n return () => {\n active = false\n }\n }, [\n attributions,\n firestore,\n kind,\n campaignId,\n totalCollectionPath,\n totalCapturedHere,\n hostId,\n ])\n\n const coverage = campaignConversionsCoverage({\n kind,\n attributed: attributedCount,\n total: totalCount,\n crossHostTotal: totalCrossesHosts,\n })\n\n /*==========================================\n * LANDING PAGES — a join, and an EXPENSIVE one, so it is asked for.\n *\n * The attribution record carries `refId`; a form submission carries the\n * `path` it was submitted from. Joining the two is the only landing-page\n * answer the stored data supports, and the notice below says plainly what\n * it is not: `path` is where the FORM was, not where the campaign link\n * landed, so a visitor who arrived on one page and submitted on another is\n * grouped under the second.\n *\n * Two reads deep and both bounded: a ceiling over the attributions, then\n * their submissions by document name in chunks of thirty. `getDocs` rather\n * than a listener — a grouping nobody is watching does not need to be kept\n * live, and a listener would go on costing after the reader looked away.\n *=========================================*/\n const [landingBusy, setLandingBusy] = useState(false)\n const [landingError, setLandingError] = useState<string | null>(null)\n const [landing, setLanding] = useState<{\n rows: { $id: string; path: string; conversions: number }[]\n /** Attributions read, which is the population the grouping describes. */\n read: number\n /** The ceiling bit: there are more credited submissions than were read. */\n truncated: boolean\n /** Of `read`, how many named a submission that could not be found. */\n missing: number\n } | null>(null)\n\n // A grouping computed for one kind describes that kind. Switching the mode\n // leaves it on screen labelled as something it is not.\n useEffect(() => {\n setLanding(null)\n setLandingError(null)\n }, [kind, campaignId])\n\n const groupByLandingPage = useCallback(async () => {\n if (landingBusy) return\n setLandingBusy(true)\n setLandingError(null)\n try {\n const credited = await getDocs(\n collectionCeiling(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', 'form'),\n where('campaignId', '==', campaignId),\n )\n : query(attributions(), where('kind', '==', 'form')),\n LANDING_PAGE_CEILING,\n ),\n )\n const { rows: credits, truncated } = ceilingedWindow(\n credited.docs.map((entry) => entry.data() as CampaignConversionRecord),\n LANDING_PAGE_CEILING,\n )\n const refIds = Array.from(\n new Set(\n credits\n .map((record) => String(record.refId ?? ''))\n .filter((id) => id.length > 0),\n ),\n )\n\n const submissions = collection(\n firestore,\n 'hosts',\n hostId,\n 'formSubmissions',\n )\n const paths = new Map<string, string>()\n for (let index = 0; index < refIds.length; index += ID_CHUNK) {\n const chunk = refIds.slice(index, index + ID_CHUNK)\n const found = await getDocs(\n query(submissions, where(documentId(), 'in', chunk)),\n )\n found.docs.forEach((entry) => {\n const path = String((entry.data() as any)?.path ?? '').trim()\n if (path) paths.set(entry.id, path)\n })\n }\n\n const counts = new Map<string, number>()\n let missing = 0\n refIds.forEach((refId) => {\n const path = paths.get(refId)\n if (!path) {\n /*\n * The submission was deleted, or it carries no path — an older one,\n * or a form posted by an integration that sent none. Counted and\n * REPORTED rather than filed under a placeholder page: a bar\n * labelled \"/\" that is really \"we do not know\" is the kind of row a\n * reader acts on.\n */\n missing += 1\n return\n }\n counts.set(path, (counts.get(path) ?? 0) + 1)\n })\n\n setLanding({\n rows: Array.from(counts.entries())\n .map(([path, conversions]) => ({ $id: path, path, conversions }))\n .sort(\n (a, b) =>\n b.conversions - a.conversions || a.path.localeCompare(b.path),\n ),\n read: refIds.length,\n truncated,\n missing,\n })\n } catch (error) {\n console.error(error)\n setLandingError('The landing pages could not be read')\n } finally {\n setLandingBusy(false)\n }\n }, [attributions, campaignId, firestore, hostId, landingBusy])\n\n const campaignHref = (row: ConversionRow) =>\n row.campaignId ? `${basePath}/campaigns/${row.campaignId}` : undefined\n\n const columns = useMemo(\n () => [\n {\n field: 'refId',\n headerName: 'Record',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }: any) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {String(row.refId ?? row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'campaign',\n headerName: 'Credited to',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => {\n const label = campaignTouchLabel(row) || '—'\n const href = campaignHref(row)\n return href ? (\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {label}\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap>\n {label}\n </Typography>\n )\n },\n },\n {\n field: 'convertedAtMs',\n headerName: 'Converted',\n width: 190,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {day(row.convertedAtMs)}\n </Typography>\n ),\n },\n listActionsColumn((row: any) => (\n <ListRowActions\n label={String(row.refId ?? row.$id)}\n items={[\n {\n key: 'campaign',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row),\n disabled: !campaignHref(row),\n /*\n * Shown disabled with the reason rather than hidden. A control\n * that vanishes and one that does not apply look identical from\n * the outside, and only one of them is honest — there is no\n * campaign document behind a `utm_` label to open.\n */\n disabledReason:\n 'This came from a tagged link rather than a campaign email, ' +\n 'so there is no campaign to open',\n },\n ]}\n />\n )),\n ],\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [kind, basePath],\n )\n\n const landingColumns = useMemo(\n () => [\n {\n field: 'path',\n headerName: 'Page the form was on',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.path}\n </Typography>\n ),\n },\n {\n field: 'conversions',\n headerName: 'Credited submissions',\n width: 190,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">\n {Number(row.conversions).toLocaleString()}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const kindCopy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n\n return (\n <CardDisplay\n header=\"Conversions\"\n subheader={\n campaignId\n ? 'Credited to this campaign'\n : 'What the campaigns caused, one kind at a time'\n }\n help={conversionsDocsHelp}\n HeaderProps={{\n action: campaignId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/conversions`}\n size=\"small\"\n color=\"primary\"\n >\n {'All conversions'}\n </Button>\n ) : undefined,\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/*\n THE KIND IS A MODE, NOT A COLUMN.\n\n One form submission by a new person writes a submission, a contact\n and a lead, so any view holding two kinds at once invites a total\n that counts one visit twice. Only one kind's rows can be on screen,\n which is what makes the addition unavailable rather than merely\n discouraged.\n */}\n <Stack spacing={1}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n color=\"primary\"\n value={kind}\n onChange={(_event, next) => {\n if (next) setKind(next as CampaignConversionKind)\n }}\n aria-label=\"Conversion kind\"\n >\n {CAMPAIGN_CONVERSION_KINDS.map((one) => (\n <ToggleButton key={one} value={one}>\n {CAMPAIGN_CONVERSION_KIND_COPY[one].label}\n </ToggleButton>\n ))}\n </ToggleButtonGroup>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'One kind at a time. The four count different things about the ' +\n 'same visits — one person filling in one form appears as a ' +\n 'submission, a contact and a lead — so they are never shown ' +\n 'together and never added.'}\n </Typography>\n </Stack>\n\n <Divider />\n\n {/*==========================================\n * WHAT IS NOT CREDITED, beside what is.\n *\n * The tables below list the successes. Without this strip the reader\n * has no way to see how much of their site's activity the campaigns\n * account for, and every campaign reads as more effective than it is.\n *=========================================*/}\n {campaignId ? (\n <Alert severity=\"info\">\n {'This is one campaign’s share. Conversions credited to no ' +\n 'campaign belong to no campaign, so they are not counted here ' +\n '— open all conversions for the site-wide figure.'}\n </Alert>\n ) : coverage ? (\n <Section title={`${kindCopy.label} on this site`}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Credited to a campaign\"\n value={coverage.attributed}\n note=\"from a campaign email or a tagged link\"\n />\n <Figure\n label=\"Not credited\"\n value={coverage.unattributed}\n note=\"arrived directly, or predate attribution\"\n />\n <Figure\n label={`${kindCopy.label} in total`}\n value={coverage.total}\n note={\n coverage.exact ? 'on this site' : 'on this site, all time'\n }\n />\n </Stack>\n {coverage.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n </Section>\n ) : (\n <Alert severity=\"info\">\n {'How many of these were credited to no campaign could not be ' +\n 'counted, so it is not shown. The lists below are the credited ' +\n 'ones only and are not a count of everything that happened.'}\n </Alert>\n )}\n\n <Divider />\n\n <Section title=\"From campaign emails\">\n <ListTable\n rows={emailList.rows}\n columns={columns as any}\n hideFooter\n // One page of a walk in stored order; a header sort would order\n // only that page.\n disableColumnSorting\n onOpen={(_id, row) => {\n const href = campaignHref(row as ConversionRow)\n if (href) router.push(href)\n }}\n noRowsLabel=\"Nothing credited to a campaign email\"\n noRowsDescription={\n 'A conversion is credited when the visitor followed a link in ' +\n 'a campaign email before converting.'\n }\n />\n <ListPagination\n page={emailList.page}\n pageSize={emailList.pageSize}\n rowCount={emailList.rows.length}\n hasMore={emailList.hasMore}\n onPageChange={emailList.setPage}\n onPageSizeChange={emailList.setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Listed in the order they are stored, which is not the order ' +\n 'they happened in — every row is reachable by paging, but the ' +\n 'first page is not the most recent conversions.'}\n </Typography>\n </Section>\n\n {/*==========================================\n * THE WEB CHANNEL — its own list, and no rollup.\n *\n * A `utm_` label is text somebody typed into a URL. There is no\n * document behind it, no id, and no bound on how many distinct\n * values exist, so it gets no rollup under a campaign and no summary\n * keyed on the label: a map anybody who can vary a query string can\n * grow is a map that grows. The records stand on their own and this\n * lists them.\n *\n * Absent entirely on a campaign-scoped view, because these belong to\n * no campaign.\n *=========================================*/}\n {campaignId ? null : (\n <Section title=\"From tagged web links\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Credited to the utm_ label on the link the visitor followed. ' +\n 'These are listed one by one rather than grouped: the label ' +\n 'is free text with no campaign behind it, so there is ' +\n 'nothing to open and no fixed set to total.'}\n </Typography>\n <ListTable\n rows={webList.rows}\n columns={columns as any}\n hideFooter\n // One page of a walk in stored order; a header sort would\n // order only that page.\n disableColumnSorting\n noRowsLabel=\"Nothing credited to a tagged link\"\n noRowsDescription={\n 'A conversion lands here when the visitor arrived on a URL ' +\n 'carrying utm_ parameters.'\n }\n />\n <ListPagination\n page={webList.page}\n pageSize={webList.pageSize}\n rowCount={webList.rows.length}\n hasMore={webList.hasMore}\n onPageChange={webList.setPage}\n onPageSizeChange={webList.setPageSize}\n />\n </Section>\n )}\n\n {/*==========================================\n * LANDING PAGES, on request.\n *\n * `refId` on the record, `path` on the submission: joining those is\n * the landing-page surface, and it is hundreds of reads, so it is a\n * button rather than something every reader of this page pays for.\n *=========================================*/}\n {kind === 'form' ? (\n <>\n <Divider />\n <Section title=\"Landing pages\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Groups credited form submissions by the page the form was ' +\n 'on. This reads up to ' +\n `${LANDING_PAGE_CEILING.toLocaleString()} attribution ` +\n 'records and their submissions, so it runs when you ask ' +\n 'for it rather than on every visit.'}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={landingBusy}\n startIcon={\n <MdiIcon path={mdiMapMarkerOutline.path} size={0.8} />\n }\n onClick={() => void groupByLandingPage()}\n >\n {landingBusy ? 'Reading…' : 'Group by landing page'}\n </Button>\n </Stack>\n {landingError ? (\n <Alert severity=\"warning\">{landingError}</Alert>\n ) : null}\n {landing ? (\n <Stack spacing={1}>\n {/*\n WHAT THIS CANNOT ANSWER, above the table rather than under\n it. Every line is a question a reader will otherwise\n assume it answered.\n */}\n <Alert severity=\"info\">\n {'This is the page each form sat on, not the page the ' +\n 'campaign link landed on — a visitor who arrived on ' +\n 'one page and submitted on another is counted under ' +\n 'the second. There is no conversion RATE here either: ' +\n 'the record carries no count of visits to a page from ' +\n 'a campaign, so there is no denominator to divide by. ' +\n 'Only form submissions carry a page at all, so leads, ' +\n 'contacts and bookings have no landing page.'}\n </Alert>\n {landing.truncated ? (\n <Alert severity=\"warning\">\n {`More than ${LANDING_PAGE_CEILING.toLocaleString()} ` +\n 'credited submissions exist. This groups the ' +\n `${landing.read.toLocaleString()} that were read, ` +\n 'which are not the most recent — they are the ones ' +\n 'that came first in storage order.'}\n </Alert>\n ) : null}\n {landing.missing ? (\n <Alert severity=\"info\">\n {`${landing.missing.toLocaleString()} of the ` +\n `${landing.read.toLocaleString()} credited ` +\n 'submissions are not grouped: the submission has ' +\n 'been deleted, or it recorded no page. They are ' +\n 'reported here rather than filed under a page they ' +\n 'may not have come from.'}\n </Alert>\n ) : null}\n <ListTable\n rows={landing.rows}\n columns={landingColumns as any}\n hideFooter\n noRowsLabel=\"No pages to group\"\n noRowsDescription=\"None of the credited submissions recorded a page.\"\n />\n <Stack direction=\"row\" spacing={1}>\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.read.toLocaleString()} credited submissions read`}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.rows.length.toLocaleString()} pages`}\n />\n </Stack>\n </Stack>\n ) : null}\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCampaignConversionsCard.displayName = 'CampaignConversionsCard'\n\nexport default CampaignConversionsCard\n"],"names":["CAPTURED_BY_HOST_FIELD","pluginDocsHelp","mdiEyeOutline","mdiMapMarkerOutline","AppLink","CardDisplay","MdiIcon","ListRowActions","ListTable","listActionsColumn","ListPagination","Figure","Section","Alert","Button","Chip","Divider","Stack","ToggleButton","ToggleButtonGroup","Typography","collection","documentId","getCountFromServer","getDocs","query","where","useCallback","useEffect","useMemo","useState","useRouter","useFirestore","useOrgDataScope","usePagedCollection","ceilingedWindow","collectionCeiling","collectionPage","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","campaignConversionsCoverage","campaignTouchLabel","LANDING_PAGE_CEILING","ID_CHUNK","conversionsDocsHelp","anchor","excerpt","day","ms","value","Number","isFinite","Date","toLocaleString","CampaignConversionsCard","props","hostId","basePath","campaignId","firestore","router","scope","dataScope","kind","setKind","attributions","emailList","pageLimit","idField","webList","totalCollectionPath","totalCapturedHere","totalCrossesHosts","attributedCount","setAttributedCount","totalCount","setTotalCount","active","then","snapshot","data","count","catch","segments","split","records","slice","coverage","attributed","total","crossHostTotal","landingBusy","setLandingBusy","landingError","setLandingError","landing","setLanding","groupByLandingPage","credited","rows","credits","truncated","docs","map","entry","refIds","Array","from","Set","record","String","refId","filter","id","length","submissions","paths","Map","index","chunk","found","forEach","path","trim","set","counts","missing","get","entries","conversions","$id","sort","a","b","localeCompare","read","error","console","campaignHref","row","undefined","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","color","label","href","onClick","event","stopPropagation","width","convertedAtMs","items","key","icon","size","disabled","disabledReason","landingColumns","align","headerAlign","kindCopy","header","subheader","help","HeaderProps","action","component","componentVariant","nativeButton","contentGutterX","contentGutterY","spacing","exclusive","onChange","_event","next","aria-label","one","severity","title","direction","useFlexGap","flexWrap","note","unattributed","exact","caveats","caveat","message","hideFooter","disableColumnSorting","onOpen","_id","push","noRowsLabel","noRowsDescription","page","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize","startIcon","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-conversions-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { pluginRecordCountSource } from '@aglyn/aglyn/plugin-manager/plugin-record-counts'\nimport {\n PLUGIN_RECORD_LIST_IDS_MAX,\n pluginRecordListByIdsQuery,\n pluginRecordsFromRows,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-lists'\nimport { mdiEyeOutline, mdiMapMarkerOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Figure, Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n getCountFromServer,\n getDocs,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useFirestore,\n useOrgDataScope,\n usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\n/*\n * The MODULE, not the barrel, for the pure query helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n collectionPage,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n CAMPAIGN_CONVERSION_RECORD_KINDS,\n campaignConversionsCoverage,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '../model/campaign-conversions'\n\n/**\n * WHAT THE CAMPAIGNS CAUSED, from the conversions' end.\n *\n * A campaign's own report says what that campaign caused. This is the list\n * the report cannot be: every credited conversion on the site, the ones\n * credited to a tagged web link rather than to a campaign document, and — the\n * half a report of successes can never show — how many were credited to\n * nothing at all.\n *\n * ## ONE KIND AT A TIME, and that is the whole design\n *\n * A form submission by a new person writes a submission record, a contact and\n * a lead. Three true statements about one visit, and a screen that showed\n * them together would invite the reader to add them and treble every figure.\n * So the kind is a MODE rather than a column: the query carries\n * `where('kind','==', …)`, only one kind's rows are ever on screen, and there\n * is no view in which two kinds could be totalled because there is no view in\n * which two kinds are both present.\n *\n * ## THE TWO CHANNELS ARE TWO LISTS, and never one\n *\n * An email touch names a campaign document — a real entity with a report to\n * open. A web touch names `utm_` text a marketer typed into a URL: no\n * document, no id, no bound on how many distinct values exist. Merging them\n * would put a linkable record and an unlinkable label in one column and imply\n * they are the same kind of thing, and grouping the web side into a rollup\n * would build a map anybody who can vary a query string can grow. So there\n * are two tables, the web one is read as records, and neither is summed into\n * the other.\n *\n * ## The read cost\n *\n * Two paged listeners bounded by the page size, and two aggregation counts.\n * Nothing scans a collection, no row triggers a read of its own, and the\n * landing-page join — the only expensive thing here — is behind a button and\n * says what it will cost before it runs.\n */\n\n/**\n * How many attribution records the landing-page join reads.\n *\n * A CEILING rather than a page size: grouping is over a window and a window\n * sliced ten at a time cannot be grouped at all. Kept low because this is the\n * one read on the surface that is measured in hundreds — see the button that\n * asks for it.\n */\nconst LANDING_PAGE_CEILING = 100\n\n/** The most submissions one by-name read asks for — Firestore's `in` cap. */\nconst ID_CHUNK = PLUGIN_RECORD_LIST_IDS_MAX\n\n/** A credited form conversion's `refId` names a submission of this kind. */\nconst SUBMISSION_RECORD_KIND = CAMPAIGN_CONVERSION_RECORD_KINDS.form\n\nconst conversionsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A conversion is credited to the last campaign whose link the visitor ' +\n 'followed. The four kinds count different things about the same visits ' +\n 'and are never added together.',\n})\n\n/** The stored record plus the document name the reader listed it under. */\ntype ConversionRow = CampaignConversionRecord & { $id: string }\n\nconst day = (ms: unknown): string => {\n const value = Number(ms ?? 0)\n return Number.isFinite(value) && value > 0\n ? new Date(value).toLocaleString()\n : '—'\n}\n\nexport interface CampaignConversionsCardProps {\n hostId: string\n /** The marketing hub URL, for the link to each credited campaign. */\n basePath: string\n /**\n * One campaign's conversions rather than the whole site's.\n *\n * Set from `/marketing/conversions/{campaignId}`. It narrows the EMAIL list\n * only: a web-channel record carries no campaign id, so there is nothing\n * for it to be narrowed by — and the uncredited figure is withheld\n * entirely, because a conversion credited to nobody belongs to no campaign\n * and attributing the site's whole direct traffic to whichever campaign the\n * reader happens to be looking at is the exact inference this join refuses\n * to make.\n */\n campaignId?: string\n}\n\nexport function CampaignConversionsCard(props: CampaignConversionsCardProps) {\n const { hostId, basePath, campaignId } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const [kind, setKind] = useState<CampaignConversionKind>('form')\n\n const attributions = useCallback(\n () => collection(firestore, 'hosts', hostId, 'campaignAttributions'),\n [firestore, hostId],\n )\n\n /*==========================================\n * THE TWO LISTS.\n *\n * Both walk the collection in DOCUMENT-NAME order through `collectionPage`,\n * which is the console's one ordering decision: `orderBy` on a field\n * matches only documents that HAVE it, so ordering on a date would hide\n * rows rather than merely mis-order them, and an unordered `limit()` is a\n * pseudo-random sample whose gaps leave nothing to notice. The walk is\n * total — every row is reachable by paging — and it is deliberately NOT\n * newest-first, which the note under each table says out loud.\n *\n * `campaignId` narrows the email list when the reader arrived from one\n * campaign. It is never applied to the web list: a `utm_` label is not a\n * campaign id and a query pretending otherwise would return nothing while\n * reading as \"this campaign caused no web conversions\".\n *=========================================*/\n const emailList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n collectionPage(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', kind),\n where('campaignId', '==', campaignId),\n )\n : query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'email'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n const webList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n campaignId\n ? null\n : collectionPage(\n query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'web'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE UNCREDITED HALF.\n *\n * Two aggregation counts, and they are on the page by DEFAULT rather than\n * behind a button, because the tables above them are a list of the\n * successes: showing only those renders \"we credited nine of these\" as\n * \"nine of these happened\", which is the conclusion this surface exists to\n * stop a reader drawing. An aggregation is billed per thousand index\n * entries rather than per document, so the honest figure costs about as\n * much as one row.\n *\n * Withheld entirely when either count fails. Defaulting the total to the\n * attributed figure would render every conversion as attributed, which is\n * the most flattering wrong answer available here.\n *=========================================*/\n /*\n * THE TOTAL IS COUNTED BY THE PLUGIN THAT KEEPS THE RECORDS\n * (`plugin-record-counts`, AGL-3080): the forms plugin's submissions, the\n * bookings plugin's bookings, the record system's leads and contacts. The\n * owner says which of its records this site produced — its own\n * submissions and bookings, the leads it CAPTURED (AGL-3275; not the ones\n * a consent group merely shares with it), and for contacts, which the\n * organization holds once for every site, all of them, which the owner\n * marks as a count that crosses sites. With no owner in this console the\n * total is withheld exactly as a failed count is.\n *\n * The source is resolved per kind and the query built inside the effect,\n * from primitives: a query object rebuilt on every render as a dependency\n * would reopen the effect, and pay for two aggregation counts, on every\n * render rather than per kind.\n */\n const orgId = dataScope ? dataScope[1] : null\n const countSource = useMemo(\n () => pluginRecordCountSource(CAMPAIGN_CONVERSION_RECORD_KINDS[kind]),\n [kind],\n )\n\n /** The kind's records live outside this host, so the total over-counts. */\n const totalCrossesHosts = countSource?.crossesSites === true\n\n const [attributedCount, setAttributedCount] = useState<number | null>(null)\n const [totalCount, setTotalCount] = useState<number | null>(null)\n\n useEffect(() => {\n // A campaign-scoped view has no uncredited figure to compute — see the\n // prop's docblock — so it does not pay for one either.\n const totalQuery = campaignId\n ? null\n : (countSource?.query(firestore, { hostId, orgId }) ?? null)\n if (!totalQuery) {\n setAttributedCount(null)\n setTotalCount(null)\n return\n }\n let active = true\n setAttributedCount(null)\n setTotalCount(null)\n void getCountFromServer(query(attributions(), where('kind', '==', kind)))\n .then((snapshot) => {\n if (active) setAttributedCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n void getCountFromServer(totalQuery)\n .then((snapshot) => {\n if (active) setTotalCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n return () => {\n active = false\n }\n }, [attributions, firestore, kind, campaignId, countSource, hostId, orgId])\n\n const coverage = campaignConversionsCoverage({\n kind,\n attributed: attributedCount,\n total: totalCount,\n crossHostTotal: totalCrossesHosts,\n })\n\n /*==========================================\n * LANDING PAGES — a join, and an EXPENSIVE one, so it is asked for.\n *\n * The attribution record carries `refId`; a form submission carries the\n * `path` it was submitted from. Joining the two is the only landing-page\n * answer the stored data supports, and the notice below says plainly what\n * it is not: `path` is where the FORM was, not where the campaign link\n * landed, so a visitor who arrived on one page and submitted on another is\n * grouped under the second.\n *\n * Two reads deep and both bounded: a ceiling over the attributions, then\n * their submissions by document name in chunks of thirty. `getDocs` rather\n * than a listener — a grouping nobody is watching does not need to be kept\n * live, and a listener would go on costing after the reader looked away.\n *=========================================*/\n const [landingBusy, setLandingBusy] = useState(false)\n const [landingError, setLandingError] = useState<string | null>(null)\n const [landing, setLanding] = useState<{\n rows: { $id: string; path: string; conversions: number }[]\n /** Attributions read, which is the population the grouping describes. */\n read: number\n /** The ceiling bit: there are more credited submissions than were read. */\n truncated: boolean\n /** Of `read`, how many named a submission that could not be found. */\n missing: number\n } | null>(null)\n\n // A grouping computed for one kind describes that kind. Switching the mode\n // leaves it on screen labelled as something it is not.\n useEffect(() => {\n setLanding(null)\n setLandingError(null)\n }, [kind, campaignId])\n\n const groupByLandingPage = useCallback(async () => {\n if (landingBusy) return\n setLandingBusy(true)\n setLandingError(null)\n try {\n const credited = await getDocs(\n collectionCeiling(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', 'form'),\n where('campaignId', '==', campaignId),\n )\n : query(attributions(), where('kind', '==', 'form')),\n LANDING_PAGE_CEILING,\n ),\n )\n const { rows: credits, truncated } = ceilingedWindow(\n credited.docs.map((entry) => entry.data() as CampaignConversionRecord),\n LANDING_PAGE_CEILING,\n )\n const refIds = Array.from(\n new Set(\n credits\n .map((record) => String(record.refId ?? ''))\n .filter((id) => id.length > 0),\n ),\n )\n\n /*\n * The page each credited submission was sent from, asked of the plugin\n * that keeps submissions (`plugin-record-lists`, by name), a chunk at a\n * time. With no such plugin here every one reads as not found, and is\n * counted as missing below rather than grouped under a guess.\n */\n const paths = new Map<string, string>()\n for (let index = 0; index < refIds.length; index += ID_CHUNK) {\n const named = pluginRecordListByIdsQuery(SUBMISSION_RECORD_KIND, firestore, {\n hostId,\n ids: refIds.slice(index, index + ID_CHUNK),\n })\n if (!named) continue\n const found = await getDocs(named)\n const records = pluginRecordsFromRows(\n SUBMISSION_RECORD_KIND,\n found.docs.map((entry) => ({ ...entry.data(), $id: entry.id })),\n )\n for (const record of records) {\n const path = String(record.facts['path'] ?? '').trim()\n if (path) paths.set(record.id, path)\n }\n }\n\n const counts = new Map<string, number>()\n let missing = 0\n refIds.forEach((refId) => {\n const path = paths.get(refId)\n if (!path) {\n /*\n * The submission was deleted, or it carries no path — an older one,\n * or a form posted by an integration that sent none. Counted and\n * REPORTED rather than filed under a placeholder page: a bar\n * labelled \"/\" that is really \"we do not know\" is the kind of row a\n * reader acts on.\n */\n missing += 1\n return\n }\n counts.set(path, (counts.get(path) ?? 0) + 1)\n })\n\n setLanding({\n rows: Array.from(counts.entries())\n .map(([path, conversions]) => ({ $id: path, path, conversions }))\n .sort(\n (a, b) =>\n b.conversions - a.conversions || a.path.localeCompare(b.path),\n ),\n read: refIds.length,\n truncated,\n missing,\n })\n } catch (error) {\n console.error(error)\n setLandingError('The landing pages could not be read')\n } finally {\n setLandingBusy(false)\n }\n }, [attributions, campaignId, firestore, hostId, landingBusy])\n\n const campaignHref = (row: ConversionRow) =>\n row.campaignId ? `${basePath}/campaigns/${row.campaignId}` : undefined\n\n const columns = useMemo(\n () => [\n {\n field: 'refId',\n headerName: 'Record',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }: any) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {String(row.refId ?? row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'campaign',\n headerName: 'Credited to',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => {\n const label = campaignTouchLabel(row) || '—'\n const href = campaignHref(row)\n return href ? (\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {label}\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap>\n {label}\n </Typography>\n )\n },\n },\n {\n field: 'convertedAtMs',\n headerName: 'Converted',\n width: 190,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {day(row.convertedAtMs)}\n </Typography>\n ),\n },\n listActionsColumn((row: any) => (\n <ListRowActions\n label={String(row.refId ?? row.$id)}\n items={[\n {\n key: 'campaign',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row),\n disabled: !campaignHref(row),\n /*\n * Shown disabled with the reason rather than hidden. A control\n * that vanishes and one that does not apply look identical from\n * the outside, and only one of them is honest — there is no\n * campaign document behind a `utm_` label to open.\n */\n disabledReason:\n 'This came from a tagged link rather than a campaign email, ' +\n 'so there is no campaign to open',\n },\n ]}\n />\n )),\n ],\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [kind, basePath],\n )\n\n const landingColumns = useMemo(\n () => [\n {\n field: 'path',\n headerName: 'Page the form was on',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.path}\n </Typography>\n ),\n },\n {\n field: 'conversions',\n headerName: 'Credited submissions',\n width: 190,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">\n {Number(row.conversions).toLocaleString()}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const kindCopy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n\n return (\n <CardDisplay\n header=\"Conversions\"\n subheader={\n campaignId\n ? 'Credited to this campaign'\n : 'What the campaigns caused, one kind at a time'\n }\n help={conversionsDocsHelp}\n HeaderProps={{\n action: campaignId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/conversions`}\n size=\"small\"\n color=\"primary\"\n >\n {'All conversions'}\n </Button>\n ) : undefined,\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/*\n THE KIND IS A MODE, NOT A COLUMN.\n\n One form submission by a new person writes a submission, a contact\n and a lead, so any view holding two kinds at once invites a total\n that counts one visit twice. Only one kind's rows can be on screen,\n which is what makes the addition unavailable rather than merely\n discouraged.\n */}\n <Stack spacing={1}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n color=\"primary\"\n value={kind}\n onChange={(_event, next) => {\n if (next) setKind(next as CampaignConversionKind)\n }}\n aria-label=\"Conversion kind\"\n >\n {CAMPAIGN_CONVERSION_KINDS.map((one) => (\n <ToggleButton key={one} value={one}>\n {CAMPAIGN_CONVERSION_KIND_COPY[one].label}\n </ToggleButton>\n ))}\n </ToggleButtonGroup>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'One kind at a time. The four count different things about the ' +\n 'same visits — one person filling in one form appears as a ' +\n 'submission, a contact and a lead — so they are never shown ' +\n 'together and never added.'}\n </Typography>\n </Stack>\n\n <Divider />\n\n {/*==========================================\n * WHAT IS NOT CREDITED, beside what is.\n *\n * The tables below list the successes. Without this strip the reader\n * has no way to see how much of their site's activity the campaigns\n * account for, and every campaign reads as more effective than it is.\n *=========================================*/}\n {campaignId ? (\n <Alert severity=\"info\">\n {'This is one campaign’s share. Conversions credited to no ' +\n 'campaign belong to no campaign, so they are not counted here ' +\n '— open all conversions for the site-wide figure.'}\n </Alert>\n ) : coverage ? (\n <Section title={`${kindCopy.label} on this site`}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Credited to a campaign\"\n value={coverage.attributed}\n note=\"from a campaign email or a tagged link\"\n />\n <Figure\n label=\"Not credited\"\n value={coverage.unattributed}\n note=\"arrived directly, or predate attribution\"\n />\n <Figure\n label={`${kindCopy.label} in total`}\n value={coverage.total}\n note={\n coverage.exact ? 'on this site' : 'on this site, all time'\n }\n />\n </Stack>\n {coverage.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n </Section>\n ) : (\n <Alert severity=\"info\">\n {'How many of these were credited to no campaign could not be ' +\n 'counted, so it is not shown. The lists below are the credited ' +\n 'ones only and are not a count of everything that happened.'}\n </Alert>\n )}\n\n <Divider />\n\n <Section title=\"From campaign emails\">\n <ListTable\n rows={emailList.rows}\n columns={columns as any}\n hideFooter\n // One page of a walk in stored order; a header sort would order\n // only that page.\n disableColumnSorting\n onOpen={(_id, row) => {\n const href = campaignHref(row as ConversionRow)\n if (href) router.push(href)\n }}\n noRowsLabel=\"Nothing credited to a campaign email\"\n noRowsDescription={\n 'A conversion is credited when the visitor followed a link in ' +\n 'a campaign email before converting.'\n }\n />\n <ListPagination\n page={emailList.page}\n pageSize={emailList.pageSize}\n rowCount={emailList.rows.length}\n hasMore={emailList.hasMore}\n onPageChange={emailList.setPage}\n onPageSizeChange={emailList.setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Listed in the order they are stored, which is not the order ' +\n 'they happened in — every row is reachable by paging, but the ' +\n 'first page is not the most recent conversions.'}\n </Typography>\n </Section>\n\n {/*==========================================\n * THE WEB CHANNEL — its own list, and no rollup.\n *\n * A `utm_` label is text somebody typed into a URL. There is no\n * document behind it, no id, and no bound on how many distinct\n * values exist, so it gets no rollup under a campaign and no summary\n * keyed on the label: a map anybody who can vary a query string can\n * grow is a map that grows. The records stand on their own and this\n * lists them.\n *\n * Absent entirely on a campaign-scoped view, because these belong to\n * no campaign.\n *=========================================*/}\n {campaignId ? null : (\n <Section title=\"From tagged web links\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Credited to the utm_ label on the link the visitor followed. ' +\n 'These are listed one by one rather than grouped: the label ' +\n 'is free text with no campaign behind it, so there is ' +\n 'nothing to open and no fixed set to total.'}\n </Typography>\n <ListTable\n rows={webList.rows}\n columns={columns as any}\n hideFooter\n // One page of a walk in stored order; a header sort would\n // order only that page.\n disableColumnSorting\n noRowsLabel=\"Nothing credited to a tagged link\"\n noRowsDescription={\n 'A conversion lands here when the visitor arrived on a URL ' +\n 'carrying utm_ parameters.'\n }\n />\n <ListPagination\n page={webList.page}\n pageSize={webList.pageSize}\n rowCount={webList.rows.length}\n hasMore={webList.hasMore}\n onPageChange={webList.setPage}\n onPageSizeChange={webList.setPageSize}\n />\n </Section>\n )}\n\n {/*==========================================\n * LANDING PAGES, on request.\n *\n * `refId` on the record, `path` on the submission: joining those is\n * the landing-page surface, and it is hundreds of reads, so it is a\n * button rather than something every reader of this page pays for.\n *=========================================*/}\n {kind === 'form' ? (\n <>\n <Divider />\n <Section title=\"Landing pages\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Groups credited form submissions by the page the form was ' +\n 'on. This reads up to ' +\n `${LANDING_PAGE_CEILING.toLocaleString()} attribution ` +\n 'records and their submissions, so it runs when you ask ' +\n 'for it rather than on every visit.'}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={landingBusy}\n startIcon={\n <MdiIcon path={mdiMapMarkerOutline.path} size={0.8} />\n }\n onClick={() => void groupByLandingPage()}\n >\n {landingBusy ? 'Reading…' : 'Group by landing page'}\n </Button>\n </Stack>\n {landingError ? (\n <Alert severity=\"warning\">{landingError}</Alert>\n ) : null}\n {landing ? (\n <Stack spacing={1}>\n {/*\n WHAT THIS CANNOT ANSWER, above the table rather than under\n it. Every line is a question a reader will otherwise\n assume it answered.\n */}\n <Alert severity=\"info\">\n {'This is the page each form sat on, not the page the ' +\n 'campaign link landed on — a visitor who arrived on ' +\n 'one page and submitted on another is counted under ' +\n 'the second. There is no conversion RATE here either: ' +\n 'the record carries no count of visits to a page from ' +\n 'a campaign, so there is no denominator to divide by. ' +\n 'Only form submissions carry a page at all, so leads, ' +\n 'contacts and bookings have no landing page.'}\n </Alert>\n {landing.truncated ? (\n <Alert severity=\"warning\">\n {`More than ${LANDING_PAGE_CEILING.toLocaleString()} ` +\n 'credited submissions exist. This groups the ' +\n `${landing.read.toLocaleString()} that were read, ` +\n 'which are not the most recent — they are the ones ' +\n 'that came first in storage order.'}\n </Alert>\n ) : null}\n {landing.missing ? (\n <Alert severity=\"info\">\n {`${landing.missing.toLocaleString()} of the ` +\n `${landing.read.toLocaleString()} credited ` +\n 'submissions are not grouped: the submission has ' +\n 'been deleted, or it recorded no page. They are ' +\n 'reported here rather than filed under a page they ' +\n 'may not have come from.'}\n </Alert>\n ) : null}\n <ListTable\n rows={landing.rows}\n columns={landingColumns as any}\n hideFooter\n noRowsLabel=\"No pages to group\"\n noRowsDescription=\"None of the credited submissions recorded a page.\"\n />\n <Stack direction=\"row\" spacing={1}>\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.read.toLocaleString()} credited submissions read`}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.rows.length.toLocaleString()} pages`}\n />\n </Stack>\n </Stack>\n ) : null}\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCampaignConversionsCard.displayName = 'CampaignConversionsCard'\n\nexport default CampaignConversionsCard\n"],"names":["pluginDocsHelp","pluginRecordCountSource","PLUGIN_RECORD_LIST_IDS_MAX","pluginRecordListByIdsQuery","pluginRecordsFromRows","mdiEyeOutline","mdiMapMarkerOutline","AppLink","CardDisplay","MdiIcon","ListRowActions","ListTable","listActionsColumn","ListPagination","Figure","Section","Alert","Button","Chip","Divider","Stack","ToggleButton","ToggleButtonGroup","Typography","collection","getCountFromServer","getDocs","query","where","useCallback","useEffect","useMemo","useState","useRouter","useFirestore","useOrgDataScope","usePagedCollection","ceilingedWindow","collectionCeiling","collectionPage","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","CAMPAIGN_CONVERSION_RECORD_KINDS","campaignConversionsCoverage","campaignTouchLabel","LANDING_PAGE_CEILING","ID_CHUNK","SUBMISSION_RECORD_KIND","form","conversionsDocsHelp","anchor","excerpt","day","ms","value","Number","isFinite","Date","toLocaleString","CampaignConversionsCard","props","hostId","basePath","campaignId","firestore","router","scope","dataScope","kind","setKind","attributions","emailList","pageLimit","idField","webList","orgId","countSource","totalCrossesHosts","crossesSites","attributedCount","setAttributedCount","totalCount","setTotalCount","totalQuery","active","then","snapshot","data","count","catch","coverage","attributed","total","crossHostTotal","landingBusy","setLandingBusy","landingError","setLandingError","landing","setLanding","groupByLandingPage","credited","rows","credits","truncated","docs","map","entry","refIds","Array","from","Set","record","String","refId","filter","id","length","paths","Map","index","named","ids","slice","found","records","$id","path","facts","trim","set","counts","missing","forEach","get","entries","conversions","sort","a","b","localeCompare","read","error","console","campaignHref","row","undefined","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","color","label","href","onClick","event","stopPropagation","width","convertedAtMs","items","key","icon","size","disabled","disabledReason","landingColumns","align","headerAlign","kindCopy","header","subheader","help","HeaderProps","action","component","componentVariant","nativeButton","contentGutterX","contentGutterY","spacing","exclusive","onChange","_event","next","aria-label","one","severity","title","direction","useFlexGap","flexWrap","note","unattributed","exact","caveats","caveat","message","hideFooter","disableColumnSorting","onOpen","_id","push","noRowsLabel","noRowsDescription","page","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize","startIcon","displayName"],"mappings":"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"}
@@ -38,7 +38,8 @@ import { useCallback, useMemo, useState } from "react";
38
38
  import { useRouter } from "next/navigation";
39
39
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
40
40
  import { useFirestore, useFirestoreCollection, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
41
- import { campaignForcedListUnsubscribeSends, campaignListUnsubscribe, campaignRollup, campaignSendDisplay, campaignSiteIds, campaignVisibleTo, campaignWindowState, emailListTimeMs } from "@aglyn/shared-ui-email-campaigns/model";
41
+ import { campaignForcedListUnsubscribeSends, campaignListUnsubscribe, campaignRollup, campaignSendDisplay, campaignSiteIds, campaignVisibleTo, campaignWindowState } from "../model/campaign-container.js";
42
+ import { emailListTimeMs } from "../model/email-record.js";
42
43
  import { CAMPAIGN_EMAILS_QUERY, CAMPAIGN_SEND_STATUS_OPTIONS, campaignContainerSearchFields, campaignSendsScope } from "../model/campaign-list-query.js";
43
44
  import CampaignComposer from "./campaign-composer.js";
44
45
  import CampaignEditDrawer from "./campaign-edit-drawer.js";