@aglyn/plugins-marketing 1.0.0-beta.146 → 1.0.0-beta.147

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-members-section.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 Section,\n} from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n buildRoute,\n CAMPAIGN_MEMBERSHIP_FIELD,\n readCampaignIds,\n Route,\n type FormStats,\n type FormStatsTotals,\n} from '@aglyn/aglyn'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignFormsRollup,\n campaignFormTotals,\n campaignPeriodRange,\n isWindowedRange,\n type CampaignFormsRollup,\n} from '../model/campaign-membership-figures'\n\n/**\n * How many members of one kind the section enumerates.\n *\n * One document past it is asked for, so \"this campaign holds more than are\n * listed\" is a fact rather than a guess — the same probe the emails table on\n * this page makes.\n */\nconst MEMBER_CEILING = 25\n\nexport interface CampaignMembersSectionProps {\n hostId: string\n campaignId: string\n /** The campaign's first day, when it has one. */\n startAtMs?: number | null\n /** The campaign's last day, when it has one. */\n endAtMs?: number | null\n}\n\n/**\n * The rows a reader may act on: the window, minus what has been deleted.\n *\n * The soft delete is filtered HERE and not in the query. `deletedAt` is\n * written only when a record is removed, so `where('deletedAt', '==', null)`\n * would match nothing at all — Firestore's equality matches documents that\n * HAVE the field — and every live record would vanish from the campaign. The\n * campaigns table filters its own soft-deleted containers the same way.\n */\nfunction live(\n docs: Array<Record<string, unknown>> | undefined,\n): Array<Record<string, unknown>> {\n return (docs ?? [])\n .slice(0, MEMBER_CEILING)\n .filter((row) => !row['deletedAt'])\n}\n\n/** One row, whatever collection it came out of. */\ninterface MemberRow {\n id: string\n name: string\n href: string | null\n /** Why there is no link, when there is none. */\n hrefReason?: string\n /** The member's own counters, for a kind that carries them. */\n totals?: FormStatsTotals\n /** Campaigns the member is filed under, this one included. */\n campaigns?: number\n}\n\n/**\n * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,\n * and what those forms have collected.\n *\n * ## Declared, and therefore different from every section above it\n *\n * The conversions, revenue and destinations sections are EVIDENCE: they join\n * on the campaign's send ids and report what visitors did BECAUSE of this\n * campaign's mail. This one is a statement the merchant made — a screen or a\n * form carries this campaign's id in {@link CAMPAIGN_MEMBERSHIP_FIELD} — plus\n * the counters those records carry on their own documents.\n *\n * The distinction survives into the wording, because the arithmetic will not\n * enforce it. A form's submissions are the form's; filing the form under a\n * campaign does not make them the campaign's, and a heading that said\n * \"this campaign produced 40 leads\" over a number meaning \"the forms filed\n * here have 40 leads\" would be a worse page than one showing nothing. So the\n * figures are labeled as what the forms HOLD, they sit in their own block\n * under their own disclaimer, and no total on this page adds them to an\n * attributed one.\n *\n * ## Three ways the forms' counters could mislead, each answered\n *\n * 1. **They are lifetime.** `stats.submissions` has counted since the form\n * existed, including every submission from before anybody filed it here.\n * Where the campaign carries dates, the figures are summed from the\n * monthly `stats.periods` series over those months instead, and the\n * caption says which of the two a reader is looking at. Months are whole,\n * so a campaign starting mid-month includes that month entire — stated,\n * not rounded away.\n * 2. **A form can be in twenty campaigns.** `campaignIds` is an array, so\n * the same submissions count toward every campaign the form is filed\n * under. Rows say so and the total refuses to present itself as\n * exclusive.\n * 3. **A counter nobody wrote is not a zero.** `stats.leads` is incremented\n * only for a form whose `routing.lead` is set, and `stats.views` only by\n * the analytics beacon. Both reach the shared `Figure`, which draws an em\n * dash and the words \"not recorded\" rather than a confident 0.\n *\n * ## Screens carry no counter, and this section will not invent one\n *\n * A form's counters ride on the form document, so the query this section\n * already makes pays for them. A screen's traffic does not: it is one\n * document per screen per day in `hosts/{hostId}/screenAnalytics`, with no\n * lifetime total anywhere, so a views column here would cost a range read\n * across screens times days on every open of this page — and the display of\n * those figures is a paid entitlement this section does not resolve. The\n * screens block names the absence and links to the surface that does measure\n * it, which is a smaller lie than a number nobody can stand behind.\n *\n * ## The join is `array-contains`, on the member's own document\n *\n * A campaign holds no member list. Each record names the campaigns it is in,\n * which is what makes deleting one record leave nothing behind and what lets\n * a record's own page draw its campaigns without a query. The query here is\n * the other direction of the same field, served by Firestore's automatic\n * single-field index.\n *\n * `orderBy(documentId())`, like every other bounded list in this console: a\n * form carries `updatedAt` only if some writer stamped one, and ordering on a\n * field a writer may omit does not mis-sort the list, it drops rows from it.\n *\n * ## Contacts are named here and NOT listed, and the reason is a query limit\n *\n * A contact is org-scoped and shared between the sites that captured it, so\n * every client read of the collection has to carry\n * `visibleTo array-contains-any` — that is what makes the read provable\n * per-document under the rules. Firestore permits ONE array clause per query,\n * and that is it: there is no client query that filters contacts by campaign\n * as well, so there is no pre-filtered address to link to either. The link\n * offered goes to the Contacts page and says it arrives unfiltered; the\n * assignment is real, is set from the contact's own drawer, and a count of it\n * would cost a scan of the collection the customer is billed on.\n */\nexport function CampaignMembersSection(props: CampaignMembersSectionProps) {\n const { hostId, campaignId, startAtMs, endAtMs } = props\n const firestore = useFirestore()\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n\n const memberQuery = (collectionName: string) => () =>\n query(\n collection(firestore, 'hosts', hostId, collectionName),\n where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId),\n orderBy(documentId()),\n limit(MEMBER_CEILING + 1),\n )\n\n const { data: screenDocs, status: screensStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('screens'), [firestore, hostId, campaignId], {\n idField: '$id',\n })\n const { data: formDocs, status: formsStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('forms'), [firestore, hostId, campaignId], { idField: '$id' })\n\n /*\n * The months the campaign's dates cover, or nothing where it has neither.\n * Derived from props the detail card already holds, so asking what a\n * figure covers costs no read.\n */\n const range = useMemo(\n () => campaignPeriodRange({ startAtMs, endAtMs }),\n [startAtMs, endAtMs],\n )\n const windowed = isWindowedRange(range)\n\n const screens = useMemo<MemberRow[]>(() => {\n return live(screenDocs).map((row) => {\n const id = String(row['$id'])\n const versionId = String(row['versionId'] ?? '')\n return {\n id,\n name: String(row['displayName'] ?? id),\n /*\n * A screen with no saved version has no detail address to build —\n * the route carries a version — so it stays plain text rather than\n * linking somewhere that 404s. The screens list applies the same\n * guard to its own name column.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : null,\n hrefReason: versionId\n ? 'This site’s console URL has not resolved yet'\n : 'This screen has no saved version yet',\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [screenDocs, orgSlug, host])\n\n /*\n * The counters ride on the documents the membership query already returned,\n * so every figure below is paid for by a read this section was making\n * anyway. Nothing here opens a second listener.\n */\n const forms = useMemo<MemberRow[]>(() => {\n return live(formDocs).map((row) => {\n const id = String(row['$id'])\n return {\n id,\n name: String(row['displayName'] ?? id),\n href:\n orgSlug && host\n ? buildRoute(Route.FORM_DETAILS, { orgSlug, host, formId: id })\n : null,\n hrefReason: 'This site’s console URL has not resolved yet',\n totals: campaignFormTotals(row['stats'] as FormStats | undefined, range),\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [formDocs, orgSlug, host, range])\n\n const rollup = useMemo(\n () =>\n campaignFormsRollup(\n forms.map((row) => ({\n totals: row.totals as FormStatsTotals,\n campaigns: row.campaigns ?? 1,\n })),\n ),\n [forms],\n )\n\n const screensTruncated = (screenDocs ?? []).length > MEMBER_CEILING\n const formsTruncated = (formDocs ?? []).length > MEMBER_CEILING\n const settled = screensStatus !== 'loading' && formsStatus !== 'loading'\n\n const analyticsHref =\n orgSlug && host\n ? buildRoute(Route.HOST_ANALYTICS, { orgSlug, host })\n : null\n const contactsHref =\n orgSlug && host ? buildRoute(Route.HOST_CONTACTS, { orgSlug, host }) : null\n\n return (\n <Section title=\"Assigned to this campaign\">\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages and forms somebody put in this campaign. Assignment is ' +\n 'a grouping — what the campaign was credited with is measured ' +\n 'above, from the links its emails carried.'}\n </Typography>\n <MemberTable\n heading=\"Screens\"\n noun=\"screen\"\n rows={screens}\n truncated={screensTruncated}\n settled={settled}\n />\n {/*\n The absence, named where a reader would look for the number.\n\n A screen's traffic is not on the screen document — it is a day doc\n per screen in `screenAnalytics` — so a views column here would be a\n read across screens times days on every open, for figures whose\n display is a paid entitlement this section does not resolve. The\n page that does both is linked instead.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Page views are not shown here. A screen keeps no running total ' +\n 'on its own record — traffic is stored a day at a time — so ' +\n 'counting it would be a fresh read of the site’s history every ' +\n 'time this page opens. '}\n {analyticsHref ? (\n <AppLink href={analyticsHref}>\n {'The site’s analytics measures it by screen.'}\n </AppLink>\n ) : (\n 'The site’s analytics page measures it by screen.'\n )}\n </Typography>\n <MemberTable\n heading=\"Forms\"\n noun=\"form\"\n rows={forms}\n truncated={formsTruncated}\n settled={settled}\n figures\n />\n {/*\n THE MEMBERSHIP TOTAL, fenced off from the attributed ones above.\n\n Same `Figure` primitive as the mail rollup, deliberately different\n words: it says what the forms HOLD, and the caption beneath refuses\n the reading a merchant will reach for first.\n */}\n {forms.length ? (\n <FormsHoldings\n rollup={rollup}\n windowed={windowed}\n from={range.from ?? null}\n to={range.to ?? null}\n />\n ) : null}\n {/*\n The people, named rather than omitted.\n\n A contact CAN be filed under a campaign and this page cannot list\n them — see the module comment for the query rule that decides it.\n Leaving the heading out entirely would read as \"contacts cannot be\n assigned\", which is the one thing it does not mean.\n */}\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Contacts'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Contacts are filed under a campaign from the contact’s own ' +\n 'panel on the Contacts page. They are not listed or counted ' +\n 'here: a contact belongs to the organization rather than to ' +\n 'this site, so the query that reads them is already spending ' +\n 'its one array filter on which sites may see the person. '}\n {contactsHref ? (\n <AppLink href={contactsHref}>{'Open Contacts'}</AppLink>\n ) : null}\n {contactsHref\n ? ' — it opens unfiltered, because that same rule leaves no ' +\n 'campaign filter for the address to carry.'\n : ''}\n </Typography>\n </Stack>\n </Stack>\n </Section>\n )\n}\n\n/**\n * What the campaign's forms have collected — and what that is NOT.\n *\n * Two sentences do the whole job of keeping this apart from the attributed\n * figures, and both are load-bearing:\n *\n * - The first refuses causation outright. A merchant reading a number under\n * a campaign's heading will assume the campaign caused it unless told\n * otherwise in the same breath.\n * - The second says which months the number covers. A lifetime total under a\n * campaign that ran for six weeks is the same lie one step quieter.\n *\n * `shared` is stated as a count rather than folded into the arithmetic. There\n * is no honest way to divide a submission between two campaigns a form is\n * filed under, so the total is left whole and the overlap is disclosed.\n */\nfunction FormsHoldings(props: {\n rollup: CampaignFormsRollup\n windowed: boolean\n from: string | null\n to: string | null\n}) {\n const { rollup, windowed, from, to } = props\n const note = (recorded: number) =>\n recorded < rollup.members\n ? `across ${recorded} of ${rollup.members} forms`\n : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`\n const span = windowed\n ? `the campaign’s months (${from ?? 'the first month recorded'} to ${\n to ?? 'now'\n })`\n : 'each form’s whole history'\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'What these forms hold'}\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Views\"\n value={rollup.views.value}\n note={note(rollup.views.recorded)}\n />\n <Figure\n label=\"Started\"\n value={rollup.starts.value}\n note={note(rollup.starts.recorded)}\n />\n <Figure\n label=\"Submissions\"\n value={rollup.submissions.value}\n note={note(rollup.submissions.recorded)}\n />\n <Figure\n label=\"Leads\"\n value={rollup.leads.value}\n note={note(rollup.leads.recorded)}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These are the forms’ own counters, not this campaign’s results. ' +\n 'A submission is counted here because somebody filed the form ' +\n 'under this campaign, whether or not this campaign’s mail sent ' +\n `the visitor. Counted over ${span}.`}\n </Typography>\n {windowed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whole calendar months: a campaign that started or ended mid-' +\n 'month takes that month entire, and a form counting nothing in ' +\n 'those months adds nothing here even where its lifetime total ' +\n 'is large.'}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign has no dates, so nothing narrows the figures to ' +\n 'it. They are lifetime totals and include everything from ' +\n 'before the forms were put in this campaign. Give the campaign ' +\n 'a start and an end to count only its months.'}\n </Typography>\n )}\n {rollup.shared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${rollup.shared} of these ${rollup.members} forms ${\n rollup.shared === 1 ? 'is' : 'are'\n } filed under another campaign too, so the same submissions are ` +\n 'counted there as well. This total is not exclusive to this ' +\n 'campaign.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nFormsHoldings.displayName = 'FormsHoldings'\n\n/** A counter as a cell: an em dash where nothing was recorded, never a zero. */\nfunction statCell(value: number | null | undefined) {\n return value === null || value === undefined ? '—' : value.toLocaleString()\n}\n\n/** The counters a form carries on its own document, in the order they are read. */\nconst MEMBER_FIGURES = [\n ['views', 'Views'],\n ['starts', 'Started'],\n ['submissions', 'Submissions'],\n ['leads', 'Leads'],\n] as const\n\n/**\n * One kind's members, as a record list in the shared grid.\n *\n * The rows are a window this component already holds — the same arrangement\n * the campaign's emails list beside it uses, and for the same reason: the\n * ceiling bounds the read, and the grid's footer lets a reader walk what came\n * back without the card deciding how many rows fit.\n *\n * `figures` adds the counter columns for a kind that carries them on its own\n * document. It is a prop rather than two lists because the row grammar — the\n * name cell, the reason a member has no link, the footer — is the part that\n * has to stay identical between screens and forms.\n */\nfunction MemberTable(props: {\n heading: string\n noun: string\n rows: MemberRow[]\n truncated: boolean\n settled: boolean\n figures?: boolean\n}) {\n const { heading, noun, rows, truncated, settled, figures } = props\n const columns = useMemo<GridColDef<MemberRow>[]>(\n () => [\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack sx={{ py: 1, minWidth: 0 }}>\n {row.href ? (\n <AppLink href={row.href}>{row.name}</AppLink>\n ) : (\n <>\n <Typography variant=\"body2\">{row.name}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.hrefReason}\n </Typography>\n </>\n )}\n {/*\n The overlap, on the row that causes it. A reader comparing two\n campaigns has to be able to see which rows they have in common\n without opening either.\n */}\n {(row.campaigns ?? 1) > 1 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Also in ${(row.campaigns ?? 1) - 1} other campaign${\n (row.campaigns ?? 1) - 1 === 1 ? '' : 's'\n }`}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n ...(figures\n ? MEMBER_FIGURES.map(\n ([figure, headerName]): GridColDef<MemberRow> => ({\n field: figure,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 120,\n valueGetter: (_value, row) => row.totals?.[figure] ?? null,\n renderCell: ({ row }) => statCell(row.totals?.[figure]),\n }),\n )\n : []),\n ],\n [figures],\n )\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {heading}\n </Typography>\n {truncated ? (\n <Alert severity=\"info\">\n {`More than ${MEMBER_CEILING} ${noun}s are in this campaign. The ` +\n `first ${MEMBER_CEILING} are listed, in document order.`}\n </Alert>\n ) : null}\n {rows.length ? (\n <ListTable\n aria-label={heading}\n rows={rows}\n columns={columns}\n getRowId={(row: MemberRow) => row.id}\n rowHeight={TABLE_ROW_HEIGHT}\n // A member's name carries why it has no link, and the other\n // campaigns it is in, beneath it.\n getRowHeight={() => 'auto'}\n />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? `No ${noun} is in this campaign. Open a ${noun} and pick this ` +\n 'campaign on its own page to put it in one.'\n : `Reading this campaign’s ${noun}s…`}\n </Typography>\n )}\n </Stack>\n )\n}\n\nCampaignMembersSection.displayName = 'CampaignMembersSection'\n\nexport default CampaignMembersSection\n"],"names":["AppLink","Figure","Section","ListTable","TABLE_ROW_HEIGHT","buildRoute","CAMPAIGN_MEMBERSHIP_FIELD","readCampaignIds","Route","Alert","Stack","Typography","collection","documentId","limit","orderBy","query","where","useMemo","useConsoleHostRoute","useFirestore","useFirestoreCollection","campaignFormsRollup","campaignFormTotals","campaignPeriodRange","isWindowedRange","MEMBER_CEILING","live","docs","slice","filter","row","CampaignMembersSection","props","range","hostId","campaignId","startAtMs","endAtMs","firestore","orgSlug","subdomain","host","memberQuery","collectionName","data","screenDocs","status","screensStatus","idField","formDocs","formsStatus","windowed","screens","map","id","String","versionId","name","href","SCREEN_DETAILS","screenId","hrefReason","campaigns","length","forms","FORM_DETAILS","formId","totals","rollup","screensTruncated","formsTruncated","settled","analyticsHref","HOST_ANALYTICS","contactsHref","HOST_CONTACTS","title","spacing","variant","color","MemberTable","heading","noun","rows","truncated","figures","FormsHoldings","from","to","note","recorded","members","span","direction","sx","flexWrap","label","value","views","starts","submissions","leads","shared","displayName","statCell","undefined","toLocaleString","MEMBER_FIGURES","columns","field","headerName","flex","minWidth","renderCell","py","figure","type","align","headerAlign","width","valueGetter","_value","severity","aria-label","getRowId","rowHeight","getRowHeight"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-members-section.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 Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n buildRoute,\n CAMPAIGN_MEMBERSHIP_FIELD,\n readCampaignIds,\n Route,\n type FormStats,\n type FormStatsTotals,\n} from '@aglyn/aglyn'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignFormsRollup,\n campaignFormTotals,\n campaignPeriodRange,\n isWindowedRange,\n type CampaignFormsRollup,\n} from '../model/campaign-membership-figures'\n\n/**\n * How many members of one kind the section enumerates.\n *\n * One document past it is asked for, so \"this campaign holds more than are\n * listed\" is a fact rather than a guess — the same probe the emails table on\n * this page makes.\n */\nconst MEMBER_CEILING = 25\n\nexport interface CampaignMembersSectionProps {\n hostId: string\n campaignId: string\n /** The campaign's first day, when it has one. */\n startAtMs?: number | null\n /** The campaign's last day, when it has one. */\n endAtMs?: number | null\n}\n\n/**\n * The rows a reader may act on: the window, minus what has been deleted.\n *\n * The soft delete is filtered HERE and not in the query. `deletedAt` is\n * written only when a record is removed, so `where('deletedAt', '==', null)`\n * would match nothing at all — Firestore's equality matches documents that\n * HAVE the field — and every live record would vanish from the campaign. The\n * campaigns table filters its own soft-deleted containers the same way.\n */\nfunction live(\n docs: Array<Record<string, unknown>> | undefined,\n): Array<Record<string, unknown>> {\n return (docs ?? [])\n .slice(0, MEMBER_CEILING)\n .filter((row) => !row['deletedAt'])\n}\n\n/** One row, whatever collection it came out of. */\ninterface MemberRow {\n id: string\n name: string\n href: string | null\n /** Why there is no link, when there is none. */\n hrefReason?: string\n /** The member's own counters, for a kind that carries them. */\n totals?: FormStatsTotals\n /** Campaigns the member is filed under, this one included. */\n campaigns?: number\n}\n\n/**\n * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,\n * and what those forms have collected.\n *\n * ## Declared, and therefore different from every section above it\n *\n * The conversions, revenue and destinations sections are EVIDENCE: they join\n * on the campaign's send ids and report what visitors did BECAUSE of this\n * campaign's mail. This one is a statement the merchant made — a screen or a\n * form carries this campaign's id in {@link CAMPAIGN_MEMBERSHIP_FIELD} — plus\n * the counters those records carry on their own documents.\n *\n * The distinction survives into the wording, because the arithmetic will not\n * enforce it. A form's submissions are the form's; filing the form under a\n * campaign does not make them the campaign's, and a heading that said\n * \"this campaign produced 40 leads\" over a number meaning \"the forms filed\n * here have 40 leads\" would be a worse page than one showing nothing. So the\n * figures are labeled as what the forms HOLD, they sit in their own block\n * under their own disclaimer, and no total on this page adds them to an\n * attributed one.\n *\n * ## Three ways the forms' counters could mislead, each answered\n *\n * 1. **They are lifetime.** `stats.submissions` has counted since the form\n * existed, including every submission from before anybody filed it here.\n * Where the campaign carries dates, the figures are summed from the\n * monthly `stats.periods` series over those months instead, and the\n * caption says which of the two a reader is looking at. Months are whole,\n * so a campaign starting mid-month includes that month entire — stated,\n * not rounded away.\n * 2. **A form can be in twenty campaigns.** `campaignIds` is an array, so\n * the same submissions count toward every campaign the form is filed\n * under. Rows say so and the total refuses to present itself as\n * exclusive.\n * 3. **A counter nobody wrote is not a zero.** `stats.leads` is incremented\n * only for a form whose `routing.lead` is set, and `stats.views` only by\n * the analytics beacon. Both reach the shared `Figure`, which draws an em\n * dash and the words \"not recorded\" rather than a confident 0.\n *\n * ## Screens carry no counter, and this section will not invent one\n *\n * A form's counters ride on the form document, so the query this section\n * already makes pays for them. A screen's traffic does not: it is one\n * document per screen per day in `hosts/{hostId}/screenAnalytics`, with no\n * lifetime total anywhere, so a views column here would cost a range read\n * across screens times days on every open of this page — and the display of\n * those figures is a paid entitlement this section does not resolve. The\n * screens block names the absence and links to the surface that does measure\n * it, which is a smaller lie than a number nobody can stand behind.\n *\n * ## The join is `array-contains`, on the member's own document\n *\n * A campaign holds no member list. Each record names the campaigns it is in,\n * which is what makes deleting one record leave nothing behind and what lets\n * a record's own page draw its campaigns without a query. The query here is\n * the other direction of the same field, served by Firestore's automatic\n * single-field index.\n *\n * `orderBy(documentId())`, like every other bounded list in this console: a\n * form carries `updatedAt` only if some writer stamped one, and ordering on a\n * field a writer may omit does not mis-sort the list, it drops rows from it.\n *\n * ## Contacts are named here and NOT listed, and the reason is a query limit\n *\n * A contact is org-scoped and shared between the sites that captured it, so\n * every client read of the collection has to carry\n * `visibleTo array-contains-any` — that is what makes the read provable\n * per-document under the rules. Firestore permits ONE array clause per query,\n * and that is it: there is no client query that filters contacts by campaign\n * as well, so there is no pre-filtered address to link to either. The link\n * offered goes to the Contacts page and says it arrives unfiltered; the\n * assignment is real, is set from the contact's own drawer, and a count of it\n * would cost a scan of the collection the customer is billed on.\n */\nexport function CampaignMembersSection(props: CampaignMembersSectionProps) {\n const { hostId, campaignId, startAtMs, endAtMs } = props\n const firestore = useFirestore()\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n\n const memberQuery = (collectionName: string) => () =>\n query(\n collection(firestore, 'hosts', hostId, collectionName),\n where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId),\n orderBy(documentId()),\n limit(MEMBER_CEILING + 1),\n )\n\n const { data: screenDocs, status: screensStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('screens'), [firestore, hostId, campaignId], {\n idField: '$id',\n })\n const { data: formDocs, status: formsStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('forms'), [firestore, hostId, campaignId], { idField: '$id' })\n\n /*\n * The months the campaign's dates cover, or nothing where it has neither.\n * Derived from props the detail card already holds, so asking what a\n * figure covers costs no read.\n */\n const range = useMemo(\n () => campaignPeriodRange({ startAtMs, endAtMs }),\n [startAtMs, endAtMs],\n )\n const windowed = isWindowedRange(range)\n\n const screens = useMemo<MemberRow[]>(() => {\n return live(screenDocs).map((row) => {\n const id = String(row['$id'])\n const versionId = String(row['versionId'] ?? '')\n return {\n id,\n name: String(row['displayName'] ?? id),\n /*\n * A screen with no saved version has no detail address to build —\n * the route carries a version — so it stays plain text rather than\n * linking somewhere that 404s. The screens list applies the same\n * guard to its own name column.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : null,\n hrefReason: versionId\n ? 'This site’s console URL has not resolved yet'\n : 'This screen has no saved version yet',\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [screenDocs, orgSlug, host])\n\n /*\n * The counters ride on the documents the membership query already returned,\n * so every figure below is paid for by a read this section was making\n * anyway. Nothing here opens a second listener.\n */\n const forms = useMemo<MemberRow[]>(() => {\n return live(formDocs).map((row) => {\n const id = String(row['$id'])\n return {\n id,\n name: String(row['displayName'] ?? id),\n href:\n orgSlug && host\n ? buildRoute(Route.FORM_DETAILS, { orgSlug, host, formId: id })\n : null,\n hrefReason: 'This site’s console URL has not resolved yet',\n totals: campaignFormTotals(row['stats'] as FormStats | undefined, range),\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [formDocs, orgSlug, host, range])\n\n const rollup = useMemo(\n () =>\n campaignFormsRollup(\n forms.map((row) => ({\n totals: row.totals as FormStatsTotals,\n campaigns: row.campaigns ?? 1,\n })),\n ),\n [forms],\n )\n\n const screensTruncated = (screenDocs ?? []).length > MEMBER_CEILING\n const formsTruncated = (formDocs ?? []).length > MEMBER_CEILING\n const settled = screensStatus !== 'loading' && formsStatus !== 'loading'\n\n const analyticsHref =\n orgSlug && host\n ? buildRoute(Route.HOST_ANALYTICS, { orgSlug, host })\n : null\n const contactsHref =\n orgSlug && host ? buildRoute(Route.HOST_CONTACTS, { orgSlug, host }) : null\n\n return (\n <Section title=\"Assigned to this campaign\">\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages and forms somebody put in this campaign. Assignment is ' +\n 'a grouping — what the campaign was credited with is measured ' +\n 'above, from the links its emails carried.'}\n </Typography>\n <MemberTable\n heading=\"Screens\"\n noun=\"screen\"\n rows={screens}\n truncated={screensTruncated}\n settled={settled}\n />\n {/*\n The absence, named where a reader would look for the number.\n\n A screen's traffic is not on the screen document — it is a day doc\n per screen in `screenAnalytics` — so a views column here would be a\n read across screens times days on every open, for figures whose\n display is a paid entitlement this section does not resolve. The\n page that does both is linked instead.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Page views are not shown here. A screen keeps no running total ' +\n 'on its own record — traffic is stored a day at a time — so ' +\n 'counting it would be a fresh read of the site’s history every ' +\n 'time this page opens. '}\n {analyticsHref ? (\n <AppLink href={analyticsHref}>\n {'The site’s analytics measures it by screen.'}\n </AppLink>\n ) : (\n 'The site’s analytics page measures it by screen.'\n )}\n </Typography>\n <MemberTable\n heading=\"Forms\"\n noun=\"form\"\n rows={forms}\n truncated={formsTruncated}\n settled={settled}\n figures\n />\n {/*\n THE MEMBERSHIP TOTAL, fenced off from the attributed ones above.\n\n Same `Figure` primitive as the mail rollup, deliberately different\n words: it says what the forms HOLD, and the caption beneath refuses\n the reading a merchant will reach for first.\n */}\n {forms.length ? (\n <FormsHoldings\n rollup={rollup}\n windowed={windowed}\n from={range.from ?? null}\n to={range.to ?? null}\n />\n ) : null}\n {/*\n The people, named rather than omitted.\n\n A contact CAN be filed under a campaign and this page cannot list\n them — see the module comment for the query rule that decides it.\n Leaving the heading out entirely would read as \"contacts cannot be\n assigned\", which is the one thing it does not mean.\n */}\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Contacts'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Contacts are filed under a campaign from the contact’s own ' +\n 'panel on the Contacts page. They are not listed or counted ' +\n 'here: a contact belongs to the organization rather than to ' +\n 'this site, so the query that reads them is already spending ' +\n 'its one array filter on which sites may see the person. '}\n {contactsHref ? (\n <AppLink href={contactsHref}>{'Open Contacts'}</AppLink>\n ) : null}\n {contactsHref\n ? ' — it opens unfiltered, because that same rule leaves no ' +\n 'campaign filter for the address to carry.'\n : ''}\n </Typography>\n </Stack>\n </Stack>\n </Section>\n )\n}\n\n/**\n * What the campaign's forms have collected — and what that is NOT.\n *\n * Two sentences do the whole job of keeping this apart from the attributed\n * figures, and both are load-bearing:\n *\n * - The first refuses causation outright. A merchant reading a number under\n * a campaign's heading will assume the campaign caused it unless told\n * otherwise in the same breath.\n * - The second says which months the number covers. A lifetime total under a\n * campaign that ran for six weeks is the same lie one step quieter.\n *\n * `shared` is stated as a count rather than folded into the arithmetic. There\n * is no honest way to divide a submission between two campaigns a form is\n * filed under, so the total is left whole and the overlap is disclosed.\n */\nfunction FormsHoldings(props: {\n rollup: CampaignFormsRollup\n windowed: boolean\n from: string | null\n to: string | null\n}) {\n const { rollup, windowed, from, to } = props\n const note = (recorded: number) =>\n recorded < rollup.members\n ? `across ${recorded} of ${rollup.members} forms`\n : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`\n const span = windowed\n ? `the campaign’s months (${from ?? 'the first month recorded'} to ${\n to ?? 'now'\n })`\n : 'each form’s whole history'\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'What these forms hold'}\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Views\"\n value={rollup.views.value}\n note={note(rollup.views.recorded)}\n />\n <Figure\n label=\"Started\"\n value={rollup.starts.value}\n note={note(rollup.starts.recorded)}\n />\n <Figure\n label=\"Submissions\"\n value={rollup.submissions.value}\n note={note(rollup.submissions.recorded)}\n />\n <Figure\n label=\"Leads\"\n value={rollup.leads.value}\n note={note(rollup.leads.recorded)}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These are the forms’ own counters, not this campaign’s results. ' +\n 'A submission is counted here because somebody filed the form ' +\n 'under this campaign, whether or not this campaign’s mail sent ' +\n `the visitor. Counted over ${span}.`}\n </Typography>\n {windowed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whole calendar months: a campaign that started or ended mid-' +\n 'month takes that month entire, and a form counting nothing in ' +\n 'those months adds nothing here even where its lifetime total ' +\n 'is large.'}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign has no dates, so nothing narrows the figures to ' +\n 'it. They are lifetime totals and include everything from ' +\n 'before the forms were put in this campaign. Give the campaign ' +\n 'a start and an end to count only its months.'}\n </Typography>\n )}\n {rollup.shared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${rollup.shared} of these ${rollup.members} forms ${\n rollup.shared === 1 ? 'is' : 'are'\n } filed under another campaign too, so the same submissions are ` +\n 'counted there as well. This total is not exclusive to this ' +\n 'campaign.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nFormsHoldings.displayName = 'FormsHoldings'\n\n/** A counter as a cell: an em dash where nothing was recorded, never a zero. */\nfunction statCell(value: number | null | undefined) {\n return value === null || value === undefined ? '—' : value.toLocaleString()\n}\n\n/** The counters a form carries on its own document, in the order they are read. */\nconst MEMBER_FIGURES = [\n ['views', 'Views'],\n ['starts', 'Started'],\n ['submissions', 'Submissions'],\n ['leads', 'Leads'],\n] as const\n\n/**\n * One kind's members, as a record list in the shared grid.\n *\n * The rows are a window this component already holds — the same arrangement\n * the campaign's emails list beside it uses, and for the same reason: the\n * ceiling bounds the read, and the grid's footer lets a reader walk what came\n * back without the card deciding how many rows fit.\n *\n * `figures` adds the counter columns for a kind that carries them on its own\n * document. It is a prop rather than two lists because the row grammar — the\n * name cell, the reason a member has no link, the footer — is the part that\n * has to stay identical between screens and forms.\n */\nfunction MemberTable(props: {\n heading: string\n noun: string\n rows: MemberRow[]\n truncated: boolean\n settled: boolean\n figures?: boolean\n}) {\n const { heading, noun, rows, truncated, settled, figures } = props\n const columns = useMemo<GridColDef<MemberRow>[]>(\n () => [\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack sx={{ py: 1, minWidth: 0 }}>\n {row.href ? (\n <AppLink href={row.href}>{row.name}</AppLink>\n ) : (\n <>\n <Typography variant=\"body2\">{row.name}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.hrefReason}\n </Typography>\n </>\n )}\n {/*\n The overlap, on the row that causes it. A reader comparing two\n campaigns has to be able to see which rows they have in common\n without opening either.\n */}\n {(row.campaigns ?? 1) > 1 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Also in ${(row.campaigns ?? 1) - 1} other campaign${\n (row.campaigns ?? 1) - 1 === 1 ? '' : 's'\n }`}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n ...(figures\n ? MEMBER_FIGURES.map(\n ([figure, headerName]): GridColDef<MemberRow> => ({\n field: figure,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 120,\n valueGetter: (_value, row) => row.totals?.[figure] ?? null,\n renderCell: ({ row }) => statCell(row.totals?.[figure]),\n }),\n )\n : []),\n ],\n [figures],\n )\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {heading}\n </Typography>\n {truncated ? (\n <Alert severity=\"info\">\n {`More than ${MEMBER_CEILING} ${noun}s are in this campaign. The ` +\n `first ${MEMBER_CEILING} are listed, in document order.`}\n </Alert>\n ) : null}\n {rows.length ? (\n <ListTable\n aria-label={heading}\n rows={rows}\n columns={columns}\n getRowId={(row: MemberRow) => row.id}\n rowHeight={TABLE_ROW_HEIGHT}\n // A member's name carries why it has no link, and the other\n // campaigns it is in, beneath it.\n getRowHeight={() => 'auto'}\n />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? `No ${noun} is in this campaign. Open a ${noun} and pick this ` +\n 'campaign on its own page to put it in one.'\n : `Reading this campaign’s ${noun}s…`}\n </Typography>\n )}\n </Stack>\n )\n}\n\nCampaignMembersSection.displayName = 'CampaignMembersSection'\n\nexport default CampaignMembersSection\n"],"names":["AppLink","Figure","Section","ListTable","TABLE_ROW_HEIGHT","buildRoute","CAMPAIGN_MEMBERSHIP_FIELD","readCampaignIds","Route","Alert","Stack","Typography","collection","documentId","limit","orderBy","query","where","useMemo","useConsoleHostRoute","useFirestore","useFirestoreCollection","campaignFormsRollup","campaignFormTotals","campaignPeriodRange","isWindowedRange","MEMBER_CEILING","live","docs","slice","filter","row","CampaignMembersSection","props","range","hostId","campaignId","startAtMs","endAtMs","firestore","orgSlug","subdomain","host","memberQuery","collectionName","data","screenDocs","status","screensStatus","idField","formDocs","formsStatus","windowed","screens","map","id","String","versionId","name","href","SCREEN_DETAILS","screenId","hrefReason","campaigns","length","forms","FORM_DETAILS","formId","totals","rollup","screensTruncated","formsTruncated","settled","analyticsHref","HOST_ANALYTICS","contactsHref","HOST_CONTACTS","title","spacing","variant","color","MemberTable","heading","noun","rows","truncated","figures","FormsHoldings","from","to","note","recorded","members","span","direction","sx","flexWrap","label","value","views","starts","submissions","leads","shared","displayName","statCell","undefined","toLocaleString","MEMBER_FIGURES","columns","field","headerName","flex","minWidth","renderCell","py","figure","type","align","headerAlign","width","valueGetter","_value","severity","aria-label","getRowId","rowHeight","getRowHeight"],"mappings":"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"}
@@ -17,7 +17,7 @@
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
19
  import { AppLink } from "@aglyn/shared-ui-jsx";
20
- import { Figure, MoneyFigure, Section } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
20
+ import { Figure, MoneyFigure, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
21
21
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
22
22
  import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
23
23
  import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
@@ -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-email-campaigns/components/report-figures'\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 doc,\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 campaignRevenueAcrossSends,\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n type CampaignConversionKind,\n type CampaignLinkRollup,\n type CampaignRevenueAcrossSends,\n type CampaignRevenueRollup,\n} from '@aglyn/shared-ui-email-campaigns/model'\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 hostId: string\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: CampaignReachProps & {\n /** The marketing hub URL, for the site-wide conversions list. */\n basePath: string\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 <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 </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 { hostId, 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) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(\n doc(\n firestore,\n 'hosts',\n hostId,\n 'campaigns',\n id,\n 'reports',\n 'revenue',\n ),\n ),\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, hostId, 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 { hostId, 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) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(\n doc(\n firestore,\n 'hosts',\n hostId,\n 'campaigns',\n id,\n 'reports',\n 'links',\n ),\n ),\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, hostId, 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"],"names":["AppLink","Figure","MoneyFigure","Section","ListPagination","TABLE_PAGE_SIZE_DEFAULT","ScrollTable","Alert","Button","Stack","TableBody","TableCell","TableHead","TableRow","Typography","collection","doc","getCountFromServer","getDoc","query","where","useCallback","useEffect","useMemo","useState","useFirestore","campaignRevenueAcrossSends","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","EMAIL_ATTRIBUTION_WINDOW_DAYS","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","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"],"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 doc,\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 campaignRevenueAcrossSends,\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n type CampaignConversionKind,\n type CampaignLinkRollup,\n type CampaignRevenueAcrossSends,\n type CampaignRevenueRollup,\n} from '@aglyn/shared-ui-email-campaigns/model'\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 hostId: string\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: CampaignReachProps & {\n /** The marketing hub URL, for the site-wide conversions list. */\n basePath: string\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 <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 </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 { hostId, 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) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(\n doc(\n firestore,\n 'hosts',\n hostId,\n 'campaigns',\n id,\n 'reports',\n 'revenue',\n ),\n ),\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, hostId, 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 { hostId, 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) return\n setBusy(true)\n setError(null)\n try {\n const snapshots = await Promise.all(\n ids.map((id) =>\n getDoc(\n doc(\n firestore,\n 'hosts',\n hostId,\n 'campaigns',\n id,\n 'reports',\n 'links',\n ),\n ),\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, hostId, 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"],"names":["AppLink","Figure","MoneyFigure","Section","ListPagination","TABLE_PAGE_SIZE_DEFAULT","ScrollTable","Alert","Button","Stack","TableBody","TableCell","TableHead","TableRow","Typography","collection","doc","getCountFromServer","getDoc","query","where","useCallback","useEffect","useMemo","useState","useFirestore","campaignRevenueAcrossSends","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","EMAIL_ATTRIBUTION_WINDOW_DAYS","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","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"],"mappings":"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"}
@@ -29,7 +29,7 @@ import { campaignConversionsReport } from "@aglyn/shared-ui-email-campaigns/mode
29
29
  * The three renderers every email report shares. Imported rather than kept
30
30
  * here, so "a rate prints its denominator" is one implementation and not a
31
31
  * convention each new card has to remember.
32
- */ import { Figure, MoneyFigure, MoneyPerMessageRow, percent, RateRow, Section } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
32
+ */ import { Figure, MoneyFigure, MoneyPerUnitRow, percent, RateRow, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
33
33
  import { emailSendTimeMs } from "@aglyn/shared-ui-email-campaigns/model/email-record";
34
34
  import { campaignSendDisplay, campaignSendProgress } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
35
35
  import { useEmailsHubPath } from "./use-emails-hub-path.js";
@@ -348,7 +348,7 @@ import { useEmailsHubPath } from "./use-emails-hub-path.js";
348
348
  })
349
349
  ]
350
350
  }),
351
- /*#__PURE__*/ _jsx(MoneyPerMessageRow, {
351
+ /*#__PURE__*/ _jsx(MoneyPerUnitRow, {
352
352
  label: "Net revenue per delivered message",
353
353
  figure: entry.netPerDelivered
354
354
  })