@aglyn/plugins-marketing 1.0.0-beta.145 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-marketing",
3
- "version": "1.0.0-beta.145",
3
+ "version": "1.0.0-beta.147",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,20 +25,20 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.145",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.145",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.145",
31
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.145",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.145",
33
- "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.145",
34
- "@aglyn/shared-ui-next": "1.0.0-beta.145",
35
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.145",
36
- "@aglyn/shared-util-email": "1.0.0-beta.145",
37
- "@aglyn/shared-util-http": "1.0.0-beta.145",
38
- "@aglyn/shared-util-timestamp": "1.0.0-beta.145",
39
- "@aglyn/tenant-data-admin": "1.0.0-beta.145",
40
- "@aglyn/tenant-feature-instance": "1.0.0-beta.145",
41
- "@aglyn/tenant-runtime": "1.0.0-beta.145",
28
+ "@aglyn/aglyn": "1.0.0-beta.147",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.147",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.147",
31
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.147",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.147",
33
+ "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.147",
34
+ "@aglyn/shared-ui-next": "1.0.0-beta.147",
35
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.147",
36
+ "@aglyn/shared-util-email": "1.0.0-beta.147",
37
+ "@aglyn/shared-util-http": "1.0.0-beta.147",
38
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.147",
39
+ "@aglyn/tenant-data-admin": "1.0.0-beta.147",
40
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.147",
41
+ "@aglyn/tenant-runtime": "1.0.0-beta.147",
42
42
  "@swc/helpers": "0.5.23"
43
43
  },
44
44
  "peerDependencies": {
@@ -21,7 +21,7 @@ import { mdiEyeOutline, mdiMapMarkerOutline } from "@aglyn/shared-data-mdi";
21
21
  import { AppLink, CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
22
22
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
23
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
24
- import { Figure, Section } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
24
+ import { Figure, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
25
25
  import { Alert, Button, Chip, Divider, Stack, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
26
26
  import { collection, documentId, getCountFromServer, getDocs, query, where } from "firebase/firestore";
27
27
  import { useCallback, useEffect, useMemo, useState } from "react";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-conversions-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiEyeOutline, mdiMapMarkerOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Figure, Section } from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n documentId,\n getCountFromServer,\n getDocs,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useFirestore,\n useOrgDataScope,\n usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\n/*\n * The MODULE, not the barrel, for the pure query helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n collectionPage,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n campaignConversionsCoverage,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions'\n\n/**\n * WHAT THE CAMPAIGNS CAUSED, from the conversions' end.\n *\n * A campaign's own report says what that campaign caused. This is the list\n * the report cannot be: every credited conversion on the site, the ones\n * credited to a tagged web link rather than to a campaign document, and — the\n * half a report of successes can never show — how many were credited to\n * nothing at all.\n *\n * ## ONE KIND AT A TIME, and that is the whole design\n *\n * A form submission by a new person writes a submission record, a contact and\n * a lead. Three true statements about one visit, and a screen that showed\n * them together would invite the reader to add them and treble every figure.\n * So the kind is a MODE rather than a column: the query carries\n * `where('kind','==', …)`, only one kind's rows are ever on screen, and there\n * is no view in which two kinds could be totalled because there is no view in\n * which two kinds are both present.\n *\n * ## THE TWO CHANNELS ARE TWO LISTS, and never one\n *\n * An email touch names a campaign document — a real entity with a report to\n * open. A web touch names `utm_` text a marketer typed into a URL: no\n * document, no id, no bound on how many distinct values exist. Merging them\n * would put a linkable record and an unlinkable label in one column and imply\n * they are the same kind of thing, and grouping the web side into a rollup\n * would build a map anybody who can vary a query string can grow. So there\n * are two tables, the web one is read as records, and neither is summed into\n * the other.\n *\n * ## The read cost\n *\n * Two paged listeners bounded by the page size, and two aggregation counts.\n * Nothing scans a collection, no row triggers a read of its own, and the\n * landing-page join — the only expensive thing here — is behind a button and\n * says what it will cost before it runs.\n */\n\n/**\n * How many attribution records the landing-page join reads.\n *\n * A CEILING rather than a page size: grouping is over a window and a window\n * sliced ten at a time cannot be grouped at all. Kept low because this is the\n * one read on the surface that is measured in hundreds — see the button that\n * asks for it.\n */\nconst LANDING_PAGE_CEILING = 100\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nconst conversionsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A conversion is credited to the last campaign whose link the visitor ' +\n 'followed. The four kinds count different things about the same visits ' +\n 'and are never added together.',\n})\n\n/** The stored record plus the document name the reader listed it under. */\ntype ConversionRow = CampaignConversionRecord & { $id: string }\n\nconst day = (ms: unknown): string => {\n const value = Number(ms ?? 0)\n return Number.isFinite(value) && value > 0\n ? new Date(value).toLocaleString()\n : '—'\n}\n\nexport interface CampaignConversionsCardProps {\n hostId: string\n /** The marketing hub URL, for the link to each credited campaign. */\n basePath: string\n /**\n * One campaign's conversions rather than the whole site's.\n *\n * Set from `/marketing/conversions/{campaignId}`. It narrows the EMAIL list\n * only: a web-channel record carries no campaign id, so there is nothing\n * for it to be narrowed by — and the uncredited figure is withheld\n * entirely, because a conversion credited to nobody belongs to no campaign\n * and attributing the site's whole direct traffic to whichever campaign the\n * reader happens to be looking at is the exact inference this join refuses\n * to make.\n */\n campaignId?: string\n}\n\nexport function CampaignConversionsCard(props: CampaignConversionsCardProps) {\n const { hostId, basePath, campaignId } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const [kind, setKind] = useState<CampaignConversionKind>('form')\n\n const attributions = useCallback(\n () => collection(firestore, 'hosts', hostId, 'campaignAttributions'),\n [firestore, hostId],\n )\n\n /*==========================================\n * THE TWO LISTS.\n *\n * Both walk the collection in DOCUMENT-NAME order through `collectionPage`,\n * which is the console's one ordering decision: `orderBy` on a field\n * matches only documents that HAVE it, so ordering on a date would hide\n * rows rather than merely mis-order them, and an unordered `limit()` is a\n * pseudo-random sample whose gaps leave nothing to notice. The walk is\n * total — every row is reachable by paging — and it is deliberately NOT\n * newest-first, which the note under each table says out loud.\n *\n * `campaignId` narrows the email list when the reader arrived from one\n * campaign. It is never applied to the web list: a `utm_` label is not a\n * campaign id and a query pretending otherwise would return nothing while\n * reading as \"this campaign caused no web conversions\".\n *=========================================*/\n const emailList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n collectionPage(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', kind),\n where('campaignId', '==', campaignId),\n )\n : query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'email'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n const webList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n campaignId\n ? null\n : collectionPage(\n query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'web'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE UNCREDITED HALF.\n *\n * Two aggregation counts, and they are on the page by DEFAULT rather than\n * behind a button, because the tables above them are a list of the\n * successes: showing only those renders \"we credited nine of these\" as\n * \"nine of these happened\", which is the conclusion this surface exists to\n * stop a reader drawing. An aggregation is billed per thousand index\n * entries rather than per document, so the honest figure costs about as\n * much as one row.\n *\n * Withheld entirely when either count fails. Defaulting the total to the\n * attributed figure would render every conversion as attributed, which is\n * the most flattering wrong answer available here.\n *=========================================*/\n /*\n * A PATH STRING and a BOOLEAN, not an object.\n *\n * Both are effect dependencies, and an object rebuilt on every render\n * reopens the effect on every render — which here means paying for two\n * aggregation counts per render rather than per kind. Primitives compare by\n * value, so the reads happen when the question changes and not when React\n * re-runs the component.\n */\n const totalCollectionPath = useMemo((): string | null => {\n switch (kind) {\n case 'form':\n return `hosts/${hostId}/formSubmissions`\n case 'lead':\n return `hosts/${hostId}/leads`\n case 'booking':\n return `hosts/${hostId}/bookings`\n case 'contact':\n /*\n * ORG-SCOPED, and the coverage model is told so below. A contact is\n * shared across every site in the org while an attribution belongs to\n * one host, so this total counts contacts created on another site\n * that could never have been credited here.\n */\n return dataScope ? `${dataScope[0]}/${dataScope[1]}/contacts` : null\n default:\n return null\n }\n }, [kind, hostId, dataScope])\n\n /** The kind's records live outside this host, so the total over-counts. */\n const totalCrossesHosts = kind === 'contact'\n\n const [attributedCount, setAttributedCount] = useState<number | null>(null)\n const [totalCount, setTotalCount] = useState<number | null>(null)\n\n useEffect(() => {\n // A campaign-scoped view has no uncredited figure to compute — see the\n // prop's docblock — so it does not pay for one either.\n if (campaignId || !totalCollectionPath) {\n setAttributedCount(null)\n setTotalCount(null)\n return\n }\n let active = true\n setAttributedCount(null)\n setTotalCount(null)\n void getCountFromServer(query(attributions(), where('kind', '==', kind)))\n .then((snapshot) => {\n if (active) setAttributedCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n const segments = totalCollectionPath.split('/')\n void getCountFromServer(\n collection(firestore, segments[0], ...segments.slice(1)),\n )\n .then((snapshot) => {\n if (active) setTotalCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n return () => {\n active = false\n }\n }, [attributions, firestore, kind, campaignId, totalCollectionPath])\n\n const coverage = campaignConversionsCoverage({\n kind,\n attributed: attributedCount,\n total: totalCount,\n crossHostTotal: totalCrossesHosts,\n })\n\n /*==========================================\n * LANDING PAGES — a join, and an EXPENSIVE one, so it is asked for.\n *\n * The attribution record carries `refId`; a form submission carries the\n * `path` it was submitted from. Joining the two is the only landing-page\n * answer the stored data supports, and the notice below says plainly what\n * it is not: `path` is where the FORM was, not where the campaign link\n * landed, so a visitor who arrived on one page and submitted on another is\n * grouped under the second.\n *\n * Two reads deep and both bounded: a ceiling over the attributions, then\n * their submissions by document name in chunks of thirty. `getDocs` rather\n * than a listener — a grouping nobody is watching does not need to be kept\n * live, and a listener would go on costing after the reader looked away.\n *=========================================*/\n const [landingBusy, setLandingBusy] = useState(false)\n const [landingError, setLandingError] = useState<string | null>(null)\n const [landing, setLanding] = useState<{\n rows: { $id: string; path: string; conversions: number }[]\n /** Attributions read, which is the population the grouping describes. */\n read: number\n /** The ceiling bit: there are more credited submissions than were read. */\n truncated: boolean\n /** Of `read`, how many named a submission that could not be found. */\n missing: number\n } | null>(null)\n\n // A grouping computed for one kind describes that kind. Switching the mode\n // leaves it on screen labelled as something it is not.\n useEffect(() => {\n setLanding(null)\n setLandingError(null)\n }, [kind, campaignId])\n\n const groupByLandingPage = useCallback(async () => {\n if (landingBusy) return\n setLandingBusy(true)\n setLandingError(null)\n try {\n const credited = await getDocs(\n collectionCeiling(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', 'form'),\n where('campaignId', '==', campaignId),\n )\n : query(attributions(), where('kind', '==', 'form')),\n LANDING_PAGE_CEILING,\n ),\n )\n const { rows: credits, truncated } = ceilingedWindow(\n credited.docs.map((entry) => entry.data() as CampaignConversionRecord),\n LANDING_PAGE_CEILING,\n )\n const refIds = Array.from(\n new Set(\n credits\n .map((record) => String(record.refId ?? ''))\n .filter((id) => id.length > 0),\n ),\n )\n\n const submissions = collection(\n firestore,\n 'hosts',\n hostId,\n 'formSubmissions',\n )\n const paths = new Map<string, string>()\n for (let index = 0; index < refIds.length; index += ID_CHUNK) {\n const chunk = refIds.slice(index, index + ID_CHUNK)\n const found = await getDocs(\n query(submissions, where(documentId(), 'in', chunk)),\n )\n found.docs.forEach((entry) => {\n const path = String((entry.data() as any)?.path ?? '').trim()\n if (path) paths.set(entry.id, path)\n })\n }\n\n const counts = new Map<string, number>()\n let missing = 0\n refIds.forEach((refId) => {\n const path = paths.get(refId)\n if (!path) {\n /*\n * The submission was deleted, or it carries no path — an older one,\n * or a form posted by an integration that sent none. Counted and\n * REPORTED rather than filed under a placeholder page: a bar\n * labelled \"/\" that is really \"we do not know\" is the kind of row a\n * reader acts on.\n */\n missing += 1\n return\n }\n counts.set(path, (counts.get(path) ?? 0) + 1)\n })\n\n setLanding({\n rows: Array.from(counts.entries())\n .map(([path, conversions]) => ({ $id: path, path, conversions }))\n .sort(\n (a, b) =>\n b.conversions - a.conversions || a.path.localeCompare(b.path),\n ),\n read: refIds.length,\n truncated,\n missing,\n })\n } catch (error) {\n console.error(error)\n setLandingError('The landing pages could not be read')\n } finally {\n setLandingBusy(false)\n }\n }, [attributions, campaignId, firestore, hostId, landingBusy])\n\n const campaignHref = (row: ConversionRow) =>\n row.campaignId ? `${basePath}/campaigns/${row.campaignId}` : undefined\n\n const columns = useMemo(\n () => [\n {\n field: 'refId',\n headerName: 'Record',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }: any) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {String(row.refId ?? row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'campaign',\n headerName: 'Credited to',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => {\n const label = campaignTouchLabel(row) || '—'\n const href = campaignHref(row)\n return href ? (\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {label}\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap>\n {label}\n </Typography>\n )\n },\n },\n {\n field: 'convertedAtMs',\n headerName: 'Converted',\n width: 190,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {day(row.convertedAtMs)}\n </Typography>\n ),\n },\n listActionsColumn((row: any) => (\n <ListRowActions\n label={String(row.refId ?? row.$id)}\n items={[\n {\n key: 'campaign',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row),\n disabled: !campaignHref(row),\n /*\n * Shown disabled with the reason rather than hidden. A control\n * that vanishes and one that does not apply look identical from\n * the outside, and only one of them is honest — there is no\n * campaign document behind a `utm_` label to open.\n */\n disabledReason:\n 'This came from a tagged link rather than a campaign email, ' +\n 'so there is no campaign to open',\n },\n ]}\n />\n )),\n ],\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [kind, basePath],\n )\n\n const landingColumns = useMemo(\n () => [\n {\n field: 'path',\n headerName: 'Page the form was on',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.path}\n </Typography>\n ),\n },\n {\n field: 'conversions',\n headerName: 'Credited submissions',\n width: 190,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">\n {Number(row.conversions).toLocaleString()}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const kindCopy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n\n return (\n <CardDisplay\n header=\"Conversions\"\n subheader={\n campaignId\n ? 'Credited to this campaign'\n : 'What the campaigns caused, one kind at a time'\n }\n help={conversionsDocsHelp}\n HeaderProps={{\n action: campaignId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/conversions`}\n size=\"small\"\n color=\"primary\"\n >\n {'All conversions'}\n </Button>\n ) : undefined,\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/*\n THE KIND IS A MODE, NOT A COLUMN.\n\n One form submission by a new person writes a submission, a contact\n and a lead, so any view holding two kinds at once invites a total\n that counts one visit twice. Only one kind's rows can be on screen,\n which is what makes the addition unavailable rather than merely\n discouraged.\n */}\n <Stack spacing={1}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n color=\"primary\"\n value={kind}\n onChange={(_event, next) => {\n if (next) setKind(next as CampaignConversionKind)\n }}\n aria-label=\"Conversion kind\"\n >\n {CAMPAIGN_CONVERSION_KINDS.map((one) => (\n <ToggleButton key={one} value={one}>\n {CAMPAIGN_CONVERSION_KIND_COPY[one].label}\n </ToggleButton>\n ))}\n </ToggleButtonGroup>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'One kind at a time. The four count different things about the ' +\n 'same visits — one person filling in one form appears as a ' +\n 'submission, a contact and a lead — so they are never shown ' +\n 'together and never added.'}\n </Typography>\n </Stack>\n\n <Divider />\n\n {/*==========================================\n * WHAT IS NOT CREDITED, beside what is.\n *\n * The tables below list the successes. Without this strip the reader\n * has no way to see how much of their site's activity the campaigns\n * account for, and every campaign reads as more effective than it is.\n *=========================================*/}\n {campaignId ? (\n <Alert severity=\"info\">\n {'This is one campaign’s share. Conversions credited to no ' +\n 'campaign belong to no campaign, so they are not counted here ' +\n '— open all conversions for the site-wide figure.'}\n </Alert>\n ) : coverage ? (\n <Section title={`${kindCopy.label} on this site`}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Credited to a campaign\"\n value={coverage.attributed}\n note=\"from a campaign email or a tagged link\"\n />\n <Figure\n label=\"Not credited\"\n value={coverage.unattributed}\n note=\"arrived directly, or predate attribution\"\n />\n <Figure\n label={`${kindCopy.label} in total`}\n value={coverage.total}\n note={\n coverage.exact ? 'on this site' : 'on this site, all time'\n }\n />\n </Stack>\n {coverage.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n </Section>\n ) : (\n <Alert severity=\"info\">\n {'How many of these were credited to no campaign could not be ' +\n 'counted, so it is not shown. The lists below are the credited ' +\n 'ones only and are not a count of everything that happened.'}\n </Alert>\n )}\n\n <Divider />\n\n <Section title=\"From campaign emails\">\n <ListTable\n rows={emailList.rows}\n columns={columns as any}\n hideFooter\n onOpen={(_id, row) => {\n const href = campaignHref(row as ConversionRow)\n if (href) router.push(href)\n }}\n noRowsLabel=\"Nothing credited to a campaign email\"\n noRowsDescription={\n 'A conversion is credited when the visitor followed a link in ' +\n 'a campaign email before converting.'\n }\n />\n <ListPagination\n page={emailList.page}\n pageSize={emailList.pageSize}\n rowCount={emailList.rows.length}\n hasMore={emailList.hasMore}\n onPageChange={emailList.setPage}\n onPageSizeChange={emailList.setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Listed in the order they are stored, which is not the order ' +\n 'they happened in — every row is reachable by paging, but the ' +\n 'first page is not the most recent conversions.'}\n </Typography>\n </Section>\n\n {/*==========================================\n * THE WEB CHANNEL — its own list, and no rollup.\n *\n * A `utm_` label is text somebody typed into a URL. There is no\n * document behind it, no id, and no bound on how many distinct\n * values exist, so it gets no rollup under a campaign and no summary\n * keyed on the label: a map anybody who can vary a query string can\n * grow is a map that grows. The records stand on their own and this\n * lists them.\n *\n * Absent entirely on a campaign-scoped view, because these belong to\n * no campaign.\n *=========================================*/}\n {campaignId ? null : (\n <Section title=\"From tagged web links\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Credited to the utm_ label on the link the visitor followed. ' +\n 'These are listed one by one rather than grouped: the label ' +\n 'is free text with no campaign behind it, so there is ' +\n 'nothing to open and no fixed set to total.'}\n </Typography>\n <ListTable\n rows={webList.rows}\n columns={columns as any}\n hideFooter\n noRowsLabel=\"Nothing credited to a tagged link\"\n noRowsDescription={\n 'A conversion lands here when the visitor arrived on a URL ' +\n 'carrying utm_ parameters.'\n }\n />\n <ListPagination\n page={webList.page}\n pageSize={webList.pageSize}\n rowCount={webList.rows.length}\n hasMore={webList.hasMore}\n onPageChange={webList.setPage}\n onPageSizeChange={webList.setPageSize}\n />\n </Section>\n )}\n\n {/*==========================================\n * LANDING PAGES, on request.\n *\n * `refId` on the record, `path` on the submission: joining those is\n * the landing-page surface, and it is hundreds of reads, so it is a\n * button rather than something every reader of this page pays for.\n *=========================================*/}\n {kind === 'form' ? (\n <>\n <Divider />\n <Section title=\"Landing pages\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Groups credited form submissions by the page the form was ' +\n 'on. This reads up to ' +\n `${LANDING_PAGE_CEILING.toLocaleString()} attribution ` +\n 'records and their submissions, so it runs when you ask ' +\n 'for it rather than on every visit.'}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={landingBusy}\n startIcon={\n <MdiIcon path={mdiMapMarkerOutline.path} size={0.8} />\n }\n onClick={() => void groupByLandingPage()}\n >\n {landingBusy ? 'Reading…' : 'Group by landing page'}\n </Button>\n </Stack>\n {landingError ? (\n <Alert severity=\"warning\">{landingError}</Alert>\n ) : null}\n {landing ? (\n <Stack spacing={1}>\n {/*\n WHAT THIS CANNOT ANSWER, above the table rather than under\n it. Every line is a question a reader will otherwise\n assume it answered.\n */}\n <Alert severity=\"info\">\n {'This is the page each form sat on, not the page the ' +\n 'campaign link landed on — a visitor who arrived on ' +\n 'one page and submitted on another is counted under ' +\n 'the second. There is no conversion RATE here either: ' +\n 'the record carries no count of visits to a page from ' +\n 'a campaign, so there is no denominator to divide by. ' +\n 'Only form submissions carry a page at all, so leads, ' +\n 'contacts and bookings have no landing page.'}\n </Alert>\n {landing.truncated ? (\n <Alert severity=\"warning\">\n {`More than ${LANDING_PAGE_CEILING.toLocaleString()} ` +\n 'credited submissions exist. This groups the ' +\n `${landing.read.toLocaleString()} that were read, ` +\n 'which are not the most recent — they are the ones ' +\n 'that came first in storage order.'}\n </Alert>\n ) : null}\n {landing.missing ? (\n <Alert severity=\"info\">\n {`${landing.missing.toLocaleString()} of the ` +\n `${landing.read.toLocaleString()} credited ` +\n 'submissions are not grouped: the submission has ' +\n 'been deleted, or it recorded no page. They are ' +\n 'reported here rather than filed under a page they ' +\n 'may not have come from.'}\n </Alert>\n ) : null}\n <ListTable\n rows={landing.rows}\n columns={landingColumns as any}\n hideFooter\n noRowsLabel=\"No pages to group\"\n noRowsDescription=\"None of the credited submissions recorded a page.\"\n />\n <Stack direction=\"row\" spacing={1}>\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.read.toLocaleString()} credited submissions read`}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.rows.length.toLocaleString()} pages`}\n />\n </Stack>\n </Stack>\n ) : null}\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCampaignConversionsCard.displayName = 'CampaignConversionsCard'\n\nexport default CampaignConversionsCard\n"],"names":["pluginDocsHelp","mdiEyeOutline","mdiMapMarkerOutline","AppLink","CardDisplay","MdiIcon","ListRowActions","ListTable","listActionsColumn","ListPagination","Figure","Section","Alert","Button","Chip","Divider","Stack","ToggleButton","ToggleButtonGroup","Typography","collection","documentId","getCountFromServer","getDocs","query","where","useCallback","useEffect","useMemo","useState","useRouter","useFirestore","useOrgDataScope","usePagedCollection","ceilingedWindow","collectionCeiling","collectionPage","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","campaignConversionsCoverage","campaignTouchLabel","LANDING_PAGE_CEILING","ID_CHUNK","conversionsDocsHelp","anchor","excerpt","day","ms","value","Number","isFinite","Date","toLocaleString","CampaignConversionsCard","props","hostId","basePath","campaignId","firestore","router","scope","dataScope","kind","setKind","attributions","emailList","pageLimit","idField","webList","totalCollectionPath","totalCrossesHosts","attributedCount","setAttributedCount","totalCount","setTotalCount","active","then","snapshot","data","count","catch","segments","split","slice","coverage","attributed","total","crossHostTotal","landingBusy","setLandingBusy","landingError","setLandingError","landing","setLanding","groupByLandingPage","credited","rows","credits","truncated","docs","map","entry","refIds","Array","from","Set","record","String","refId","filter","id","length","submissions","paths","Map","index","chunk","found","forEach","path","trim","set","counts","missing","get","entries","conversions","$id","sort","a","b","localeCompare","read","error","console","campaignHref","row","undefined","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","color","label","href","onClick","event","stopPropagation","width","convertedAtMs","items","key","icon","size","disabled","disabledReason","landingColumns","align","headerAlign","kindCopy","header","subheader","help","HeaderProps","action","component","componentVariant","nativeButton","contentGutterX","contentGutterY","spacing","exclusive","onChange","_event","next","aria-label","one","severity","title","direction","useFlexGap","flexWrap","note","unattributed","exact","caveats","caveat","message","hideFooter","onOpen","_id","push","noRowsLabel","noRowsDescription","page","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize","startIcon","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SAASA,cAAc,QAAQ,eAAc;AAC7C,SAASC,aAAa,EAAEC,mBAAmB,QAAQ,yBAAwB;AAC3E,SAASC,OAAO,EAAEC,WAAW,EAAEC,OAAO,QAAQ,uBAAsB;AACpE,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAC7D,SAASC,cAAc,QAAQ,4DAA2D;AAC1F,SAASC,MAAM,EAAEC,OAAO,QAAQ,6DAA4D;AAC5F,SACEC,KAAK,EACLC,MAAM,EACNC,IAAI,EACJC,OAAO,EACPC,KAAK,EACLC,YAAY,EACZC,iBAAiB,EACjBC,UAAU,QACL,gBAAe;AACtB,SACEC,UAAU,EACVC,UAAU,EACVC,kBAAkB,EAClBC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,WAAW,EAAEC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACjE,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SACEC,YAAY,EACZC,eAAe,EACfC,kBAAkB,QACb,iCAAgC;AACvC;;;;CAIC,GACD,SACEC,eAAe,EACfC,iBAAiB,EACjBC,cAAc,QACT,+DAA8D;AACrE,SACEC,yBAAyB,EACzBC,6BAA6B,EAC7BC,2BAA2B,EAC3BC,kBAAkB,QAGb,8DAA6D;AAEpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED;;;;;;;CAOC,GACD,MAAMC,uBAAuB;AAE7B,sDAAsD,GACtD,MAAMC,WAAW;AAEjB,MAAMC,sBAAsB3C,eAAe,kBAAkB;IAC3D4C,QAAQ;IACRC,SACE,0EACA,2EACA;AACJ;AAKA,MAAMC,MAAM,CAACC;IACX,MAAMC,QAAQC,OAAOF,aAAAA,KAAM;IAC3B,OAAOE,OAAOC,QAAQ,CAACF,UAAUA,QAAQ,IACrC,IAAIG,KAAKH,OAAOI,cAAc,KAC9B;AACN;AAoBA,OAAO,SAASC,wBAAwBC,KAAmC;IACzE,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,UAAU,EAAE,GAAGH;IACzC,MAAMI,YAAY3B;IAClB,MAAM4B,SAAS7B;IACf,MAAM,EAAE8B,OAAOC,SAAS,EAAE,GAAG7B,gBAAgB;QAAEuB;IAAO;IACtD,MAAM,CAACO,MAAMC,QAAQ,GAAGlC,SAAiC;IAEzD,MAAMmC,eAAetC,YACnB,IAAMN,WAAWsC,WAAW,SAASH,QAAQ,yBAC7C;QAACG;QAAWH;KAAO;IAGrB;;;;;;;;;;;;;;;6CAe2C,GAC3C,MAAMU,YAAYhC,mBAChB,CAACiC,YACC9B,eACEqB,aACIjC,MACEwC,gBACAvC,MAAM,QAAQ,MAAMqC,OACpBrC,MAAM,cAAc,MAAMgC,eAE5BjC,MACEwC,gBACAvC,MAAM,QAAQ,MAAMqC,OACpBrC,MAAM,WAAW,MAAM,WAE7ByC,YAEJ;QAACR;QAAWH;QAAQO;QAAML;KAAW,EACrC;QAAEU,SAAS;IAAM;IAGnB,MAAMC,UAAUnC,mBACd,CAACiC,YACCT,aACI,OACArB,eACEZ,MACEwC,gBACAvC,MAAM,QAAQ,MAAMqC,OACpBrC,MAAM,WAAW,MAAM,SAEzByC,YAER;QAACR;QAAWH;QAAQO;QAAML;KAAW,EACrC;QAAEU,SAAS;IAAM;IAGnB;;;;;;;;;;;;;;6CAc2C,GAC3C;;;;;;;;GAQC,GACD,MAAME,sBAAsBzC,QAAQ;QAClC,OAAQkC;YACN,KAAK;gBACH,OAAO,CAAC,MAAM,EAAEP,OAAO,gBAAgB,CAAC;YAC1C,KAAK;gBACH,OAAO,CAAC,MAAM,EAAEA,OAAO,MAAM,CAAC;YAChC,KAAK;gBACH,OAAO,CAAC,MAAM,EAAEA,OAAO,SAAS,CAAC;YACnC,KAAK;gBACH;;;;;SAKC,GACD,OAAOM,YAAY,GAAGA,SAAS,CAAC,EAAE,CAAC,CAAC,EAAEA,SAAS,CAAC,EAAE,CAAC,SAAS,CAAC,GAAG;YAClE;gBACE,OAAO;QACX;IACF,GAAG;QAACC;QAAMP;QAAQM;KAAU;IAE5B,yEAAyE,GACzE,MAAMS,oBAAoBR,SAAS;IAEnC,MAAM,CAACS,iBAAiBC,mBAAmB,GAAG3C,SAAwB;IACtE,MAAM,CAAC4C,YAAYC,cAAc,GAAG7C,SAAwB;IAE5DF,UAAU;QACR,uEAAuE;QACvE,uDAAuD;QACvD,IAAI8B,cAAc,CAACY,qBAAqB;YACtCG,mBAAmB;YACnBE,cAAc;YACd;QACF;QACA,IAAIC,SAAS;QACbH,mBAAmB;QACnBE,cAAc;QACd,KAAKpD,mBAAmBE,MAAMwC,gBAAgBvC,MAAM,QAAQ,MAAMqC,QAC/Dc,IAAI,CAAC,CAACC;YACL,IAAIF,QAAQH,mBAAmBK,SAASC,IAAI,GAAGC,KAAK;QACtD,GACCC,KAAK,CAAC;QACL,+DAA+D;QACjE;QACF,MAAMC,WAAWZ,oBAAoBa,KAAK,CAAC;QAC3C,KAAK5D,mBACHF,WAAWsC,WAAWuB,QAAQ,CAAC,EAAE,KAAKA,SAASE,KAAK,CAAC,KAEpDP,IAAI,CAAC,CAACC;YACL,IAAIF,QAAQD,cAAcG,SAASC,IAAI,GAAGC,KAAK;QACjD,GACCC,KAAK,CAAC;QACL,+DAA+D;QACjE;QACF,OAAO;YACLL,SAAS;QACX;IACF,GAAG;QAACX;QAAcN;QAAWI;QAAML;QAAYY;KAAoB;IAEnE,MAAMe,WAAW7C,4BAA4B;QAC3CuB;QACAuB,YAAYd;QACZe,OAAOb;QACPc,gBAAgBjB;IAClB;IAEA;;;;;;;;;;;;;;6CAc2C,GAC3C,MAAM,CAACkB,aAAaC,eAAe,GAAG5D,SAAS;IAC/C,MAAM,CAAC6D,cAAcC,gBAAgB,GAAG9D,SAAwB;IAChE,MAAM,CAAC+D,SAASC,WAAW,GAAGhE,SAQpB;IAEV,2EAA2E;IAC3E,uDAAuD;IACvDF,UAAU;QACRkE,WAAW;QACXF,gBAAgB;IAClB,GAAG;QAAC7B;QAAML;KAAW;IAErB,MAAMqC,qBAAqBpE,YAAY;QACrC,IAAI8D,aAAa;QACjBC,eAAe;QACfE,gBAAgB;QAChB,IAAI;YACF,MAAMI,WAAW,MAAMxE,QACrBY,kBACEsB,aACIjC,MACEwC,gBACAvC,MAAM,QAAQ,MAAM,SACpBA,MAAM,cAAc,MAAMgC,eAE5BjC,MAAMwC,gBAAgBvC,MAAM,QAAQ,MAAM,UAC9CgB;YAGJ,MAAM,EAAEuD,MAAMC,OAAO,EAAEC,SAAS,EAAE,GAAGhE,gBACnC6D,SAASI,IAAI,CAACC,GAAG,CAAC,CAACC,QAAUA,MAAMvB,IAAI,KACvCrC;YAEF,MAAM6D,SAASC,MAAMC,IAAI,CACvB,IAAIC,IACFR,QACGG,GAAG,CAAC,CAACM;oBAAkBA;uBAAPC,QAAOD,gBAAAA,OAAOE,KAAK,YAAZF,gBAAgB;eACvCG,MAAM,CAAC,CAACC,KAAOA,GAAGC,MAAM,GAAG;YAIlC,MAAMC,cAAc5F,WAClBsC,WACA,SACAH,QACA;YAEF,MAAM0D,QAAQ,IAAIC;YAClB,IAAK,IAAIC,QAAQ,GAAGA,QAAQb,OAAOS,MAAM,EAAEI,SAASzE,SAAU;gBAC5D,MAAM0E,QAAQd,OAAOnB,KAAK,CAACgC,OAAOA,QAAQzE;gBAC1C,MAAM2E,QAAQ,MAAM9F,QAClBC,MAAMwF,aAAavF,MAAMJ,cAAc,MAAM+F;gBAE/CC,MAAMlB,IAAI,CAACmB,OAAO,CAAC,CAACjB;;wBACGA;oBAArB,MAAMkB,OAAOZ,gBAAQN,cAAAA,MAAMvB,IAAI,uBAAX,AAACuB,YAAsBkB,IAAI,mBAAI,IAAIC,IAAI;oBAC3D,IAAID,MAAMN,MAAMQ,GAAG,CAACpB,MAAMS,EAAE,EAAES;gBAChC;YACF;YAEA,MAAMG,SAAS,IAAIR;YACnB,IAAIS,UAAU;YACdrB,OAAOgB,OAAO,CAAC,CAACV;oBAaIc;gBAZlB,MAAMH,OAAON,MAAMW,GAAG,CAAChB;gBACvB,IAAI,CAACW,MAAM;oBACT;;;;;;WAMC,GACDI,WAAW;oBACX;gBACF;gBACAD,OAAOD,GAAG,CAACF,MAAM,EAACG,cAAAA,OAAOE,GAAG,CAACL,iBAAXG,cAAoB,KAAK;YAC7C;YAEA7B,WAAW;gBACTG,MAAMO,MAAMC,IAAI,CAACkB,OAAOG,OAAO,IAC5BzB,GAAG,CAAC,CAAC,CAACmB,MAAMO,YAAY,GAAM,CAAA;wBAAEC,KAAKR;wBAAMA;wBAAMO;oBAAY,CAAA,GAC7DE,IAAI,CACH,CAACC,GAAGC,IACFA,EAAEJ,WAAW,GAAGG,EAAEH,WAAW,IAAIG,EAAEV,IAAI,CAACY,aAAa,CAACD,EAAEX,IAAI;gBAElEa,MAAM9B,OAAOS,MAAM;gBACnBb;gBACAyB;YACF;QACF,EAAE,OAAOU,OAAO;YACdC,QAAQD,KAAK,CAACA;YACd1C,gBAAgB;QAClB,SAAU;YACRF,eAAe;QACjB;IACF,GAAG;QAACzB;QAAcP;QAAYC;QAAWH;QAAQiC;KAAY;IAE7D,MAAM+C,eAAe,CAACC,MACpBA,IAAI/E,UAAU,GAAG,GAAGD,SAAS,WAAW,EAAEgF,IAAI/E,UAAU,EAAE,GAAGgF;IAE/D,MAAMC,UAAU9G,QACd,IAAM;YACJ;gBACE+G,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEP,GAAG,EAAO;wBAGXA;yCAFZ,MAACxH;wBAAMgI,IAAI;4BAAEF,UAAU;wBAAE;;0CACvB,KAAC3H;gCAAW8H,SAAQ;gCAAQC,MAAM;0CAC/BvC,QAAO6B,aAAAA,IAAI5B,KAAK,YAAT4B,aAAaA,IAAIT,GAAG;;0CAE9B,KAAC5G;gCAAW8H,SAAQ;gCAAUE,OAAM;gCAAiBD,MAAM;0CACxD5G,6BAA6B,CAACwB,KAAK,CAACsF,KAAK;;;;;YAIlD;YACA;gBACET,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEP,GAAG,EAAO;oBACvB,MAAMY,QAAQ5G,mBAAmBgG,QAAQ;oBACzC,MAAMa,OAAOd,aAAaC;oBAC1B,OAAOa,qBACL,KAAClJ;wBACCkJ,MAAMA;wBACNC,SAAS,CAACC,QACRA,MAAMC,eAAe;kCAGtBJ;uCAGH,KAACjI;wBAAW8H,SAAQ;wBAAQC,MAAM;kCAC/BE;;gBAGP;YACF;YACA;gBACET,OAAO;gBACPC,YAAY;gBACZa,OAAO;gBACPV,YAAY,CAAC,EAAEP,GAAG,EAAO,iBACvB,KAACrH;wBAAW8H,SAAQ;wBAAQC,MAAM;kCAC/BpG,IAAI0F,IAAIkB,aAAa;;YAG5B;YACAlJ,kBAAkB,CAACgI;oBAEDA;qCADhB,KAAClI;oBACC8I,OAAOzC,QAAO6B,aAAAA,IAAI5B,KAAK,YAAT4B,aAAaA,IAAIT,GAAG;oBAClC4B,OAAO;wBACL;4BACEC,KAAK;4BACLR,OAAO;4BACPS,oBAAM,KAACxJ;gCAAQkH,MAAMtH,cAAcsH,IAAI;gCAAEuC,MAAM;;4BAC/CT,MAAMd,aAAaC;4BACnBuB,UAAU,CAACxB,aAAaC;4BACxB;;;;;eAKC,GACDwB,gBACE,gEACA;wBACJ;qBACD;;;SAGN,EACD,uDAAuD;IACvD;QAAClG;QAAMN;KAAS;IAGlB,MAAMyG,iBAAiBrI,QACrB,IAAM;YACJ;gBACE+G,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEP,GAAG,EAAO,iBACvB,KAACrH;wBAAW8H,SAAQ;wBAAQC,MAAM;kCAC/BV,IAAIjB,IAAI;;YAGf;YACA;gBACEoB,OAAO;gBACPC,YAAY;gBACZa,OAAO;gBACPS,OAAO;gBACPC,aAAa;gBACbpB,YAAY,CAAC,EAAEP,GAAG,EAAO,iBACvB,KAACrH;wBAAW8H,SAAQ;kCACjBhG,OAAOuF,IAAIV,WAAW,EAAE1E,cAAc;;YAG7C;SACD,EACD,EAAE;IAGJ,MAAMgH,WAAW9H,6BAA6B,CAACwB,KAAK;IAEpD,qBACE,KAAC1D;QACCiK,QAAO;QACPC,WACE7G,aACI,8BACA;QAEN8G,MAAM5H;QACN6H,aAAa;YACXC,QAAQhH,2BACN,KAAC5C;gBACC6J,WAAWvK;eACN;gBAAEwK,kBAAkB;gBAASC,cAAc;YAAM;gBACtDvB,MAAM,GAAG7F,SAAS,YAAY,CAAC;gBAC/BsG,MAAK;gBACLX,OAAM;0BAEL;kBAEDV;QACN;QACAoC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAAC9J;YAAM+J,SAAS;;8BAUd,MAAC/J;oBAAM+J,SAAS;;sCACd,KAAC7J;4BACC8J,SAAS;4BACTlB,MAAK;4BACLX,OAAM;4BACNnG,OAAOc;4BACPmH,UAAU,CAACC,QAAQC;gCACjB,IAAIA,MAAMpH,QAAQoH;4BACpB;4BACAC,cAAW;sCAEV/I,0BAA0B+D,GAAG,CAAC,CAACiF,oBAC9B,KAACpK;oCAAuB+B,OAAOqI;8CAC5B/I,6BAA6B,CAAC+I,IAAI,CAACjC,KAAK;mCADxBiC;;sCAKvB,KAAClK;4BAAW8H,SAAQ;4BAAUE,OAAM;sCACjC,mEACC,+DACA,gEACA;;;;8BAIN,KAACpI;gBASA0C,2BACC,KAAC7C;oBAAM0K,UAAS;8BACb,8DACC,kEACA;qBAEFlG,yBACF,MAACzE;oBAAQ4K,OAAO,GAAGnB,SAAShB,KAAK,CAAC,aAAa,CAAC;;sCAC9C,MAACpI;4BACCwK,WAAU;4BACVT,SAAS;4BACTU,UAAU;4BACVzC,IAAI;gCAAE0C,UAAU;4BAAO;;8CAEvB,KAAChL;oCACC0I,OAAM;oCACNpG,OAAOoC,SAASC,UAAU;oCAC1BsG,MAAK;;8CAEP,KAACjL;oCACC0I,OAAM;oCACNpG,OAAOoC,SAASwG,YAAY;oCAC5BD,MAAK;;8CAEP,KAACjL;oCACC0I,OAAO,GAAGgB,SAAShB,KAAK,CAAC,SAAS,CAAC;oCACnCpG,OAAOoC,SAASE,KAAK;oCACrBqG,MACEvG,SAASyG,KAAK,GAAG,iBAAiB;;;;wBAIvCzG,SAAS0G,OAAO,CAAC1F,GAAG,CAAC,CAAC2F,uBACrB,KAACnL;gCAAsB0K,UAAS;0CAC7BS,OAAOC,OAAO;+BADLD,OAAOjF,EAAE;;mCAMzB,KAAClG;oBAAM0K,UAAS;8BACb,iEACC,mEACA;;8BAIN,KAACvK;8BAED,MAACJ;oBAAQ4K,OAAM;;sCACb,KAAChL;4BACCyF,MAAM/B,UAAU+B,IAAI;4BACpB0C,SAASA;4BACTuD,UAAU;4BACVC,QAAQ,CAACC,KAAK3D;gCACZ,MAAMa,OAAOd,aAAaC;gCAC1B,IAAIa,MAAM1F,OAAOyI,IAAI,CAAC/C;4BACxB;4BACAgD,aAAY;4BACZC,mBACE,kEACA;;sCAGJ,KAAC7L;4BACC8L,MAAMtI,UAAUsI,IAAI;4BACpBC,UAAUvI,UAAUuI,QAAQ;4BAC5BC,UAAUxI,UAAU+B,IAAI,CAACe,MAAM;4BAC/B2F,SAASzI,UAAUyI,OAAO;4BAC1BC,cAAc1I,UAAU2I,OAAO;4BAC/BC,kBAAkB5I,UAAU6I,WAAW;;sCAEzC,KAAC3L;4BAAW8H,SAAQ;4BAAUE,OAAM;sCACjC,iEACC,kEACA;;;;gBAiBL1F,aAAa,qBACZ,MAAC9C;oBAAQ4K,OAAM;;sCACb,KAACpK;4BAAW8H,SAAQ;4BAAQE,OAAM;sCAC/B,kEACC,gEACA,0DACA;;sCAEJ,KAAC5I;4BACCyF,MAAM5B,QAAQ4B,IAAI;4BAClB0C,SAASA;4BACTuD,UAAU;4BACVI,aAAY;4BACZC,mBACE,+DACA;;sCAGJ,KAAC7L;4BACC8L,MAAMnI,QAAQmI,IAAI;4BAClBC,UAAUpI,QAAQoI,QAAQ;4BAC1BC,UAAUrI,QAAQ4B,IAAI,CAACe,MAAM;4BAC7B2F,SAAStI,QAAQsI,OAAO;4BACxBC,cAAcvI,QAAQwI,OAAO;4BAC7BC,kBAAkBzI,QAAQ0I,WAAW;;;;gBAY1ChJ,SAAS,uBACR;;sCACE,KAAC/C;sCACD,MAACJ;4BAAQ4K,OAAM;;8CACb,KAACpK;oCAAW8H,SAAQ;oCAAQE,OAAM;8CAC/B,+DACC,0BACA,GAAG1G,qBAAqBW,cAAc,GAAG,aAAa,CAAC,GACvD,4DACA;;8CAEJ,KAACpC;oCAAMwK,WAAU;oCAAMT,SAAS;8CAC9B,cAAA,KAAClK;wCACCiJ,MAAK;wCACLb,SAAQ;wCACRc,UAAUvE;wCACVuH,yBACE,KAAC1M;4CAAQkH,MAAMrH,oBAAoBqH,IAAI;4CAAEuC,MAAM;;wCAEjDR,SAAS,IAAM,KAAKxD;kDAEnBN,cAAc,aAAa;;;gCAG/BE,6BACC,KAAC9E;oCAAM0K,UAAS;8CAAW5F;qCACzB;gCACHE,wBACC,MAAC5E;oCAAM+J,SAAS;;sDAMd,KAACnK;4CAAM0K,UAAS;sDACb,yDACC,wDACA,wDACA,0DACA,0DACA,0DACA,0DACA;;wCAEH1F,QAAQM,SAAS,iBAChB,KAACtF;4CAAM0K,UAAS;sDACb,CAAC,UAAU,EAAE7I,qBAAqBW,cAAc,GAAG,CAAC,CAAC,GACpD,iDACA,GAAGwC,QAAQwC,IAAI,CAAChF,cAAc,GAAG,iBAAiB,CAAC,GACnD,uDACA;6CAEF;wCACHwC,QAAQ+B,OAAO,iBACd,KAAC/G;4CAAM0K,UAAS;sDACb,GAAG1F,QAAQ+B,OAAO,CAACvE,cAAc,GAAG,QAAQ,CAAC,GAC5C,GAAGwC,QAAQwC,IAAI,CAAChF,cAAc,GAAG,UAAU,CAAC,GAC5C,qDACA,oDACA,uDACA;6CAEF;sDACJ,KAAC7C;4CACCyF,MAAMJ,QAAQI,IAAI;4CAClB0C,SAASuB;4CACTgC,UAAU;4CACVI,aAAY;4CACZC,mBAAkB;;sDAEpB,MAACtL;4CAAMwK,WAAU;4CAAMT,SAAS;;8DAC9B,KAACjK;oDACCgJ,MAAK;oDACLb,SAAQ;oDACRG,OAAO,GAAGxD,QAAQwC,IAAI,CAAChF,cAAc,GAAG,0BAA0B,CAAC;;8DAErE,KAACtC;oDACCgJ,MAAK;oDACLb,SAAQ;oDACRG,OAAO,GAAGxD,QAAQI,IAAI,CAACe,MAAM,CAAC3D,cAAc,GAAG,MAAM,CAAC;;;;;qCAI1D;;;;qBAGN;;;;AAIZ;AACAC,wBAAwB2J,WAAW,GAAG;AAEtC,eAAe3J,wBAAuB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-conversions-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiEyeOutline, mdiMapMarkerOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Figure, Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n documentId,\n getCountFromServer,\n getDocs,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useFirestore,\n useOrgDataScope,\n usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\n/*\n * The MODULE, not the barrel, for the pure query helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n collectionPage,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n campaignConversionsCoverage,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions'\n\n/**\n * WHAT THE CAMPAIGNS CAUSED, from the conversions' end.\n *\n * A campaign's own report says what that campaign caused. This is the list\n * the report cannot be: every credited conversion on the site, the ones\n * credited to a tagged web link rather than to a campaign document, and — the\n * half a report of successes can never show — how many were credited to\n * nothing at all.\n *\n * ## ONE KIND AT A TIME, and that is the whole design\n *\n * A form submission by a new person writes a submission record, a contact and\n * a lead. Three true statements about one visit, and a screen that showed\n * them together would invite the reader to add them and treble every figure.\n * So the kind is a MODE rather than a column: the query carries\n * `where('kind','==', …)`, only one kind's rows are ever on screen, and there\n * is no view in which two kinds could be totalled because there is no view in\n * which two kinds are both present.\n *\n * ## THE TWO CHANNELS ARE TWO LISTS, and never one\n *\n * An email touch names a campaign document — a real entity with a report to\n * open. A web touch names `utm_` text a marketer typed into a URL: no\n * document, no id, no bound on how many distinct values exist. Merging them\n * would put a linkable record and an unlinkable label in one column and imply\n * they are the same kind of thing, and grouping the web side into a rollup\n * would build a map anybody who can vary a query string can grow. So there\n * are two tables, the web one is read as records, and neither is summed into\n * the other.\n *\n * ## The read cost\n *\n * Two paged listeners bounded by the page size, and two aggregation counts.\n * Nothing scans a collection, no row triggers a read of its own, and the\n * landing-page join — the only expensive thing here — is behind a button and\n * says what it will cost before it runs.\n */\n\n/**\n * How many attribution records the landing-page join reads.\n *\n * A CEILING rather than a page size: grouping is over a window and a window\n * sliced ten at a time cannot be grouped at all. Kept low because this is the\n * one read on the surface that is measured in hundreds — see the button that\n * asks for it.\n */\nconst LANDING_PAGE_CEILING = 100\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nconst conversionsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A conversion is credited to the last campaign whose link the visitor ' +\n 'followed. The four kinds count different things about the same visits ' +\n 'and are never added together.',\n})\n\n/** The stored record plus the document name the reader listed it under. */\ntype ConversionRow = CampaignConversionRecord & { $id: string }\n\nconst day = (ms: unknown): string => {\n const value = Number(ms ?? 0)\n return Number.isFinite(value) && value > 0\n ? new Date(value).toLocaleString()\n : '—'\n}\n\nexport interface CampaignConversionsCardProps {\n hostId: string\n /** The marketing hub URL, for the link to each credited campaign. */\n basePath: string\n /**\n * One campaign's conversions rather than the whole site's.\n *\n * Set from `/marketing/conversions/{campaignId}`. It narrows the EMAIL list\n * only: a web-channel record carries no campaign id, so there is nothing\n * for it to be narrowed by — and the uncredited figure is withheld\n * entirely, because a conversion credited to nobody belongs to no campaign\n * and attributing the site's whole direct traffic to whichever campaign the\n * reader happens to be looking at is the exact inference this join refuses\n * to make.\n */\n campaignId?: string\n}\n\nexport function CampaignConversionsCard(props: CampaignConversionsCardProps) {\n const { hostId, basePath, campaignId } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const [kind, setKind] = useState<CampaignConversionKind>('form')\n\n const attributions = useCallback(\n () => collection(firestore, 'hosts', hostId, 'campaignAttributions'),\n [firestore, hostId],\n )\n\n /*==========================================\n * THE TWO LISTS.\n *\n * Both walk the collection in DOCUMENT-NAME order through `collectionPage`,\n * which is the console's one ordering decision: `orderBy` on a field\n * matches only documents that HAVE it, so ordering on a date would hide\n * rows rather than merely mis-order them, and an unordered `limit()` is a\n * pseudo-random sample whose gaps leave nothing to notice. The walk is\n * total — every row is reachable by paging — and it is deliberately NOT\n * newest-first, which the note under each table says out loud.\n *\n * `campaignId` narrows the email list when the reader arrived from one\n * campaign. It is never applied to the web list: a `utm_` label is not a\n * campaign id and a query pretending otherwise would return nothing while\n * reading as \"this campaign caused no web conversions\".\n *=========================================*/\n const emailList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n collectionPage(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', kind),\n where('campaignId', '==', campaignId),\n )\n : query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'email'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n const webList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n campaignId\n ? null\n : collectionPage(\n query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'web'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE UNCREDITED HALF.\n *\n * Two aggregation counts, and they are on the page by DEFAULT rather than\n * behind a button, because the tables above them are a list of the\n * successes: showing only those renders \"we credited nine of these\" as\n * \"nine of these happened\", which is the conclusion this surface exists to\n * stop a reader drawing. An aggregation is billed per thousand index\n * entries rather than per document, so the honest figure costs about as\n * much as one row.\n *\n * Withheld entirely when either count fails. Defaulting the total to the\n * attributed figure would render every conversion as attributed, which is\n * the most flattering wrong answer available here.\n *=========================================*/\n /*\n * A PATH STRING and a BOOLEAN, not an object.\n *\n * Both are effect dependencies, and an object rebuilt on every render\n * reopens the effect on every render — which here means paying for two\n * aggregation counts per render rather than per kind. Primitives compare by\n * value, so the reads happen when the question changes and not when React\n * re-runs the component.\n */\n const totalCollectionPath = useMemo((): string | null => {\n switch (kind) {\n case 'form':\n return `hosts/${hostId}/formSubmissions`\n case 'lead':\n return `hosts/${hostId}/leads`\n case 'booking':\n return `hosts/${hostId}/bookings`\n case 'contact':\n /*\n * ORG-SCOPED, and the coverage model is told so below. A contact is\n * shared across every site in the org while an attribution belongs to\n * one host, so this total counts contacts created on another site\n * that could never have been credited here.\n */\n return dataScope ? `${dataScope[0]}/${dataScope[1]}/contacts` : null\n default:\n return null\n }\n }, [kind, hostId, dataScope])\n\n /** The kind's records live outside this host, so the total over-counts. */\n const totalCrossesHosts = kind === 'contact'\n\n const [attributedCount, setAttributedCount] = useState<number | null>(null)\n const [totalCount, setTotalCount] = useState<number | null>(null)\n\n useEffect(() => {\n // A campaign-scoped view has no uncredited figure to compute — see the\n // prop's docblock — so it does not pay for one either.\n if (campaignId || !totalCollectionPath) {\n setAttributedCount(null)\n setTotalCount(null)\n return\n }\n let active = true\n setAttributedCount(null)\n setTotalCount(null)\n void getCountFromServer(query(attributions(), where('kind', '==', kind)))\n .then((snapshot) => {\n if (active) setAttributedCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n const segments = totalCollectionPath.split('/')\n void getCountFromServer(\n collection(firestore, segments[0], ...segments.slice(1)),\n )\n .then((snapshot) => {\n if (active) setTotalCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n return () => {\n active = false\n }\n }, [attributions, firestore, kind, campaignId, totalCollectionPath])\n\n const coverage = campaignConversionsCoverage({\n kind,\n attributed: attributedCount,\n total: totalCount,\n crossHostTotal: totalCrossesHosts,\n })\n\n /*==========================================\n * LANDING PAGES — a join, and an EXPENSIVE one, so it is asked for.\n *\n * The attribution record carries `refId`; a form submission carries the\n * `path` it was submitted from. Joining the two is the only landing-page\n * answer the stored data supports, and the notice below says plainly what\n * it is not: `path` is where the FORM was, not where the campaign link\n * landed, so a visitor who arrived on one page and submitted on another is\n * grouped under the second.\n *\n * Two reads deep and both bounded: a ceiling over the attributions, then\n * their submissions by document name in chunks of thirty. `getDocs` rather\n * than a listener — a grouping nobody is watching does not need to be kept\n * live, and a listener would go on costing after the reader looked away.\n *=========================================*/\n const [landingBusy, setLandingBusy] = useState(false)\n const [landingError, setLandingError] = useState<string | null>(null)\n const [landing, setLanding] = useState<{\n rows: { $id: string; path: string; conversions: number }[]\n /** Attributions read, which is the population the grouping describes. */\n read: number\n /** The ceiling bit: there are more credited submissions than were read. */\n truncated: boolean\n /** Of `read`, how many named a submission that could not be found. */\n missing: number\n } | null>(null)\n\n // A grouping computed for one kind describes that kind. Switching the mode\n // leaves it on screen labelled as something it is not.\n useEffect(() => {\n setLanding(null)\n setLandingError(null)\n }, [kind, campaignId])\n\n const groupByLandingPage = useCallback(async () => {\n if (landingBusy) return\n setLandingBusy(true)\n setLandingError(null)\n try {\n const credited = await getDocs(\n collectionCeiling(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', 'form'),\n where('campaignId', '==', campaignId),\n )\n : query(attributions(), where('kind', '==', 'form')),\n LANDING_PAGE_CEILING,\n ),\n )\n const { rows: credits, truncated } = ceilingedWindow(\n credited.docs.map((entry) => entry.data() as CampaignConversionRecord),\n LANDING_PAGE_CEILING,\n )\n const refIds = Array.from(\n new Set(\n credits\n .map((record) => String(record.refId ?? ''))\n .filter((id) => id.length > 0),\n ),\n )\n\n const submissions = collection(\n firestore,\n 'hosts',\n hostId,\n 'formSubmissions',\n )\n const paths = new Map<string, string>()\n for (let index = 0; index < refIds.length; index += ID_CHUNK) {\n const chunk = refIds.slice(index, index + ID_CHUNK)\n const found = await getDocs(\n query(submissions, where(documentId(), 'in', chunk)),\n )\n found.docs.forEach((entry) => {\n const path = String((entry.data() as any)?.path ?? '').trim()\n if (path) paths.set(entry.id, path)\n })\n }\n\n const counts = new Map<string, number>()\n let missing = 0\n refIds.forEach((refId) => {\n const path = paths.get(refId)\n if (!path) {\n /*\n * The submission was deleted, or it carries no path — an older one,\n * or a form posted by an integration that sent none. Counted and\n * REPORTED rather than filed under a placeholder page: a bar\n * labelled \"/\" that is really \"we do not know\" is the kind of row a\n * reader acts on.\n */\n missing += 1\n return\n }\n counts.set(path, (counts.get(path) ?? 0) + 1)\n })\n\n setLanding({\n rows: Array.from(counts.entries())\n .map(([path, conversions]) => ({ $id: path, path, conversions }))\n .sort(\n (a, b) =>\n b.conversions - a.conversions || a.path.localeCompare(b.path),\n ),\n read: refIds.length,\n truncated,\n missing,\n })\n } catch (error) {\n console.error(error)\n setLandingError('The landing pages could not be read')\n } finally {\n setLandingBusy(false)\n }\n }, [attributions, campaignId, firestore, hostId, landingBusy])\n\n const campaignHref = (row: ConversionRow) =>\n row.campaignId ? `${basePath}/campaigns/${row.campaignId}` : undefined\n\n const columns = useMemo(\n () => [\n {\n field: 'refId',\n headerName: 'Record',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }: any) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {String(row.refId ?? row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'campaign',\n headerName: 'Credited to',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => {\n const label = campaignTouchLabel(row) || '—'\n const href = campaignHref(row)\n return href ? (\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {label}\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap>\n {label}\n </Typography>\n )\n },\n },\n {\n field: 'convertedAtMs',\n headerName: 'Converted',\n width: 190,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {day(row.convertedAtMs)}\n </Typography>\n ),\n },\n listActionsColumn((row: any) => (\n <ListRowActions\n label={String(row.refId ?? row.$id)}\n items={[\n {\n key: 'campaign',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row),\n disabled: !campaignHref(row),\n /*\n * Shown disabled with the reason rather than hidden. A control\n * that vanishes and one that does not apply look identical from\n * the outside, and only one of them is honest — there is no\n * campaign document behind a `utm_` label to open.\n */\n disabledReason:\n 'This came from a tagged link rather than a campaign email, ' +\n 'so there is no campaign to open',\n },\n ]}\n />\n )),\n ],\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [kind, basePath],\n )\n\n const landingColumns = useMemo(\n () => [\n {\n field: 'path',\n headerName: 'Page the form was on',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.path}\n </Typography>\n ),\n },\n {\n field: 'conversions',\n headerName: 'Credited submissions',\n width: 190,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">\n {Number(row.conversions).toLocaleString()}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const kindCopy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n\n return (\n <CardDisplay\n header=\"Conversions\"\n subheader={\n campaignId\n ? 'Credited to this campaign'\n : 'What the campaigns caused, one kind at a time'\n }\n help={conversionsDocsHelp}\n HeaderProps={{\n action: campaignId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/conversions`}\n size=\"small\"\n color=\"primary\"\n >\n {'All conversions'}\n </Button>\n ) : undefined,\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/*\n THE KIND IS A MODE, NOT A COLUMN.\n\n One form submission by a new person writes a submission, a contact\n and a lead, so any view holding two kinds at once invites a total\n that counts one visit twice. Only one kind's rows can be on screen,\n which is what makes the addition unavailable rather than merely\n discouraged.\n */}\n <Stack spacing={1}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n color=\"primary\"\n value={kind}\n onChange={(_event, next) => {\n if (next) setKind(next as CampaignConversionKind)\n }}\n aria-label=\"Conversion kind\"\n >\n {CAMPAIGN_CONVERSION_KINDS.map((one) => (\n <ToggleButton key={one} value={one}>\n {CAMPAIGN_CONVERSION_KIND_COPY[one].label}\n </ToggleButton>\n ))}\n </ToggleButtonGroup>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'One kind at a time. The four count different things about the ' +\n 'same visits — one person filling in one form appears as a ' +\n 'submission, a contact and a lead — so they are never shown ' +\n 'together and never added.'}\n </Typography>\n </Stack>\n\n <Divider />\n\n {/*==========================================\n * WHAT IS NOT CREDITED, beside what is.\n *\n * The tables below list the successes. Without this strip the reader\n * has no way to see how much of their site's activity the campaigns\n * account for, and every campaign reads as more effective than it is.\n *=========================================*/}\n {campaignId ? (\n <Alert severity=\"info\">\n {'This is one campaign’s share. Conversions credited to no ' +\n 'campaign belong to no campaign, so they are not counted here ' +\n '— open all conversions for the site-wide figure.'}\n </Alert>\n ) : coverage ? (\n <Section title={`${kindCopy.label} on this site`}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Credited to a campaign\"\n value={coverage.attributed}\n note=\"from a campaign email or a tagged link\"\n />\n <Figure\n label=\"Not credited\"\n value={coverage.unattributed}\n note=\"arrived directly, or predate attribution\"\n />\n <Figure\n label={`${kindCopy.label} in total`}\n value={coverage.total}\n note={\n coverage.exact ? 'on this site' : 'on this site, all time'\n }\n />\n </Stack>\n {coverage.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n </Section>\n ) : (\n <Alert severity=\"info\">\n {'How many of these were credited to no campaign could not be ' +\n 'counted, so it is not shown. The lists below are the credited ' +\n 'ones only and are not a count of everything that happened.'}\n </Alert>\n )}\n\n <Divider />\n\n <Section title=\"From campaign emails\">\n <ListTable\n rows={emailList.rows}\n columns={columns as any}\n hideFooter\n onOpen={(_id, row) => {\n const href = campaignHref(row as ConversionRow)\n if (href) router.push(href)\n }}\n noRowsLabel=\"Nothing credited to a campaign email\"\n noRowsDescription={\n 'A conversion is credited when the visitor followed a link in ' +\n 'a campaign email before converting.'\n }\n />\n <ListPagination\n page={emailList.page}\n pageSize={emailList.pageSize}\n rowCount={emailList.rows.length}\n hasMore={emailList.hasMore}\n onPageChange={emailList.setPage}\n onPageSizeChange={emailList.setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Listed in the order they are stored, which is not the order ' +\n 'they happened in — every row is reachable by paging, but the ' +\n 'first page is not the most recent conversions.'}\n </Typography>\n </Section>\n\n {/*==========================================\n * THE WEB CHANNEL — its own list, and no rollup.\n *\n * A `utm_` label is text somebody typed into a URL. There is no\n * document behind it, no id, and no bound on how many distinct\n * values exist, so it gets no rollup under a campaign and no summary\n * keyed on the label: a map anybody who can vary a query string can\n * grow is a map that grows. The records stand on their own and this\n * lists them.\n *\n * Absent entirely on a campaign-scoped view, because these belong to\n * no campaign.\n *=========================================*/}\n {campaignId ? null : (\n <Section title=\"From tagged web links\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Credited to the utm_ label on the link the visitor followed. ' +\n 'These are listed one by one rather than grouped: the label ' +\n 'is free text with no campaign behind it, so there is ' +\n 'nothing to open and no fixed set to total.'}\n </Typography>\n <ListTable\n rows={webList.rows}\n columns={columns as any}\n hideFooter\n noRowsLabel=\"Nothing credited to a tagged link\"\n noRowsDescription={\n 'A conversion lands here when the visitor arrived on a URL ' +\n 'carrying utm_ parameters.'\n }\n />\n <ListPagination\n page={webList.page}\n pageSize={webList.pageSize}\n rowCount={webList.rows.length}\n hasMore={webList.hasMore}\n onPageChange={webList.setPage}\n onPageSizeChange={webList.setPageSize}\n />\n </Section>\n )}\n\n {/*==========================================\n * LANDING PAGES, on request.\n *\n * `refId` on the record, `path` on the submission: joining those is\n * the landing-page surface, and it is hundreds of reads, so it is a\n * button rather than something every reader of this page pays for.\n *=========================================*/}\n {kind === 'form' ? (\n <>\n <Divider />\n <Section title=\"Landing pages\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Groups credited form submissions by the page the form was ' +\n 'on. This reads up to ' +\n `${LANDING_PAGE_CEILING.toLocaleString()} attribution ` +\n 'records and their submissions, so it runs when you ask ' +\n 'for it rather than on every visit.'}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={landingBusy}\n startIcon={\n <MdiIcon path={mdiMapMarkerOutline.path} size={0.8} />\n }\n onClick={() => void groupByLandingPage()}\n >\n {landingBusy ? 'Reading…' : 'Group by landing page'}\n </Button>\n </Stack>\n {landingError ? (\n <Alert severity=\"warning\">{landingError}</Alert>\n ) : null}\n {landing ? (\n <Stack spacing={1}>\n {/*\n WHAT THIS CANNOT ANSWER, above the table rather than under\n it. Every line is a question a reader will otherwise\n assume it answered.\n */}\n <Alert severity=\"info\">\n {'This is the page each form sat on, not the page the ' +\n 'campaign link landed on — a visitor who arrived on ' +\n 'one page and submitted on another is counted under ' +\n 'the second. There is no conversion RATE here either: ' +\n 'the record carries no count of visits to a page from ' +\n 'a campaign, so there is no denominator to divide by. ' +\n 'Only form submissions carry a page at all, so leads, ' +\n 'contacts and bookings have no landing page.'}\n </Alert>\n {landing.truncated ? (\n <Alert severity=\"warning\">\n {`More than ${LANDING_PAGE_CEILING.toLocaleString()} ` +\n 'credited submissions exist. This groups the ' +\n `${landing.read.toLocaleString()} that were read, ` +\n 'which are not the most recent — they are the ones ' +\n 'that came first in storage order.'}\n </Alert>\n ) : null}\n {landing.missing ? (\n <Alert severity=\"info\">\n {`${landing.missing.toLocaleString()} of the ` +\n `${landing.read.toLocaleString()} credited ` +\n 'submissions are not grouped: the submission has ' +\n 'been deleted, or it recorded no page. They are ' +\n 'reported here rather than filed under a page they ' +\n 'may not have come from.'}\n </Alert>\n ) : null}\n <ListTable\n rows={landing.rows}\n columns={landingColumns as any}\n hideFooter\n noRowsLabel=\"No pages to group\"\n noRowsDescription=\"None of the credited submissions recorded a page.\"\n />\n <Stack direction=\"row\" spacing={1}>\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.read.toLocaleString()} credited submissions read`}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.rows.length.toLocaleString()} pages`}\n />\n </Stack>\n </Stack>\n ) : null}\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCampaignConversionsCard.displayName = 'CampaignConversionsCard'\n\nexport default CampaignConversionsCard\n"],"names":["pluginDocsHelp","mdiEyeOutline","mdiMapMarkerOutline","AppLink","CardDisplay","MdiIcon","ListRowActions","ListTable","listActionsColumn","ListPagination","Figure","Section","Alert","Button","Chip","Divider","Stack","ToggleButton","ToggleButtonGroup","Typography","collection","documentId","getCountFromServer","getDocs","query","where","useCallback","useEffect","useMemo","useState","useRouter","useFirestore","useOrgDataScope","usePagedCollection","ceilingedWindow","collectionCeiling","collectionPage","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","campaignConversionsCoverage","campaignTouchLabel","LANDING_PAGE_CEILING","ID_CHUNK","conversionsDocsHelp","anchor","excerpt","day","ms","value","Number","isFinite","Date","toLocaleString","CampaignConversionsCard","props","hostId","basePath","campaignId","firestore","router","scope","dataScope","kind","setKind","attributions","emailList","pageLimit","idField","webList","totalCollectionPath","totalCrossesHosts","attributedCount","setAttributedCount","totalCount","setTotalCount","active","then","snapshot","data","count","catch","segments","split","slice","coverage","attributed","total","crossHostTotal","landingBusy","setLandingBusy","landingError","setLandingError","landing","setLanding","groupByLandingPage","credited","rows","credits","truncated","docs","map","entry","refIds","Array","from","Set","record","String","refId","filter","id","length","submissions","paths","Map","index","chunk","found","forEach","path","trim","set","counts","missing","get","entries","conversions","$id","sort","a","b","localeCompare","read","error","console","campaignHref","row","undefined","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","color","label","href","onClick","event","stopPropagation","width","convertedAtMs","items","key","icon","size","disabled","disabledReason","landingColumns","align","headerAlign","kindCopy","header","subheader","help","HeaderProps","action","component","componentVariant","nativeButton","contentGutterX","contentGutterY","spacing","exclusive","onChange","_event","next","aria-label","one","severity","title","direction","useFlexGap","flexWrap","note","unattributed","exact","caveats","caveat","message","hideFooter","onOpen","_id","push","noRowsLabel","noRowsDescription","page","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize","startIcon","displayName"],"mappings":"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"}
@@ -18,7 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
19
  import { mdiDeleteOutline, mdiEyeOutline, mdiPaletteOutline, mdiPencilOutline } from "@aglyn/shared-data-mdi";
20
20
  import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
21
- import { Figure, RateRow, Section } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
21
+ import { Figure, RateRow, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
22
22
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
23
  import RowActionsMenu from "@aglyn/shared-ui-jsx/components/row-actions-menu.component";
24
24
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-detail-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPaletteOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n Alert,\n Box,\n Button,\n Chip,\n Divider,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteField,\n doc,\n documentId,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignRollup,\n campaignSendDisplay,\n campaignWindowState,\n emailListTimeMs,\n type CampaignAggregate,\n type CampaignSend,\n type EmailCampaign,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport CampaignComposer from './campaign-composer'\nimport CampaignEditDrawer, {\n type CampaignEditValues,\n} from './campaign-edit-drawer'\nimport {\n CampaignConversionsSection,\n CampaignDestinationsSection,\n CampaignRevenueSection,\n} from './campaign-reach-sections'\nimport CampaignMembersSection from './campaign-members-section'\nimport CampaignReportCard from './campaign-report-card'\nimport { useCampaignManageApi } from './use-campaign-send-api'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport { useCampaignTopicOptions } from './use-campaign-topic-options'\n\n/** How many of a campaign's emails the detail page enumerates. */\nconst CAMPAIGN_EMAIL_CEILING = 50\n\nconst detailDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A campaign groups the emails sent to its lists. The figures here are ' +\n 'the sum across those emails, and each one keeps its own report.',\n})\n\n/**\n * A rolled-up figure, in the shared `Figure`'s shape.\n *\n * The note is where an aggregate differs from a single send's count: a total\n * measured over fewer emails than the campaign holds says which part of the\n * campaign it describes, rather than presenting a partial sum as a complete\n * one. `null` still renders as an em dash and says \"not recorded\", which is\n * the property the shared component already owns.\n */\nconst rolled = (\n value: CampaignAggregate,\n): { value: number | null; note: string } => ({\n value: value.value,\n note:\n value.value !== null && value.recorded < value.sends\n ? `across ${value.recorded} of ${value.sends} emails`\n : `across ${value.sends} email${value.sends === 1 ? '' : 's'}`,\n})\n\nexport interface CampaignDetailCardProps {\n hostId: string\n /** A campaign container id, or a send id from before containers existed. */\n campaignId: string\n /** The marketing hub URL, for the way back to the campaigns list. */\n basePath: string\n}\n\n/**\n * ONE CAMPAIGN: its lists, what it caused, what it earned, where it sent\n * people, and the emails it did all of that with.\n *\n * ## Why the emails are a section rather than the page\n *\n * A campaign reaches people by email, which is not the same as a campaign\n * BEING a list of emails. It runs over a window, against a set of audiences,\n * and the pages its links land on, the money credited to it and the\n * conversions credited to it are facts about the campaign that no single\n * message in it holds. So the body is sectioned — what it caused, what it\n * earned, where it sent people, then the mail's own figures, then its emails\n * — and each section names the population it describes.\n *\n * The ORDER is the part that says which of those the page is about. Delivery,\n * engagement and rates are mechanics of a message; a page that met the reader\n * with them and reached the outcomes only after scrolling was a mail report\n * whatever its headings claimed. `campaign-report-card.tsx` — the page a\n * campaign of one email gets — carries the same two outcome headings first,\n * for the same reason and in the same words.\n *\n * The three middle sections live in `campaign-reach-sections.tsx`, which is\n * also where the reason a campaign cannot simply DECLARE the screens and\n * forms it runs across is written down: no such edge is stored, attribution\n * is the mechanism, and all three sections join on this campaign's send ids.\n *\n * ## Why this resolves two kinds of id\n *\n * The id in `campaigns/{id}` may name a campaign CONTAINER or a single SEND,\n * and which it is is answered by reading: a container at `emailCampaigns/{id}`\n * renders this page, and anything else falls through to that send's own\n * report.\n *\n * Not every send belongs to a container. One written before campaigns grouped\n * their emails names none, its own id is the only id the URL can carry, and\n * that id is also inside the HMAC of every unsubscribe footer already\n * delivered — so a send id has to go on addressing a page rather than a 404.\n * The fall-through is what makes the container additive: no send document is\n * rewritten and no id is reassigned.\n *\n * The extra read is one document, and it buys the guarantee that a pasted\n * report link resolves whichever kind of id it carries.\n */\nexport function CampaignDetailCard(props: CampaignDetailCardProps) {\n const { hostId, campaignId, basePath } = props\n const firestore = useFirestore()\n // The sibling hub, for the two records this page links to but does not own:\n // each message's report and the template it was built from.\n const emailsHub = useEmailsHubPath()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n const manageApi = useCampaignManageApi(hostId)\n const [composing, setComposing] = useState(false)\n const [editing, setEditing] = useState(false)\n const [saving, setSaving] = useState(false)\n const [saveError, setSaveError] = useState<string | null>(null)\n const [deleting, setDeleting] = useState(false)\n\n const { data: campaign, status } = useFirestoreDoc<EmailCampaign>(\n () => doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId),\n [firestore, hostId, campaignId],\n )\n\n /*\n * ONLY WHEN THE ID NAMES A CAMPAIGN.\n *\n * Both queries below are gated on the container having been found, because\n * the other half of this component is the single-send report — and that\n * screen's whole design is that it reads two documents whatever the size of\n * the audience. Listening unconditionally would add two collection reads to\n * every legacy report URL, which is the cost the report route was split out\n * to avoid. Not listening is what a null builder means.\n *\n * The campaign's emails come back by the field each send carries: equality\n * on one field ordered by DOCUMENT NAME, which Firestore's automatic\n * single-field index serves without a composite one. Ordering on a date\n * would need that index AND would drop every send missing that particular\n * date — a sent send carries `sentAt`, a scheduled one `sendAtMs`, and\n * neither is on both. Sorted by date below, over a window this page holds\n * whole.\n *\n * One document past the ceiling, so \"this campaign has more emails than are\n * listed\" is a fact rather than a guess.\n */\n const { data: sendDocs } = useFirestoreCollection<any>(\n () =>\n campaign\n ? query(\n collection(firestore, 'hosts', hostId, 'campaigns'),\n where('emailCampaignId', '==', campaignId),\n orderBy(documentId()),\n limit(CAMPAIGN_EMAIL_CEILING + 1),\n )\n : null,\n [firestore, hostId, campaignId, Boolean(campaign)],\n { idField: '$id' },\n )\n\n const { data: listDocs } = useFirestoreCollection<any>(\n () =>\n campaign && dataScope\n ? query(\n collection(firestore, dataScope[0], dataScope[1], 'lists'),\n limit(50),\n )\n : null,\n [firestore, dataScope, Boolean(campaign)],\n { idField: '$id' },\n )\n\n /*\n * The org's topic catalog, read only while the EDIT DRAWER is open.\n *\n * It fills one picker in that drawer and is drawn nowhere else on this\n * page, so reading it on mount would charge every reader who came for the\n * campaign's numbers for a field they are not looking at. The composer\n * carries its own read of the same catalog, behind its own button, for the\n * same reason.\n */\n const { topics, source: topicSource } = useCampaignTopicOptions(hostId, {\n enabled: editing,\n })\n const topicOptions = useMemo(\n () =>\n topics.map((topic) => ({\n value: topic.id,\n label: topic.name,\n })),\n [topics],\n )\n\n const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow<any>(\n sendDocs,\n CAMPAIGN_EMAIL_CEILING,\n )\n /*\n * Newest first on the time each email SITS at — its send time where it has\n * one, its creation where it does not. A draft has neither `sentAt` nor\n * `sendAtMs`, so ordering on the send time alone gave every draft the key 0\n * and filed the email a merchant is in the middle of writing below mail\n * sent years ago.\n */\n const sends = useMemo(\n () =>\n [...(readSends as CampaignSend[])].sort(\n (a, b) => emailListTimeMs(b) - emailListTimeMs(a),\n ),\n [readSends],\n )\n const rollup = useMemo(() => campaignRollup(sends), [sends])\n // The ids the two sections beneath the figures join on. Derived from the\n // window this card already holds, so neither of them reads the send list\n // again to find out which emails the campaign has.\n const sendIds = useMemo(\n () => sends.map((send) => String(send.$id)).filter(Boolean),\n [sends],\n )\n\n /*==========================================\n * SAVING THE CONTAINER, WITH THE CLIENT SDK.\n *\n * The same door the create drawer already uses. `emailCampaigns` is\n * deliberately outside the security rules' server-only exclusion list — a\n * container holds no counter, no consent record and no entitlement input,\n * so an editor who writes one cannot buy themselves a send — and giving the\n * edit a route of its own would be a second writer to keep in step with the\n * create.\n *\n * `updateDoc` rather than a merge-set, because this is an edit of a\n * document that exists: a set would CREATE a campaign at this id for\n * somebody who deleted it in another tab, which is the shape the delivery\n * webhook is careful about on the send collection for the same reason.\n *\n * A cleared date is written as `null` — the absence the model already\n * spells — and a cleared topic REMOVES the field, because the model has no\n * null topic and `campaignTopicId` is handed to the composer as one.\n *=========================================*/\n const handleSave = useCallback(\n async (values: CampaignEditValues) => {\n if (saving) return\n setSaving(true)\n setSaveError(null)\n try {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId),\n {\n name: values.name,\n startAtMs: values.startAtMs,\n endAtMs: values.endAtMs,\n listIds: values.listIds,\n topicId: values.topicId ? values.topicId : deleteField(),\n },\n )\n setEditing(false)\n enqueueSnackbar('Campaign updated', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n setSaveError('The campaign could not be saved')\n } finally {\n setSaving(false)\n }\n },\n [campaignId, enqueueSnackbar, firestore, hostId, saving],\n )\n\n /*==========================================\n * DELETING THE CONTAINER, WHICH IS NOT DELETING ITS MAIL.\n *\n * The route detaches every email first and then removes the campaign, so\n * what a merchant is agreeing to is the grouping going away — the emails\n * stay, keep their ids, keep their reports, and read afterwards as single\n * sends. Their unsubscribe links carry those ids inside an HMAC and go on\n * working, which is why the container can never take them with it.\n *\n * The confirmation says the count and says what a delete does NOT do. A\n * scheduled email inside the campaign still goes out at its time — stopping\n * somebody's mail is `cancel`, on that email's own page — and a merchant\n * who reads \"delete campaign\" as \"stop the campaign\" has to be told\n * otherwise before they press it, not after.\n *=========================================*/\n const handleDelete = useCallback(async () => {\n if (deleting) return\n /*\n * Emails with mail still to go: the ones waiting for their time AND the\n * ones part way through an audience larger than one batch. Both are what\n * a merchant needs warning about, and counting only `scheduled` would\n * miss the case that needs it most — a campaign that is delivering right\n * now is stored as `scheduled` too, but `rollup.scheduled` is the\n * narrower reading that excludes it.\n */\n const unfinished = rollup.scheduled + rollup.sending\n const agreed = await confirm({\n title: 'Delete this campaign?',\n description:\n (sends.length\n ? `The ${sends.length.toLocaleString()} ` +\n `${sends.length === 1 ? 'email' : 'emails'} in it are kept — ` +\n 'each keeps its own report and its unsubscribe links, and they ' +\n 'appear in the campaigns list as single sends. '\n : 'It holds no emails. ') +\n (unfinished\n ? `${unfinished.toLocaleString()} of them ${\n unfinished === 1 ? 'is' : 'are'\n } still going out or still due, and deleting the campaign does ` +\n 'not stop that — the sender picks each one up from its own ' +\n 'record. Stop a send from its own page. '\n : '') +\n 'Any screens, forms and contacts assigned to it come out of it and ' +\n 'are otherwise untouched. Only the campaign itself goes.',\n confirmationText: 'Delete campaign',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDeleting(true)\n try {\n const { response, payload } = await manageApi({\n action: 'deleteCampaign',\n campaignId,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The campaign could not be deleted',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Campaign deleted', { variant: 'success', persist: false })\n router.push(`${basePath}/campaigns`)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The campaign could not be deleted', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDeleting(false)\n }\n }, [\n basePath,\n campaignId,\n confirm,\n deleting,\n enqueueSnackbar,\n manageApi,\n rollup.scheduled,\n rollup.sending,\n router,\n sends.length,\n ])\n\n // Still settling. Falling through to the send report here would flash \"this\n // campaign could not be loaded\" on every open of a campaign that exists.\n if (!campaign && status === 'loading') return null\n\n /*\n * NOT A CONTAINER — so it is a send id, and the send's own report is what\n * this URL has always meant.\n */\n if (!campaign) {\n return (\n <CampaignReportCard\n hostId={hostId}\n campaignId={campaignId}\n basePath={basePath}\n />\n )\n }\n\n const listIds = campaign.listIds ?? []\n const names = new Map<string, string>(\n (listDocs ?? []).map((list: any) => [\n String(list.$id),\n String(list.name ?? list.$id),\n ]),\n )\n /*==========================================\n * ONE EMAIL'S PAGE — the same one every other route to that record opens.\n *\n * The Emails hub's `emails/{sendId}`, which is where the messages list and\n * a template's messages table also send a reader. One record has one page:\n * a link that resolved somewhere else depending on which list it was\n * clicked in would give the same message two pages, and the reader would\n * have no way to tell which one they were on.\n *\n * `campaigns/{sendId}` on THIS hub goes on resolving as well, through the\n * fall-through at the top of this file. That is not a convenience — every\n * unsubscribe footer already delivered carries `cid={sendId}`, those\n * messages sit in inboxes forever, and merchants paste report URLs into\n * their own mail. Which link the console GENERATES and which URLs ANSWER\n * are two separate questions, and only the first is decided here.\n *\n * Nor is a redirect the answer to the second. A redirect would be another\n * thing to be wrong about an id that is inside an HMAC; a page that keeps\n * working has nothing to get wrong, and it costs nothing to leave standing.\n *=========================================*/\n const sendHref = (send: CampaignSend) =>\n emailsHub ? `${emailsHub}/messages/${send.$id}` : undefined\n\n /**\n * What one of this campaign's emails can be opened into.\n *\n * The same two the Emails hub offers, less the campaign — this page IS the\n * campaign. Both destinations are records the Emails console owns, so they\n * are built from {@link useEmailsHubPath} rather than from this surface's\n * own `basePath`.\n *\n * A message composed inline was built from no template, so that entry is\n * shown DISABLED with the reason rather than hidden: an absent control and\n * an inapplicable one look identical, and only one is honest.\n */\n const sendActions = (send: CampaignSend): RowActionsMenuItem[] => {\n const templateScreenId = String((send as any).templateScreenId ?? '')\n return [\n {\n key: 'details',\n label: 'Open report',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: sendHref(send),\n disabled: !sendHref(send),\n disabledReason: 'This site’s console URL has not resolved yet',\n },\n {\n key: 'template',\n label: 'Open its template',\n icon: <MdiIcon path={mdiPaletteOutline.path} size={0.8} />,\n href:\n templateScreenId && emailsHub\n ? `${emailsHub}/templates/${templateScreenId}`\n : undefined,\n disabled: !templateScreenId || !emailsHub,\n disabledReason: templateScreenId\n ? 'This site’s console URL has not resolved yet'\n : 'This message was not built from a template',\n },\n ]\n }\n\n /*\n * The campaign's emails, on the surface's own row grammar: the row opens\n * the email's report, its subject is also a real link so it can be\n * middle-clicked and copied, and the trailing cluster holds the actions.\n * The window is one the card already holds, so the grid pages it.\n */\n const sendColumns: GridColDef<CampaignSend>[] = [\n {\n field: 'subject',\n headerName: 'Subject',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, send) => send.subject || send.$id,\n /*\n Plain text until the Emails hub resolves: a link with no destination\n is worse than none, and this cell is the message's subject either way.\n */\n renderCell: ({ row: send, value }) => {\n const href = sendHref(send)\n return href ? (\n <AppLink\n href={href}\n // The row's own handler would fire too and push the same route\n // twice — one history entry per back press.\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {value}\n </AppLink>\n ) : (\n value\n )\n },\n },\n {\n /*\n WHAT THE EMAIL IS DOING, not the field it stores.\n\n This branched on the status, which reads \"Scheduled\" about an email\n that has delivered five hundred messages and is between batches — the\n stored state the processor claims to resume it. The derivation reads\n the counters beside the status; the due date is appended only where\n there is genuinely nothing delivered yet, which is the one case a time\n answers.\n */\n field: 'state',\n headerName: 'State',\n width: 220,\n valueGetter: (_value, send) => {\n const display = campaignSendDisplay(send)\n return display.state === 'pending' && send.sendAtMs\n ? `${display.label} · ${new Date(send.sendAtMs).toLocaleString()}`\n : display.label\n },\n renderCell: ({ row: send, value }) => {\n const state = campaignSendDisplay(send).state\n return (\n <Chip\n size=\"small\"\n color={\n state === 'sending' ? 'info' : state === 'stopped' ? 'warning' : undefined\n }\n label={value}\n />\n )\n },\n },\n {\n /*\n Sent over addressed, on one line: the two figures only mean anything\n beside each other, and the gap between them is the suppression list\n doing its work.\n */\n field: 'sent',\n headerName: 'Sent',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 110,\n valueGetter: (_value, send) => Number(send.stats?.sent ?? 0),\n renderCell: ({ row: send }) =>\n `${send.stats?.sent ?? 0}/${send.stats?.recipients ?? 0}`,\n },\n {\n field: 'opens',\n headerName: 'Opens',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n valueGetter: (_value, send) => Number(send.stats?.opens ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n },\n {\n field: 'clicks',\n headerName: 'Clicks',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n valueGetter: (_value, send) => Number(send.stats?.clicks ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n },\n listActionsColumn(\n (send: CampaignSend) => (\n <ListRowActions\n label={String(send.subject || send.$id)}\n items={sendActions(send)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n const windowState = campaignWindowState(campaign, Date.now())\n const start = campaign.startAtMs\n ? new Date(campaign.startAtMs).toLocaleDateString()\n : ''\n const end = campaign.endAtMs\n ? new Date(campaign.endAtMs).toLocaleDateString()\n : ''\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Campaign'}\n subheader={\n start || end\n ? `${start || 'Open'} – ${end || 'open-ended'}`\n : 'No campaign dates'\n }\n help={detailDocsHelp}\n HeaderProps={{\n /*\n NAVIGATION, THEN THE OVERFLOW.\n\n Editing and deleting a campaign live here rather than on the\n campaigns table, because a record is edited on its own page in this\n console. `RowActionsMenu` is named for table rows and its rendering\n is not — a kebab and a menu whose items carry `destructive` and\n `disabled` — so reusing it is what keeps this overflow behaving like\n every other one on the surface, including the email page's.\n */\n action: (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'All campaigns'}\n </Button>\n <RowActionsMenu\n label={campaign.name || 'Campaign'}\n items={[\n {\n key: 'edit',\n label: 'Edit campaign',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n onClick: () => {\n setSaveError(null)\n setEditing(true)\n },\n },\n {\n key: 'delete',\n label: 'Delete campaign',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: deleting,\n disabledReason: 'This campaign is being deleted',\n onClick: () => void handleDelete(),\n },\n ]}\n />\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip size=\"small\" label={windowState} />\n {listIds.length ? (\n listIds.map((id) => (\n <Chip\n key={id}\n size=\"small\"\n variant=\"outlined\"\n label={names.get(id) ?? id}\n />\n ))\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No lists assigned — its emails pick their own audience'}\n </Typography>\n )}\n </Stack>\n\n <Divider />\n {/*\n A CAMPAIGN IS NOT ONLY ITS MAIL, and it leads with the part that\n is not.\n\n What the campaign CAUSED and where it SENT people are facts about the\n campaign; delivery, engagement and rates are mechanics of the\n messages it used, and the list of those messages is one section among\n several rather than the page. Ordering the mail first said the\n opposite in the only way a page can — by what a reader meets before\n they scroll — and a campaign of ONE email says it loudest, which is\n why `campaign-report-card.tsx` carries these two headings in this\n order too.\n\n All three sections join on the campaign's own send ids, which is the\n only handle the attribution, revenue and click-report collections\n offer — see `campaign-reach-sections.tsx` for why no other join\n exists, and why the web channel is named there rather than quietly\n omitted.\n */}\n <CampaignConversionsSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n basePath={basePath}\n />\n\n <Divider />\n {/*\n WHAT IT EARNED, between what it caused and where it sent people —\n the order `campaign-report-card.tsx` puts these three in, so a\n campaign of one email and a campaign of ten answer the merchant's\n questions in the same sequence.\n\n How many messages a container holds is not a fact about whether its\n revenue is knowable, so the money question is answered here whatever\n the count. Reading it costs one record per email, so it asks first —\n see `campaign-reach-sections.tsx` for that bargain, and for why the\n currencies are reported apart and never totalled.\n */}\n <CampaignRevenueSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n />\n\n <Divider />\n <CampaignDestinationsSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n />\n\n <Divider />\n {/*\n WHAT THE MERCHANT SAID BELONGS HERE, under what the visitors did.\n\n Everything above this line is measured — the conversions, the money\n and the pages the mail's own links pointed at, all joined on this\n campaign's send ids. This section is the only one on the page that\n reports a DECLARATION: a screen or a form carrying this campaign's\n id in its own document because somebody put it there.\n\n It sits after the evidence deliberately. A reader who met the\n assigned list first would take it for the campaign's reach, and the\n two are not the same list — a landing page can be assigned to a\n campaign no email ever linked to, and a campaign's mail can drive\n traffic to a page assigned to nothing.\n\n The dates go with it. A form's counters are lifetime, so a campaign\n with a window has to be able to confine them to its own months; a\n campaign without one is told so, and says its figures are lifetime\n rather than implying they are its own.\n */}\n <CampaignMembersSection\n hostId={hostId}\n campaignId={campaignId}\n startAtMs={campaign.startAtMs}\n endAtMs={campaign.endAtMs}\n />\n\n <Divider />\n {/*\n THE MAIL'S OWN FIGURES, under the outcomes rather than over them.\n\n The sum over the campaign's emails, not a second set of counters.\n Nothing is stored per campaign: a rollup document would have to be\n kept true against every delivery event of every email in it, and the\n numbers it duplicates are already on the sends this page reads.\n\n Drawn by the shared figures so a campaign's rate and a single\n message's read the same way — denominator named on the line, and an\n em dash rather than a zero where nothing was recorded.\n */}\n <Section title=\"The mail, across this campaign\">\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure label=\"Addressed\" {...rolled(rollup.addressed)} />\n <Figure label=\"Sent\" {...rolled(rollup.sent)} />\n <Figure label=\"Delivered\" {...rolled(rollup.delivered)} />\n <Figure label=\"Opens\" {...rolled(rollup.opens)} />\n <Figure label=\"Clicks\" {...rolled(rollup.clicks)} />\n <Figure label=\"Unsubscribed\" {...rolled(rollup.unsubscribes)} />\n </Stack>\n </Section>\n <Stack spacing={0.5}>\n <RateRow label=\"Open rate\" rate={rollup.openRate} />\n <RateRow label=\"Click rate\" rate={rollup.clickRate} />\n <RateRow label=\"Unsubscribe rate\" rate={rollup.unsubscribeRate} />\n </Stack>\n\n <Divider />\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', justifyContent: 'space-between' }}\n >\n {/*\n The section rail's own heading style, so this reads as one section\n among the others rather than as the page's subject.\n */}\n <Typography variant=\"overline\" color=\"text.secondary\">\n {`Emails (${sends.length})`}\n </Typography>\n <Button\n size=\"small\"\n variant={composing ? 'text' : 'contained'}\n onClick={() => setComposing((open) => !open)}\n >\n {composing ? 'Close composer' : 'Write an email'}\n </Button>\n </Stack>\n {sends.length ? (\n <Stack spacing={0.5}>\n <ListTable\n aria-label=\"The campaign's emails\"\n rows={sends}\n columns={sendColumns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, send) => {\n const href = sendHref(send)\n if (href) router.push(href)\n }}\n />\n {sendsTruncated ? (\n <Alert severity=\"info\">\n {`Showing ${CAMPAIGN_EMAIL_CEILING} of this campaign's ` +\n 'emails. It has sent more — the ones listed are not ' +\n 'necessarily the most recent, because a send carries no ' +\n 'date field that every writer stamps, and the figures above ' +\n 'cover the emails listed.'}\n </Alert>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails in this campaign yet.'}\n </Typography>\n )}\n\n {/*\n THE COMPOSER, ON DEMAND.\n\n It opens listens of its own — the site's email designs, the org's\n lists and segments, the running experiments — and this page is also\n where somebody arrives to read numbers. Mounting it unasked would\n make every reader pay for a composer they did not open, which is the\n same cost the campaign report was split onto its own route to avoid.\n */}\n {composing ? (\n <Box>\n <Divider sx={{ mb: 2 }} />\n <CampaignComposer\n hostId={hostId}\n emailCampaignId={campaignId}\n campaignListIds={listIds}\n campaignTopicId={campaign.topicId}\n onSent={() => setComposing(false)}\n />\n </Box>\n ) : null}\n </Stack>\n {/*\n THE EDIT DRAWER, on the campaign's own page.\n\n Its list picker reads the same `listDocs` the chips above already\n cost, so opening it adds one read — the topic catalog — and only\n while it is open.\n */}\n {/* Draws nothing: the zone the topic catalog's owner answers through. */}\n {topicSource}\n <CampaignEditDrawer\n open={editing}\n onClose={() => setEditing(false)}\n campaign={campaign}\n lists={(listDocs ?? []).map((list: any) => ({\n value: String(list.$id),\n label: String(list.name ?? list.$id),\n }))}\n topics={topicOptions}\n busy={saving}\n error={saveError}\n onSubmit={(values) => void handleSave(values)}\n />\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the campaign; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={campaign.name || campaignId} />\n {card}\n </>\n )\n}\nCampaignDetailCard.displayName = 'CampaignDetailCard'\n\nexport default CampaignDetailCard\n"],"names":["mdiDeleteOutline","mdiEyeOutline","mdiPaletteOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","Figure","RateRow","Section","ListRowActions","ListTable","listActionsColumn","RowActionsMenu","TABLE_ROW_HEIGHT","ceilingedWindow","PageHeaderRecord","pluginDocsHelp","Alert","Box","Button","Chip","Divider","Stack","Typography","collection","deleteField","doc","documentId","limit","orderBy","query","updateDoc","where","useCallback","useMemo","useState","useRouter","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useOrgDataScope","campaignRollup","campaignSendDisplay","campaignWindowState","emailListTimeMs","CampaignComposer","CampaignEditDrawer","CampaignConversionsSection","CampaignDestinationsSection","CampaignRevenueSection","CampaignMembersSection","CampaignReportCard","useCampaignManageApi","useEmailsHubPath","useCampaignTopicOptions","CAMPAIGN_EMAIL_CEILING","detailDocsHelp","anchor","excerpt","rolled","value","note","recorded","sends","CampaignDetailCard","props","campaign","hostId","campaignId","basePath","firestore","emailsHub","router","scope","dataScope","confirm","enqueueSnackbar","manageApi","composing","setComposing","editing","setEditing","saving","setSaving","saveError","setSaveError","deleting","setDeleting","data","status","sendDocs","Boolean","idField","listDocs","topics","source","topicSource","enabled","topicOptions","map","topic","id","label","name","rows","readSends","truncated","sendsTruncated","sort","a","b","rollup","sendIds","send","String","$id","filter","handleSave","values","startAtMs","endAtMs","listIds","topicId","variant","persist","error","console","handleDelete","unfinished","scheduled","sending","agreed","title","description","length","toLocaleString","confirmationText","then","catch","response","payload","action","ok","allowDuplicate","push","names","Map","list","sendHref","undefined","sendActions","templateScreenId","key","icon","path","size","href","disabled","disabledReason","sendColumns","field","headerName","flex","minWidth","valueGetter","_value","subject","renderCell","row","onClick","event","stopPropagation","width","display","state","sendAtMs","Date","color","type","align","headerAlign","Number","stats","sent","recipients","opens","valueFormatter","clicks","items","windowState","now","start","toLocaleDateString","end","card","header","subheader","help","HeaderProps","direction","spacing","sx","alignItems","component","componentVariant","nativeButton","destructive","contentGutterX","contentGutterY","get","flexWrap","addressed","delivered","unsubscribes","rate","openRate","clickRate","unsubscribeRate","justifyContent","open","aria-label","columns","rowHeight","onOpen","_id","severity","mb","emailCampaignId","campaignListIds","campaignTopicId","onSent","onClose","lists","busy","onSubmit","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-detail-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPaletteOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n Alert,\n Box,\n Button,\n Chip,\n Divider,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteField,\n doc,\n documentId,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignRollup,\n campaignSendDisplay,\n campaignWindowState,\n emailListTimeMs,\n type CampaignAggregate,\n type CampaignSend,\n type EmailCampaign,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport CampaignComposer from './campaign-composer'\nimport CampaignEditDrawer, {\n type CampaignEditValues,\n} from './campaign-edit-drawer'\nimport {\n CampaignConversionsSection,\n CampaignDestinationsSection,\n CampaignRevenueSection,\n} from './campaign-reach-sections'\nimport CampaignMembersSection from './campaign-members-section'\nimport CampaignReportCard from './campaign-report-card'\nimport { useCampaignManageApi } from './use-campaign-send-api'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport { useCampaignTopicOptions } from './use-campaign-topic-options'\n\n/** How many of a campaign's emails the detail page enumerates. */\nconst CAMPAIGN_EMAIL_CEILING = 50\n\nconst detailDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A campaign groups the emails sent to its lists. The figures here are ' +\n 'the sum across those emails, and each one keeps its own report.',\n})\n\n/**\n * A rolled-up figure, in the shared `Figure`'s shape.\n *\n * The note is where an aggregate differs from a single send's count: a total\n * measured over fewer emails than the campaign holds says which part of the\n * campaign it describes, rather than presenting a partial sum as a complete\n * one. `null` still renders as an em dash and says \"not recorded\", which is\n * the property the shared component already owns.\n */\nconst rolled = (\n value: CampaignAggregate,\n): { value: number | null; note: string } => ({\n value: value.value,\n note:\n value.value !== null && value.recorded < value.sends\n ? `across ${value.recorded} of ${value.sends} emails`\n : `across ${value.sends} email${value.sends === 1 ? '' : 's'}`,\n})\n\nexport interface CampaignDetailCardProps {\n hostId: string\n /** A campaign container id, or a send id from before containers existed. */\n campaignId: string\n /** The marketing hub URL, for the way back to the campaigns list. */\n basePath: string\n}\n\n/**\n * ONE CAMPAIGN: its lists, what it caused, what it earned, where it sent\n * people, and the emails it did all of that with.\n *\n * ## Why the emails are a section rather than the page\n *\n * A campaign reaches people by email, which is not the same as a campaign\n * BEING a list of emails. It runs over a window, against a set of audiences,\n * and the pages its links land on, the money credited to it and the\n * conversions credited to it are facts about the campaign that no single\n * message in it holds. So the body is sectioned — what it caused, what it\n * earned, where it sent people, then the mail's own figures, then its emails\n * — and each section names the population it describes.\n *\n * The ORDER is the part that says which of those the page is about. Delivery,\n * engagement and rates are mechanics of a message; a page that met the reader\n * with them and reached the outcomes only after scrolling was a mail report\n * whatever its headings claimed. `campaign-report-card.tsx` — the page a\n * campaign of one email gets — carries the same two outcome headings first,\n * for the same reason and in the same words.\n *\n * The three middle sections live in `campaign-reach-sections.tsx`, which is\n * also where the reason a campaign cannot simply DECLARE the screens and\n * forms it runs across is written down: no such edge is stored, attribution\n * is the mechanism, and all three sections join on this campaign's send ids.\n *\n * ## Why this resolves two kinds of id\n *\n * The id in `campaigns/{id}` may name a campaign CONTAINER or a single SEND,\n * and which it is is answered by reading: a container at `emailCampaigns/{id}`\n * renders this page, and anything else falls through to that send's own\n * report.\n *\n * Not every send belongs to a container. One written before campaigns grouped\n * their emails names none, its own id is the only id the URL can carry, and\n * that id is also inside the HMAC of every unsubscribe footer already\n * delivered — so a send id has to go on addressing a page rather than a 404.\n * The fall-through is what makes the container additive: no send document is\n * rewritten and no id is reassigned.\n *\n * The extra read is one document, and it buys the guarantee that a pasted\n * report link resolves whichever kind of id it carries.\n */\nexport function CampaignDetailCard(props: CampaignDetailCardProps) {\n const { hostId, campaignId, basePath } = props\n const firestore = useFirestore()\n // The sibling hub, for the two records this page links to but does not own:\n // each message's report and the template it was built from.\n const emailsHub = useEmailsHubPath()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n const manageApi = useCampaignManageApi(hostId)\n const [composing, setComposing] = useState(false)\n const [editing, setEditing] = useState(false)\n const [saving, setSaving] = useState(false)\n const [saveError, setSaveError] = useState<string | null>(null)\n const [deleting, setDeleting] = useState(false)\n\n const { data: campaign, status } = useFirestoreDoc<EmailCampaign>(\n () => doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId),\n [firestore, hostId, campaignId],\n )\n\n /*\n * ONLY WHEN THE ID NAMES A CAMPAIGN.\n *\n * Both queries below are gated on the container having been found, because\n * the other half of this component is the single-send report — and that\n * screen's whole design is that it reads two documents whatever the size of\n * the audience. Listening unconditionally would add two collection reads to\n * every legacy report URL, which is the cost the report route was split out\n * to avoid. Not listening is what a null builder means.\n *\n * The campaign's emails come back by the field each send carries: equality\n * on one field ordered by DOCUMENT NAME, which Firestore's automatic\n * single-field index serves without a composite one. Ordering on a date\n * would need that index AND would drop every send missing that particular\n * date — a sent send carries `sentAt`, a scheduled one `sendAtMs`, and\n * neither is on both. Sorted by date below, over a window this page holds\n * whole.\n *\n * One document past the ceiling, so \"this campaign has more emails than are\n * listed\" is a fact rather than a guess.\n */\n const { data: sendDocs } = useFirestoreCollection<any>(\n () =>\n campaign\n ? query(\n collection(firestore, 'hosts', hostId, 'campaigns'),\n where('emailCampaignId', '==', campaignId),\n orderBy(documentId()),\n limit(CAMPAIGN_EMAIL_CEILING + 1),\n )\n : null,\n [firestore, hostId, campaignId, Boolean(campaign)],\n { idField: '$id' },\n )\n\n const { data: listDocs } = useFirestoreCollection<any>(\n () =>\n campaign && dataScope\n ? query(\n collection(firestore, dataScope[0], dataScope[1], 'lists'),\n limit(50),\n )\n : null,\n [firestore, dataScope, Boolean(campaign)],\n { idField: '$id' },\n )\n\n /*\n * The org's topic catalog, read only while the EDIT DRAWER is open.\n *\n * It fills one picker in that drawer and is drawn nowhere else on this\n * page, so reading it on mount would charge every reader who came for the\n * campaign's numbers for a field they are not looking at. The composer\n * carries its own read of the same catalog, behind its own button, for the\n * same reason.\n */\n const { topics, source: topicSource } = useCampaignTopicOptions(hostId, {\n enabled: editing,\n })\n const topicOptions = useMemo(\n () =>\n topics.map((topic) => ({\n value: topic.id,\n label: topic.name,\n })),\n [topics],\n )\n\n const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow<any>(\n sendDocs,\n CAMPAIGN_EMAIL_CEILING,\n )\n /*\n * Newest first on the time each email SITS at — its send time where it has\n * one, its creation where it does not. A draft has neither `sentAt` nor\n * `sendAtMs`, so ordering on the send time alone gave every draft the key 0\n * and filed the email a merchant is in the middle of writing below mail\n * sent years ago.\n */\n const sends = useMemo(\n () =>\n [...(readSends as CampaignSend[])].sort(\n (a, b) => emailListTimeMs(b) - emailListTimeMs(a),\n ),\n [readSends],\n )\n const rollup = useMemo(() => campaignRollup(sends), [sends])\n // The ids the two sections beneath the figures join on. Derived from the\n // window this card already holds, so neither of them reads the send list\n // again to find out which emails the campaign has.\n const sendIds = useMemo(\n () => sends.map((send) => String(send.$id)).filter(Boolean),\n [sends],\n )\n\n /*==========================================\n * SAVING THE CONTAINER, WITH THE CLIENT SDK.\n *\n * The same door the create drawer already uses. `emailCampaigns` is\n * deliberately outside the security rules' server-only exclusion list — a\n * container holds no counter, no consent record and no entitlement input,\n * so an editor who writes one cannot buy themselves a send — and giving the\n * edit a route of its own would be a second writer to keep in step with the\n * create.\n *\n * `updateDoc` rather than a merge-set, because this is an edit of a\n * document that exists: a set would CREATE a campaign at this id for\n * somebody who deleted it in another tab, which is the shape the delivery\n * webhook is careful about on the send collection for the same reason.\n *\n * A cleared date is written as `null` — the absence the model already\n * spells — and a cleared topic REMOVES the field, because the model has no\n * null topic and `campaignTopicId` is handed to the composer as one.\n *=========================================*/\n const handleSave = useCallback(\n async (values: CampaignEditValues) => {\n if (saving) return\n setSaving(true)\n setSaveError(null)\n try {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'emailCampaigns', campaignId),\n {\n name: values.name,\n startAtMs: values.startAtMs,\n endAtMs: values.endAtMs,\n listIds: values.listIds,\n topicId: values.topicId ? values.topicId : deleteField(),\n },\n )\n setEditing(false)\n enqueueSnackbar('Campaign updated', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n setSaveError('The campaign could not be saved')\n } finally {\n setSaving(false)\n }\n },\n [campaignId, enqueueSnackbar, firestore, hostId, saving],\n )\n\n /*==========================================\n * DELETING THE CONTAINER, WHICH IS NOT DELETING ITS MAIL.\n *\n * The route detaches every email first and then removes the campaign, so\n * what a merchant is agreeing to is the grouping going away — the emails\n * stay, keep their ids, keep their reports, and read afterwards as single\n * sends. Their unsubscribe links carry those ids inside an HMAC and go on\n * working, which is why the container can never take them with it.\n *\n * The confirmation says the count and says what a delete does NOT do. A\n * scheduled email inside the campaign still goes out at its time — stopping\n * somebody's mail is `cancel`, on that email's own page — and a merchant\n * who reads \"delete campaign\" as \"stop the campaign\" has to be told\n * otherwise before they press it, not after.\n *=========================================*/\n const handleDelete = useCallback(async () => {\n if (deleting) return\n /*\n * Emails with mail still to go: the ones waiting for their time AND the\n * ones part way through an audience larger than one batch. Both are what\n * a merchant needs warning about, and counting only `scheduled` would\n * miss the case that needs it most — a campaign that is delivering right\n * now is stored as `scheduled` too, but `rollup.scheduled` is the\n * narrower reading that excludes it.\n */\n const unfinished = rollup.scheduled + rollup.sending\n const agreed = await confirm({\n title: 'Delete this campaign?',\n description:\n (sends.length\n ? `The ${sends.length.toLocaleString()} ` +\n `${sends.length === 1 ? 'email' : 'emails'} in it are kept — ` +\n 'each keeps its own report and its unsubscribe links, and they ' +\n 'appear in the campaigns list as single sends. '\n : 'It holds no emails. ') +\n (unfinished\n ? `${unfinished.toLocaleString()} of them ${\n unfinished === 1 ? 'is' : 'are'\n } still going out or still due, and deleting the campaign does ` +\n 'not stop that — the sender picks each one up from its own ' +\n 'record. Stop a send from its own page. '\n : '') +\n 'Any screens, forms and contacts assigned to it come out of it and ' +\n 'are otherwise untouched. Only the campaign itself goes.',\n confirmationText: 'Delete campaign',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDeleting(true)\n try {\n const { response, payload } = await manageApi({\n action: 'deleteCampaign',\n campaignId,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The campaign could not be deleted',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Campaign deleted', { variant: 'success', persist: false })\n router.push(`${basePath}/campaigns`)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The campaign could not be deleted', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDeleting(false)\n }\n }, [\n basePath,\n campaignId,\n confirm,\n deleting,\n enqueueSnackbar,\n manageApi,\n rollup.scheduled,\n rollup.sending,\n router,\n sends.length,\n ])\n\n // Still settling. Falling through to the send report here would flash \"this\n // campaign could not be loaded\" on every open of a campaign that exists.\n if (!campaign && status === 'loading') return null\n\n /*\n * NOT A CONTAINER — so it is a send id, and the send's own report is what\n * this URL has always meant.\n */\n if (!campaign) {\n return (\n <CampaignReportCard\n hostId={hostId}\n campaignId={campaignId}\n basePath={basePath}\n />\n )\n }\n\n const listIds = campaign.listIds ?? []\n const names = new Map<string, string>(\n (listDocs ?? []).map((list: any) => [\n String(list.$id),\n String(list.name ?? list.$id),\n ]),\n )\n /*==========================================\n * ONE EMAIL'S PAGE — the same one every other route to that record opens.\n *\n * The Emails hub's `emails/{sendId}`, which is where the messages list and\n * a template's messages table also send a reader. One record has one page:\n * a link that resolved somewhere else depending on which list it was\n * clicked in would give the same message two pages, and the reader would\n * have no way to tell which one they were on.\n *\n * `campaigns/{sendId}` on THIS hub goes on resolving as well, through the\n * fall-through at the top of this file. That is not a convenience — every\n * unsubscribe footer already delivered carries `cid={sendId}`, those\n * messages sit in inboxes forever, and merchants paste report URLs into\n * their own mail. Which link the console GENERATES and which URLs ANSWER\n * are two separate questions, and only the first is decided here.\n *\n * Nor is a redirect the answer to the second. A redirect would be another\n * thing to be wrong about an id that is inside an HMAC; a page that keeps\n * working has nothing to get wrong, and it costs nothing to leave standing.\n *=========================================*/\n const sendHref = (send: CampaignSend) =>\n emailsHub ? `${emailsHub}/messages/${send.$id}` : undefined\n\n /**\n * What one of this campaign's emails can be opened into.\n *\n * The same two the Emails hub offers, less the campaign — this page IS the\n * campaign. Both destinations are records the Emails console owns, so they\n * are built from {@link useEmailsHubPath} rather than from this surface's\n * own `basePath`.\n *\n * A message composed inline was built from no template, so that entry is\n * shown DISABLED with the reason rather than hidden: an absent control and\n * an inapplicable one look identical, and only one is honest.\n */\n const sendActions = (send: CampaignSend): RowActionsMenuItem[] => {\n const templateScreenId = String((send as any).templateScreenId ?? '')\n return [\n {\n key: 'details',\n label: 'Open report',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: sendHref(send),\n disabled: !sendHref(send),\n disabledReason: 'This site’s console URL has not resolved yet',\n },\n {\n key: 'template',\n label: 'Open its template',\n icon: <MdiIcon path={mdiPaletteOutline.path} size={0.8} />,\n href:\n templateScreenId && emailsHub\n ? `${emailsHub}/templates/${templateScreenId}`\n : undefined,\n disabled: !templateScreenId || !emailsHub,\n disabledReason: templateScreenId\n ? 'This site’s console URL has not resolved yet'\n : 'This message was not built from a template',\n },\n ]\n }\n\n /*\n * The campaign's emails, on the surface's own row grammar: the row opens\n * the email's report, its subject is also a real link so it can be\n * middle-clicked and copied, and the trailing cluster holds the actions.\n * The window is one the card already holds, so the grid pages it.\n */\n const sendColumns: GridColDef<CampaignSend>[] = [\n {\n field: 'subject',\n headerName: 'Subject',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, send) => send.subject || send.$id,\n /*\n Plain text until the Emails hub resolves: a link with no destination\n is worse than none, and this cell is the message's subject either way.\n */\n renderCell: ({ row: send, value }) => {\n const href = sendHref(send)\n return href ? (\n <AppLink\n href={href}\n // The row's own handler would fire too and push the same route\n // twice — one history entry per back press.\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {value}\n </AppLink>\n ) : (\n value\n )\n },\n },\n {\n /*\n WHAT THE EMAIL IS DOING, not the field it stores.\n\n This branched on the status, which reads \"Scheduled\" about an email\n that has delivered five hundred messages and is between batches — the\n stored state the processor claims to resume it. The derivation reads\n the counters beside the status; the due date is appended only where\n there is genuinely nothing delivered yet, which is the one case a time\n answers.\n */\n field: 'state',\n headerName: 'State',\n width: 220,\n valueGetter: (_value, send) => {\n const display = campaignSendDisplay(send)\n return display.state === 'pending' && send.sendAtMs\n ? `${display.label} · ${new Date(send.sendAtMs).toLocaleString()}`\n : display.label\n },\n renderCell: ({ row: send, value }) => {\n const state = campaignSendDisplay(send).state\n return (\n <Chip\n size=\"small\"\n color={\n state === 'sending' ? 'info' : state === 'stopped' ? 'warning' : undefined\n }\n label={value}\n />\n )\n },\n },\n {\n /*\n Sent over addressed, on one line: the two figures only mean anything\n beside each other, and the gap between them is the suppression list\n doing its work.\n */\n field: 'sent',\n headerName: 'Sent',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 110,\n valueGetter: (_value, send) => Number(send.stats?.sent ?? 0),\n renderCell: ({ row: send }) =>\n `${send.stats?.sent ?? 0}/${send.stats?.recipients ?? 0}`,\n },\n {\n field: 'opens',\n headerName: 'Opens',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n valueGetter: (_value, send) => Number(send.stats?.opens ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n },\n {\n field: 'clicks',\n headerName: 'Clicks',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n valueGetter: (_value, send) => Number(send.stats?.clicks ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n },\n listActionsColumn(\n (send: CampaignSend) => (\n <ListRowActions\n label={String(send.subject || send.$id)}\n items={sendActions(send)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n const windowState = campaignWindowState(campaign, Date.now())\n const start = campaign.startAtMs\n ? new Date(campaign.startAtMs).toLocaleDateString()\n : ''\n const end = campaign.endAtMs\n ? new Date(campaign.endAtMs).toLocaleDateString()\n : ''\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Campaign'}\n subheader={\n start || end\n ? `${start || 'Open'} – ${end || 'open-ended'}`\n : 'No campaign dates'\n }\n help={detailDocsHelp}\n HeaderProps={{\n /*\n NAVIGATION, THEN THE OVERFLOW.\n\n Editing and deleting a campaign live here rather than on the\n campaigns table, because a record is edited on its own page in this\n console. `RowActionsMenu` is named for table rows and its rendering\n is not — a kebab and a menu whose items carry `destructive` and\n `disabled` — so reusing it is what keeps this overflow behaving like\n every other one on the surface, including the email page's.\n */\n action: (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'All campaigns'}\n </Button>\n <RowActionsMenu\n label={campaign.name || 'Campaign'}\n items={[\n {\n key: 'edit',\n label: 'Edit campaign',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n onClick: () => {\n setSaveError(null)\n setEditing(true)\n },\n },\n {\n key: 'delete',\n label: 'Delete campaign',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: deleting,\n disabledReason: 'This campaign is being deleted',\n onClick: () => void handleDelete(),\n },\n ]}\n />\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Chip size=\"small\" label={windowState} />\n {listIds.length ? (\n listIds.map((id) => (\n <Chip\n key={id}\n size=\"small\"\n variant=\"outlined\"\n label={names.get(id) ?? id}\n />\n ))\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No lists assigned — its emails pick their own audience'}\n </Typography>\n )}\n </Stack>\n\n <Divider />\n {/*\n A CAMPAIGN IS NOT ONLY ITS MAIL, and it leads with the part that\n is not.\n\n What the campaign CAUSED and where it SENT people are facts about the\n campaign; delivery, engagement and rates are mechanics of the\n messages it used, and the list of those messages is one section among\n several rather than the page. Ordering the mail first said the\n opposite in the only way a page can — by what a reader meets before\n they scroll — and a campaign of ONE email says it loudest, which is\n why `campaign-report-card.tsx` carries these two headings in this\n order too.\n\n All three sections join on the campaign's own send ids, which is the\n only handle the attribution, revenue and click-report collections\n offer — see `campaign-reach-sections.tsx` for why no other join\n exists, and why the web channel is named there rather than quietly\n omitted.\n */}\n <CampaignConversionsSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n basePath={basePath}\n />\n\n <Divider />\n {/*\n WHAT IT EARNED, between what it caused and where it sent people —\n the order `campaign-report-card.tsx` puts these three in, so a\n campaign of one email and a campaign of ten answer the merchant's\n questions in the same sequence.\n\n How many messages a container holds is not a fact about whether its\n revenue is knowable, so the money question is answered here whatever\n the count. Reading it costs one record per email, so it asks first —\n see `campaign-reach-sections.tsx` for that bargain, and for why the\n currencies are reported apart and never totalled.\n */}\n <CampaignRevenueSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n />\n\n <Divider />\n <CampaignDestinationsSection\n hostId={hostId}\n sendIds={sendIds}\n truncated={sendsTruncated}\n />\n\n <Divider />\n {/*\n WHAT THE MERCHANT SAID BELONGS HERE, under what the visitors did.\n\n Everything above this line is measured — the conversions, the money\n and the pages the mail's own links pointed at, all joined on this\n campaign's send ids. This section is the only one on the page that\n reports a DECLARATION: a screen or a form carrying this campaign's\n id in its own document because somebody put it there.\n\n It sits after the evidence deliberately. A reader who met the\n assigned list first would take it for the campaign's reach, and the\n two are not the same list — a landing page can be assigned to a\n campaign no email ever linked to, and a campaign's mail can drive\n traffic to a page assigned to nothing.\n\n The dates go with it. A form's counters are lifetime, so a campaign\n with a window has to be able to confine them to its own months; a\n campaign without one is told so, and says its figures are lifetime\n rather than implying they are its own.\n */}\n <CampaignMembersSection\n hostId={hostId}\n campaignId={campaignId}\n startAtMs={campaign.startAtMs}\n endAtMs={campaign.endAtMs}\n />\n\n <Divider />\n {/*\n THE MAIL'S OWN FIGURES, under the outcomes rather than over them.\n\n The sum over the campaign's emails, not a second set of counters.\n Nothing is stored per campaign: a rollup document would have to be\n kept true against every delivery event of every email in it, and the\n numbers it duplicates are already on the sends this page reads.\n\n Drawn by the shared figures so a campaign's rate and a single\n message's read the same way — denominator named on the line, and an\n em dash rather than a zero where nothing was recorded.\n */}\n <Section title=\"The mail, across this campaign\">\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure label=\"Addressed\" {...rolled(rollup.addressed)} />\n <Figure label=\"Sent\" {...rolled(rollup.sent)} />\n <Figure label=\"Delivered\" {...rolled(rollup.delivered)} />\n <Figure label=\"Opens\" {...rolled(rollup.opens)} />\n <Figure label=\"Clicks\" {...rolled(rollup.clicks)} />\n <Figure label=\"Unsubscribed\" {...rolled(rollup.unsubscribes)} />\n </Stack>\n </Section>\n <Stack spacing={0.5}>\n <RateRow label=\"Open rate\" rate={rollup.openRate} />\n <RateRow label=\"Click rate\" rate={rollup.clickRate} />\n <RateRow label=\"Unsubscribe rate\" rate={rollup.unsubscribeRate} />\n </Stack>\n\n <Divider />\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', justifyContent: 'space-between' }}\n >\n {/*\n The section rail's own heading style, so this reads as one section\n among the others rather than as the page's subject.\n */}\n <Typography variant=\"overline\" color=\"text.secondary\">\n {`Emails (${sends.length})`}\n </Typography>\n <Button\n size=\"small\"\n variant={composing ? 'text' : 'contained'}\n onClick={() => setComposing((open) => !open)}\n >\n {composing ? 'Close composer' : 'Write an email'}\n </Button>\n </Stack>\n {sends.length ? (\n <Stack spacing={0.5}>\n <ListTable\n aria-label=\"The campaign's emails\"\n rows={sends}\n columns={sendColumns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, send) => {\n const href = sendHref(send)\n if (href) router.push(href)\n }}\n />\n {sendsTruncated ? (\n <Alert severity=\"info\">\n {`Showing ${CAMPAIGN_EMAIL_CEILING} of this campaign's ` +\n 'emails. It has sent more — the ones listed are not ' +\n 'necessarily the most recent, because a send carries no ' +\n 'date field that every writer stamps, and the figures above ' +\n 'cover the emails listed.'}\n </Alert>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No emails in this campaign yet.'}\n </Typography>\n )}\n\n {/*\n THE COMPOSER, ON DEMAND.\n\n It opens listens of its own — the site's email designs, the org's\n lists and segments, the running experiments — and this page is also\n where somebody arrives to read numbers. Mounting it unasked would\n make every reader pay for a composer they did not open, which is the\n same cost the campaign report was split onto its own route to avoid.\n */}\n {composing ? (\n <Box>\n <Divider sx={{ mb: 2 }} />\n <CampaignComposer\n hostId={hostId}\n emailCampaignId={campaignId}\n campaignListIds={listIds}\n campaignTopicId={campaign.topicId}\n onSent={() => setComposing(false)}\n />\n </Box>\n ) : null}\n </Stack>\n {/*\n THE EDIT DRAWER, on the campaign's own page.\n\n Its list picker reads the same `listDocs` the chips above already\n cost, so opening it adds one read — the topic catalog — and only\n while it is open.\n */}\n {/* Draws nothing: the zone the topic catalog's owner answers through. */}\n {topicSource}\n <CampaignEditDrawer\n open={editing}\n onClose={() => setEditing(false)}\n campaign={campaign}\n lists={(listDocs ?? []).map((list: any) => ({\n value: String(list.$id),\n label: String(list.name ?? list.$id),\n }))}\n topics={topicOptions}\n busy={saving}\n error={saveError}\n onSubmit={(values) => void handleSave(values)}\n />\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the campaign; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={campaign.name || campaignId} />\n {card}\n </>\n )\n}\nCampaignDetailCard.displayName = 'CampaignDetailCard'\n\nexport default CampaignDetailCard\n"],"names":["mdiDeleteOutline","mdiEyeOutline","mdiPaletteOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","Figure","RateRow","Section","ListRowActions","ListTable","listActionsColumn","RowActionsMenu","TABLE_ROW_HEIGHT","ceilingedWindow","PageHeaderRecord","pluginDocsHelp","Alert","Box","Button","Chip","Divider","Stack","Typography","collection","deleteField","doc","documentId","limit","orderBy","query","updateDoc","where","useCallback","useMemo","useState","useRouter","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useOrgDataScope","campaignRollup","campaignSendDisplay","campaignWindowState","emailListTimeMs","CampaignComposer","CampaignEditDrawer","CampaignConversionsSection","CampaignDestinationsSection","CampaignRevenueSection","CampaignMembersSection","CampaignReportCard","useCampaignManageApi","useEmailsHubPath","useCampaignTopicOptions","CAMPAIGN_EMAIL_CEILING","detailDocsHelp","anchor","excerpt","rolled","value","note","recorded","sends","CampaignDetailCard","props","campaign","hostId","campaignId","basePath","firestore","emailsHub","router","scope","dataScope","confirm","enqueueSnackbar","manageApi","composing","setComposing","editing","setEditing","saving","setSaving","saveError","setSaveError","deleting","setDeleting","data","status","sendDocs","Boolean","idField","listDocs","topics","source","topicSource","enabled","topicOptions","map","topic","id","label","name","rows","readSends","truncated","sendsTruncated","sort","a","b","rollup","sendIds","send","String","$id","filter","handleSave","values","startAtMs","endAtMs","listIds","topicId","variant","persist","error","console","handleDelete","unfinished","scheduled","sending","agreed","title","description","length","toLocaleString","confirmationText","then","catch","response","payload","action","ok","allowDuplicate","push","names","Map","list","sendHref","undefined","sendActions","templateScreenId","key","icon","path","size","href","disabled","disabledReason","sendColumns","field","headerName","flex","minWidth","valueGetter","_value","subject","renderCell","row","onClick","event","stopPropagation","width","display","state","sendAtMs","Date","color","type","align","headerAlign","Number","stats","sent","recipients","opens","valueFormatter","clicks","items","windowState","now","start","toLocaleDateString","end","card","header","subheader","help","HeaderProps","direction","spacing","sx","alignItems","component","componentVariant","nativeButton","destructive","contentGutterX","contentGutterY","get","flexWrap","addressed","delivered","unsubscribes","rate","openRate","clickRate","unsubscribeRate","justifyContent","open","aria-label","columns","rowHeight","onOpen","_id","severity","mb","emailCampaignId","campaignListIds","campaignTopicId","onSent","onClose","lists","busy","onSubmit","displayName"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
18
  import { AppLink } from "@aglyn/shared-ui-jsx";
19
- import { Figure, Section } from "@aglyn/shared-ui-email-campaigns/components/report-figures";
19
+ import { Figure, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
20
20
  import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component";
21
21
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
22
22
  import { buildRoute, CAMPAIGN_MEMBERSHIP_FIELD, readCampaignIds, Route } from "@aglyn/aglyn";