@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
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-reach-sections.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n MoneyFigure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Button,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n getCountFromServer,\n getDoc,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n campaignSequencesReport,\n type CampaignLinkRollup,\n type CampaignSequencesRollup,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { EMAIL_ATTRIBUTION_WINDOW_DAYS } from '@aglyn/shared-util-email'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n type CampaignConversionKind,\n} from '../model/campaign-conversions'\nimport {\n campaignRevenueAcrossSends,\n type CampaignRevenueAcrossSends,\n type CampaignRevenueRollup,\n} from '../model/campaign-revenue'\nimport {\n campaignSendReportDoc,\n campaignSequenceReportDoc,\n} from './campaign-queries'\n\n/**\n * A CAMPAIGN BEYOND ITS MAIL — what it caused, what it earned, and where it\n * sent people.\n *\n * ## Attribution is the mechanism, and there is no other one\n *\n * A campaign owns no screen, no form and no overlay. Nothing in the stored\n * model connects `emailCampaigns/{id}` to a page a visitor lands on:\n * `HostExperiment` names a screen and never a campaign, `HostOverlay` names\n * neither, and a form document carries no campaign field. So a section\n * claiming a campaign CONTAINS a landing page would be inventing an edge, and\n * the containment would be a claim nothing in the data supports.\n *\n * What the data does support is EVIDENCE, of two kinds, and both are already\n * being written:\n *\n * - **Where it sent people.** `campaigns/{sendId}/reports/links` counts\n * clicks per destination URL, so the union across a campaign's emails is\n * the set of pages that campaign drove traffic to — observed rather than\n * declared, which is also why it stays true when a marketer re-points a\n * link.\n * - **What it caused there.** `campaignAttributions` records one row per\n * identify moment — a form submission, a lead, a contact, a booking —\n * carrying the SEND whose link the visitor followed.\n * - **What it earned there.** `campaigns/{sendId}/reports/revenue` buckets\n * the orders credited to that send by currency, so the merge across a\n * campaign's emails is what the campaign was credited with — per currency,\n * because the buckets have units and a total across them would not.\n *\n * All three sections below therefore hang off the campaign's own send ids,\n * which is the only handle any of those collections offers.\n *\n * ## What neither section can say, and says so instead\n *\n * A conversion credited to a WEB touch carries `utm_` labels and no send id.\n * A campaign container has no `utm_` label of its own — a marketer types\n * those into a URL — so there is no join to make, and no amount of matching\n * on the campaign's NAME would be one: two campaigns may share a name, and a\n * label is a string anybody who can vary a query string can mint. The\n * site-wide Conversions section is where the web channel is read, and the\n * copy below points at it rather than quietly leaving it out.\n *\n * A conversion with no touch at all is credited to nobody and is in neither\n * collection. The same holds for an order: one placed by somebody who never\n * clicked is credited to no campaign. That makes a page showing only credited\n * outcomes a partial account by construction, which is why the figures are\n * labelled \"credited to this campaign\" everywhere they appear.\n */\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nexport interface CampaignReachProps {\n /**\n * The org whose sends the rollups sit under, `orgs/{orgId}/campaigns/{id}/\n * reports/*`; nothing is read until it is known.\n */\n orgId: string | null\n /**\n * The campaign's emails, by send id — the handle both collections join on.\n *\n * A stable array is not required: both sections key their reads on the\n * joined string, so a caller rebuilding the array every render pays for one\n * set of reads rather than one per render.\n */\n sendIds: readonly string[]\n /** The campaign holds more emails than `sendIds` names. */\n truncated: boolean\n}\n\n/** The ids as one primitive, so an effect depends on the VALUE not the array. */\nconst idsKey = (sendIds: readonly string[]): string => sendIds.join(',')\n\n/** `['a','b','c']` in runs of at most {@link ID_CHUNK}. */\nfunction chunked(ids: readonly string[]): string[][] {\n const out: string[][] = []\n for (let index = 0; index < ids.length; index += ID_CHUNK) {\n out.push(ids.slice(index, index + ID_CHUNK))\n }\n return out\n}\n\n/**\n * WHAT THE CAMPAIGN CAUSED, counted rather than listed.\n *\n * Four aggregation counts over `campaignAttributions`, one per kind, each\n * narrowed to this campaign's own sends. An aggregation is billed per\n * thousand index entries rather than per document, so the honest figure costs\n * about as much as a single row — which is what lets this run on mount\n * instead of behind a button. Listing the records would not: that is the\n * per-record read every figure on this page is arranged to avoid, and the\n * site-wide Conversions section already lists them.\n *\n * The composite index the query needs — `kind` then `campaignId` — is already\n * declared for the conversions list's own campaign-scoped view, and an `in`\n * runs as a disjunction of equalities against that same index.\n *\n * ## The kinds are never added together\n *\n * One form submission by a new person writes a submission, a contact AND a\n * lead: three true statements about one visit. A total would count that visit\n * three times and would look exactly like a bigger number, so there is no\n * total here and the note under the figures says why.\n */\nexport function CampaignConversionsSection(\n props: Omit<CampaignReachProps, 'orgId'> & {\n /**\n * The site whose conversions are counted. Conversions are a site's\n * visitors and are recorded under it, so on the org hub the caller picks\n * one.\n */\n hostId: string\n /**\n * That site's marketing hub URL, for its conversions list; `null` hides\n * the link rather than pointing it nowhere.\n */\n basePath: string | null\n },\n) {\n const { hostId, sendIds, truncated, basePath } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [counts, setCounts] = useState<Record<string, number> | null>(null)\n const [failed, setFailed] = useState(false)\n\n useEffect(() => {\n const ids = key ? key.split(',') : []\n setCounts(null)\n setFailed(false)\n if (!ids.length) return\n let active = true\n const attributions = collection(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n )\n /*\n * Summed ACROSS chunks, which is safe because a record carries exactly\n * one `campaignId`: the chunks partition the send ids, so no record can\n * be counted by two of them.\n */\n void Promise.all(\n CAMPAIGN_CONVERSION_KINDS.map(async (kind) => {\n const perChunk = await Promise.all(\n chunked(ids).map((chunk) =>\n getCountFromServer(\n query(\n attributions,\n where('kind', '==', kind),\n where('campaignId', 'in', chunk),\n ),\n ).then((snapshot) => Number(snapshot.data().count ?? 0)),\n ),\n )\n return [kind, perChunk.reduce((sum, value) => sum + value, 0)] as const\n }),\n )\n .then((entries) => {\n if (active) setCounts(Object.fromEntries(entries))\n })\n .catch(() => {\n /*\n * WITHHELD, never zeroed. A count that failed and a campaign that\n * caused nothing are opposite facts, and rendering the first as the\n * second is the most flattering wrong answer available here.\n */\n if (active) setFailed(true)\n })\n return () => {\n active = false\n }\n }, [firestore, hostId, key])\n\n return (\n <Section title=\"What it caused\">\n {!key ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so nothing can be ' +\n 'credited to it yet.'}\n </Typography>\n ) : failed ? (\n <Alert severity=\"warning\">\n {'The conversions credited to this campaign could not be counted.'}\n </Alert>\n ) : !counts ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Counting what this campaign is credited with…'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n {CAMPAIGN_CONVERSION_KINDS.map((kind: CampaignConversionKind) => (\n <Figure\n key={kind}\n label={CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n value={counts[kind] ?? 0}\n note={CAMPAIGN_CONVERSION_KIND_COPY[kind].note}\n />\n ))}\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These count different things about the same visits and are ' +\n 'deliberately not added together — one person filling in one ' +\n 'form appears as a submission, a contact and a lead.'}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to this campaign’s own emails. Somebody who arrived ' +\n 'from a link tagged with utm_ parameters is credited to that ' +\n 'label instead, and somebody who arrived directly is credited ' +\n 'to nobody — both are counted on the site’s conversions list.'}\n </Typography>\n {truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Over the emails listed below. This campaign has sent more ' +\n 'than the page holds, and their conversions are not in these ' +\n 'figures.'}\n </Typography>\n ) : null}\n </Stack>\n )}\n {basePath ? (\n <Stack direction=\"row\">\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 </Stack>\n ) : null}\n </Section>\n )\n}\nCampaignConversionsSection.displayName = 'CampaignConversionsSection'\n\n/**\n * The rule the amounts were credited under, in the sentence a reader can\n * check them against.\n *\n * Plural where the campaign is plural. A container can hold emails credited\n * under different models or windows, and printing one of them as though it\n * governed all of them would be the same lie as printing one currency symbol\n * over two currencies.\n */\nfunction creditedUnder(report: CampaignRevenueAcrossSends): string {\n const model = report.models.length === 1 ? report.models[0] : ''\n const rule =\n model === 'last-click'\n ? 'to the last campaign whose link the buyer clicked'\n : model\n ? `under the ${model} model`\n : 'under more than one attribution model'\n const window =\n report.windowDays.length === 1\n ? `within ${report.windowDays[0]} days of that click`\n : `within ${report.windowDays.join(' or ')} days of that click, ` +\n 'depending on the email'\n return `Credited ${rule}, ${window}.`\n}\n\n/**\n * WHAT THE CAMPAIGN EARNED — per currency, and never across them.\n *\n * The same question a campaign of one email answers, answered the same way by\n * a campaign of several: how many messages a container happens to hold is not\n * a fact about whether its revenue is knowable. So this is the same section\n * the single-send report carries — `Revenue`, in the same place in the page's\n * order, drawn by the same money components — over a merge rather than over\n * one document.\n *\n * ## Why it takes a merge, and what the merge may not do\n *\n * `reports/revenue` is written per SEND. There is no container-level rollup\n * and there should not be one: it would have to be kept true against every\n * order and every refund of every email in the campaign, duplicating figures\n * that are already correct one level down. So the campaign's figure is one\n * document per email and an addition — and the addition is the danger.\n *\n * **Currency is to money what kind is to a conversion.** The section above\n * refuses to total form submissions with leads because one visit writes both;\n * this refuses to total USD with EUR because the sum has no unit. Both\n * refusals are structural rather than remembered:\n * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so\n * amounts in different currencies never reach the same addition, and the\n * result carries no combined field for a screen to print.\n *\n * ## One currency reads as one figure; several read as several\n *\n * The common case is one currency, and it is drawn exactly as a single\n * email's report draws it — net, gross, refunded, orders — with no currency\n * heading, because a label saying `USD` over a page that only ever shows\n * dollars is noise. The moment a second currency appears every block gets its\n * code as a heading and its own email count, and a caveat says in words what\n * the headings say in layout: these do not add up, and nothing here converts\n * between them. The rare case pays for itself; the common one does not pay\n * for the rare one.\n *\n * ## Behind a button, and the button says the price\n *\n * One document per email — the per-record read the conversions section above\n * is shaped to avoid. That section can run on mount because an aggregation is\n * billed per thousand index entries, so its four honest figures cost about as\n * much as a single row however large the campaign is. This cannot: fifty\n * emails is fifty document reads, every time the page opens, for a section\n * most readers of a campaign are not opening the page for. So it is asked\n * for, at the same control and in the same words as the destinations section\n * below, which buys its rows the same way.\n *\n * ## Nothing, and nothing readable, are different sentences\n *\n * The rollup is created by the writer on the first order it credits, so an\n * email with no record has never earned. A campaign where NO email has one\n * has not been shown to have earned nothing — it is also every campaign sent\n * before the join existed and every campaign on a site with no store, which\n * is the distinction the single-send report draws in the same two sentences.\n * A read that is REFUSED shows neither: the figures are withheld behind a\n * warning, and any figures already on screen are dropped rather than left\n * standing beside it, because a stale total under a fresh error is the one\n * arrangement that looks authoritative and is not.\n */\nexport function CampaignRevenueSection(props: CampaignReachProps) {\n const { orgId, sendIds, truncated } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [report, setReport] = useState<CampaignRevenueAcrossSends | null>(null)\n\n // A merge over one set of emails describes that set. Leaving it on screen\n // after the campaign's emails change labels it as something it is not.\n useEffect(() => {\n setReport(null)\n setError(null)\n }, [key])\n\n const load = useCallback(async () => {\n if (busy) return\n const ids = key ? key.split(',') : []\n if (!ids.length || !orgId) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(campaignSendReportDoc(firestore, orgId, id, 'revenue')),\n ),\n )\n /*\n * `data()` is `undefined` on a document that does not exist, and it is\n * handed on as `undefined` rather than as an empty rollup: the merge\n * counts the records that EXIST to tell a campaign that earned nothing\n * from one whose revenue was never recorded at all.\n */\n setReport(\n campaignRevenueAcrossSends(\n snapshots.map(\n (snapshot) => snapshot.data() as CampaignRevenueRollup | undefined,\n ),\n ),\n )\n } catch (caught) {\n console.error(caught)\n /*\n * WITHHELD, never zeroed and never left stale. A refused read and a\n * campaign that earned nothing are opposite facts, and the second is\n * the more flattering of the two.\n */\n setReport(null)\n setError('The revenue credited to this campaign could not be read')\n } finally {\n setBusy(false)\n }\n }, [busy, firestore, orgId, key])\n\n const count = key ? key.split(',').length : 0\n\n return (\n <Section title=\"Revenue\">\n {!count ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so nothing can be ' +\n 'credited to it yet.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'What this campaign’s emails were credited with, read from each ' +\n 'email’s own revenue record and merged by currency.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button size=\"small\" onClick={() => void load()} disabled={busy}>\n {busy ? 'Reading…' : report ? 'Read again' : 'Show revenue'}\n </Button>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Reads one record per email — ${count.toLocaleString()} of them.`}\n </Typography>\n </Stack>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {report ? (\n report.currencies.length ? (\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {report.currencies.map((entry) => (\n <Stack key={entry.currency} spacing={1}>\n {/*\n * The currency is a HEADING when there is more than one\n * and absent when there is one — the single-send report's\n * rule, so the two shapes of campaign page read alike.\n */}\n {report.multiCurrency ? (\n <Typography variant=\"subtitle2\">\n {entry.currency.toUpperCase()}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <MoneyFigure\n label=\"Net revenue\"\n cents={entry.netCents}\n currency={entry.currency}\n note=\"after refunds\"\n />\n <MoneyFigure\n label=\"Gross revenue\"\n cents={entry.grossCents}\n currency={entry.currency}\n note=\"as charged\"\n />\n <MoneyFigure\n label=\"Refunded\"\n cents={entry.refundedCents}\n currency={entry.currency}\n note=\"handed back\"\n />\n <Figure\n label=\"Orders\"\n value={entry.orders}\n note=\"credited to this campaign\"\n />\n <Figure\n label=\"Fully refunded\"\n value={entry.refundedOrders}\n note=\"of those orders\"\n />\n {/*\n * Only where it says something. With one currency this\n * repeats the coverage line under the figures; with\n * two it is what shows the blocks are disjoint parts of\n * the campaign rather than two views of all of it.\n */}\n {report.multiCurrency ? (\n <Figure\n label=\"Emails\"\n value={entry.emails}\n note={`earned in ${entry.currency.toUpperCase()}`}\n />\n ) : null}\n </Stack>\n </Stack>\n ))}\n {/*\n * NO PER-MESSAGE AVERAGE, and this is where a reader of the\n * single-send report would look for one. That page divides by\n * the send's own delivered count, read from the same document\n * at the same instant. A campaign has no such pair — its\n * revenue covers the emails with a revenue record and its\n * delivery total covers the emails that recorded a delivery\n * count, which are different subsets — so the quotient would\n * be an average over a population nobody named.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${report.attributedOrders.toLocaleString()} ` +\n `${report.attributedOrders === 1 ? 'order' : 'orders'} ` +\n `credited, across ${report.recorded.toLocaleString()} of ` +\n `this campaign’s ${report.read.toLocaleString()} ` +\n `${report.read === 1 ? 'email' : 'emails'}. The rest have ` +\n 'never been credited with a sale.'}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${creditedUnder(report)} Clicks only — an open is not ` +\n 'treated as evidence that anybody read the email. An ' +\n 'order placed by somebody who never clicked, or who ' +\n 'checked out without giving an address, is credited to no ' +\n 'campaign, so this is a floor rather than every sale this ' +\n 'campaign influenced.'}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.recorded\n ? 'No orders have been credited to this campaign.'\n : 'No revenue has been attributed to any of this ' +\n 'campaign’s emails. Orders are credited to the last ' +\n 'campaign whose link the buyer clicked, within ' +\n `${EMAIL_ATTRIBUTION_WINDOW_DAYS} days — emails sent ` +\n 'before that was recorded, or a site with no store, will ' +\n 'never show a figure here.'}\n </Typography>\n )\n ) : null}\n {/*\n OUTSIDE both branches, because it qualifies both. A campaign that\n reports nothing over the emails this page holds has not reported\n nothing about itself, and the emptier answer is the one that most\n needs its population named.\n */}\n {report && truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Across the ${report.read.toLocaleString()} emails this page ` +\n 'holds. The campaign has sent more.'}\n </Typography>\n ) : null}\n </Stack>\n )}\n </Section>\n )\n}\nCampaignRevenueSection.displayName = 'CampaignRevenueSection'\n\n/** One destination, summed across the campaign's emails. */\ninterface DestinationRow {\n url: string\n clicks: number\n /** How many of the campaign's emails carried a link to it. */\n emails: number\n}\n\n/** What one pass over the campaign's link rollups produced. */\ninterface DestinationsResult {\n rows: DestinationRow[]\n /** Emails whose rollup was read, which is the population the rows describe. */\n read: number\n /** Clicks on destinations past each email's own rollup cap. */\n overflowClicks: number\n /** Clicks that arrived naming no destination at all. */\n unattributedClicks: number\n}\n\n/**\n * WHERE THE CAMPAIGN SENT PEOPLE — the pages its mail pointed at.\n *\n * The union of `campaigns/{sendId}/reports/links` across the campaign's\n * emails, which is the closest the stored data comes to \"the surfaces this\n * campaign runs across\". It is observed rather than declared: a page is on\n * this list because a link in this campaign's mail was followed to it.\n *\n * ## Behind a button, and the button says the price\n *\n * One document per email, up to the ceiling the page holds — which is the\n * per-record read the figures above are shaped to avoid, so it is asked for\n * rather than paid on mount. The same bargain the conversions list makes for\n * its landing-page grouping.\n *\n * ## What a row is, and what it is not\n *\n * A destination is the link's address with its query string dropped, because\n * a campaign body is merged per recipient and a personalised query would mint\n * one row per person — and could carry that person's address into an\n * aggregate the whole team reads. The cost is that two links to one page\n * distinguished only by their tracking parameters are one row, which the note\n * under the table states rather than leaving the reader to discover.\n *\n * Clicks are EVENTS. One reader clicking twice counts twice, so a destination\n * total is not a count of people.\n */\nexport function CampaignDestinationsSection(props: CampaignReachProps) {\n const { orgId, sendIds, truncated } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [result, setResult] = useState<DestinationsResult | null>(null)\n /*\n * The page is a SLICE of a window this section already holds. One campaign\n * can reach fifty destinations per email over fifty emails, so the row\n * count is bounded by the rollups rather than small — and a table that\n * grows with the campaign gets a footer like every other one.\n */\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n const visibleRows = useMemo(\n () => (result?.rows ?? []).slice(page * pageSize, page * pageSize + pageSize),\n [result, page, pageSize],\n )\n\n // A grouping read for one set of emails describes that set. Leaving it on\n // screen after the campaign's emails change labels it as something it is not.\n useEffect(() => {\n setResult(null)\n setError(null)\n setPage(0)\n }, [key])\n\n const load = useCallback(async () => {\n if (busy) return\n const ids = key ? key.split(',') : []\n if (!ids.length || !orgId) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(campaignSendReportDoc(firestore, orgId, id, 'links')),\n ),\n )\n const byUrl = new Map<string, DestinationRow>()\n let overflowClicks = 0\n let unattributedClicks = 0\n snapshots.forEach((snapshot) => {\n const rollup = snapshot.data() as CampaignLinkRollup | undefined\n if (!rollup) return\n overflowClicks += Number(rollup.overflowClicks ?? 0)\n unattributedClicks += Number(rollup.unattributedClicks ?? 0)\n Object.values(rollup.links ?? {}).forEach((entry) => {\n const url = String(entry?.url ?? '').trim()\n if (!url) return\n const row = byUrl.get(url) ?? { url, clicks: 0, emails: 0 }\n row.clicks += Number(entry?.clicks ?? 0)\n // One rollup holds at most one entry per destination, so this\n // counts EMAILS that linked there rather than link occurrences.\n row.emails += 1\n byUrl.set(url, row)\n })\n })\n setPage(0)\n setResult({\n rows: [...byUrl.values()].sort(\n (a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url),\n ),\n read: ids.length,\n overflowClicks,\n unattributedClicks,\n })\n } catch (caught) {\n console.error(caught)\n setError('The destinations could not be read')\n } finally {\n setBusy(false)\n }\n }, [busy, firestore, orgId, key])\n\n const count = key ? key.split(',').length : 0\n\n return (\n <Section title=\"Where it sent people\">\n {!count ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so it has sent ' +\n 'nobody anywhere yet.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages this campaign’s links were followed to. A campaign ' +\n 'does not own a page — this is where its mail actually sent ' +\n 'people, read from each email’s own click report.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button size=\"small\" onClick={() => void load()} disabled={busy}>\n {busy ? 'Reading…' : result ? 'Read again' : 'Show destinations'}\n </Button>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Reads one record per email — ${count.toLocaleString()} of them.`}\n </Typography>\n </Stack>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {result ? (\n result.rows.length ? (\n <Stack spacing={0.5}>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Emails'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {visibleRows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\">\n {row.emails.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.clicks.toLocaleString()}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={visibleRows.length}\n count={result.rows.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'A destination is the address without its query string, ' +\n 'so two links to one page that differ only in their ' +\n 'tracking parameters are one row. Clicks are events — ' +\n 'one reader clicking twice counts twice.'}\n </Typography>\n {result.overflowClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${result.overflowClicks.toLocaleString()} further ` +\n 'clicks landed on destinations past the per-email cap ' +\n 'and are not in the table.'}\n </Typography>\n ) : null}\n {result.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${result.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived naming no destination, so they belong to no ' +\n 'row.'}\n </Typography>\n ) : null}\n {truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Across the ${result.read.toLocaleString()} emails this ` +\n 'page holds. The campaign has sent more.'}\n </Typography>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'None of this campaign’s emails has recorded a followed ' +\n 'link yet.'}\n </Typography>\n )\n ) : null}\n </Stack>\n )}\n </Section>\n )\n}\nCampaignDestinationsSection.displayName = 'CampaignDestinationsSection'\n\n/**\n * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign from its own document, and the Outreach\n * runtime credits what each enrollment produced to the campaign's one\n * sequences report — `campaignSequenceReports/{campaignId}` on the org,\n * server-only like the conversions rollup, and readable by org-wide members\n * alone: a site collaborator is told it could not be read. One keyed read, joined on the\n * CONTAINER's id rather than on send ids: a sequence's emails are not the\n * campaign's sends, so the join the three sections above make does not\n * exist for it, and the report is written under the campaign directly.\n *\n * Absent is not zero: a campaign no sequence was ever in has no document,\n * and says so in words rather than as five dashes.\n */\nexport function CampaignSequencesSection(props: {\n orgId: string | null\n campaignId: string\n}) {\n const { orgId, campaignId } = props\n const firestore = useFirestore()\n const [rollup, setRollup] = useState<CampaignSequencesRollup | undefined | null>(null)\n const [failed, setFailed] = useState(false)\n\n useEffect(() => {\n let active = true\n setRollup(null)\n setFailed(false)\n if (!orgId) return undefined\n getDoc(campaignSequenceReportDoc(firestore, orgId, campaignId))\n .then((snapshot) => {\n if (active) setRollup((snapshot.data() as CampaignSequencesRollup | undefined) ?? undefined)\n })\n .catch(() => {\n // Withheld, never zeroed — the conversions section's reason.\n if (active) setFailed(true)\n })\n return () => {\n active = false\n }\n }, [firestore, orgId, campaignId])\n\n const report = rollup === null ? null : campaignSequencesReport(rollup)\n\n return (\n <Section title=\"What its sequences produced\">\n {failed ? (\n <Alert severity=\"warning\">{'What this campaign’s sequences produced could not be read.'}</Alert>\n ) : !report ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Reading what this campaign’s sequences produced…'}\n </Typography>\n ) : !report.recorded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No sequence has been in this campaign. Put one in it from the ' +\n 'sequence’s settings, and everyone enrolled from then on counts here.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n {report.figures.map((figure) => (\n <Figure key={figure.outcome} label={figure.label} value={figure.value} note={figure.note} />\n ))}\n </Stack>\n {report.caveats.map((caveat) => (\n <Typography key={caveat.id} variant=\"caption\" color=\"text.secondary\">\n {caveat.message}\n </Typography>\n ))}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to the campaigns a sequence was in when each person was ' +\n 'enrolled. A meeting counts when it was booked from a link in a ' +\n 'sequence email; a conversion when a lead in a sequence became a contact.'}\n </Typography>\n </Stack>\n )}\n </Section>\n )\n}\nCampaignSequencesSection.displayName = 'CampaignSequencesSection'\n"],"names":["AppLink","Figure","MoneyFigure","Section","ListPagination","TABLE_PAGE_SIZE_DEFAULT","ScrollTable","Alert","Button","Stack","TableBody","TableCell","TableHead","TableRow","Typography","collection","getCountFromServer","getDoc","query","where","useCallback","useEffect","useMemo","useState","useFirestore","campaignSequencesReport","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","campaignRevenueAcrossSends","campaignSendReportDoc","campaignSequenceReportDoc","ID_CHUNK","idsKey","sendIds","join","chunked","ids","out","index","length","push","slice","CampaignConversionsSection","props","hostId","truncated","basePath","firestore","key","counts","setCounts","failed","setFailed","split","active","attributions","Promise","all","map","kind","perChunk","chunk","then","snapshot","Number","data","count","reduce","sum","value","entries","Object","fromEntries","catch","title","variant","color","severity","spacing","direction","sx","flexWrap","label","note","component","componentVariant","nativeButton","href","size","displayName","creditedUnder","report","model","models","rule","window","windowDays","CampaignRevenueSection","orgId","busy","setBusy","error","setError","setReport","load","snapshots","id","caught","console","alignItems","onClick","disabled","toLocaleString","currencies","caveats","caveat","message","entry","multiCurrency","currency","toUpperCase","useFlexGap","cents","netCents","grossCents","refundedCents","orders","refundedOrders","emails","attributedOrders","recorded","read","CampaignDestinationsSection","result","setResult","page","setPage","pageSize","setPageSize","visibleRows","rows","byUrl","Map","overflowClicks","unattributedClicks","forEach","rollup","values","links","url","String","trim","row","get","clicks","set","sort","a","b","localeCompare","align","wordBreak","rowCount","onPageChange","onPageSizeChange","CampaignSequencesSection","campaignId","setRollup","undefined","figures","figure","outcome"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-reach-sections.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n MoneyFigure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Button,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n getCountFromServer,\n getDoc,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { type SendLinkRollup } from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport {\n campaignSequencesReport,\n type CampaignSequencesRollup,\n} from '../model/campaign-report'\nimport { EMAIL_ATTRIBUTION_WINDOW_DAYS } from '@aglyn/shared-util-email'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n type CampaignConversionKind,\n} from '../model/campaign-conversions'\nimport {\n campaignRevenueAcrossSends,\n type CampaignRevenueAcrossSends,\n type CampaignRevenueRollup,\n} from '../model/campaign-revenue'\nimport {\n campaignSendReportDoc,\n campaignSequenceReportDoc,\n} from './campaign-queries'\n\n/**\n * A CAMPAIGN BEYOND ITS MAIL — what it caused, what it earned, and where it\n * sent people.\n *\n * ## Attribution is the mechanism, and there is no other one\n *\n * A campaign owns no screen, no form and no overlay. Nothing in the stored\n * model connects `emailCampaigns/{id}` to a page a visitor lands on:\n * `HostExperiment` names a screen and never a campaign, `HostOverlay` names\n * neither, and a form document carries no campaign field. So a section\n * claiming a campaign CONTAINS a landing page would be inventing an edge, and\n * the containment would be a claim nothing in the data supports.\n *\n * What the data does support is EVIDENCE, of two kinds, and both are already\n * being written:\n *\n * - **Where it sent people.** `campaigns/{sendId}/reports/links` counts\n * clicks per destination URL, so the union across a campaign's emails is\n * the set of pages that campaign drove traffic to — observed rather than\n * declared, which is also why it stays true when a marketer re-points a\n * link.\n * - **What it caused there.** `campaignAttributions` records one row per\n * identify moment — a form submission, a lead, a contact, a booking —\n * carrying the SEND whose link the visitor followed.\n * - **What it earned there.** `campaigns/{sendId}/reports/revenue` buckets\n * the orders credited to that send by currency, so the merge across a\n * campaign's emails is what the campaign was credited with — per currency,\n * because the buckets have units and a total across them would not.\n *\n * All three sections below therefore hang off the campaign's own send ids,\n * which is the only handle any of those collections offers.\n *\n * ## What neither section can say, and says so instead\n *\n * A conversion credited to a WEB touch carries `utm_` labels and no send id.\n * A campaign container has no `utm_` label of its own — a marketer types\n * those into a URL — so there is no join to make, and no amount of matching\n * on the campaign's NAME would be one: two campaigns may share a name, and a\n * label is a string anybody who can vary a query string can mint. The\n * site-wide Conversions section is where the web channel is read, and the\n * copy below points at it rather than quietly leaving it out.\n *\n * A conversion with no touch at all is credited to nobody and is in neither\n * collection. The same holds for an order: one placed by somebody who never\n * clicked is credited to no campaign. That makes a page showing only credited\n * outcomes a partial account by construction, which is why the figures are\n * labelled \"credited to this campaign\" everywhere they appear.\n */\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nexport interface CampaignReachProps {\n /**\n * The org whose sends the rollups sit under, `orgs/{orgId}/campaigns/{id}/\n * reports/*`; nothing is read until it is known.\n */\n orgId: string | null\n /**\n * The campaign's emails, by send id — the handle both collections join on.\n *\n * A stable array is not required: both sections key their reads on the\n * joined string, so a caller rebuilding the array every render pays for one\n * set of reads rather than one per render.\n */\n sendIds: readonly string[]\n /** The campaign holds more emails than `sendIds` names. */\n truncated: boolean\n}\n\n/** The ids as one primitive, so an effect depends on the VALUE not the array. */\nconst idsKey = (sendIds: readonly string[]): string => sendIds.join(',')\n\n/** `['a','b','c']` in runs of at most {@link ID_CHUNK}. */\nfunction chunked(ids: readonly string[]): string[][] {\n const out: string[][] = []\n for (let index = 0; index < ids.length; index += ID_CHUNK) {\n out.push(ids.slice(index, index + ID_CHUNK))\n }\n return out\n}\n\n/**\n * WHAT THE CAMPAIGN CAUSED, counted rather than listed.\n *\n * Four aggregation counts over `campaignAttributions`, one per kind, each\n * narrowed to this campaign's own sends. An aggregation is billed per\n * thousand index entries rather than per document, so the honest figure costs\n * about as much as a single row — which is what lets this run on mount\n * instead of behind a button. Listing the records would not: that is the\n * per-record read every figure on this page is arranged to avoid, and the\n * site-wide Conversions section already lists them.\n *\n * The composite index the query needs — `kind` then `campaignId` — is already\n * declared for the conversions list's own campaign-scoped view, and an `in`\n * runs as a disjunction of equalities against that same index.\n *\n * ## The kinds are never added together\n *\n * One form submission by a new person writes a submission, a contact AND a\n * lead: three true statements about one visit. A total would count that visit\n * three times and would look exactly like a bigger number, so there is no\n * total here and the note under the figures says why.\n */\nexport function CampaignConversionsSection(\n props: Omit<CampaignReachProps, 'orgId'> & {\n /**\n * The site whose conversions are counted. Conversions are a site's\n * visitors and are recorded under it, so on the org hub the caller picks\n * one.\n */\n hostId: string\n /**\n * That site's marketing hub URL, for its conversions list; `null` hides\n * the link rather than pointing it nowhere.\n */\n basePath: string | null\n },\n) {\n const { hostId, sendIds, truncated, basePath } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [counts, setCounts] = useState<Record<string, number> | null>(null)\n const [failed, setFailed] = useState(false)\n\n useEffect(() => {\n const ids = key ? key.split(',') : []\n setCounts(null)\n setFailed(false)\n if (!ids.length) return\n let active = true\n const attributions = collection(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n )\n /*\n * Summed ACROSS chunks, which is safe because a record carries exactly\n * one `campaignId`: the chunks partition the send ids, so no record can\n * be counted by two of them.\n */\n void Promise.all(\n CAMPAIGN_CONVERSION_KINDS.map(async (kind) => {\n const perChunk = await Promise.all(\n chunked(ids).map((chunk) =>\n getCountFromServer(\n query(\n attributions,\n where('kind', '==', kind),\n where('campaignId', 'in', chunk),\n ),\n ).then((snapshot) => Number(snapshot.data().count ?? 0)),\n ),\n )\n return [kind, perChunk.reduce((sum, value) => sum + value, 0)] as const\n }),\n )\n .then((entries) => {\n if (active) setCounts(Object.fromEntries(entries))\n })\n .catch(() => {\n /*\n * WITHHELD, never zeroed. A count that failed and a campaign that\n * caused nothing are opposite facts, and rendering the first as the\n * second is the most flattering wrong answer available here.\n */\n if (active) setFailed(true)\n })\n return () => {\n active = false\n }\n }, [firestore, hostId, key])\n\n return (\n <Section title=\"What it caused\">\n {!key ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so nothing can be ' +\n 'credited to it yet.'}\n </Typography>\n ) : failed ? (\n <Alert severity=\"warning\">\n {'The conversions credited to this campaign could not be counted.'}\n </Alert>\n ) : !counts ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Counting what this campaign is credited with…'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n {CAMPAIGN_CONVERSION_KINDS.map((kind: CampaignConversionKind) => (\n <Figure\n key={kind}\n label={CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n value={counts[kind] ?? 0}\n note={CAMPAIGN_CONVERSION_KIND_COPY[kind].note}\n />\n ))}\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These count different things about the same visits and are ' +\n 'deliberately not added together — one person filling in one ' +\n 'form appears as a submission, a contact and a lead.'}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to this campaign’s own emails. Somebody who arrived ' +\n 'from a link tagged with utm_ parameters is credited to that ' +\n 'label instead, and somebody who arrived directly is credited ' +\n 'to nobody — both are counted on the site’s conversions list.'}\n </Typography>\n {truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Over the emails listed below. This campaign has sent more ' +\n 'than the page holds, and their conversions are not in these ' +\n 'figures.'}\n </Typography>\n ) : null}\n </Stack>\n )}\n {basePath ? (\n <Stack direction=\"row\">\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 </Stack>\n ) : null}\n </Section>\n )\n}\nCampaignConversionsSection.displayName = 'CampaignConversionsSection'\n\n/**\n * The rule the amounts were credited under, in the sentence a reader can\n * check them against.\n *\n * Plural where the campaign is plural. A container can hold emails credited\n * under different models or windows, and printing one of them as though it\n * governed all of them would be the same lie as printing one currency symbol\n * over two currencies.\n */\nfunction creditedUnder(report: CampaignRevenueAcrossSends): string {\n const model = report.models.length === 1 ? report.models[0] : ''\n const rule =\n model === 'last-click'\n ? 'to the last campaign whose link the buyer clicked'\n : model\n ? `under the ${model} model`\n : 'under more than one attribution model'\n const window =\n report.windowDays.length === 1\n ? `within ${report.windowDays[0]} days of that click`\n : `within ${report.windowDays.join(' or ')} days of that click, ` +\n 'depending on the email'\n return `Credited ${rule}, ${window}.`\n}\n\n/**\n * WHAT THE CAMPAIGN EARNED — per currency, and never across them.\n *\n * The same question a campaign of one email answers, answered the same way by\n * a campaign of several: how many messages a container happens to hold is not\n * a fact about whether its revenue is knowable. So this is the same section\n * the single-send report carries — `Revenue`, in the same place in the page's\n * order, drawn by the same money components — over a merge rather than over\n * one document.\n *\n * ## Why it takes a merge, and what the merge may not do\n *\n * `reports/revenue` is written per SEND. There is no container-level rollup\n * and there should not be one: it would have to be kept true against every\n * order and every refund of every email in the campaign, duplicating figures\n * that are already correct one level down. So the campaign's figure is one\n * document per email and an addition — and the addition is the danger.\n *\n * **Currency is to money what kind is to a conversion.** The section above\n * refuses to total form submissions with leads because one visit writes both;\n * this refuses to total USD with EUR because the sum has no unit. Both\n * refusals are structural rather than remembered:\n * {@link campaignRevenueAcrossSends} keys its accumulator on the currency, so\n * amounts in different currencies never reach the same addition, and the\n * result carries no combined field for a screen to print.\n *\n * ## One currency reads as one figure; several read as several\n *\n * The common case is one currency, and it is drawn exactly as a single\n * email's report draws it — net, gross, refunded, orders — with no currency\n * heading, because a label saying `USD` over a page that only ever shows\n * dollars is noise. The moment a second currency appears every block gets its\n * code as a heading and its own email count, and a caveat says in words what\n * the headings say in layout: these do not add up, and nothing here converts\n * between them. The rare case pays for itself; the common one does not pay\n * for the rare one.\n *\n * ## Behind a button, and the button says the price\n *\n * One document per email — the per-record read the conversions section above\n * is shaped to avoid. That section can run on mount because an aggregation is\n * billed per thousand index entries, so its four honest figures cost about as\n * much as a single row however large the campaign is. This cannot: fifty\n * emails is fifty document reads, every time the page opens, for a section\n * most readers of a campaign are not opening the page for. So it is asked\n * for, at the same control and in the same words as the destinations section\n * below, which buys its rows the same way.\n *\n * ## Nothing, and nothing readable, are different sentences\n *\n * The rollup is created by the writer on the first order it credits, so an\n * email with no record has never earned. A campaign where NO email has one\n * has not been shown to have earned nothing — it is also every campaign sent\n * before the join existed and every campaign on a site with no store, which\n * is the distinction the single-send report draws in the same two sentences.\n * A read that is REFUSED shows neither: the figures are withheld behind a\n * warning, and any figures already on screen are dropped rather than left\n * standing beside it, because a stale total under a fresh error is the one\n * arrangement that looks authoritative and is not.\n */\nexport function CampaignRevenueSection(props: CampaignReachProps) {\n const { orgId, sendIds, truncated } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [report, setReport] = useState<CampaignRevenueAcrossSends | null>(null)\n\n // A merge over one set of emails describes that set. Leaving it on screen\n // after the campaign's emails change labels it as something it is not.\n useEffect(() => {\n setReport(null)\n setError(null)\n }, [key])\n\n const load = useCallback(async () => {\n if (busy) return\n const ids = key ? key.split(',') : []\n if (!ids.length || !orgId) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(campaignSendReportDoc(firestore, orgId, id, 'revenue')),\n ),\n )\n /*\n * `data()` is `undefined` on a document that does not exist, and it is\n * handed on as `undefined` rather than as an empty rollup: the merge\n * counts the records that EXIST to tell a campaign that earned nothing\n * from one whose revenue was never recorded at all.\n */\n setReport(\n campaignRevenueAcrossSends(\n snapshots.map(\n (snapshot) => snapshot.data() as CampaignRevenueRollup | undefined,\n ),\n ),\n )\n } catch (caught) {\n console.error(caught)\n /*\n * WITHHELD, never zeroed and never left stale. A refused read and a\n * campaign that earned nothing are opposite facts, and the second is\n * the more flattering of the two.\n */\n setReport(null)\n setError('The revenue credited to this campaign could not be read')\n } finally {\n setBusy(false)\n }\n }, [busy, firestore, orgId, key])\n\n const count = key ? key.split(',').length : 0\n\n return (\n <Section title=\"Revenue\">\n {!count ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so nothing can be ' +\n 'credited to it yet.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'What this campaign’s emails were credited with, read from each ' +\n 'email’s own revenue record and merged by currency.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button size=\"small\" onClick={() => void load()} disabled={busy}>\n {busy ? 'Reading…' : report ? 'Read again' : 'Show revenue'}\n </Button>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Reads one record per email — ${count.toLocaleString()} of them.`}\n </Typography>\n </Stack>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {report ? (\n report.currencies.length ? (\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {report.currencies.map((entry) => (\n <Stack key={entry.currency} spacing={1}>\n {/*\n * The currency is a HEADING when there is more than one\n * and absent when there is one — the single-send report's\n * rule, so the two shapes of campaign page read alike.\n */}\n {report.multiCurrency ? (\n <Typography variant=\"subtitle2\">\n {entry.currency.toUpperCase()}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <MoneyFigure\n label=\"Net revenue\"\n cents={entry.netCents}\n currency={entry.currency}\n note=\"after refunds\"\n />\n <MoneyFigure\n label=\"Gross revenue\"\n cents={entry.grossCents}\n currency={entry.currency}\n note=\"as charged\"\n />\n <MoneyFigure\n label=\"Refunded\"\n cents={entry.refundedCents}\n currency={entry.currency}\n note=\"handed back\"\n />\n <Figure\n label=\"Orders\"\n value={entry.orders}\n note=\"credited to this campaign\"\n />\n <Figure\n label=\"Fully refunded\"\n value={entry.refundedOrders}\n note=\"of those orders\"\n />\n {/*\n * Only where it says something. With one currency this\n * repeats the coverage line under the figures; with\n * two it is what shows the blocks are disjoint parts of\n * the campaign rather than two views of all of it.\n */}\n {report.multiCurrency ? (\n <Figure\n label=\"Emails\"\n value={entry.emails}\n note={`earned in ${entry.currency.toUpperCase()}`}\n />\n ) : null}\n </Stack>\n </Stack>\n ))}\n {/*\n * NO PER-MESSAGE AVERAGE, and this is where a reader of the\n * single-send report would look for one. That page divides by\n * the send's own delivered count, read from the same document\n * at the same instant. A campaign has no such pair — its\n * revenue covers the emails with a revenue record and its\n * delivery total covers the emails that recorded a delivery\n * count, which are different subsets — so the quotient would\n * be an average over a population nobody named.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${report.attributedOrders.toLocaleString()} ` +\n `${report.attributedOrders === 1 ? 'order' : 'orders'} ` +\n `credited, across ${report.recorded.toLocaleString()} of ` +\n `this campaign’s ${report.read.toLocaleString()} ` +\n `${report.read === 1 ? 'email' : 'emails'}. The rest have ` +\n 'never been credited with a sale.'}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${creditedUnder(report)} Clicks only — an open is not ` +\n 'treated as evidence that anybody read the email. An ' +\n 'order placed by somebody who never clicked, or who ' +\n 'checked out without giving an address, is credited to no ' +\n 'campaign, so this is a floor rather than every sale this ' +\n 'campaign influenced.'}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.recorded\n ? 'No orders have been credited to this campaign.'\n : 'No revenue has been attributed to any of this ' +\n 'campaign’s emails. Orders are credited to the last ' +\n 'campaign whose link the buyer clicked, within ' +\n `${EMAIL_ATTRIBUTION_WINDOW_DAYS} days — emails sent ` +\n 'before that was recorded, or a site with no store, will ' +\n 'never show a figure here.'}\n </Typography>\n )\n ) : null}\n {/*\n OUTSIDE both branches, because it qualifies both. A campaign that\n reports nothing over the emails this page holds has not reported\n nothing about itself, and the emptier answer is the one that most\n needs its population named.\n */}\n {report && truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Across the ${report.read.toLocaleString()} emails this page ` +\n 'holds. The campaign has sent more.'}\n </Typography>\n ) : null}\n </Stack>\n )}\n </Section>\n )\n}\nCampaignRevenueSection.displayName = 'CampaignRevenueSection'\n\n/** One destination, summed across the campaign's emails. */\ninterface DestinationRow {\n url: string\n clicks: number\n /** How many of the campaign's emails carried a link to it. */\n emails: number\n}\n\n/** What one pass over the campaign's link rollups produced. */\ninterface DestinationsResult {\n rows: DestinationRow[]\n /** Emails whose rollup was read, which is the population the rows describe. */\n read: number\n /** Clicks on destinations past each email's own rollup cap. */\n overflowClicks: number\n /** Clicks that arrived naming no destination at all. */\n unattributedClicks: number\n}\n\n/**\n * WHERE THE CAMPAIGN SENT PEOPLE — the pages its mail pointed at.\n *\n * The union of `campaigns/{sendId}/reports/links` across the campaign's\n * emails, which is the closest the stored data comes to \"the surfaces this\n * campaign runs across\". It is observed rather than declared: a page is on\n * this list because a link in this campaign's mail was followed to it.\n *\n * ## Behind a button, and the button says the price\n *\n * One document per email, up to the ceiling the page holds — which is the\n * per-record read the figures above are shaped to avoid, so it is asked for\n * rather than paid on mount. The same bargain the conversions list makes for\n * its landing-page grouping.\n *\n * ## What a row is, and what it is not\n *\n * A destination is the link's address with its query string dropped, because\n * a campaign body is merged per recipient and a personalised query would mint\n * one row per person — and could carry that person's address into an\n * aggregate the whole team reads. The cost is that two links to one page\n * distinguished only by their tracking parameters are one row, which the note\n * under the table states rather than leaving the reader to discover.\n *\n * Clicks are EVENTS. One reader clicking twice counts twice, so a destination\n * total is not a count of people.\n */\nexport function CampaignDestinationsSection(props: CampaignReachProps) {\n const { orgId, sendIds, truncated } = props\n const firestore = useFirestore()\n const key = idsKey(sendIds)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [result, setResult] = useState<DestinationsResult | null>(null)\n /*\n * The page is a SLICE of a window this section already holds. One campaign\n * can reach fifty destinations per email over fifty emails, so the row\n * count is bounded by the rollups rather than small — and a table that\n * grows with the campaign gets a footer like every other one.\n */\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n const visibleRows = useMemo(\n () => (result?.rows ?? []).slice(page * pageSize, page * pageSize + pageSize),\n [result, page, pageSize],\n )\n\n // A grouping read for one set of emails describes that set. Leaving it on\n // screen after the campaign's emails change labels it as something it is not.\n useEffect(() => {\n setResult(null)\n setError(null)\n setPage(0)\n }, [key])\n\n const load = useCallback(async () => {\n if (busy) return\n const ids = key ? key.split(',') : []\n if (!ids.length || !orgId) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(campaignSendReportDoc(firestore, orgId, id, 'links')),\n ),\n )\n const byUrl = new Map<string, DestinationRow>()\n let overflowClicks = 0\n let unattributedClicks = 0\n snapshots.forEach((snapshot) => {\n const rollup = snapshot.data() as SendLinkRollup | undefined\n if (!rollup) return\n overflowClicks += Number(rollup.overflowClicks ?? 0)\n unattributedClicks += Number(rollup.unattributedClicks ?? 0)\n Object.values(rollup.links ?? {}).forEach((entry) => {\n const url = String(entry?.url ?? '').trim()\n if (!url) return\n const row = byUrl.get(url) ?? { url, clicks: 0, emails: 0 }\n row.clicks += Number(entry?.clicks ?? 0)\n // One rollup holds at most one entry per destination, so this\n // counts EMAILS that linked there rather than link occurrences.\n row.emails += 1\n byUrl.set(url, row)\n })\n })\n setPage(0)\n setResult({\n rows: [...byUrl.values()].sort(\n (a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url),\n ),\n read: ids.length,\n overflowClicks,\n unattributedClicks,\n })\n } catch (caught) {\n console.error(caught)\n setError('The destinations could not be read')\n } finally {\n setBusy(false)\n }\n }, [busy, firestore, orgId, key])\n\n const count = key ? key.split(',').length : 0\n\n return (\n <Section title=\"Where it sent people\">\n {!count ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails have gone out under this campaign, so it has sent ' +\n 'nobody anywhere yet.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages this campaign’s links were followed to. A campaign ' +\n 'does not own a page — this is where its mail actually sent ' +\n 'people, read from each email’s own click report.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button size=\"small\" onClick={() => void load()} disabled={busy}>\n {busy ? 'Reading…' : result ? 'Read again' : 'Show destinations'}\n </Button>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Reads one record per email — ${count.toLocaleString()} of them.`}\n </Typography>\n </Stack>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {result ? (\n result.rows.length ? (\n <Stack spacing={0.5}>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Emails'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {visibleRows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\">\n {row.emails.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.clicks.toLocaleString()}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={visibleRows.length}\n count={result.rows.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'A destination is the address without its query string, ' +\n 'so two links to one page that differ only in their ' +\n 'tracking parameters are one row. Clicks are events — ' +\n 'one reader clicking twice counts twice.'}\n </Typography>\n {result.overflowClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${result.overflowClicks.toLocaleString()} further ` +\n 'clicks landed on destinations past the per-email cap ' +\n 'and are not in the table.'}\n </Typography>\n ) : null}\n {result.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${result.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived naming no destination, so they belong to no ' +\n 'row.'}\n </Typography>\n ) : null}\n {truncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Across the ${result.read.toLocaleString()} emails this ` +\n 'page holds. The campaign has sent more.'}\n </Typography>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'None of this campaign’s emails has recorded a followed ' +\n 'link yet.'}\n </Typography>\n )\n ) : null}\n </Stack>\n )}\n </Section>\n )\n}\nCampaignDestinationsSection.displayName = 'CampaignDestinationsSection'\n\n/**\n * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign from its own document, and the Outreach\n * runtime credits what each enrollment produced to the campaign's one\n * sequences report — `campaignSequenceReports/{campaignId}` on the org,\n * server-only like the conversions rollup, and readable by org-wide members\n * alone: a site collaborator is told it could not be read. One keyed read, joined on the\n * CONTAINER's id rather than on send ids: a sequence's emails are not the\n * campaign's sends, so the join the three sections above make does not\n * exist for it, and the report is written under the campaign directly.\n *\n * Absent is not zero: a campaign no sequence was ever in has no document,\n * and says so in words rather than as five dashes.\n */\nexport function CampaignSequencesSection(props: {\n orgId: string | null\n campaignId: string\n}) {\n const { orgId, campaignId } = props\n const firestore = useFirestore()\n const [rollup, setRollup] = useState<CampaignSequencesRollup | undefined | null>(null)\n const [failed, setFailed] = useState(false)\n\n useEffect(() => {\n let active = true\n setRollup(null)\n setFailed(false)\n if (!orgId) return undefined\n getDoc(campaignSequenceReportDoc(firestore, orgId, campaignId))\n .then((snapshot) => {\n if (active) setRollup((snapshot.data() as CampaignSequencesRollup | undefined) ?? undefined)\n })\n .catch(() => {\n // Withheld, never zeroed — the conversions section's reason.\n if (active) setFailed(true)\n })\n return () => {\n active = false\n }\n }, [firestore, orgId, campaignId])\n\n const report = rollup === null ? null : campaignSequencesReport(rollup)\n\n return (\n <Section title=\"What its sequences produced\">\n {failed ? (\n <Alert severity=\"warning\">{'What this campaign’s sequences produced could not be read.'}</Alert>\n ) : !report ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Reading what this campaign’s sequences produced…'}\n </Typography>\n ) : !report.recorded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No sequence has been in this campaign. Put one in it from the ' +\n 'sequence’s settings, and everyone enrolled from then on counts here.'}\n </Typography>\n ) : (\n <Stack spacing={1}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n {report.figures.map((figure) => (\n <Figure key={figure.outcome} label={figure.label} value={figure.value} note={figure.note} />\n ))}\n </Stack>\n {report.caveats.map((caveat) => (\n <Typography key={caveat.id} variant=\"caption\" color=\"text.secondary\">\n {caveat.message}\n </Typography>\n ))}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to the campaigns a sequence was in when each person was ' +\n 'enrolled. A meeting counts when it was booked from a link in a ' +\n 'sequence email; a conversion when a lead in a sequence became a contact.'}\n </Typography>\n </Stack>\n )}\n </Section>\n )\n}\nCampaignSequencesSection.displayName = 'CampaignSequencesSection'\n"],"names":["AppLink","Figure","MoneyFigure","Section","ListPagination","TABLE_PAGE_SIZE_DEFAULT","ScrollTable","Alert","Button","Stack","TableBody","TableCell","TableHead","TableRow","Typography","collection","getCountFromServer","getDoc","query","where","useCallback","useEffect","useMemo","useState","useFirestore","campaignSequencesReport","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","campaignRevenueAcrossSends","campaignSendReportDoc","campaignSequenceReportDoc","ID_CHUNK","idsKey","sendIds","join","chunked","ids","out","index","length","push","slice","CampaignConversionsSection","props","hostId","truncated","basePath","firestore","key","counts","setCounts","failed","setFailed","split","active","attributions","Promise","all","map","kind","perChunk","chunk","then","snapshot","Number","data","count","reduce","sum","value","entries","Object","fromEntries","catch","title","variant","color","severity","spacing","direction","sx","flexWrap","label","note","component","componentVariant","nativeButton","href","size","displayName","creditedUnder","report","model","models","rule","window","windowDays","CampaignRevenueSection","orgId","busy","setBusy","error","setError","setReport","load","snapshots","id","caught","console","alignItems","onClick","disabled","toLocaleString","currencies","caveats","caveat","message","entry","multiCurrency","currency","toUpperCase","useFlexGap","cents","netCents","grossCents","refundedCents","orders","refundedOrders","emails","attributedOrders","recorded","read","CampaignDestinationsSection","result","setResult","page","setPage","pageSize","setPageSize","visibleRows","rows","byUrl","Map","overflowClicks","unattributedClicks","forEach","rollup","values","links","url","String","trim","row","get","clicks","set","sort","a","b","localeCompare","align","wordBreak","rowCount","onPageChange","onPageSizeChange","CampaignSequencesSection","campaignId","setRollup","undefined","figures","figure","outcome"],"mappings":"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"}
@@ -22,7 +22,8 @@ import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
22
22
  import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
23
23
  import { Alert, Box, Button, Chip, Divider, Stack, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
24
24
  import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
25
- import { campaignLinkReport, campaignReport } from "@aglyn/shared-ui-email-campaigns/model/campaign-report";
25
+ import { sendLinkReport } from "@aglyn/shared-ui-email-campaigns/model/send-report";
26
+ import { campaignReport } from "../model/campaign-report.js";
26
27
  import { campaignRevenueReport } from "../model/campaign-revenue.js";
27
28
  import { campaignConversionsReport } from "../model/campaign-conversions.js";
28
29
  /*
@@ -30,8 +31,8 @@ import { campaignConversionsReport } from "../model/campaign-conversions.js";
30
31
  * here, so "a rate prints its denominator" is one implementation and not a
31
32
  * convention each new card has to remember.
32
33
  */ import { Figure, MoneyFigure, MoneyPerUnitRow, percent, RateRow, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
33
- import { emailSendTimeMs } from "@aglyn/shared-ui-email-campaigns/model/email-record";
34
- import { campaignSendDisplay, campaignSendProgress } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
34
+ import { emailSendTimeMs } from "../model/email-record.js";
35
+ import { campaignSendDisplay, campaignSendProgress } from "../model/campaign-container.js";
35
36
  import { campaignSendDoc, campaignSendReportDoc } from "./campaign-queries.js";
36
37
  import { orgSiteHubPath, useMarketingOrgId, useMarketingOrgMount } from "./marketing-org-mount.js";
37
38
  import { useRecordRouteContext } from "./record-route-context.js";
@@ -193,7 +194,7 @@ import { useRecordRouteContext } from "./record-route-context.js";
193
194
  const messageHref = messageHrefOf(campaignId);
194
195
  const conversionsHub = orgMount ? orgSiteHubPath(orgMount, String((_ref = campaign == null ? void 0 : campaign.hostId) != null ? _ref : ''), 'marketing') : basePath;
195
196
  const report = campaignReport(campaign == null ? void 0 : campaign.stats);
196
- const linkReport = campaignLinkReport(links);
197
+ const linkReport = sendLinkReport(links);
197
198
  const revenueReport = campaignRevenueReport({
198
199
  rollup: revenue,
199
200
  /*
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-report-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 { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Box,\n Button,\n Chip,\n Divider,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport {\n campaignLinkReport,\n campaignReport,\n type CampaignLinkRollup,\n type CampaignStats,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-report'\nimport {\n campaignRevenueReport,\n type CampaignRevenueRollup,\n} from '../model/campaign-revenue'\nimport {\n campaignConversionsReport,\n type CampaignConversionsRollup,\n} from '../model/campaign-conversions'\n/*\n * The three renderers every email report shares. Imported rather than kept\n * here, so \"a rate prints its denominator\" is one implementation and not a\n * convention each new card has to remember.\n */\nimport {\n Figure,\n MoneyFigure,\n MoneyPerUnitRow,\n percent,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n emailSendTimeMs,\n} from '@aglyn/shared-ui-email-campaigns/model/email-record'\nimport {\n campaignSendDisplay,\n campaignSendProgress,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport { campaignSendDoc, campaignSendReportDoc } from './campaign-queries'\nimport {\n orgSiteHubPath,\n useMarketingOrgId,\n useMarketingOrgMount,\n} from './marketing-org-mount'\nimport { useRecordRouteContext } from './record-route-context'\n\n/**\n * The help affordance, hoisted so BOTH headers carry it.\n *\n * The unreadable-campaign branch below renders its own `CardDisplay`, and a\n * header without help is a header a reader has no way out of — which is\n * exactly the state that branch describes.\n */\nconst reportDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'What one campaign did: delivery, opens, clicks, bounces, complaints, ' +\n 'unsubscribes and the revenue it was credited with, each with the ' +\n 'population it is measured against.',\n})\n\nexport interface CampaignReportCardProps {\n /** The site, or `null` on the org Marketing hub. */\n hostId: string | null\n campaignId: string\n /** The marketing hub URL, for the way back to the campaigns list. */\n basePath: string\n}\n\n/**\n * ONE CAMPAIGN, MEASURED — and every denominator on screen.\n *\n * ## What was missing\n *\n * Opens and clicks have been recorded on the campaign document since AGL-268\n * and displayed as two words in a history row: `12 opens · 3 clicks`. Nothing\n * anywhere showed a rate, a delivery count, a bounce, a complaint or an\n * unsubscribe, and the populations the send itself measured — how large the\n * audience was, how many the consent rule refused, how many were already\n * suppressed — were computed on every send and written nowhere.\n *\n * ## The rule this screen is built around\n *\n * **Every rate names its denominator, in the same breath as the number.**\n *\n * That is not decoration. An open rate over `sent` and an open rate over\n * `delivered` are different numbers that share a label, and the difference\n * between them is exactly the mail that bounced — so a report that showed one\n * while the reader assumed the other would be wrong by however bad the\n * address list is, which is the case where being wrong matters most. Worse,\n * \"click rate\" is used in the wild for two unrelated quantities — clicks over\n * delivered, and clicks over openers — and they differ by a factor of three\n * or four on ordinary mail.\n *\n * So the arithmetic is not done here at all. `campaign-report.ts` returns\n * each rate as a triple carrying `denominatorLabel`, this file renders that\n * label, and a rate whose denominator is zero or unrecorded comes back `null`\n * and is drawn as \"—\" with a caveat naming the reason. There is no path\n * through this component that prints a percentage without saying what it is a\n * percentage of.\n *\n * ## What the page is about comes before how the mail did\n *\n * A campaign of one email is the ordinary shape, and this card is the page it\n * gets: the URL names a send, so there is no container above it and no list of\n * emails to draw. That collapse is honest, but it decided what the page was\n * ABOUT — delivery, engagement and rates are mechanics of a message, and a\n * page that led with them at length read as a mail report wearing the word\n * campaign.\n *\n * So the outcomes lead: what it caused, what it earned, where it sent people.\n * Two of those headings are the ones `campaign-reach-sections.tsx` puts at the\n * top of a campaign holding SEVERAL emails, and they are the same words here\n * deliberately — a merchant who opens a campaign of one and a campaign of six\n * is asking the same question and should not have to learn two vocabularies to\n * find the answer. The email's own figures follow, under a row that names the\n * message they belong to.\n *\n * Nothing about the reads changed with the order. The conversions, revenue and\n * destination figures were already on this page and already came from the four\n * documents below; they were simply underneath the mail.\n *\n * ## Three documents, whatever the campaign's size\n *\n * The whole screen is one campaign document plus one link-rollup document\n * plus one revenue-rollup document. All three are single-document listens, so\n * the cost does not move with the audience — a report over a 50,000-recipient\n * send reads the same three documents as one over ten. Aggregating the\n * per-recipient delivery log on mount would have been the obvious\n * implementation and would have read one document per recipient, every time\n * anybody opened the page; joining orders to campaigns at read time would\n * have been the same mistake one collection along, which is why the join is\n * done once at the sale and stored.\n *\n * ## No per-recipient view here, deliberately\n *\n * Every number on this screen is an aggregate that carries no address, and\n * that is what makes it three single-document reads. A per-recipient list is a\n * different read and a different question, so it lives where it is asked:\n * the design report's recipients table, which resolves the campaigns from the\n * design on a server that has already checked the reader's site role, and\n * returns only rows tagged with that site.\n *\n * ## The same four documents at the org level\n *\n * A send and its rollups are the org's, so the org hub reads the same four\n * documents. The one link that leads to a SITE fact — the conversions list,\n * which is that site's visitors — goes to the site the send was sent as.\n *\n * The other per-recipient view — the staff delivery log on the user detail\n * page — spans every site on the install and is behind a staff claim that\n * records who looked. Neither reaches this card: adding a recipient list here\n * would put an unbounded read behind a widget whose whole cost model is that\n * it has none.\n */\nexport function CampaignReportCard(props: CampaignReportCardProps) {\n const { hostId, campaignId, basePath } = props\n // The scope the message's own page is asked in: it belongs to the Emails page.\n const routeContext = useRecordRouteContext()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(hostId)\n const firestore = useFirestore()\n\n const { data: campaign, status } = useFirestoreDoc<\n Record<string, unknown> & { stats?: CampaignStats }\n >(\n () => (orgId ? campaignSendDoc(firestore, orgId, campaignId) : null),\n [firestore, orgId, campaignId],\n )\n /*\n * The link rollup, its own document rather than a field on the campaign.\n *\n * A map of destinations grows with the campaign's content and a campaign\n * document is read by the history list, the glance widget and the send\n * path; putting an unbounded map on it would make every one of those reads\n * larger. Split, the rollup is read by exactly the screen that renders it.\n */\n const { data: links } = useFirestoreDoc<CampaignLinkRollup>(\n () =>\n orgId ? campaignSendReportDoc(firestore, orgId, campaignId, 'links') : null,\n [firestore, orgId, campaignId],\n )\n /*\n * The revenue rollup, a third single-document listen.\n *\n * Its own document beside the link rollup rather than a field on the\n * campaign, for the reason the link rollup is: the campaign document is\n * read by the history list, the glance widget and the send path, and a map\n * that grows with the campaign's sales would enlarge every one of those\n * reads. The cost model in this file's header is unchanged in the way that\n * matters — the page still reads a fixed number of documents whatever the\n * audience, and no per-order or per-recipient read exists anywhere on it.\n */\n const { data: revenue } = useFirestoreDoc<CampaignRevenueRollup>(\n () =>\n orgId\n ? campaignSendReportDoc(firestore, orgId, campaignId, 'revenue')\n : null,\n [firestore, orgId, campaignId],\n )\n\n /*\n * The conversions rollup, a fourth single-document listen.\n *\n * Beside `reports/revenue` and read the same way, because it is the same\n * join answering the other half of the question: revenue is what a campaign\n * EARNED from people who named themselves at checkout, and this is what it\n * CAUSED among people who were anonymous until the moment they submitted,\n * signed up or booked. The cost model in this file's header is unchanged in\n * the way that matters — a fixed number of documents whatever the audience,\n * and no per-conversion read anywhere on the page.\n */\n const { data: conversions } = useFirestoreDoc<CampaignConversionsRollup>(\n () =>\n orgId\n ? campaignSendReportDoc(firestore, orgId, campaignId, 'conversions')\n : null,\n [firestore, orgId, campaignId],\n )\n /*\n * Where this send's links lead. The message's page is on the Emails page —\n * the site's under a site, the organization's over the org; the\n * conversions list is always the sending site's.\n */\n const messageHrefOf = (id: string): string | null =>\n routeContext ? pluginRecordHref('emailMessage', routeContext, id) : null\n const messageHref = messageHrefOf(campaignId)\n const conversionsHub = orgMount\n ? orgSiteHubPath(orgMount, String(campaign?.hostId ?? ''), 'marketing')\n : basePath\n\n const report = campaignReport(campaign?.stats)\n const linkReport = campaignLinkReport(links)\n const revenueReport = campaignRevenueReport({\n rollup: revenue,\n /*\n * The DENOMINATOR, handed over from the same document read at the same\n * instant rather than re-read here. `report.delivered` is `null` when no\n * delivery event was ever recorded, and it is passed as `null` so the\n * revenue figures over it are withheld for exactly the reason every other\n * rate over it is.\n */\n delivered: report.delivered,\n midFlight: campaign\n ? campaignSendProgress(campaign as never).state === 'sending'\n : false,\n })\n /*\n * No denominator is handed in, unlike the revenue report. A conversion RATE\n * over delivered messages would repeat the defect the revenue section\n * refuses for orders — one visitor can submit two forms, so the quotient\n * passes 100% without anything being wrong — and counting distinct people\n * instead needs a document per person per campaign, which is the\n * per-recipient read this screen's cost model exists to refuse.\n */\n const conversionsReport = campaignConversionsReport({ rollup: conversions })\n const subject = String(campaign?.subject ?? 'Campaign')\n const sendTimeMs = campaign ? emailSendTimeMs(campaign) : 0\n\n const backButton = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'All campaigns'}\n </Button>\n )\n\n // Nothing to say until the read has had its chance to answer.\n if (!campaign && (status === 'loading' || !orgId)) return null\n\n if (!campaign) {\n return (\n <CardDisplay\n header={'Campaign report'}\n help={reportDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: backButton }}\n >\n {/*\n * Not \"no data\". A campaign that cannot be read is a different\n * situation from one with no engagement, and rendering an empty\n * report for the first is how somebody comes to believe a campaign\n * they sent reached nobody.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This campaign could not be loaded. It may have been deleted.'}\n </Typography>\n </CardDisplay>\n )\n }\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Campaign report'}\n help={reportDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: backButton }}\n >\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n\n {/*==========================================\n * WHAT THIS CAMPAIGN CAUSED — the page's first section, and the\n * heading a campaign of several emails leads with too.\n *\n * The same join as the revenue below, credited under the same rule,\n * answering it for people who were anonymous until the moment being\n * counted: they arrived from a campaign link, browsed, and only\n * became somebody when they submitted a form, signed up or booked.\n *\n * THE FOUR FIGURES ARE NEVER ADDED. One person filling in one form\n * writes a submission, a contact and a lead, so a total would count\n * that visit three times — and it would look like a bigger version\n * of a real number, which is why the model carries no total for this\n * JSX to reach for and the caveat says so in words above them.\n *\n * They are laid out in a row of independent `Figure`s rather than in\n * a table with a footer, because a footer row is where a reader\n * expects the sum to be.\n *=========================================*/}\n <Section title=\"What it caused\">\n {conversionsReport.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {conversionsReport.any ? (\n <Stack spacing={1}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n {conversionsReport.kinds.map((entry) => (\n <Figure\n key={entry.kind}\n label={entry.label}\n value={entry.value}\n note={entry.note}\n />\n ))}\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Credited ${conversionsReport.model === 'last-click' ? 'to the last campaign whose link the visitor clicked' : `under the ${conversionsReport.model} model`}, ` +\n `within ${conversionsReport.windowDays} days of that click. ` +\n 'Somebody who converted without ever following a campaign ' +\n 'link is credited to no campaign at all — nothing is ' +\n 'inferred from a referrer — so these are a floor rather ' +\n 'than everything this campaign influenced. The ones ' +\n 'credited to nobody are counted under Conversions in the ' +\n 'marketing console.'}\n </Typography>\n {/*\n THE RECORDS BEHIND THE FIGURES. The rollup says how many; the\n list says which, and it is the only place the uncredited half\n is counted. A link rather than a table here — the records are\n a paged read and this page's whole cost model is a fixed\n number of documents whatever the audience.\n */}\n {conversionsHub ? (\n <Box>\n <Button\n component={AppLink as any}\n {...({\n componentVariant: 'naked',\n nativeButton: false,\n } as any)}\n href={`${conversionsHub}/conversions/${campaignId}`}\n size=\"small\"\n color=\"primary\"\n >\n {'See these conversions'}\n </Button>\n </Box>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {conversionsReport.recorded\n ? 'Nothing has been credited to this campaign yet.'\n : 'No conversions have been attributed to this campaign. A ' +\n 'form submission, lead, contact or booking is credited to ' +\n 'the last campaign whose link the visitor clicked, within ' +\n `${conversionsReport.windowDays} days — a campaign sent ` +\n 'before that was recorded will never show a figure here.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT THIS CAMPAIGN EARNED.\n *\n * The merchant's second question, and the one every compared\n * product answers by reconstructing revenue probabilistically\n * because it does not own the order. Here it is a join: the click\n * and the order are rows in one database, so there is no tracking\n * script, no catalog sync and nothing estimated except the model.\n *\n * THE MODEL IS ON SCREEN, in a sentence, above the money. A revenue\n * figure whose rule the reader cannot state is worse than no\n * figure, because they will use it anyway — and the two things they\n * have to know to use it are which campaign gets the credit when\n * several touched the buyer, and how long a click stays creditable.\n *\n * NET LEADS. Gross and refunded are both shown beside it, because\n * \"this campaign made $4,000\" and \"this campaign made $4,000 of\n * which $3,100 came back\" are different facts about whether to send\n * another one.\n *=========================================*/}\n <Section title=\"Revenue\">\n {revenueReport.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {revenueReport.currencies.length ? (\n <Stack spacing={3}>\n {revenueReport.currencies.map((entry) => (\n <Stack key={entry.currency} spacing={1}>\n {/*\n * The currency is a HEADING when there is more than one,\n * and absent when there is one. A per-currency label on a\n * report that only ever shows dollars is noise; the moment\n * there are two, the reader has to be able to see at a\n * glance that the blocks are not addable — which is also\n * what the caveat above says in words.\n */}\n {revenueReport.multiCurrency ? (\n <Typography variant=\"subtitle2\">\n {entry.currency.toUpperCase()}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <MoneyFigure\n label=\"Net revenue\"\n cents={entry.netCents}\n currency={entry.currency}\n note=\"after refunds\"\n />\n <MoneyFigure\n label=\"Gross revenue\"\n cents={entry.grossCents}\n currency={entry.currency}\n note=\"as charged\"\n />\n <MoneyFigure\n label=\"Refunded\"\n cents={entry.refundedCents}\n currency={entry.currency}\n note=\"handed back\"\n />\n <Figure\n label=\"Orders\"\n value={entry.orders}\n note=\"credited to this campaign\"\n />\n <Figure\n label=\"Fully refunded\"\n value={entry.refundedOrders}\n note=\"of those orders\"\n />\n </Stack>\n <MoneyPerUnitRow\n label=\"Net revenue per delivered message\"\n figure={entry.netPerDelivered}\n />\n </Stack>\n ))}\n {/*\n * NO CONVERSION RATE, and this is where a reader would look\n * for one. Orders over delivered is not a rate: one buyer can\n * place two orders, so the quotient passes 100% without\n * anything being wrong — the same defect that keeps `opens`\n * out of the open-rate numerator two sections above. Counting\n * distinct buyers instead would need a document per person per\n * campaign, which is the per-recipient read this screen's cost\n * model exists to refuse. The order count is shown as a count.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Credited ${revenueReport.model === 'last-click' ? 'to the last campaign whose link the buyer clicked' : `under the ${revenueReport.model} model`}, ` +\n `within ${revenueReport.windowDays} days of that click. ` +\n 'Clicks only — an open is not treated as evidence that ' +\n 'anybody read the email. An order placed by somebody who ' +\n 'never clicked, or who checked out without giving an ' +\n 'address, is credited to no campaign, so this is a floor ' +\n 'rather than every sale this campaign influenced.'}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {revenueReport.recorded\n ? 'No orders have been credited to this campaign.'\n : 'No revenue has been attributed to this campaign. Orders ' +\n 'are credited to the last campaign whose link the buyer ' +\n `clicked, within ${revenueReport.windowDays} days — a ` +\n 'campaign sent before that was recorded, or one on a site ' +\n 'with no store, will never show a figure here.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n <Section title=\"Where it sent people\">\n {/*\n The heading a campaign of several emails carries over the same\n question, so the two shapes of campaign page read alike. A\n campaign owns no page — this is where its mail was actually\n followed to, which is the only account of it the data holds.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages this campaign’s links were followed to.'}\n </Typography>\n {linkReport.rows.length ? (\n <>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n <TableCell align=\"right\">{'Share'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {linkReport.rows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {row.clicks.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.share\n ? `${percent(row.share.value)} of ${row.share.denominator.toLocaleString()} ${row.share.denominatorLabel}`\n : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n {/*\n * The normalisation, stated rather than left for somebody to\n * discover from a total that does not add up. Two links to the\n * same page distinguished only by their UTM tags are one row\n * here, and a merchant comparing this table with the campaign\n * they built has to be told that before they conclude a link\n * is missing.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Counted by address and path — query strings are dropped, so ' +\n 'two links to the same page with different tracking ' +\n 'parameters count as one row.'}\n </Typography>\n {linkReport.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${linkReport.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived without a destination and are not in this table.'}\n </Typography>\n ) : null}\n {linkReport.overflowClicks ? (\n <Alert severity=\"info\">\n {`This campaign has more distinct destinations than the ` +\n `rollup keeps. ${linkReport.overflowClicks.toLocaleString()} ` +\n 'clicks landed on links past that limit and are counted ' +\n 'in the click total above but not in this table.'}\n </Alert>\n ) : null}\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.clicks\n ? 'Clicks were recorded for this campaign, but none of them ' +\n 'carried a destination, so there is nothing to break down ' +\n 'by link.'\n : 'No link clicks have been recorded for this campaign.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n {/*==========================================\n * THE EMAIL THE FIGURES BELOW CAME FROM — and the hinge between the\n * two halves of this page.\n *\n * This screen is reached at `/marketing/campaigns/{sendId}`, which\n * is a CAMPAIGN url resolving to a send — the fall-through that keeps\n * every unsubscribe footer and every pasted report link working. So\n * a reader arrives from the campaigns table, where the row carries a\n * \"Single send\" chip, and everything under this row is a fact about\n * a message rather than about the campaign.\n *\n * Figures with no visible source read as a campaign holding no\n * emails while reporting real numbers, which is a contradiction\n * rather than a shortage of detail. Naming the one email — and\n * saying it IS the campaign, which is what the chip already claims\n * and what `campaignListRows` already does at read time — is what\n * makes the page answer the question it raises.\n *\n * One row, from the document this card already read: no query, no\n * second listen, and the two-document cost the header describes is\n * unchanged.\n *=========================================*/}\n <Section title=\"The email in this campaign\">\n <Stack spacing={1}>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The MESSAGE's own page, which the Emails console owns — hence\n the sibling hub rather than this surface's `basePath`. Plain\n text until that hub resolves: a link with no destination is\n worse than none, and the subject is what the reader came for\n either way.\n */}\n {messageHref ? (\n <AppLink href={messageHref}>{subject}</AppLink>\n ) : (\n <Typography variant=\"body2\">{subject}</Typography>\n )}\n {/*\n What the email is DOING, not the field it stores. One\n delivering an audience larger than one batch is written back\n as `scheduled` between runs, so a chip rendering the status\n said \"Scheduled\" at the head of a report of five hundred\n deliveries.\n */}\n <Chip\n size=\"small\"\n label={campaignSendDisplay(campaign as never).label}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label=\"Single send\"\n title={\n 'One email, sent on its own rather than as part of a ' +\n 'campaign of several. Its report and its unsubscribe links ' +\n 'are unchanged.'\n }\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {sendTimeMs\n ? new Date(sendTimeMs).toLocaleString()\n : 'not recorded'}\n </Typography>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign is one email, so the sections above are what ' +\n 'that one email caused. What follows is the mail itself — ' +\n 'how it was delivered and how it was read. Open the message ' +\n 'for its body and the people it reached.'}\n </Typography>\n </Stack>\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT HAPPENED TO THE MAIL.\n *\n * Counts first and rates second, in that order and not the other\n * way round, because a rate is only readable once you know what it\n * was taken over — and because the counts are the part that is\n * always true. Every rate below can be absent; none of these can.\n *=========================================*/}\n <Section title=\"Delivery\">\n <Stack direction=\"row\" spacing={4} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Addressed\"\n value={report.recipients}\n note=\"after the per-send cap\"\n />\n <Figure\n label=\"Sent\"\n value={report.sent}\n note=\"accepted by the provider\"\n />\n <Figure\n label=\"Delivered\"\n value={report.delivered}\n note=\"accepted by the receiving server\"\n />\n <Figure label=\"Bounced\" value={report.bounced} note=\"of sent\" />\n <Figure\n label=\"Marked as spam\"\n value={report.complained}\n note=\"of delivered\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Engagement\">\n {/*\n * BOTH the event count and the distinct count, side by side.\n *\n * `Opens` is every open event — one reader opening four times is\n * four — and it is the number this product has always shown. It\n * cannot be a rate numerator: divided by anything it exceeds 100%\n * the moment somebody reads an email twice. `Readers who opened`\n * is the distinct count, and it is what the rate below divides.\n * Showing only one of them would either hide activity or invite a\n * rate nobody can defend, so both are here with different names.\n */}\n <Stack direction=\"row\" spacing={4} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Opens\"\n value={report.opens}\n note=\"every open, repeats included\"\n />\n <Figure\n label=\"Readers who opened\"\n value={report.uniqueOpens}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Clicks\"\n value={report.clicks}\n note=\"every click, repeats included\"\n />\n <Figure\n label=\"Readers who clicked\"\n value={report.uniqueClicks}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Unsubscribed\"\n value={report.unsubscribes}\n note=\"through this campaign's link\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Rates\">\n <Stack spacing={1}>\n <RateRow label=\"Delivery rate\" rate={report.rates.delivery} />\n <RateRow label=\"Open rate\" rate={report.rates.open} />\n <RateRow label=\"Click rate\" rate={report.rates.click} />\n <RateRow\n label=\"Click-to-open rate\"\n rate={report.rates.clickToOpen}\n />\n <RateRow label=\"Bounce rate\" rate={report.rates.bounce} />\n <RateRow label=\"Complaint rate\" rate={report.rates.complaint} />\n <RateRow\n label=\"Unsubscribe rate\"\n rate={report.rates.unsubscribe}\n />\n </Stack>\n </Section>\n\n {report.populations.length ? (\n <>\n <Divider />\n <Section title=\"Who this went to\">\n {/*\n * The populations the SEND measured, recorded at send time.\n *\n * Not recomputed here, and the note under the heading says so:\n * consent records change and addresses get suppressed, so\n * asking the list today produces a number that is true of the\n * list and false of the campaign.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Measured when this campaign was sent, and stored as it was ' +\n 'then. These figures describe the send, not the audience ' +\n 'as it stands today.'}\n </Typography>\n <ScrollTable size=\"small\">\n <TableBody>\n {report.populations.map((population) => (\n <TableRow key={population.id}>\n <TableCell>{population.label}</TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {population.count.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`of ${population.of.toLocaleString()} ${population.ofLabel}`}\n </Typography>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the send; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord\n title={\n campaign ? String(campaign.subject || '') || campaignId : undefined\n }\n />\n {card}\n </>\n )\n}\nCampaignReportCard.displayName = 'CampaignReportCard'\n\nexport default CampaignReportCard\n"],"names":["PageHeaderRecord","pluginDocsHelp","pluginRecordHref","AppLink","CardDisplay","ScrollTable","Alert","Box","Button","Chip","Divider","Stack","TableBody","TableCell","TableHead","TableRow","Typography","useFirestore","useFirestoreDoc","campaignLinkReport","campaignReport","campaignRevenueReport","campaignConversionsReport","Figure","MoneyFigure","MoneyPerUnitRow","percent","RateRow","Section","emailSendTimeMs","campaignSendDisplay","campaignSendProgress","campaignSendDoc","campaignSendReportDoc","orgSiteHubPath","useMarketingOrgId","useMarketingOrgMount","useRecordRouteContext","reportDocsHelp","anchor","excerpt","CampaignReportCard","props","hostId","campaignId","basePath","routeContext","orgMount","orgId","firestore","data","campaign","status","links","revenue","conversions","messageHrefOf","id","messageHref","conversionsHub","String","report","stats","linkReport","revenueReport","rollup","delivered","midFlight","state","conversionsReport","subject","sendTimeMs","backButton","component","componentVariant","nativeButton","href","size","color","header","help","contentGutterX","contentGutterY","HeaderProps","action","variant","card","spacing","caveats","map","caveat","severity","message","title","any","direction","useFlexGap","sx","flexWrap","kinds","entry","label","value","note","kind","model","windowDays","recorded","currencies","length","multiCurrency","currency","toUpperCase","cents","netCents","grossCents","refundedCents","orders","refundedOrders","figure","netPerDelivered","rows","align","row","wordBreak","url","fontWeight","clicks","toLocaleString","share","denominator","denominatorLabel","unattributedClicks","overflowClicks","alignItems","Date","recipients","sent","bounced","complained","opens","uniqueOpens","uniqueClicks","unsubscribes","rate","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","populations","population","count","of","ofLabel","undefined","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SAASA,gBAAgB,EAAEC,cAAc,QAAQ,eAAc;AAC/D,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,WAAW,QAAQ,yDAAwD;AACpF,SACEC,KAAK,EACLC,GAAG,EACHC,MAAM,EACNC,IAAI,EACJC,OAAO,EACPC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,QAAQ,EACRC,UAAU,QACL,gBAAe;AACtB,SAASC,YAAY,EAAEC,eAAe,QAAQ,iCAAgC;AAC9E,SACEC,kBAAkB,EAClBC,cAAc,QAGT,yDAAwD;AAC/D,SACEC,qBAAqB,QAEhB,+BAA2B;AAClC,SACEC,yBAAyB,QAEpB,mCAA+B;AACtC;;;;CAIC,GACD,SACEC,MAAM,EACNC,WAAW,EACXC,eAAe,EACfC,OAAO,EACPC,OAAO,EACPC,OAAO,QACF,6DAA4D;AACnE,SACEC,eAAe,QACV,sDAAqD;AAC5D,SACEC,mBAAmB,EACnBC,oBAAoB,QACf,4DAA2D;AAClE,SAASC,eAAe,EAAEC,qBAAqB,QAAQ,wBAAoB;AAC3E,SACEC,cAAc,EACdC,iBAAiB,EACjBC,oBAAoB,QACf,2BAAuB;AAC9B,SAASC,qBAAqB,QAAQ,4BAAwB;AAE9D;;;;;;CAMC,GACD,MAAMC,iBAAiBrC,eAAe,kBAAkB;IACtDsC,QAAQ;IACRC,SACE,0EACA,sEACA;AACJ;AAUA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqFC,GACD,OAAO,SAASC,mBAAmBC,KAA8B;;IAC/D,MAAM,EAAEC,MAAM,EAAEC,UAAU,EAAEC,QAAQ,EAAE,GAAGH;IACzC,+EAA+E;IAC/E,MAAMI,eAAeT;IACrB,MAAMU,WAAWX;IACjB,MAAM,EAAEY,KAAK,EAAE,GAAGb,kBAAkBQ;IACpC,MAAMM,YAAYhC;IAElB,MAAM,EAAEiC,MAAMC,QAAQ,EAAEC,MAAM,EAAE,GAAGlC,gBAGjC,IAAO8B,QAAQhB,gBAAgBiB,WAAWD,OAAOJ,cAAc,MAC/D;QAACK;QAAWD;QAAOJ;KAAW;IAEhC;;;;;;;GAOC,GACD,MAAM,EAAEM,MAAMG,KAAK,EAAE,GAAGnC,gBACtB,IACE8B,QAAQf,sBAAsBgB,WAAWD,OAAOJ,YAAY,WAAW,MACzE;QAACK;QAAWD;QAAOJ;KAAW;IAEhC;;;;;;;;;;GAUC,GACD,MAAM,EAAEM,MAAMI,OAAO,EAAE,GAAGpC,gBACxB,IACE8B,QACIf,sBAAsBgB,WAAWD,OAAOJ,YAAY,aACpD,MACN;QAACK;QAAWD;QAAOJ;KAAW;IAGhC;;;;;;;;;;GAUC,GACD,MAAM,EAAEM,MAAMK,WAAW,EAAE,GAAGrC,gBAC5B,IACE8B,QACIf,sBAAsBgB,WAAWD,OAAOJ,YAAY,iBACpD,MACN;QAACK;QAAWD;QAAOJ;KAAW;IAEhC;;;;GAIC,GACD,MAAMY,gBAAgB,CAACC,KACrBX,eAAe5C,iBAAiB,gBAAgB4C,cAAcW,MAAM;IACtE,MAAMC,cAAcF,cAAcZ;IAClC,MAAMe,iBAAiBZ,WACnBb,eAAea,UAAUa,eAAOT,4BAAAA,SAAUR,MAAM,mBAAI,KAAK,eACzDE;IAEJ,MAAMgB,SAASzC,eAAe+B,4BAAAA,SAAUW,KAAK;IAC7C,MAAMC,aAAa5C,mBAAmBkC;IACtC,MAAMW,gBAAgB3C,sBAAsB;QAC1C4C,QAAQX;QACR;;;;;;KAMC,GACDY,WAAWL,OAAOK,SAAS;QAC3BC,WAAWhB,WACPpB,qBAAqBoB,UAAmBiB,KAAK,KAAK,YAClD;IACN;IACA;;;;;;;GAOC,GACD,MAAMC,oBAAoB/C,0BAA0B;QAAE2C,QAAQV;IAAY;IAC1E,MAAMe,UAAUV,gBAAOT,4BAAAA,SAAUmB,OAAO,oBAAI;IAC5C,MAAMC,aAAapB,WAAWtB,gBAAgBsB,YAAY;IAE1D,MAAMqB,2BACJ,KAAChE;QACCiE,WAAWtE;OACN;QAAEuE,kBAAkB;QAASC,cAAc;IAAM;QACtDC,MAAM,GAAG/B,SAAS,UAAU,CAAC;QAC7BgC,MAAK;QACLC,OAAM;kBAEL;;IAIL,8DAA8D;IAC9D,IAAI,CAAC3B,YAAaC,CAAAA,WAAW,aAAa,CAACJ,KAAI,GAAI,OAAO;IAE1D,IAAI,CAACG,UAAU;QACb,qBACE,KAAC/C;YACC2E,QAAQ;YACRC,MAAM1C;YACN2C,cAAc;YACdC,cAAc;YACdC,aAAa;gBAAEC,QAAQZ;YAAW;sBAQlC,cAAA,KAACxD;gBAAWqE,SAAQ;gBAAQP,OAAM;0BAC/B;;;IAIT;IAEA;yCACuC,GACvC,MAAMQ,qBACJ,KAAClF;QACC2E,QAAQ;QACRC,MAAM1C;QACN2C,cAAc;QACdC,cAAc;QACdC,aAAa;YAAEC,QAAQZ;QAAW;kBAElC,cAAA,MAAC7D;YAAM4E,SAAS;;gBACb1B,OAAO2B,OAAO,CAACC,GAAG,CAAC,CAACC,uBACnB,KAACpF;wBAAsBqF,UAAS;kCAC7BD,OAAOE,OAAO;uBADLF,OAAOjC,EAAE;8BAwBvB,MAAC7B;oBAAQiE,OAAM;;wBACZxB,kBAAkBmB,OAAO,CAACC,GAAG,CAAC,CAACC,uBAC9B,KAACpF;gCAAsBqF,UAAS;0CAC7BD,OAAOE,OAAO;+BADLF,OAAOjC,EAAE;wBAItBY,kBAAkByB,GAAG,iBACpB,MAACnF;4BAAM4E,SAAS;;8CACd,KAAC5E;oCACCoF,WAAU;oCACVR,SAAS;oCACTS,UAAU;oCACVC,IAAI;wCAAEC,UAAU;oCAAO;8CAEtB7B,kBAAkB8B,KAAK,CAACV,GAAG,CAAC,CAACW,sBAC5B,KAAC7E;4CAEC8E,OAAOD,MAAMC,KAAK;4CAClBC,OAAOF,MAAME,KAAK;4CAClBC,MAAMH,MAAMG,IAAI;2CAHXH,MAAMI,IAAI;;8CAOrB,KAACxF;oCAAWqE,SAAQ;oCAAUP,OAAM;8CACjC,CAAC,SAAS,EAAET,kBAAkBoC,KAAK,KAAK,eAAe,wDAAwD,CAAC,UAAU,EAAEpC,kBAAkBoC,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,GAC9J,CAAC,OAAO,EAAEpC,kBAAkBqC,UAAU,CAAC,qBAAqB,CAAC,GAC7D,8DACA,yDACA,4DACA,wDACA,6DACA;;gCASH/C,+BACC,KAACpD;8CACC,cAAA,KAACC;wCACCiE,WAAWtE;uCACN;wCACHuE,kBAAkB;wCAClBC,cAAc;oCAChB;wCACAC,MAAM,GAAGjB,eAAe,aAAa,EAAEf,YAAY;wCACnDiC,MAAK;wCACLC,OAAM;kDAEL;;qCAGH;;2CAGN,KAAC9D;4BAAWqE,SAAQ;4BAAQP,OAAM;sCAC/BT,kBAAkBsC,QAAQ,GACvB,oDACA,6DACA,8DACA,8DACA,GAAGtC,kBAAkBqC,UAAU,CAAC,wBAAwB,CAAC,GACzD;;;;8BAKV,KAAChG;8BAsBD,MAACkB;oBAAQiE,OAAM;;wBACZ7B,cAAcwB,OAAO,CAACC,GAAG,CAAC,CAACC,uBAC1B,KAACpF;gCAAsBqF,UAAS;0CAC7BD,OAAOE,OAAO;+BADLF,OAAOjC,EAAE;wBAItBO,cAAc4C,UAAU,CAACC,MAAM,iBAC9B,MAAClG;4BAAM4E,SAAS;;gCACbvB,cAAc4C,UAAU,CAACnB,GAAG,CAAC,CAACW,sBAC7B,MAACzF;wCAA2B4E,SAAS;;4CASlCvB,cAAc8C,aAAa,iBAC1B,KAAC9F;gDAAWqE,SAAQ;0DACjBe,MAAMW,QAAQ,CAACC,WAAW;iDAE3B;0DACJ,MAACrG;gDACCoF,WAAU;gDACVR,SAAS;gDACTS,UAAU;gDACVC,IAAI;oDAAEC,UAAU;gDAAO;;kEAEvB,KAAC1E;wDACC6E,OAAM;wDACNY,OAAOb,MAAMc,QAAQ;wDACrBH,UAAUX,MAAMW,QAAQ;wDACxBR,MAAK;;kEAEP,KAAC/E;wDACC6E,OAAM;wDACNY,OAAOb,MAAMe,UAAU;wDACvBJ,UAAUX,MAAMW,QAAQ;wDACxBR,MAAK;;kEAEP,KAAC/E;wDACC6E,OAAM;wDACNY,OAAOb,MAAMgB,aAAa;wDAC1BL,UAAUX,MAAMW,QAAQ;wDACxBR,MAAK;;kEAEP,KAAChF;wDACC8E,OAAM;wDACNC,OAAOF,MAAMiB,MAAM;wDACnBd,MAAK;;kEAEP,KAAChF;wDACC8E,OAAM;wDACNC,OAAOF,MAAMkB,cAAc;wDAC3Bf,MAAK;;;;0DAGT,KAAC9E;gDACC4E,OAAM;gDACNkB,QAAQnB,MAAMoB,eAAe;;;uCAnDrBpB,MAAMW,QAAQ;8CAiE5B,KAAC/F;oCAAWqE,SAAQ;oCAAUP,OAAM;8CACjC,CAAC,SAAS,EAAEd,cAAcyC,KAAK,KAAK,eAAe,sDAAsD,CAAC,UAAU,EAAEzC,cAAcyC,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,GACpJ,CAAC,OAAO,EAAEzC,cAAc0C,UAAU,CAAC,qBAAqB,CAAC,GACzD,2DACA,6DACA,yDACA,6DACA;;;2CAIN,KAAC1F;4BAAWqE,SAAQ;4BAAQP,OAAM;sCAC/Bd,cAAc2C,QAAQ,GACnB,mDACA,6DACA,4DACA,CAAC,gBAAgB,EAAE3C,cAAc0C,UAAU,CAAC,UAAU,CAAC,GACvD,8DACA;;;;8BAKV,KAAChG;8BAED,MAACkB;oBAAQiE,OAAM;;sCAOb,KAAC7E;4BAAWqE,SAAQ;4BAAQP,OAAM;sCAC/B;;wBAEFf,WAAW0D,IAAI,CAACZ,MAAM,iBACrB;;8CACE,MAACxG;oCAAYwE,MAAK;;sDAChB,KAAC/D;sDACC,cAAA,MAACC;;kEACC,KAACF;kEAAW;;kEACZ,KAACA;wDAAU6G,OAAM;kEAAS;;kEAC1B,KAAC7G;wDAAU6G,OAAM;kEAAS;;;;;sDAG9B,KAAC9G;sDACEmD,WAAW0D,IAAI,CAAChC,GAAG,CAAC,CAACkC,oBACpB,MAAC5G;;sEACC,KAACF;4DAAUoF,IAAI;gEAAE2B,WAAW;4DAAY;sEACrCD,IAAIE,GAAG;;sEAEV,KAAChH;4DAAU6G,OAAM;4DAAQzB,IAAI;gEAAE6B,YAAY;4DAAO;sEAC/CH,IAAII,MAAM,CAACC,cAAc;;sEAE5B,KAACnH;4DAAU6G,OAAM;sEACdC,IAAIM,KAAK,GACN,GAAGvG,QAAQiG,IAAIM,KAAK,CAAC3B,KAAK,EAAE,IAAI,EAAEqB,IAAIM,KAAK,CAACC,WAAW,CAACF,cAAc,GAAG,CAAC,EAAEL,IAAIM,KAAK,CAACE,gBAAgB,EAAE,GACxG;;;mDAVOR,IAAIE,GAAG;;;;8CAwB5B,KAAC7G;oCAAWqE,SAAQ;oCAAUP,OAAM;8CACjC,iEACC,wDACA;;gCAEHf,WAAWqE,kBAAkB,iBAC5B,KAACpH;oCAAWqE,SAAQ;oCAAUP,OAAM;8CACjC,GAAGf,WAAWqE,kBAAkB,CAACJ,cAAc,GAAG,QAAQ,CAAC,GAC1D;qCAEF;gCACHjE,WAAWsE,cAAc,iBACxB,KAAC/H;oCAAMqF,UAAS;8CACb,CAAC,sDAAsD,CAAC,GACvD,CAAC,cAAc,EAAE5B,WAAWsE,cAAc,CAACL,cAAc,GAAG,CAAC,CAAC,GAC9D,4DACA;qCAEF;;2CAGN,KAAChH;4BAAWqE,SAAQ;4BAAQP,OAAM;sCAC/BjB,OAAOkE,MAAM,GACV,8DACA,8DACA,aACA;;;;8BAKV,KAACrH;8BAwBD,KAACkB;oBAAQiE,OAAM;8BACb,cAAA,MAAClF;wBAAM4E,SAAS;;0CACd,MAAC5E;gCACCoF,WAAU;gCACVR,SAAS;gCACTS,UAAU;gCACVC,IAAI;oCAAEqC,YAAY;oCAAUpC,UAAU;gCAAO;;oCAS5CxC,4BACC,KAACvD;wCAAQyE,MAAMlB;kDAAcY;uDAE7B,KAACtD;wCAAWqE,SAAQ;kDAASf;;kDAS/B,KAAC7D;wCACCoE,MAAK;wCACLwB,OAAOvE,oBAAoBqB,UAAmBkD,KAAK;;kDAErD,KAAC5F;wCACCoE,MAAK;wCACLQ,SAAQ;wCACRgB,OAAM;wCACNR,OACE,yDACA,+DACA;;kDAGJ,KAAC7E;wCAAWqE,SAAQ;wCAAUP,OAAM;kDACjCP,aACG,IAAIgE,KAAKhE,YAAYyD,cAAc,KACnC;;;;0CAGR,KAAChH;gCAAWqE,SAAQ;gCAAUP,OAAM;0CACjC,gEACC,8DACA,gEACA;;;;;8BAKR,KAACpE;8BAUD,KAACkB;oBAAQiE,OAAM;8BACb,cAAA,MAAClF;wBAAMoF,WAAU;wBAAMR,SAAS;wBAAGS,UAAU;wBAACC,IAAI;4BAAEC,UAAU;wBAAO;;0CACnE,KAAC3E;gCACC8E,OAAM;gCACNC,OAAOzC,OAAO2E,UAAU;gCACxBjC,MAAK;;0CAEP,KAAChF;gCACC8E,OAAM;gCACNC,OAAOzC,OAAO4E,IAAI;gCAClBlC,MAAK;;0CAEP,KAAChF;gCACC8E,OAAM;gCACNC,OAAOzC,OAAOK,SAAS;gCACvBqC,MAAK;;0CAEP,KAAChF;gCAAO8E,OAAM;gCAAUC,OAAOzC,OAAO6E,OAAO;gCAAEnC,MAAK;;0CACpD,KAAChF;gCACC8E,OAAM;gCACNC,OAAOzC,OAAO8E,UAAU;gCACxBpC,MAAK;;;;;8BAKX,KAAC7F;8BAED,KAACkB;oBAAQiE,OAAM;8BAYb,cAAA,MAAClF;wBAAMoF,WAAU;wBAAMR,SAAS;wBAAGS,UAAU;wBAACC,IAAI;4BAAEC,UAAU;wBAAO;;0CACnE,KAAC3E;gCACC8E,OAAM;gCACNC,OAAOzC,OAAO+E,KAAK;gCACnBrC,MAAK;;0CAEP,KAAChF;gCACC8E,OAAM;gCACNC,OAAOzC,OAAOgF,WAAW;gCACzBtC,MAAK;;0CAEP,KAAChF;gCACC8E,OAAM;gCACNC,OAAOzC,OAAOkE,MAAM;gCACpBxB,MAAK;;0CAEP,KAAChF;gCACC8E,OAAM;gCACNC,OAAOzC,OAAOiF,YAAY;gCAC1BvC,MAAK;;0CAEP,KAAChF;gCACC8E,OAAM;gCACNC,OAAOzC,OAAOkF,YAAY;gCAC1BxC,MAAK;;;;;8BAKX,KAAC7F;8BAED,KAACkB;oBAAQiE,OAAM;8BACb,cAAA,MAAClF;wBAAM4E,SAAS;;0CACd,KAAC5D;gCAAQ0E,OAAM;gCAAgB2C,MAAMnF,OAAOoF,KAAK,CAACC,QAAQ;;0CAC1D,KAACvH;gCAAQ0E,OAAM;gCAAY2C,MAAMnF,OAAOoF,KAAK,CAACE,IAAI;;0CAClD,KAACxH;gCAAQ0E,OAAM;gCAAa2C,MAAMnF,OAAOoF,KAAK,CAACG,KAAK;;0CACpD,KAACzH;gCACC0E,OAAM;gCACN2C,MAAMnF,OAAOoF,KAAK,CAACI,WAAW;;0CAEhC,KAAC1H;gCAAQ0E,OAAM;gCAAc2C,MAAMnF,OAAOoF,KAAK,CAACK,MAAM;;0CACtD,KAAC3H;gCAAQ0E,OAAM;gCAAiB2C,MAAMnF,OAAOoF,KAAK,CAACM,SAAS;;0CAC5D,KAAC5H;gCACC0E,OAAM;gCACN2C,MAAMnF,OAAOoF,KAAK,CAACO,WAAW;;;;;gBAKnC3F,OAAO4F,WAAW,CAAC5C,MAAM,iBACxB;;sCACE,KAACnG;sCACD,MAACkB;4BAAQiE,OAAM;;8CASb,KAAC7E;oCAAWqE,SAAQ;oCAAQP,OAAM;8CAC/B,gEACC,6DACA;;8CAEJ,KAACzE;oCAAYwE,MAAK;8CAChB,cAAA,KAACjE;kDACEiD,OAAO4F,WAAW,CAAChE,GAAG,CAAC,CAACiE,2BACvB,MAAC3I;;kEACC,KAACF;kEAAW6I,WAAWrD,KAAK;;kEAC5B,KAACxF;wDAAU6G,OAAM;wDAAQzB,IAAI;4DAAE6B,YAAY;wDAAO;kEAC/C4B,WAAWC,KAAK,CAAC3B,cAAc;;kEAElC,KAACnH;wDAAU6G,OAAM;kEACf,cAAA,KAAC1G;4DAAWqE,SAAQ;4DAAUP,OAAM;sEACjC,CAAC,GAAG,EAAE4E,WAAWE,EAAE,CAAC5B,cAAc,GAAG,CAAC,EAAE0B,WAAWG,OAAO,EAAE;;;;+CAPpDH,WAAWjG,EAAE;;;;;;qBAgBpC;;;;IAKV,qBACE;;0BAGE,KAACzD;gBACC6F,OACE1C,WAAWS,OAAOT,SAASmB,OAAO,IAAI,OAAO1B,aAAakH;;YAG7DxE;;;AAGP;AACA7C,mBAAmBsH,WAAW,GAAG;AAEjC,eAAetH,mBAAkB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-report-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 { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Box,\n Button,\n Chip,\n Divider,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport {\n sendLinkReport,\n type SendLinkRollup,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { campaignReport } from '../model/campaign-report'\nimport {\n campaignRevenueReport,\n type CampaignRevenueRollup,\n} from '../model/campaign-revenue'\nimport {\n campaignConversionsReport,\n type CampaignConversionsRollup,\n} from '../model/campaign-conversions'\n/*\n * The three renderers every email report shares. Imported rather than kept\n * here, so \"a rate prints its denominator\" is one implementation and not a\n * convention each new card has to remember.\n */\nimport {\n Figure,\n MoneyFigure,\n MoneyPerUnitRow,\n percent,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { emailSendTimeMs } from '../model/email-record'\nimport {\n campaignSendDisplay,\n campaignSendProgress,\n} from '../model/campaign-container'\nimport { campaignSendDoc, campaignSendReportDoc } from './campaign-queries'\nimport {\n orgSiteHubPath,\n useMarketingOrgId,\n useMarketingOrgMount,\n} from './marketing-org-mount'\nimport { useRecordRouteContext } from './record-route-context'\n\n/**\n * The help affordance, hoisted so BOTH headers carry it.\n *\n * The unreadable-campaign branch below renders its own `CardDisplay`, and a\n * header without help is a header a reader has no way out of — which is\n * exactly the state that branch describes.\n */\nconst reportDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'What one campaign did: delivery, opens, clicks, bounces, complaints, ' +\n 'unsubscribes and the revenue it was credited with, each with the ' +\n 'population it is measured against.',\n})\n\nexport interface CampaignReportCardProps {\n /** The site, or `null` on the org Marketing hub. */\n hostId: string | null\n campaignId: string\n /** The marketing hub URL, for the way back to the campaigns list. */\n basePath: string\n}\n\n/**\n * ONE CAMPAIGN, MEASURED — and every denominator on screen.\n *\n * ## What was missing\n *\n * Opens and clicks have been recorded on the campaign document since AGL-268\n * and displayed as two words in a history row: `12 opens · 3 clicks`. Nothing\n * anywhere showed a rate, a delivery count, a bounce, a complaint or an\n * unsubscribe, and the populations the send itself measured — how large the\n * audience was, how many the consent rule refused, how many were already\n * suppressed — were computed on every send and written nowhere.\n *\n * ## The rule this screen is built around\n *\n * **Every rate names its denominator, in the same breath as the number.**\n *\n * That is not decoration. An open rate over `sent` and an open rate over\n * `delivered` are different numbers that share a label, and the difference\n * between them is exactly the mail that bounced — so a report that showed one\n * while the reader assumed the other would be wrong by however bad the\n * address list is, which is the case where being wrong matters most. Worse,\n * \"click rate\" is used in the wild for two unrelated quantities — clicks over\n * delivered, and clicks over openers — and they differ by a factor of three\n * or four on ordinary mail.\n *\n * So the arithmetic is not done here at all. `campaign-report.ts` returns\n * each rate as a triple carrying `denominatorLabel`, this file renders that\n * label, and a rate whose denominator is zero or unrecorded comes back `null`\n * and is drawn as \"—\" with a caveat naming the reason. There is no path\n * through this component that prints a percentage without saying what it is a\n * percentage of.\n *\n * ## What the page is about comes before how the mail did\n *\n * A campaign of one email is the ordinary shape, and this card is the page it\n * gets: the URL names a send, so there is no container above it and no list of\n * emails to draw. That collapse is honest, but it decided what the page was\n * ABOUT — delivery, engagement and rates are mechanics of a message, and a\n * page that led with them at length read as a mail report wearing the word\n * campaign.\n *\n * So the outcomes lead: what it caused, what it earned, where it sent people.\n * Two of those headings are the ones `campaign-reach-sections.tsx` puts at the\n * top of a campaign holding SEVERAL emails, and they are the same words here\n * deliberately — a merchant who opens a campaign of one and a campaign of six\n * is asking the same question and should not have to learn two vocabularies to\n * find the answer. The email's own figures follow, under a row that names the\n * message they belong to.\n *\n * Nothing about the reads changed with the order. The conversions, revenue and\n * destination figures were already on this page and already came from the four\n * documents below; they were simply underneath the mail.\n *\n * ## Three documents, whatever the campaign's size\n *\n * The whole screen is one campaign document plus one link-rollup document\n * plus one revenue-rollup document. All three are single-document listens, so\n * the cost does not move with the audience — a report over a 50,000-recipient\n * send reads the same three documents as one over ten. Aggregating the\n * per-recipient delivery log on mount would have been the obvious\n * implementation and would have read one document per recipient, every time\n * anybody opened the page; joining orders to campaigns at read time would\n * have been the same mistake one collection along, which is why the join is\n * done once at the sale and stored.\n *\n * ## No per-recipient view here, deliberately\n *\n * Every number on this screen is an aggregate that carries no address, and\n * that is what makes it three single-document reads. A per-recipient list is a\n * different read and a different question, so it lives where it is asked:\n * the design report's recipients table, which resolves the campaigns from the\n * design on a server that has already checked the reader's site role, and\n * returns only rows tagged with that site.\n *\n * ## The same four documents at the org level\n *\n * A send and its rollups are the org's, so the org hub reads the same four\n * documents. The one link that leads to a SITE fact — the conversions list,\n * which is that site's visitors — goes to the site the send was sent as.\n *\n * The other per-recipient view — the staff delivery log on the user detail\n * page — spans every site on the install and is behind a staff claim that\n * records who looked. Neither reaches this card: adding a recipient list here\n * would put an unbounded read behind a widget whose whole cost model is that\n * it has none.\n */\nexport function CampaignReportCard(props: CampaignReportCardProps) {\n const { hostId, campaignId, basePath } = props\n // The scope the message's own page is asked in: it belongs to the Emails page.\n const routeContext = useRecordRouteContext()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(hostId)\n const firestore = useFirestore()\n\n const { data: campaign, status } = useFirestoreDoc<\n Record<string, unknown> & { stats?: SendStats }\n >(\n () => (orgId ? campaignSendDoc(firestore, orgId, campaignId) : null),\n [firestore, orgId, campaignId],\n )\n /*\n * The link rollup, its own document rather than a field on the campaign.\n *\n * A map of destinations grows with the campaign's content and a campaign\n * document is read by the history list, the glance widget and the send\n * path; putting an unbounded map on it would make every one of those reads\n * larger. Split, the rollup is read by exactly the screen that renders it.\n */\n const { data: links } = useFirestoreDoc<SendLinkRollup>(\n () =>\n orgId ? campaignSendReportDoc(firestore, orgId, campaignId, 'links') : null,\n [firestore, orgId, campaignId],\n )\n /*\n * The revenue rollup, a third single-document listen.\n *\n * Its own document beside the link rollup rather than a field on the\n * campaign, for the reason the link rollup is: the campaign document is\n * read by the history list, the glance widget and the send path, and a map\n * that grows with the campaign's sales would enlarge every one of those\n * reads. The cost model in this file's header is unchanged in the way that\n * matters — the page still reads a fixed number of documents whatever the\n * audience, and no per-order or per-recipient read exists anywhere on it.\n */\n const { data: revenue } = useFirestoreDoc<CampaignRevenueRollup>(\n () =>\n orgId\n ? campaignSendReportDoc(firestore, orgId, campaignId, 'revenue')\n : null,\n [firestore, orgId, campaignId],\n )\n\n /*\n * The conversions rollup, a fourth single-document listen.\n *\n * Beside `reports/revenue` and read the same way, because it is the same\n * join answering the other half of the question: revenue is what a campaign\n * EARNED from people who named themselves at checkout, and this is what it\n * CAUSED among people who were anonymous until the moment they submitted,\n * signed up or booked. The cost model in this file's header is unchanged in\n * the way that matters — a fixed number of documents whatever the audience,\n * and no per-conversion read anywhere on the page.\n */\n const { data: conversions } = useFirestoreDoc<CampaignConversionsRollup>(\n () =>\n orgId\n ? campaignSendReportDoc(firestore, orgId, campaignId, 'conversions')\n : null,\n [firestore, orgId, campaignId],\n )\n /*\n * Where this send's links lead. The message's page is on the Emails page —\n * the site's under a site, the organization's over the org; the\n * conversions list is always the sending site's.\n */\n const messageHrefOf = (id: string): string | null =>\n routeContext ? pluginRecordHref('emailMessage', routeContext, id) : null\n const messageHref = messageHrefOf(campaignId)\n const conversionsHub = orgMount\n ? orgSiteHubPath(orgMount, String(campaign?.hostId ?? ''), 'marketing')\n : basePath\n\n const report = campaignReport(campaign?.stats)\n const linkReport = sendLinkReport(links)\n const revenueReport = campaignRevenueReport({\n rollup: revenue,\n /*\n * The DENOMINATOR, handed over from the same document read at the same\n * instant rather than re-read here. `report.delivered` is `null` when no\n * delivery event was ever recorded, and it is passed as `null` so the\n * revenue figures over it are withheld for exactly the reason every other\n * rate over it is.\n */\n delivered: report.delivered,\n midFlight: campaign\n ? campaignSendProgress(campaign as never).state === 'sending'\n : false,\n })\n /*\n * No denominator is handed in, unlike the revenue report. A conversion RATE\n * over delivered messages would repeat the defect the revenue section\n * refuses for orders — one visitor can submit two forms, so the quotient\n * passes 100% without anything being wrong — and counting distinct people\n * instead needs a document per person per campaign, which is the\n * per-recipient read this screen's cost model exists to refuse.\n */\n const conversionsReport = campaignConversionsReport({ rollup: conversions })\n const subject = String(campaign?.subject ?? 'Campaign')\n const sendTimeMs = campaign ? emailSendTimeMs(campaign) : 0\n\n const backButton = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'All campaigns'}\n </Button>\n )\n\n // Nothing to say until the read has had its chance to answer.\n if (!campaign && (status === 'loading' || !orgId)) return null\n\n if (!campaign) {\n return (\n <CardDisplay\n header={'Campaign report'}\n help={reportDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: backButton }}\n >\n {/*\n * Not \"no data\". A campaign that cannot be read is a different\n * situation from one with no engagement, and rendering an empty\n * report for the first is how somebody comes to believe a campaign\n * they sent reached nobody.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This campaign could not be loaded. It may have been deleted.'}\n </Typography>\n </CardDisplay>\n )\n }\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Campaign report'}\n help={reportDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: backButton }}\n >\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n\n {/*==========================================\n * WHAT THIS CAMPAIGN CAUSED — the page's first section, and the\n * heading a campaign of several emails leads with too.\n *\n * The same join as the revenue below, credited under the same rule,\n * answering it for people who were anonymous until the moment being\n * counted: they arrived from a campaign link, browsed, and only\n * became somebody when they submitted a form, signed up or booked.\n *\n * THE FOUR FIGURES ARE NEVER ADDED. One person filling in one form\n * writes a submission, a contact and a lead, so a total would count\n * that visit three times — and it would look like a bigger version\n * of a real number, which is why the model carries no total for this\n * JSX to reach for and the caveat says so in words above them.\n *\n * They are laid out in a row of independent `Figure`s rather than in\n * a table with a footer, because a footer row is where a reader\n * expects the sum to be.\n *=========================================*/}\n <Section title=\"What it caused\">\n {conversionsReport.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {conversionsReport.any ? (\n <Stack spacing={1}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n {conversionsReport.kinds.map((entry) => (\n <Figure\n key={entry.kind}\n label={entry.label}\n value={entry.value}\n note={entry.note}\n />\n ))}\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Credited ${conversionsReport.model === 'last-click' ? 'to the last campaign whose link the visitor clicked' : `under the ${conversionsReport.model} model`}, ` +\n `within ${conversionsReport.windowDays} days of that click. ` +\n 'Somebody who converted without ever following a campaign ' +\n 'link is credited to no campaign at all — nothing is ' +\n 'inferred from a referrer — so these are a floor rather ' +\n 'than everything this campaign influenced. The ones ' +\n 'credited to nobody are counted under Conversions in the ' +\n 'marketing console.'}\n </Typography>\n {/*\n THE RECORDS BEHIND THE FIGURES. The rollup says how many; the\n list says which, and it is the only place the uncredited half\n is counted. A link rather than a table here — the records are\n a paged read and this page's whole cost model is a fixed\n number of documents whatever the audience.\n */}\n {conversionsHub ? (\n <Box>\n <Button\n component={AppLink as any}\n {...({\n componentVariant: 'naked',\n nativeButton: false,\n } as any)}\n href={`${conversionsHub}/conversions/${campaignId}`}\n size=\"small\"\n color=\"primary\"\n >\n {'See these conversions'}\n </Button>\n </Box>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {conversionsReport.recorded\n ? 'Nothing has been credited to this campaign yet.'\n : 'No conversions have been attributed to this campaign. A ' +\n 'form submission, lead, contact or booking is credited to ' +\n 'the last campaign whose link the visitor clicked, within ' +\n `${conversionsReport.windowDays} days — a campaign sent ` +\n 'before that was recorded will never show a figure here.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT THIS CAMPAIGN EARNED.\n *\n * The merchant's second question, and the one every compared\n * product answers by reconstructing revenue probabilistically\n * because it does not own the order. Here it is a join: the click\n * and the order are rows in one database, so there is no tracking\n * script, no catalog sync and nothing estimated except the model.\n *\n * THE MODEL IS ON SCREEN, in a sentence, above the money. A revenue\n * figure whose rule the reader cannot state is worse than no\n * figure, because they will use it anyway — and the two things they\n * have to know to use it are which campaign gets the credit when\n * several touched the buyer, and how long a click stays creditable.\n *\n * NET LEADS. Gross and refunded are both shown beside it, because\n * \"this campaign made $4,000\" and \"this campaign made $4,000 of\n * which $3,100 came back\" are different facts about whether to send\n * another one.\n *=========================================*/}\n <Section title=\"Revenue\">\n {revenueReport.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {revenueReport.currencies.length ? (\n <Stack spacing={3}>\n {revenueReport.currencies.map((entry) => (\n <Stack key={entry.currency} spacing={1}>\n {/*\n * The currency is a HEADING when there is more than one,\n * and absent when there is one. A per-currency label on a\n * report that only ever shows dollars is noise; the moment\n * there are two, the reader has to be able to see at a\n * glance that the blocks are not addable — which is also\n * what the caveat above says in words.\n */}\n {revenueReport.multiCurrency ? (\n <Typography variant=\"subtitle2\">\n {entry.currency.toUpperCase()}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <MoneyFigure\n label=\"Net revenue\"\n cents={entry.netCents}\n currency={entry.currency}\n note=\"after refunds\"\n />\n <MoneyFigure\n label=\"Gross revenue\"\n cents={entry.grossCents}\n currency={entry.currency}\n note=\"as charged\"\n />\n <MoneyFigure\n label=\"Refunded\"\n cents={entry.refundedCents}\n currency={entry.currency}\n note=\"handed back\"\n />\n <Figure\n label=\"Orders\"\n value={entry.orders}\n note=\"credited to this campaign\"\n />\n <Figure\n label=\"Fully refunded\"\n value={entry.refundedOrders}\n note=\"of those orders\"\n />\n </Stack>\n <MoneyPerUnitRow\n label=\"Net revenue per delivered message\"\n figure={entry.netPerDelivered}\n />\n </Stack>\n ))}\n {/*\n * NO CONVERSION RATE, and this is where a reader would look\n * for one. Orders over delivered is not a rate: one buyer can\n * place two orders, so the quotient passes 100% without\n * anything being wrong — the same defect that keeps `opens`\n * out of the open-rate numerator two sections above. Counting\n * distinct buyers instead would need a document per person per\n * campaign, which is the per-recipient read this screen's cost\n * model exists to refuse. The order count is shown as a count.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Credited ${revenueReport.model === 'last-click' ? 'to the last campaign whose link the buyer clicked' : `under the ${revenueReport.model} model`}, ` +\n `within ${revenueReport.windowDays} days of that click. ` +\n 'Clicks only — an open is not treated as evidence that ' +\n 'anybody read the email. An order placed by somebody who ' +\n 'never clicked, or who checked out without giving an ' +\n 'address, is credited to no campaign, so this is a floor ' +\n 'rather than every sale this campaign influenced.'}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {revenueReport.recorded\n ? 'No orders have been credited to this campaign.'\n : 'No revenue has been attributed to this campaign. Orders ' +\n 'are credited to the last campaign whose link the buyer ' +\n `clicked, within ${revenueReport.windowDays} days — a ` +\n 'campaign sent before that was recorded, or one on a site ' +\n 'with no store, will never show a figure here.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n <Section title=\"Where it sent people\">\n {/*\n The heading a campaign of several emails carries over the same\n question, so the two shapes of campaign page read alike. A\n campaign owns no page — this is where its mail was actually\n followed to, which is the only account of it the data holds.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages this campaign’s links were followed to.'}\n </Typography>\n {linkReport.rows.length ? (\n <>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n <TableCell align=\"right\">{'Share'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {linkReport.rows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {row.clicks.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.share\n ? `${percent(row.share.value)} of ${row.share.denominator.toLocaleString()} ${row.share.denominatorLabel}`\n : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n {/*\n * The normalisation, stated rather than left for somebody to\n * discover from a total that does not add up. Two links to the\n * same page distinguished only by their UTM tags are one row\n * here, and a merchant comparing this table with the campaign\n * they built has to be told that before they conclude a link\n * is missing.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Counted by address and path — query strings are dropped, so ' +\n 'two links to the same page with different tracking ' +\n 'parameters count as one row.'}\n </Typography>\n {linkReport.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${linkReport.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived without a destination and are not in this table.'}\n </Typography>\n ) : null}\n {linkReport.overflowClicks ? (\n <Alert severity=\"info\">\n {`This campaign has more distinct destinations than the ` +\n `rollup keeps. ${linkReport.overflowClicks.toLocaleString()} ` +\n 'clicks landed on links past that limit and are counted ' +\n 'in the click total above but not in this table.'}\n </Alert>\n ) : null}\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.clicks\n ? 'Clicks were recorded for this campaign, but none of them ' +\n 'carried a destination, so there is nothing to break down ' +\n 'by link.'\n : 'No link clicks have been recorded for this campaign.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n {/*==========================================\n * THE EMAIL THE FIGURES BELOW CAME FROM — and the hinge between the\n * two halves of this page.\n *\n * This screen is reached at `/marketing/campaigns/{sendId}`, which\n * is a CAMPAIGN url resolving to a send — the fall-through that keeps\n * every unsubscribe footer and every pasted report link working. So\n * a reader arrives from the campaigns table, where the row carries a\n * \"Single send\" chip, and everything under this row is a fact about\n * a message rather than about the campaign.\n *\n * Figures with no visible source read as a campaign holding no\n * emails while reporting real numbers, which is a contradiction\n * rather than a shortage of detail. Naming the one email — and\n * saying it IS the campaign, which is what the chip already claims\n * and what `campaignListRows` already does at read time — is what\n * makes the page answer the question it raises.\n *\n * One row, from the document this card already read: no query, no\n * second listen, and the two-document cost the header describes is\n * unchanged.\n *=========================================*/}\n <Section title=\"The email in this campaign\">\n <Stack spacing={1}>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The MESSAGE's own page, which the Emails console owns — hence\n the sibling hub rather than this surface's `basePath`. Plain\n text until that hub resolves: a link with no destination is\n worse than none, and the subject is what the reader came for\n either way.\n */}\n {messageHref ? (\n <AppLink href={messageHref}>{subject}</AppLink>\n ) : (\n <Typography variant=\"body2\">{subject}</Typography>\n )}\n {/*\n What the email is DOING, not the field it stores. One\n delivering an audience larger than one batch is written back\n as `scheduled` between runs, so a chip rendering the status\n said \"Scheduled\" at the head of a report of five hundred\n deliveries.\n */}\n <Chip\n size=\"small\"\n label={campaignSendDisplay(campaign as never).label}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label=\"Single send\"\n title={\n 'One email, sent on its own rather than as part of a ' +\n 'campaign of several. Its report and its unsubscribe links ' +\n 'are unchanged.'\n }\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {sendTimeMs\n ? new Date(sendTimeMs).toLocaleString()\n : 'not recorded'}\n </Typography>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign is one email, so the sections above are what ' +\n 'that one email caused. What follows is the mail itself — ' +\n 'how it was delivered and how it was read. Open the message ' +\n 'for its body and the people it reached.'}\n </Typography>\n </Stack>\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT HAPPENED TO THE MAIL.\n *\n * Counts first and rates second, in that order and not the other\n * way round, because a rate is only readable once you know what it\n * was taken over — and because the counts are the part that is\n * always true. Every rate below can be absent; none of these can.\n *=========================================*/}\n <Section title=\"Delivery\">\n <Stack direction=\"row\" spacing={4} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Addressed\"\n value={report.recipients}\n note=\"after the per-send cap\"\n />\n <Figure\n label=\"Sent\"\n value={report.sent}\n note=\"accepted by the provider\"\n />\n <Figure\n label=\"Delivered\"\n value={report.delivered}\n note=\"accepted by the receiving server\"\n />\n <Figure label=\"Bounced\" value={report.bounced} note=\"of sent\" />\n <Figure\n label=\"Marked as spam\"\n value={report.complained}\n note=\"of delivered\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Engagement\">\n {/*\n * BOTH the event count and the distinct count, side by side.\n *\n * `Opens` is every open event — one reader opening four times is\n * four — and it is the number this product has always shown. It\n * cannot be a rate numerator: divided by anything it exceeds 100%\n * the moment somebody reads an email twice. `Readers who opened`\n * is the distinct count, and it is what the rate below divides.\n * Showing only one of them would either hide activity or invite a\n * rate nobody can defend, so both are here with different names.\n */}\n <Stack direction=\"row\" spacing={4} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Opens\"\n value={report.opens}\n note=\"every open, repeats included\"\n />\n <Figure\n label=\"Readers who opened\"\n value={report.uniqueOpens}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Clicks\"\n value={report.clicks}\n note=\"every click, repeats included\"\n />\n <Figure\n label=\"Readers who clicked\"\n value={report.uniqueClicks}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Unsubscribed\"\n value={report.unsubscribes}\n note=\"through this campaign's link\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Rates\">\n <Stack spacing={1}>\n <RateRow label=\"Delivery rate\" rate={report.rates.delivery} />\n <RateRow label=\"Open rate\" rate={report.rates.open} />\n <RateRow label=\"Click rate\" rate={report.rates.click} />\n <RateRow\n label=\"Click-to-open rate\"\n rate={report.rates.clickToOpen}\n />\n <RateRow label=\"Bounce rate\" rate={report.rates.bounce} />\n <RateRow label=\"Complaint rate\" rate={report.rates.complaint} />\n <RateRow\n label=\"Unsubscribe rate\"\n rate={report.rates.unsubscribe}\n />\n </Stack>\n </Section>\n\n {report.populations.length ? (\n <>\n <Divider />\n <Section title=\"Who this went to\">\n {/*\n * The populations the SEND measured, recorded at send time.\n *\n * Not recomputed here, and the note under the heading says so:\n * consent records change and addresses get suppressed, so\n * asking the list today produces a number that is true of the\n * list and false of the campaign.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Measured when this campaign was sent, and stored as it was ' +\n 'then. These figures describe the send, not the audience ' +\n 'as it stands today.'}\n </Typography>\n <ScrollTable size=\"small\">\n <TableBody>\n {report.populations.map((population) => (\n <TableRow key={population.id}>\n <TableCell>{population.label}</TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {population.count.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`of ${population.of.toLocaleString()} ${population.ofLabel}`}\n </Typography>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the send; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord\n title={\n campaign ? String(campaign.subject || '') || campaignId : undefined\n }\n />\n {card}\n </>\n )\n}\nCampaignReportCard.displayName = 'CampaignReportCard'\n\nexport default CampaignReportCard\n"],"names":["PageHeaderRecord","pluginDocsHelp","pluginRecordHref","AppLink","CardDisplay","ScrollTable","Alert","Box","Button","Chip","Divider","Stack","TableBody","TableCell","TableHead","TableRow","Typography","useFirestore","useFirestoreDoc","sendLinkReport","campaignReport","campaignRevenueReport","campaignConversionsReport","Figure","MoneyFigure","MoneyPerUnitRow","percent","RateRow","Section","emailSendTimeMs","campaignSendDisplay","campaignSendProgress","campaignSendDoc","campaignSendReportDoc","orgSiteHubPath","useMarketingOrgId","useMarketingOrgMount","useRecordRouteContext","reportDocsHelp","anchor","excerpt","CampaignReportCard","props","hostId","campaignId","basePath","routeContext","orgMount","orgId","firestore","data","campaign","status","links","revenue","conversions","messageHrefOf","id","messageHref","conversionsHub","String","report","stats","linkReport","revenueReport","rollup","delivered","midFlight","state","conversionsReport","subject","sendTimeMs","backButton","component","componentVariant","nativeButton","href","size","color","header","help","contentGutterX","contentGutterY","HeaderProps","action","variant","card","spacing","caveats","map","caveat","severity","message","title","any","direction","useFlexGap","sx","flexWrap","kinds","entry","label","value","note","kind","model","windowDays","recorded","currencies","length","multiCurrency","currency","toUpperCase","cents","netCents","grossCents","refundedCents","orders","refundedOrders","figure","netPerDelivered","rows","align","row","wordBreak","url","fontWeight","clicks","toLocaleString","share","denominator","denominatorLabel","unattributedClicks","overflowClicks","alignItems","Date","recipients","sent","bounced","complained","opens","uniqueOpens","uniqueClicks","unsubscribes","rate","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","populations","population","count","of","ofLabel","undefined","displayName"],"mappings":"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"}
@@ -38,7 +38,7 @@ import { collection, limit, query, setDoc, where } from "firebase/firestore";
38
38
  import { useCallback, useMemo, useState } from "react";
39
39
  import { useRouter } from "next/navigation";
40
40
  import { useConsoleHostRoute, useFirestore, useFirestoreCollection, useUser } from "@aglyn/tenant-feature-instance";
41
- import { campaignListRows, campaignSiteIds, campaignVisibleTo } from "@aglyn/shared-ui-email-campaigns/model";
41
+ import { campaignListRows, campaignSiteIds, campaignVisibleTo } from "../model/campaign-container.js";
42
42
  import { CreateArtifactDrawer } from "@aglyn/shared-ui-jsx-forms";
43
43
  import { CAMPAIGN_SITE_SEARCH_NOTICE, SINGLE_SENDS_BASE, campaignContainerSearchFields, campaignContainersListQuery, campaignContainersScope, campaignSendsScope, campaignSingleSendsListQuery, campaignSiteSearchClause } from "../model/campaign-list-query.js";
44
44
  import { campaignContainerDoc, campaignContainersCollection, campaignSendsCollection } from "./campaign-queries.js";