@aglyn/plugins-marketing 1.0.0-beta.217 → 1.0.0-beta.219
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/README.md +2 -0
- package/package.json +15 -15
- package/src/lib/analytics-provider.d.ts +177 -0
- package/src/lib/analytics-provider.js +370 -0
- package/src/lib/analytics-provider.js.map +1 -0
- package/src/lib/components/campaign-detail-card.js +16 -16
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-members-section.js +11 -11
- package/src/lib/components/campaign-members-section.js.map +1 -1
- package/src/lib/components/campaign-report-card.js +6 -4
- package/src/lib/components/campaign-report-card.js.map +1 -1
- package/src/lib/components/campaigns-card.js +1 -1
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/conversion-attribution.component.js +1 -1
- package/src/lib/components/conversion-attribution.component.js.map +1 -1
- package/src/lib/components/email-detail.js +12 -7
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/components/email-messages-widget.js +1 -1
- package/src/lib/components/email-messages-widget.js.map +1 -1
- package/src/lib/components/emails-list-card.js +11 -5
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +12 -7
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/components/org-experiments-card.js +1 -1
- package/src/lib/components/org-experiments-card.js.map +1 -1
- package/src/lib/components/record-route-context.d.ts +41 -0
- package/src/lib/components/record-route-context.js +63 -0
- package/src/lib/components/record-route-context.js.map +1 -0
- package/src/lib/components/use-marketing-hub-path.d.ts +18 -5
- package/src/lib/components/use-marketing-hub-path.js +36 -6
- package/src/lib/components/use-marketing-hub-path.js.map +1 -1
- package/src/lib/model/campaign-send-time.d.ts +74 -0
- package/src/lib/model/campaign-send-time.js +120 -0
- package/src/lib/model/campaign-send-time.js.map +1 -0
- package/src/lib/model/experiments.js +1 -1
- package/src/lib/model/experiments.js.map +1 -1
- package/src/lib/server/campaign-batch-plan.d.ts +73 -0
- package/src/lib/server/campaign-batch-plan.js +79 -0
- package/src/lib/server/campaign-batch-plan.js.map +1 -0
- package/src/lib/server/campaign-send.js +10 -5
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/email-events.js +7 -6
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/server/list-send-time-facts.d.ts +60 -0
- package/src/lib/server/list-send-time-facts.js +108 -0
- package/src/lib/server/list-send-time-facts.js.map +1 -0
- package/src/lib/server.js +4 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.d.ts +29 -0
- package/src/lib/tenant-emails.js +41 -0
- package/src/lib/tenant-emails.js.map +1 -0
- package/src/lib/testing/stand-in-emails-page-routes.d.ts +27 -0
- package/src/lib/testing/stand-in-emails-page-routes.js +42 -0
- package/src/lib/testing/stand-in-emails-page-routes.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +0 -58
- package/src/lib/components/use-emails-hub-path.js +0 -109
- package/src/lib/components/use-emails-hub-path.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-members-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n buildRoute,\n CAMPAIGN_MEMBERSHIP_FIELD,\n readCampaignIds,\n Route,\n type FormStats,\n type FormStatsTotals,\n} from '@aglyn/aglyn'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignFormsRollup,\n campaignFormTotals,\n campaignPeriodRange,\n isWindowedRange,\n type CampaignFormsRollup,\n} from '../model/campaign-membership-figures'\nimport {\n CAMPAIGN_MEMBERS_QUERY,\n CAMPAIGN_MEMBERS_SEARCH_NOTICE,\n campaignMembersBase,\n campaignMembersSearchClause,\n type CampaignMemberCollection,\n} from '../model/campaign-members-query'\n\n/**\n * How many forms the holdings figures add up.\n *\n * The tables page through every member; the figures are a sum, read from a\n * window of their own. One document past it is asked for, so \"the figures\n * cover some of the forms\" is a fact rather than a guess — the same probe\n * the emails figures on this page make.\n */\nconst FIGURES_CEILING = 25\n\nexport interface CampaignMembersSectionProps {\n hostId: string\n /**\n * The site's name, when the section is one of several — the org hub draws\n * one per site the campaign is placed on, and each heading says whose\n * forms and screens it lists.\n */\n siteName?: string\n campaignId: string\n /** The campaign's first day, when it has one. */\n startAtMs?: number | null\n /** The campaign's last day, when it has one. */\n endAtMs?: number | null\n}\n\n/** One row, whatever collection it came out of. */\ninterface MemberRow {\n id: string\n name: string\n href: string | null\n /** Why there is no link, when there is none. */\n hrefReason?: string\n /** The member's own counters, for a kind that carries them. */\n totals?: FormStatsTotals\n /** Campaigns the member is filed under, this one included. */\n campaigns?: number\n}\n\n/**\n * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,\n * and what those forms have collected.\n *\n * ## Declared, and therefore different from every section above it\n *\n * The conversions, revenue and destinations sections are EVIDENCE: they join\n * on the campaign's send ids and report what visitors did BECAUSE of this\n * campaign's mail. This one is a statement the merchant made — a screen or a\n * form carries this campaign's id in {@link CAMPAIGN_MEMBERSHIP_FIELD} — plus\n * the counters those records carry on their own documents.\n *\n * The distinction survives into the wording, because the arithmetic will not\n * enforce it. A form's submissions are the form's; filing the form under a\n * campaign does not make them the campaign's, and a heading that said\n * \"this campaign produced 40 leads\" over a number meaning \"the forms filed\n * here have 40 leads\" would be a worse page than one showing nothing. So the\n * figures are labeled as what the forms HOLD, they sit in their own block\n * under their own disclaimer, and no total on this page adds them to an\n * attributed one.\n *\n * ## Three ways the forms' counters could mislead, each answered\n *\n * 1. **They are lifetime.** `stats.submissions` has counted since the form\n * existed, including every submission from before anybody filed it here.\n * Where the campaign carries dates, the figures are summed from the\n * monthly `stats.periods` series over those months instead, and the\n * caption says which of the two a reader is looking at. Months are whole,\n * so a campaign starting mid-month includes that month entire — stated,\n * not rounded away.\n * 2. **A form can be in twenty campaigns.** `campaignIds` is an array, so\n * the same submissions count toward every campaign the form is filed\n * under. Rows say so and the total refuses to present itself as\n * exclusive.\n * 3. **A counter nobody wrote is not a zero.** `stats.leads` is counted\n * only for a form whose `routing.lead` is set, and `stats.views` only by\n * the analytics beacon. Both reach the shared `Figure`, which draws an em\n * dash and the words \"not recorded\" rather than a confident 0.\n *\n * ## Screens carry no counter, and this section will not invent one\n *\n * A form's counters ride on the form document, so the query this section\n * already makes pays for them. A screen's traffic does not: it is one\n * document per screen per day in `hosts/{hostId}/screenAnalytics`, with no\n * lifetime total anywhere, so a views column here would cost a range read\n * across screens times days on every open of this page — and the display of\n * those figures is a paid entitlement this section does not resolve. The\n * screens block names the absence and links to the surface that does measure\n * it, which is a smaller lie than a number nobody can stand behind.\n *\n * ## The join is `array-contains`, on the member's own document\n *\n * A campaign holds no member list. Each record names the campaigns it is in,\n * which is what makes deleting one record leave nothing behind and what lets\n * a record's own page draw its campaigns without a query. The query here is\n * the other direction of the same field.\n *\n * ## Each kind is a table paged by its own query (AGL-3321)\n *\n * The screens and the forms are each a list a reader pages and searches, so\n * the membership, the soft delete and the search word are all on the\n * table's Firestore query (`campaign-members-query.ts`), and every member is\n * reachable a page at a time. Nothing is matched over rows already read: a\n * deleted screen is left out by `deletedAt == null`, which every screen\n * stores, and the search is a prefix range on the stored `nameLower`.\n *\n * Unsearched, a table walks the document name, which every record has — a\n * form carries `updatedAt` only if some writer stamped one, and ordering on a\n * field a writer may omit does not mis-sort the list, it drops rows from it.\n *\n * The forms' HOLDINGS are a sum, not a list, so they read a window of their\n * own — the first {@link FIGURES_CEILING} forms by document name — and say\n * when the campaign holds more, as the mail figures on this page do.\n *\n * ## Contacts are named here and NOT listed, and the reason is a query limit\n *\n * A contact is org-scoped and shared between the sites that captured it, so\n * every client read of the collection has to carry\n * `visibleTo array-contains-any` — that is what makes the read provable\n * per-document under the rules. Firestore permits ONE array clause per query,\n * and that is it: there is no client query that filters contacts by campaign\n * as well, so there is no pre-filtered address to link to either. The link\n * offered goes to the Contacts page and says it arrives unfiltered; the\n * assignment is real, is set from the contact's own drawer, and a count of it\n * would cost a scan of the collection the customer is billed on.\n */\nexport function CampaignMembersSection(props: CampaignMembersSectionProps) {\n const { hostId, siteName, campaignId, startAtMs, endAtMs } = props\n const firestore = useFirestore()\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n\n /*\n * The window the holdings figures add up: the forms filed here, by\n * document name, one past the ceiling as the probe. A form has no soft\n * delete — it is deleted outright — so every document this returns is a\n * form in use or a retired one, and both hold what they collected.\n */\n const { data: figureDocs } = useFirestoreCollection<Record<string, unknown>>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'forms'),\n where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId),\n orderBy(documentId()),\n limit(FIGURES_CEILING + 1),\n ),\n [firestore, hostId, campaignId],\n { idField: '$id' },\n )\n\n /*\n * The months the campaign's dates cover, or nothing where it has neither.\n * Derived from props the detail card already holds, so asking what a\n * figure covers costs no read.\n */\n const range = useMemo(\n () => campaignPeriodRange({ startAtMs, endAtMs }),\n [startAtMs, endAtMs],\n )\n const windowed = isWindowedRange(range)\n\n const screenRow = useCallback(\n (row: Record<string, unknown>): MemberRow => {\n const id = String(row['$id'])\n const versionId = String(row['versionId'] ?? '')\n return {\n id,\n name: String(row['displayName'] ?? id),\n /*\n * A screen with no saved version has no detail address to build —\n * the route carries a version — so it stays plain text rather than\n * linking somewhere that 404s. The screens list applies the same\n * guard to its own name column.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : null,\n hrefReason: versionId\n ? 'This site’s console URL has not resolved yet'\n : 'This screen has no saved version yet',\n campaigns: readCampaignIds(row).length,\n }\n },\n [orgSlug, host],\n )\n\n /*\n * The counters ride on the form documents, so each row's figures are paid\n * for by the read that lists it.\n */\n const formRow = useCallback(\n (row: Record<string, unknown>): MemberRow => {\n const id = String(row['$id'])\n return {\n id,\n name: String(row['displayName'] ?? id),\n href:\n orgSlug && host\n ? buildRoute(Route.FORM_DETAILS, { orgSlug, host, formId: id })\n : null,\n hrefReason: 'This site’s console URL has not resolved yet',\n totals: campaignFormTotals(row['stats'] as FormStats | undefined, range),\n campaigns: readCampaignIds(row).length,\n }\n },\n [orgSlug, host, range],\n )\n\n const figureForms = useMemo(\n () => (figureDocs ?? []).slice(0, FIGURES_CEILING).map(formRow),\n [figureDocs, formRow],\n )\n const figuresTruncated = (figureDocs ?? []).length > FIGURES_CEILING\n const rollup = useMemo(\n () =>\n campaignFormsRollup(\n figureForms.map((row) => ({\n totals: row.totals as FormStatsTotals,\n campaigns: row.campaigns ?? 1,\n })),\n ),\n [figureForms],\n )\n\n const analyticsHref =\n orgSlug && host\n ? buildRoute(Route.HOST_ANALYTICS, { orgSlug, host })\n : null\n const contactsHref =\n orgSlug && host ? buildRoute(Route.HOST_CONTACTS, { orgSlug, host }) : null\n\n return (\n <Section\n title={\n siteName\n ? `Assigned to this campaign on ${siteName}`\n : 'Assigned to this campaign'\n }\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages and forms somebody put in this campaign. Assignment is ' +\n 'a grouping — what the campaign was credited with is measured ' +\n 'above, from the links its emails carried.'}\n </Typography>\n <MemberTable\n heading=\"Screens\"\n noun=\"screen\"\n collectionName=\"screens\"\n hostId={hostId}\n campaignId={campaignId}\n toRow={screenRow}\n />\n {/*\n The absence, named where a reader would look for the number.\n\n A screen's traffic is not on the screen document — it is a day doc\n per screen in `screenAnalytics` — so a views column here would be a\n read across screens times days on every open, for figures whose\n display is a paid entitlement this section does not resolve. The\n page that does both is linked instead.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Page views are not shown here. A screen keeps no running total ' +\n 'on its own record — traffic is stored a day at a time — so ' +\n 'counting it would be a fresh read of the site’s history every ' +\n 'time this page opens. '}\n {analyticsHref ? (\n <AppLink href={analyticsHref}>\n {'The site’s analytics measures it by screen.'}\n </AppLink>\n ) : (\n 'The site’s analytics page measures it by screen.'\n )}\n </Typography>\n <MemberTable\n heading=\"Forms\"\n noun=\"form\"\n collectionName=\"forms\"\n hostId={hostId}\n campaignId={campaignId}\n toRow={formRow}\n figures\n />\n {/*\n THE MEMBERSHIP TOTAL, fenced off from the attributed ones above.\n\n Same `Figure` primitive as the mail rollup, deliberately different\n words: it says what the forms HOLD, and the caption beneath refuses\n the reading a merchant will reach for first.\n */}\n {figureForms.length ? (\n <FormsHoldings\n rollup={rollup}\n windowed={windowed}\n from={range.from ?? null}\n to={range.to ?? null}\n truncated={figuresTruncated}\n />\n ) : null}\n {/*\n The people, named rather than omitted.\n\n A contact CAN be filed under a campaign and this page cannot list\n them — see the module comment for the query rule that decides it.\n Leaving the heading out entirely would read as \"contacts cannot be\n assigned\", which is the one thing it does not mean.\n */}\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Contacts'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Contacts are filed under a campaign from the contact’s own ' +\n 'panel on the Contacts page. They are not listed or counted ' +\n 'here: a contact belongs to the organization rather than to ' +\n 'this site, so the query that reads them is already spending ' +\n 'its one array filter on which sites may see the person. '}\n {contactsHref ? (\n <AppLink href={contactsHref}>{'Open Contacts'}</AppLink>\n ) : null}\n {contactsHref\n ? ' — it opens unfiltered, because that same rule leaves no ' +\n 'campaign filter for the address to carry.'\n : ''}\n </Typography>\n </Stack>\n </Stack>\n </Section>\n )\n}\n\n/**\n * What the campaign's forms have collected — and what that is NOT.\n *\n * Two sentences do the whole job of keeping this apart from the attributed\n * figures, and both are load-bearing:\n *\n * - The first refuses causation outright. A merchant reading a number under\n * a campaign's heading will assume the campaign caused it unless told\n * otherwise in the same breath.\n * - The second says which months the number covers. A lifetime total under a\n * campaign that ran for six weeks is the same lie one step quieter.\n *\n * `shared` is stated as a count rather than folded into the arithmetic. There\n * is no honest way to divide a submission between two campaigns a form is\n * filed under, so the total is left whole and the overlap is disclosed.\n */\nfunction FormsHoldings(props: {\n rollup: CampaignFormsRollup\n windowed: boolean\n from: string | null\n to: string | null\n /** The campaign holds more forms than the figures add up. */\n truncated: boolean\n}) {\n const { rollup, windowed, from, to, truncated } = props\n const note = (recorded: number) =>\n recorded < rollup.members\n ? `across ${recorded} of ${rollup.members} forms`\n : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`\n const span = windowed\n ? `the campaign’s months (${from ?? 'the first month recorded'} to ${\n to ?? 'now'\n })`\n : 'each form’s whole history'\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'What these forms hold'}\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Views\"\n value={rollup.views.value}\n note={note(rollup.views.recorded)}\n />\n <Figure\n label=\"Started\"\n value={rollup.starts.value}\n note={note(rollup.starts.recorded)}\n />\n <Figure\n label=\"Submissions\"\n value={rollup.submissions.value}\n note={note(rollup.submissions.recorded)}\n />\n <Figure\n label=\"Leads\"\n value={rollup.leads.value}\n note={note(rollup.leads.recorded)}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These are the forms’ own counters, not this campaign’s results. ' +\n 'A submission is counted here because somebody filed the form ' +\n 'under this campaign, whether or not this campaign’s mail sent ' +\n `the visitor. Counted over ${span}.`}\n </Typography>\n {windowed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whole calendar months: a campaign that started or ended mid-' +\n 'month takes that month entire, and a form counting nothing in ' +\n 'those months adds nothing here even where its lifetime total ' +\n 'is large.'}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign has no dates, so nothing narrows the figures to ' +\n 'it. They are lifetime totals and include everything from ' +\n 'before the forms were put in this campaign. Give the campaign ' +\n 'a start and an end to count only its months.'}\n </Typography>\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`These figures add up the first ${FIGURES_CEILING} forms in this ` +\n 'campaign, in document order. It holds more; the table above ' +\n 'lists every one of them.'}\n </Alert>\n ) : null}\n {rollup.shared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${rollup.shared} of these ${rollup.members} forms ${\n rollup.shared === 1 ? 'is' : 'are'\n } filed under another campaign too, so the same submissions are ` +\n 'counted there as well. This total is not exclusive to this ' +\n 'campaign.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nFormsHoldings.displayName = 'FormsHoldings'\n\n/** A counter as a cell: an em dash where nothing was recorded, never a zero. */\nfunction statCell(value: number | null | undefined) {\n return value === null || value === undefined ? '—' : value.toLocaleString()\n}\n\n/** The counters a form carries on its own document, in the order they are read. */\nconst MEMBER_FIGURES = [\n ['views', 'Views'],\n ['starts', 'Started'],\n ['submissions', 'Submissions'],\n ['leads', 'Leads'],\n] as const\n\n/** How the search reads on a notice: the box, not a column. */\nconst MEMBER_SEARCH_HEADERS: Readonly<Record<string, string>> = {\n name: 'Search',\n}\n\n/**\n * One kind's members, as a record list in the shared grid, paged by its own\n * query (AGL-3321).\n *\n * The membership, the soft delete and the search word are predicates on the\n * query (`campaign-members-query.ts`); the grid neither filters nor sorts\n * the page it is handed. The search box is the grid's quick filter, turned\n * into \"name starts with\" because the membership holds the query's one\n * array clause; the Filters panel is not offered, because every column\n * beside it would need an index of its own for a list this size.\n *\n * `figures` adds the counter columns for a kind that carries them on its own\n * document. It is a prop rather than two lists because the row grammar — the\n * name cell, the reason a member has no link, the footer — is the part that\n * has to stay identical between screens and forms.\n */\nfunction MemberTable(props: {\n heading: string\n noun: string\n collectionName: CampaignMemberCollection\n hostId: string\n campaignId: string\n toRow: (row: Record<string, unknown>) => MemberRow\n figures?: boolean\n}) {\n const { heading, noun, collectionName, hostId, campaignId, toRow, figures } =\n props\n const firestore = useFirestore()\n const gridFilter = useListGridFilter({})\n const search = campaignMembersSearchClause(gridFilter.searchWords)\n const page = useListQuery<Record<string, unknown>>({\n collection: collection(firestore, 'hosts', hostId, collectionName),\n declaration: CAMPAIGN_MEMBERS_QUERY,\n request: {\n clauses: search ? [search] : [],\n base: campaignMembersBase(collectionName, campaignId),\n },\n deps: [firestore, hostId, collectionName, campaignId],\n idField: '$id',\n })\n const rows = useMemo(() => page.rows.map(toRow), [page.rows, toRow])\n // Nothing typed and nothing on the first page: the campaign holds none,\n // which is said in words rather than as an empty grid.\n const none = !search && page.page === 0 && rows.length === 0\n const columns = useMemo<GridColDef<MemberRow>[]>(\n () => [\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack sx={{ py: 1, minWidth: 0 }}>\n {row.href ? (\n <AppLink href={row.href}>{row.name}</AppLink>\n ) : (\n <>\n <Typography variant=\"body2\">{row.name}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.hrefReason}\n </Typography>\n </>\n )}\n {/*\n The overlap, on the row that causes it. A reader comparing two\n campaigns has to be able to see which rows they have in common\n without opening either.\n */}\n {(row.campaigns ?? 1) > 1 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Also in ${(row.campaigns ?? 1) - 1} other campaign${\n (row.campaigns ?? 1) - 1 === 1 ? '' : 's'\n }`}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n ...(figures\n ? MEMBER_FIGURES.map(\n ([figure, headerName]): GridColDef<MemberRow> => ({\n field: figure,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 120,\n valueGetter: (_value, row) => row.totals?.[figure] ?? null,\n renderCell: ({ row }) => statCell(row.totals?.[figure]),\n }),\n )\n : []),\n ],\n [figures],\n )\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {heading}\n </Typography>\n {none ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {page.status === 'loading'\n ? `Reading this campaign’s ${noun}s…`\n : `No ${noun} is in this campaign. Open a ${noun} and pick this ` +\n 'campaign on its own page to put it in one.'}\n </Typography>\n ) : (\n <>\n <ListQueryNotices\n refused={listQueryRefusals(page.plan.refused, {\n fields: CAMPAIGN_MEMBERS_QUERY.fields,\n headers: MEMBER_SEARCH_HEADERS,\n })}\n notices={\n search\n ? [CAMPAIGN_MEMBERS_SEARCH_NOTICE, ...page.plan.notices]\n : page.plan.notices\n }\n />\n <ListTable\n aria-label={heading}\n rows={rows}\n columns={columns}\n getRowId={(row: MemberRow) => row.id}\n rowHeight={TABLE_ROW_HEIGHT}\n // A member's name carries why it has no link, and the other\n // campaigns it is in, beneath it.\n getRowHeight={() => 'auto'}\n loading={page.status === 'loading'}\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnFilter\n disableColumnSorting\n hideFooter\n noRowsLabel={`No ${noun} in this campaign starts with that`}\n />\n <ListPagination\n page={page.page}\n pageSize={page.pageSize}\n rowCount={rows.length}\n hasMore={page.hasMore}\n onPageChange={page.setPage}\n onPageSizeChange={page.setPageSize}\n />\n </>\n )}\n </Stack>\n )\n}\n\nCampaignMembersSection.displayName = 'CampaignMembersSection'\n\nexport default CampaignMembersSection\n"],"names":["AppLink","Figure","Section","ListPagination","ListQueryNotices","listQueryRefusals","ListTable","useListGridFilter","TABLE_ROW_HEIGHT","useListQuery","buildRoute","CAMPAIGN_MEMBERSHIP_FIELD","readCampaignIds","Route","Alert","Stack","Typography","collection","documentId","limit","orderBy","query","where","useCallback","useMemo","useConsoleHostRoute","useFirestore","useFirestoreCollection","campaignFormsRollup","campaignFormTotals","campaignPeriodRange","isWindowedRange","CAMPAIGN_MEMBERS_QUERY","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersBase","campaignMembersSearchClause","FIGURES_CEILING","CampaignMembersSection","props","range","hostId","siteName","campaignId","startAtMs","endAtMs","firestore","orgSlug","subdomain","host","data","figureDocs","idField","windowed","screenRow","row","id","String","versionId","name","href","SCREEN_DETAILS","screenId","hrefReason","campaigns","length","formRow","FORM_DETAILS","formId","totals","figureForms","slice","map","figuresTruncated","rollup","analyticsHref","HOST_ANALYTICS","contactsHref","HOST_CONTACTS","title","spacing","variant","color","MemberTable","heading","noun","collectionName","toRow","figures","FormsHoldings","from","to","truncated","note","recorded","members","span","direction","sx","flexWrap","label","value","views","starts","submissions","leads","severity","shared","displayName","statCell","undefined","toLocaleString","MEMBER_FIGURES","MEMBER_SEARCH_HEADERS","gridFilter","search","searchWords","page","declaration","request","clauses","base","deps","rows","none","columns","field","headerName","flex","minWidth","renderCell","py","figure","type","align","headerAlign","width","valueGetter","_value","status","refused","plan","fields","headers","notices","aria-label","getRowId","rowHeight","getRowHeight","loading","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnFilter","disableColumnSorting","hideFooter","noRowsLabel","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-members-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n pluginRecordHref,\n pluginRecordListHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n buildRoute,\n CAMPAIGN_MEMBERSHIP_FIELD,\n readCampaignIds,\n Route,\n type FormStats,\n type FormStatsTotals,\n} from '@aglyn/aglyn'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignFormsRollup,\n campaignFormTotals,\n campaignPeriodRange,\n isWindowedRange,\n type CampaignFormsRollup,\n} from '../model/campaign-membership-figures'\nimport {\n CAMPAIGN_MEMBERS_QUERY,\n CAMPAIGN_MEMBERS_SEARCH_NOTICE,\n campaignMembersBase,\n campaignMembersSearchClause,\n type CampaignMemberCollection,\n} from '../model/campaign-members-query'\n\n/**\n * How many forms the holdings figures add up.\n *\n * The tables page through every member; the figures are a sum, read from a\n * window of their own. One document past it is asked for, so \"the figures\n * cover some of the forms\" is a fact rather than a guess — the same probe\n * the emails figures on this page make.\n */\nconst FIGURES_CEILING = 25\n\nexport interface CampaignMembersSectionProps {\n hostId: string\n /**\n * The site's name, when the section is one of several — the org hub draws\n * one per site the campaign is placed on, and each heading says whose\n * forms and screens it lists.\n */\n siteName?: string\n campaignId: string\n /** The campaign's first day, when it has one. */\n startAtMs?: number | null\n /** The campaign's last day, when it has one. */\n endAtMs?: number | null\n}\n\n/** One row, whatever collection it came out of. */\ninterface MemberRow {\n id: string\n name: string\n href: string | null\n /** Why there is no link, when there is none. */\n hrefReason?: string\n /** The member's own counters, for a kind that carries them. */\n totals?: FormStatsTotals\n /** Campaigns the member is filed under, this one included. */\n campaigns?: number\n}\n\n/**\n * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,\n * and what those forms have collected.\n *\n * ## Declared, and therefore different from every section above it\n *\n * The conversions, revenue and destinations sections are EVIDENCE: they join\n * on the campaign's send ids and report what visitors did BECAUSE of this\n * campaign's mail. This one is a statement the merchant made — a screen or a\n * form carries this campaign's id in {@link CAMPAIGN_MEMBERSHIP_FIELD} — plus\n * the counters those records carry on their own documents.\n *\n * The distinction survives into the wording, because the arithmetic will not\n * enforce it. A form's submissions are the form's; filing the form under a\n * campaign does not make them the campaign's, and a heading that said\n * \"this campaign produced 40 leads\" over a number meaning \"the forms filed\n * here have 40 leads\" would be a worse page than one showing nothing. So the\n * figures are labeled as what the forms HOLD, they sit in their own block\n * under their own disclaimer, and no total on this page adds them to an\n * attributed one.\n *\n * ## Three ways the forms' counters could mislead, each answered\n *\n * 1. **They are lifetime.** `stats.submissions` has counted since the form\n * existed, including every submission from before anybody filed it here.\n * Where the campaign carries dates, the figures are summed from the\n * monthly `stats.periods` series over those months instead, and the\n * caption says which of the two a reader is looking at. Months are whole,\n * so a campaign starting mid-month includes that month entire — stated,\n * not rounded away.\n * 2. **A form can be in twenty campaigns.** `campaignIds` is an array, so\n * the same submissions count toward every campaign the form is filed\n * under. Rows say so and the total refuses to present itself as\n * exclusive.\n * 3. **A counter nobody wrote is not a zero.** `stats.leads` is counted\n * only for a form whose `routing.lead` is set, and `stats.views` only by\n * the analytics beacon. Both reach the shared `Figure`, which draws an em\n * dash and the words \"not recorded\" rather than a confident 0.\n *\n * ## Screens carry no counter, and this section will not invent one\n *\n * A form's counters ride on the form document, so the query this section\n * already makes pays for them. A screen's traffic does not: it is one\n * document per screen per day in `hosts/{hostId}/screenAnalytics`, with no\n * lifetime total anywhere, so a views column here would cost a range read\n * across screens times days on every open of this page — and the display of\n * those figures is a paid entitlement this section does not resolve. The\n * screens block names the absence and links to the surface that does measure\n * it, which is a smaller lie than a number nobody can stand behind.\n *\n * ## The join is `array-contains`, on the member's own document\n *\n * A campaign holds no member list. Each record names the campaigns it is in,\n * which is what makes deleting one record leave nothing behind and what lets\n * a record's own page draw its campaigns without a query. The query here is\n * the other direction of the same field.\n *\n * ## Each kind is a table paged by its own query (AGL-3321)\n *\n * The screens and the forms are each a list a reader pages and searches, so\n * the membership, the soft delete and the search word are all on the\n * table's Firestore query (`campaign-members-query.ts`), and every member is\n * reachable a page at a time. Nothing is matched over rows already read: a\n * deleted screen is left out by `deletedAt == null`, which every screen\n * stores, and the search is a prefix range on the stored `nameLower`.\n *\n * Unsearched, a table walks the document name, which every record has — a\n * form carries `updatedAt` only if some writer stamped one, and ordering on a\n * field a writer may omit does not mis-sort the list, it drops rows from it.\n *\n * The forms' HOLDINGS are a sum, not a list, so they read a window of their\n * own — the first {@link FIGURES_CEILING} forms by document name — and say\n * when the campaign holds more, as the mail figures on this page do.\n *\n * ## Contacts are named here and NOT listed, and the reason is a query limit\n *\n * A contact is org-scoped and shared between the sites that captured it, so\n * every client read of the collection has to carry\n * `visibleTo array-contains-any` — that is what makes the read provable\n * per-document under the rules. Firestore permits ONE array clause per query,\n * and that is it: there is no client query that filters contacts by campaign\n * as well, so there is no pre-filtered address to link to either. The link\n * offered goes to the Contacts page and says it arrives unfiltered; the\n * assignment is real, is set from the contact's own drawer, and a count of it\n * would cost a scan of the collection the customer is billed on.\n */\nexport function CampaignMembersSection(props: CampaignMembersSectionProps) {\n const { hostId, siteName, campaignId, startAtMs, endAtMs } = props\n const firestore = useFirestore()\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n\n /*\n * The window the holdings figures add up: the forms filed here, by\n * document name, one past the ceiling as the probe. A form has no soft\n * delete — it is deleted outright — so every document this returns is a\n * form in use or a retired one, and both hold what they collected.\n */\n const { data: figureDocs } = useFirestoreCollection<Record<string, unknown>>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'forms'),\n where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId),\n orderBy(documentId()),\n limit(FIGURES_CEILING + 1),\n ),\n [firestore, hostId, campaignId],\n { idField: '$id' },\n )\n\n /*\n * The months the campaign's dates cover, or nothing where it has neither.\n * Derived from props the detail card already holds, so asking what a\n * figure covers costs no read.\n */\n const range = useMemo(\n () => campaignPeriodRange({ startAtMs, endAtMs }),\n [startAtMs, endAtMs],\n )\n const windowed = isWindowedRange(range)\n\n const screenRow = useCallback(\n (row: Record<string, unknown>): MemberRow => {\n const id = String(row['$id'])\n const versionId = String(row['versionId'] ?? '')\n return {\n id,\n name: String(row['displayName'] ?? id),\n /*\n * A screen with no saved version has no detail address to build —\n * the route carries a version — so it stays plain text rather than\n * linking somewhere that 404s. The screens list applies the same\n * guard to its own name column.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : null,\n hrefReason: versionId\n ? 'This site’s console URL has not resolved yet'\n : 'This page has no saved version yet',\n campaigns: readCampaignIds(row).length,\n }\n },\n [orgSlug, host],\n )\n\n /*\n * The counters ride on the form documents, so each row's figures are paid\n * for by the read that lists it.\n */\n const formRow = useCallback(\n (row: Record<string, unknown>): MemberRow => {\n const id = String(row['$id'])\n return {\n id,\n name: String(row['displayName'] ?? id),\n href: orgSlug && host ? pluginRecordHref('form', { orgSlug, host }, id) : null,\n hrefReason: 'This site’s console URL has not resolved yet',\n totals: campaignFormTotals(row['stats'] as FormStats | undefined, range),\n campaigns: readCampaignIds(row).length,\n }\n },\n [orgSlug, host, range],\n )\n\n const figureForms = useMemo(\n () => (figureDocs ?? []).slice(0, FIGURES_CEILING).map(formRow),\n [figureDocs, formRow],\n )\n const figuresTruncated = (figureDocs ?? []).length > FIGURES_CEILING\n const rollup = useMemo(\n () =>\n campaignFormsRollup(\n figureForms.map((row) => ({\n totals: row.totals as FormStatsTotals,\n campaigns: row.campaigns ?? 1,\n })),\n ),\n [figureForms],\n )\n\n const analyticsHref =\n orgSlug && host\n ? buildRoute(Route.HOST_ANALYTICS, { orgSlug, host })\n : null\n const contactsHref =\n orgSlug && host ? pluginRecordListHref('contact', { orgSlug, host }) : null\n\n return (\n <Section\n title={\n siteName\n ? `Assigned to this campaign on ${siteName}`\n : 'Assigned to this campaign'\n }\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages and forms somebody put in this campaign. Assignment is ' +\n 'a grouping — what the campaign was credited with is measured ' +\n 'above, from the links its emails carried.'}\n </Typography>\n <MemberTable\n heading=\"Pages\"\n noun=\"page\"\n collectionName=\"screens\"\n hostId={hostId}\n campaignId={campaignId}\n toRow={screenRow}\n />\n {/*\n The absence, named where a reader would look for the number.\n\n A screen's traffic is not on the screen document — it is a day doc\n per screen in `screenAnalytics` — so a views column here would be a\n read across screens times days on every open, for figures whose\n display is a paid entitlement this section does not resolve. The\n page that does both is linked instead.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Page views are not shown here. A page keeps no running total ' +\n 'on its own record — traffic is stored a day at a time — so ' +\n 'counting it would be a fresh read of the site’s history every ' +\n 'time this view opens. '}\n {analyticsHref ? (\n <AppLink href={analyticsHref}>\n {'The site’s analytics measures it by page.'}\n </AppLink>\n ) : (\n 'The site’s analytics view measures it by page.'\n )}\n </Typography>\n <MemberTable\n heading=\"Forms\"\n noun=\"form\"\n collectionName=\"forms\"\n hostId={hostId}\n campaignId={campaignId}\n toRow={formRow}\n figures\n />\n {/*\n THE MEMBERSHIP TOTAL, fenced off from the attributed ones above.\n\n Same `Figure` primitive as the mail rollup, deliberately different\n words: it says what the forms HOLD, and the caption beneath refuses\n the reading a merchant will reach for first.\n */}\n {figureForms.length ? (\n <FormsHoldings\n rollup={rollup}\n windowed={windowed}\n from={range.from ?? null}\n to={range.to ?? null}\n truncated={figuresTruncated}\n />\n ) : null}\n {/*\n The people, named rather than omitted.\n\n A contact CAN be filed under a campaign and this page cannot list\n them — see the module comment for the query rule that decides it.\n Leaving the heading out entirely would read as \"contacts cannot be\n assigned\", which is the one thing it does not mean.\n */}\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Contacts'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Contacts are filed under a campaign from the contact’s own ' +\n 'panel on the Contacts page. They are not listed or counted ' +\n 'here: a contact belongs to the organization rather than to ' +\n 'this site, so the query that reads them is already spending ' +\n 'its one array filter on which sites may see the person. '}\n {contactsHref ? (\n <AppLink href={contactsHref}>{'Open Contacts'}</AppLink>\n ) : null}\n {contactsHref\n ? ' — it opens unfiltered, because that same rule leaves no ' +\n 'campaign filter for the address to carry.'\n : ''}\n </Typography>\n </Stack>\n </Stack>\n </Section>\n )\n}\n\n/**\n * What the campaign's forms have collected — and what that is NOT.\n *\n * Two sentences do the whole job of keeping this apart from the attributed\n * figures, and both are load-bearing:\n *\n * - The first refuses causation outright. A merchant reading a number under\n * a campaign's heading will assume the campaign caused it unless told\n * otherwise in the same breath.\n * - The second says which months the number covers. A lifetime total under a\n * campaign that ran for six weeks is the same lie one step quieter.\n *\n * `shared` is stated as a count rather than folded into the arithmetic. There\n * is no honest way to divide a submission between two campaigns a form is\n * filed under, so the total is left whole and the overlap is disclosed.\n */\nfunction FormsHoldings(props: {\n rollup: CampaignFormsRollup\n windowed: boolean\n from: string | null\n to: string | null\n /** The campaign holds more forms than the figures add up. */\n truncated: boolean\n}) {\n const { rollup, windowed, from, to, truncated } = props\n const note = (recorded: number) =>\n recorded < rollup.members\n ? `across ${recorded} of ${rollup.members} forms`\n : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`\n const span = windowed\n ? `the campaign’s months (${from ?? 'the first month recorded'} to ${\n to ?? 'now'\n })`\n : 'each form’s whole history'\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'What these forms hold'}\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Views\"\n value={rollup.views.value}\n note={note(rollup.views.recorded)}\n />\n <Figure\n label=\"Started\"\n value={rollup.starts.value}\n note={note(rollup.starts.recorded)}\n />\n <Figure\n label=\"Submissions\"\n value={rollup.submissions.value}\n note={note(rollup.submissions.recorded)}\n />\n <Figure\n label=\"Leads\"\n value={rollup.leads.value}\n note={note(rollup.leads.recorded)}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These are the forms’ own counters, not this campaign’s results. ' +\n 'A submission is counted here because somebody filed the form ' +\n 'under this campaign, whether or not this campaign’s mail sent ' +\n `the visitor. Counted over ${span}.`}\n </Typography>\n {windowed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whole calendar months: a campaign that started or ended mid-' +\n 'month takes that month entire, and a form counting nothing in ' +\n 'those months adds nothing here even where its lifetime total ' +\n 'is large.'}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign has no dates, so nothing narrows the figures to ' +\n 'it. They are lifetime totals and include everything from ' +\n 'before the forms were put in this campaign. Give the campaign ' +\n 'a start and an end to count only its months.'}\n </Typography>\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`These figures add up the first ${FIGURES_CEILING} forms in this ` +\n 'campaign, in document order. It holds more; the table above ' +\n 'lists every one of them.'}\n </Alert>\n ) : null}\n {rollup.shared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${rollup.shared} of these ${rollup.members} forms ${\n rollup.shared === 1 ? 'is' : 'are'\n } filed under another campaign too, so the same submissions are ` +\n 'counted there as well. This total is not exclusive to this ' +\n 'campaign.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nFormsHoldings.displayName = 'FormsHoldings'\n\n/** A counter as a cell: an em dash where nothing was recorded, never a zero. */\nfunction statCell(value: number | null | undefined) {\n return value === null || value === undefined ? '—' : value.toLocaleString()\n}\n\n/** The counters a form carries on its own document, in the order they are read. */\nconst MEMBER_FIGURES = [\n ['views', 'Views'],\n ['starts', 'Started'],\n ['submissions', 'Submissions'],\n ['leads', 'Leads'],\n] as const\n\n/** How the search reads on a notice: the box, not a column. */\nconst MEMBER_SEARCH_HEADERS: Readonly<Record<string, string>> = {\n name: 'Search',\n}\n\n/**\n * One kind's members, as a record list in the shared grid, paged by its own\n * query (AGL-3321).\n *\n * The membership, the soft delete and the search word are predicates on the\n * query (`campaign-members-query.ts`); the grid neither filters nor sorts\n * the page it is handed. The search box is the grid's quick filter, turned\n * into \"name starts with\" because the membership holds the query's one\n * array clause; the Filters panel is not offered, because every column\n * beside it would need an index of its own for a list this size.\n *\n * `figures` adds the counter columns for a kind that carries them on its own\n * document. It is a prop rather than two lists because the row grammar — the\n * name cell, the reason a member has no link, the footer — is the part that\n * has to stay identical between screens and forms.\n */\nfunction MemberTable(props: {\n heading: string\n noun: string\n collectionName: CampaignMemberCollection\n hostId: string\n campaignId: string\n toRow: (row: Record<string, unknown>) => MemberRow\n figures?: boolean\n}) {\n const { heading, noun, collectionName, hostId, campaignId, toRow, figures } =\n props\n const firestore = useFirestore()\n const gridFilter = useListGridFilter({})\n const search = campaignMembersSearchClause(gridFilter.searchWords)\n const page = useListQuery<Record<string, unknown>>({\n collection: collection(firestore, 'hosts', hostId, collectionName),\n declaration: CAMPAIGN_MEMBERS_QUERY,\n request: {\n clauses: search ? [search] : [],\n base: campaignMembersBase(collectionName, campaignId),\n },\n deps: [firestore, hostId, collectionName, campaignId],\n idField: '$id',\n })\n const rows = useMemo(() => page.rows.map(toRow), [page.rows, toRow])\n // Nothing typed and nothing on the first page: the campaign holds none,\n // which is said in words rather than as an empty grid.\n const none = !search && page.page === 0 && rows.length === 0\n const columns = useMemo<GridColDef<MemberRow>[]>(\n () => [\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack sx={{ py: 1, minWidth: 0 }}>\n {row.href ? (\n <AppLink href={row.href}>{row.name}</AppLink>\n ) : (\n <>\n <Typography variant=\"body2\">{row.name}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.hrefReason}\n </Typography>\n </>\n )}\n {/*\n The overlap, on the row that causes it. A reader comparing two\n campaigns has to be able to see which rows they have in common\n without opening either.\n */}\n {(row.campaigns ?? 1) > 1 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Also in ${(row.campaigns ?? 1) - 1} other campaign${\n (row.campaigns ?? 1) - 1 === 1 ? '' : 's'\n }`}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n ...(figures\n ? MEMBER_FIGURES.map(\n ([figure, headerName]): GridColDef<MemberRow> => ({\n field: figure,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 120,\n valueGetter: (_value, row) => row.totals?.[figure] ?? null,\n renderCell: ({ row }) => statCell(row.totals?.[figure]),\n }),\n )\n : []),\n ],\n [figures],\n )\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {heading}\n </Typography>\n {none ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {page.status === 'loading'\n ? `Reading this campaign’s ${noun}s…`\n : `No ${noun} is in this campaign. Open a ${noun} and pick this ` +\n 'campaign on its own page to put it in one.'}\n </Typography>\n ) : (\n <>\n <ListQueryNotices\n refused={listQueryRefusals(page.plan.refused, {\n fields: CAMPAIGN_MEMBERS_QUERY.fields,\n headers: MEMBER_SEARCH_HEADERS,\n })}\n notices={\n search\n ? [CAMPAIGN_MEMBERS_SEARCH_NOTICE, ...page.plan.notices]\n : page.plan.notices\n }\n />\n <ListTable\n aria-label={heading}\n rows={rows}\n columns={columns}\n getRowId={(row: MemberRow) => row.id}\n rowHeight={TABLE_ROW_HEIGHT}\n // A member's name carries why it has no link, and the other\n // campaigns it is in, beneath it.\n getRowHeight={() => 'auto'}\n loading={page.status === 'loading'}\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnFilter\n disableColumnSorting\n hideFooter\n noRowsLabel={`No ${noun} in this campaign starts with that`}\n />\n <ListPagination\n page={page.page}\n pageSize={page.pageSize}\n rowCount={rows.length}\n hasMore={page.hasMore}\n onPageChange={page.setPage}\n onPageSizeChange={page.setPageSize}\n />\n </>\n )}\n </Stack>\n )\n}\n\nCampaignMembersSection.displayName = 'CampaignMembersSection'\n\nexport default CampaignMembersSection\n"],"names":["pluginRecordHref","pluginRecordListHref","AppLink","Figure","Section","ListPagination","ListQueryNotices","listQueryRefusals","ListTable","useListGridFilter","TABLE_ROW_HEIGHT","useListQuery","buildRoute","CAMPAIGN_MEMBERSHIP_FIELD","readCampaignIds","Route","Alert","Stack","Typography","collection","documentId","limit","orderBy","query","where","useCallback","useMemo","useConsoleHostRoute","useFirestore","useFirestoreCollection","campaignFormsRollup","campaignFormTotals","campaignPeriodRange","isWindowedRange","CAMPAIGN_MEMBERS_QUERY","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersBase","campaignMembersSearchClause","FIGURES_CEILING","CampaignMembersSection","props","range","hostId","siteName","campaignId","startAtMs","endAtMs","firestore","orgSlug","subdomain","host","data","figureDocs","idField","windowed","screenRow","row","id","String","versionId","name","href","SCREEN_DETAILS","screenId","hrefReason","campaigns","length","formRow","totals","figureForms","slice","map","figuresTruncated","rollup","analyticsHref","HOST_ANALYTICS","contactsHref","title","spacing","variant","color","MemberTable","heading","noun","collectionName","toRow","figures","FormsHoldings","from","to","truncated","note","recorded","members","span","direction","sx","flexWrap","label","value","views","starts","submissions","leads","severity","shared","displayName","statCell","undefined","toLocaleString","MEMBER_FIGURES","MEMBER_SEARCH_HEADERS","gridFilter","search","searchWords","page","declaration","request","clauses","base","deps","rows","none","columns","field","headerName","flex","minWidth","renderCell","py","figure","type","align","headerAlign","width","valueGetter","_value","status","refused","plan","fields","headers","notices","aria-label","getRowId","rowHeight","getRowHeight","loading","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnFilter","disableColumnSorting","hideFooter","noRowsLabel","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,gBAAgB,EAChBC,oBAAoB,QACf,mDAAkD;AACzD,SAASC,OAAO,QAAQ,uBAAsB;AAC9C,SACEC,MAAM,EACNC,OAAO,QACF,6DAA4D;AACnE,SAASC,cAAc,QAAQ,4DAA2D;AAC1F,OAAOC,oBACLC,iBAAiB,QACZ,+DAA8D;AACrE,SAASC,SAAS,QAAQ,uDAAsD;AAChF,SAASC,iBAAiB,QAAQ,kDAAiD;AACnF,SAASC,gBAAgB,QAAQ,8CAA6C;AAC9E,SAASC,YAAY,QAAQ,sDAAqD;AAClF,SACEC,UAAU,EACVC,yBAAyB,EACzBC,eAAe,EACfC,KAAK,QAGA,eAAc;AACrB,SAASC,KAAK,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AAExD,SACEC,UAAU,EACVC,UAAU,EACVC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,WAAW,EAAEC,OAAO,QAAQ,QAAO;AAC5C,SACEC,mBAAmB,EACnBC,YAAY,EACZC,sBAAsB,QACjB,iCAAgC;AACvC,SACEC,mBAAmB,EACnBC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,QAEV,0CAAsC;AAC7C,SACEC,sBAAsB,EACtBC,8BAA8B,EAC9BC,mBAAmB,EACnBC,2BAA2B,QAEtB,qCAAiC;AAExC;;;;;;;CAOC,GACD,MAAMC,kBAAkB;AA8BxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqFC,GACD,OAAO,SAASC,uBAAuBC,KAAkC;QA2KvDC,aACFA;IA3Kd,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,UAAU,EAAEC,SAAS,EAAEC,OAAO,EAAE,GAAGN;IAC7D,MAAMO,YAAYnB;IAClB,MAAM,EAAEoB,OAAO,EAAEC,WAAWC,IAAI,EAAE,GAAGvB,oBAAoBe;IAEzD;;;;;GAKC,GACD,MAAM,EAAES,MAAMC,UAAU,EAAE,GAAGvB,uBAC3B,IACEN,MACEJ,WAAW4B,WAAW,SAASL,QAAQ,UACvClB,MAAMX,2BAA2B,kBAAkB+B,aACnDtB,QAAQF,eACRC,MAAMiB,kBAAkB,KAE5B;QAACS;QAAWL;QAAQE;KAAW,EAC/B;QAAES,SAAS;IAAM;IAGnB;;;;GAIC,GACD,MAAMZ,QAAQf,QACZ,IAAMM,oBAAoB;YAAEa;YAAWC;QAAQ,IAC/C;QAACD;QAAWC;KAAQ;IAEtB,MAAMQ,WAAWrB,gBAAgBQ;IAEjC,MAAMc,YAAY9B,YAChB,CAAC+B;YAE0BA,gBAGVA;QAJf,MAAMC,KAAKC,OAAOF,GAAG,CAAC,MAAM;QAC5B,MAAMG,YAAYD,QAAOF,iBAAAA,GAAG,CAAC,YAAY,YAAhBA,iBAAoB;QAC7C,OAAO;YACLC;YACAG,MAAMF,QAAOF,mBAAAA,GAAG,CAAC,cAAc,YAAlBA,mBAAsBC;YACnC;;;;;SAKC,GACDI,MACEF,aAAaX,WAAWE,OACpBtC,WAAWG,MAAM+C,cAAc,EAAE;gBAC/Bd;gBACAE;gBACAa,UAAUN;gBACVE;YACF,KACA;YACNK,YAAYL,YACR,iDACA;YACJM,WAAWnD,gBAAgB0C,KAAKU,MAAM;QACxC;IACF,GACA;QAAClB;QAASE;KAAK;IAGjB;;;GAGC,GACD,MAAMiB,UAAU1C,YACd,CAAC+B;YAIgBA;QAHf,MAAMC,KAAKC,OAAOF,GAAG,CAAC,MAAM;QAC5B,OAAO;YACLC;YACAG,MAAMF,QAAOF,mBAAAA,GAAG,CAAC,cAAc,YAAlBA,mBAAsBC;YACnCI,MAAMb,WAAWE,OAAOlD,iBAAiB,QAAQ;gBAAEgD;gBAASE;YAAK,GAAGO,MAAM;YAC1EO,YAAY;YACZI,QAAQrC,mBAAmByB,GAAG,CAAC,QAAQ,EAA2Bf;YAClEwB,WAAWnD,gBAAgB0C,KAAKU,MAAM;QACxC;IACF,GACA;QAAClB;QAASE;QAAMT;KAAM;IAGxB,MAAM4B,cAAc3C,QAClB,IAAM,CAAC0B,qBAAAA,aAAc,EAAE,EAAEkB,KAAK,CAAC,GAAGhC,iBAAiBiC,GAAG,CAACJ,UACvD;QAACf;QAAYe;KAAQ;IAEvB,MAAMK,mBAAmB,CAACpB,qBAAAA,aAAc,EAAE,EAAEc,MAAM,GAAG5B;IACrD,MAAMmC,SAAS/C,QACb,IACEI,oBACEuC,YAAYE,GAAG,CAAC,CAACf;gBAEJA;mBAFa;gBACxBY,QAAQZ,IAAIY,MAAM;gBAClBH,SAAS,GAAET,iBAAAA,IAAIS,SAAS,YAAbT,iBAAiB;YAC9B;aAEJ;QAACa;KAAY;IAGf,MAAMK,gBACJ1B,WAAWE,OACPtC,WAAWG,MAAM4D,cAAc,EAAE;QAAE3B;QAASE;IAAK,KACjD;IACN,MAAM0B,eACJ5B,WAAWE,OAAOjD,qBAAqB,WAAW;QAAE+C;QAASE;IAAK,KAAK;IAEzE,qBACE,KAAC9C;QACCyE,OACElC,WACI,CAAC,6BAA6B,EAAEA,UAAU,GAC1C;kBAGN,cAAA,MAAC1B;YAAM6D,SAAS;;8BACd,KAAC5D;oBAAW6D,SAAQ;oBAAQC,OAAM;8BAC/B,sEACC,kEACA;;8BAEJ,KAACC;oBACCC,SAAQ;oBACRC,MAAK;oBACLC,gBAAe;oBACf1C,QAAQA;oBACRE,YAAYA;oBACZyC,OAAO9B;;8BAWT,MAACrC;oBAAW6D,SAAQ;oBAAUC,OAAM;;wBACjC,kEACC,gEACA,mEACA;wBACDN,8BACC,KAACxE;4BAAQ2D,MAAMa;sCACZ;6BAGH;;;8BAGJ,KAACO;oBACCC,SAAQ;oBACRC,MAAK;oBACLC,gBAAe;oBACf1C,QAAQA;oBACRE,YAAYA;oBACZyC,OAAOlB;oBACPmB,OAAO;;gBASRjB,YAAYH,MAAM,iBACjB,KAACqB;oBACCd,QAAQA;oBACRnB,UAAUA;oBACVkC,IAAI,GAAE/C,cAAAA,MAAM+C,IAAI,YAAV/C,cAAc;oBACpBgD,EAAE,GAAEhD,YAAAA,MAAMgD,EAAE,YAARhD,YAAY;oBAChBiD,WAAWlB;qBAEX;8BASJ,MAACvD;oBAAM6D,SAAS;;sCACd,KAAC5D;4BAAW6D,SAAQ;4BAAWC,OAAM;sCAClC;;sCAEH,MAAC9D;4BAAW6D,SAAQ;4BAAQC,OAAM;;gCAC/B,gEACC,gEACA,gEACA,iEACA;gCACDJ,6BACC,KAAC1E;oCAAQ2D,MAAMe;8CAAe;qCAC5B;gCACHA,eACG,8DACA,8CACA;;;;;;;;AAMhB;AAEA;;;;;;;;;;;;;;;CAeC,GACD,SAASW,cAAc/C,KAOtB;IACC,MAAM,EAAEiC,MAAM,EAAEnB,QAAQ,EAAEkC,IAAI,EAAEC,EAAE,EAAEC,SAAS,EAAE,GAAGlD;IAClD,MAAMmD,OAAO,CAACC,WACZA,WAAWnB,OAAOoB,OAAO,GACrB,CAAC,OAAO,EAAED,SAAS,IAAI,EAAEnB,OAAOoB,OAAO,CAAC,MAAM,CAAC,GAC/C,CAAC,OAAO,EAAEpB,OAAOoB,OAAO,CAAC,KAAK,EAAEpB,OAAOoB,OAAO,KAAK,IAAI,KAAK,KAAK;IACvE,MAAMC,OAAOxC,WACT,CAAC,uBAAuB,EAAEkC,eAAAA,OAAQ,2BAA2B,IAAI,EAC/DC,aAAAA,KAAM,MACP,CAAC,CAAC,GACH;IACJ,qBACE,MAACxE;QAAM6D,SAAS;;0BACd,KAAC5D;gBAAW6D,SAAQ;gBAAWC,OAAM;0BAClC;;0BAEH,MAAC/D;gBAAM8E,WAAU;gBAAMjB,SAAS;gBAAGkB,IAAI;oBAAEC,UAAU;gBAAO;;kCACxD,KAAC9F;wBACC+F,OAAM;wBACNC,OAAO1B,OAAO2B,KAAK,CAACD,KAAK;wBACzBR,MAAMA,KAAKlB,OAAO2B,KAAK,CAACR,QAAQ;;kCAElC,KAACzF;wBACC+F,OAAM;wBACNC,OAAO1B,OAAO4B,MAAM,CAACF,KAAK;wBAC1BR,MAAMA,KAAKlB,OAAO4B,MAAM,CAACT,QAAQ;;kCAEnC,KAACzF;wBACC+F,OAAM;wBACNC,OAAO1B,OAAO6B,WAAW,CAACH,KAAK;wBAC/BR,MAAMA,KAAKlB,OAAO6B,WAAW,CAACV,QAAQ;;kCAExC,KAACzF;wBACC+F,OAAM;wBACNC,OAAO1B,OAAO8B,KAAK,CAACJ,KAAK;wBACzBR,MAAMA,KAAKlB,OAAO8B,KAAK,CAACX,QAAQ;;;;0BAGpC,KAAC1E;gBAAW6D,SAAQ;gBAAUC,OAAM;0BACjC,qEACC,kEACA,mEACA,CAAC,0BAA0B,EAAEc,KAAK,CAAC,CAAC;;YAEvCxC,yBACC,KAACpC;gBAAW6D,SAAQ;gBAAUC,OAAM;0BACjC,iEACC,mEACA,kEACA;+BAGJ,KAAC9D;gBAAW6D,SAAQ;gBAAUC,OAAM;0BACjC,mEACC,8DACA,mEACA;;YAGLU,0BACC,KAAC1E;gBAAMwF,UAAS;0BACb,CAAC,+BAA+B,EAAElE,gBAAgB,eAAe,CAAC,GACjE,iEACA;iBAEF;YACHmC,OAAOgC,MAAM,iBACZ,KAACvF;gBAAW6D,SAAQ;gBAAUC,OAAM;0BACjC,GAAGP,OAAOgC,MAAM,CAAC,UAAU,EAAEhC,OAAOoB,OAAO,CAAC,OAAO,EAClDpB,OAAOgC,MAAM,KAAK,IAAI,OAAO,MAC9B,+DAA+D,CAAC,GAC/D,gEACA;iBAEF;;;AAGV;AACAlB,cAAcmB,WAAW,GAAG;AAE5B,8EAA8E,GAC9E,SAASC,SAASR,KAAgC;IAChD,OAAOA,UAAU,QAAQA,UAAUS,YAAY,MAAMT,MAAMU,cAAc;AAC3E;AAEA,iFAAiF,GACjF,MAAMC,iBAAiB;IACrB;QAAC;QAAS;KAAQ;IAClB;QAAC;QAAU;KAAU;IACrB;QAAC;QAAe;KAAc;IAC9B;QAAC;QAAS;KAAQ;CACnB;AAED,6DAA6D,GAC7D,MAAMC,wBAA0D;IAC9DnD,MAAM;AACR;AAEA;;;;;;;;;;;;;;;CAeC,GACD,SAASqB,YAAYzC,KAQpB;IACC,MAAM,EAAE0C,OAAO,EAAEC,IAAI,EAAEC,cAAc,EAAE1C,MAAM,EAAEE,UAAU,EAAEyC,KAAK,EAAEC,OAAO,EAAE,GACzE9C;IACF,MAAMO,YAAYnB;IAClB,MAAMoF,aAAavG,kBAAkB,CAAC;IACtC,MAAMwG,SAAS5E,4BAA4B2E,WAAWE,WAAW;IACjE,MAAMC,OAAOxG,aAAsC;QACjDQ,YAAYA,WAAW4B,WAAW,SAASL,QAAQ0C;QACnDgC,aAAalF;QACbmF,SAAS;YACPC,SAASL,SAAS;gBAACA;aAAO,GAAG,EAAE;YAC/BM,MAAMnF,oBAAoBgD,gBAAgBxC;QAC5C;QACA4E,MAAM;YAACzE;YAAWL;YAAQ0C;YAAgBxC;SAAW;QACrDS,SAAS;IACX;IACA,MAAMoE,OAAO/F,QAAQ,IAAMyF,KAAKM,IAAI,CAAClD,GAAG,CAACc,QAAQ;QAAC8B,KAAKM,IAAI;QAAEpC;KAAM;IACnE,wEAAwE;IACxE,uDAAuD;IACvD,MAAMqC,OAAO,CAACT,UAAUE,KAAKA,IAAI,KAAK,KAAKM,KAAKvD,MAAM,KAAK;IAC3D,MAAMyD,UAAUjG,QACd,IAAM;YACJ;gBACEkG,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAExE,GAAG,EAAE;wBAiBdA,gBAEeA,iBACVA;yCAnBT,MAACvC;wBAAM+E,IAAI;4BAAEiC,IAAI;4BAAGF,UAAU;wBAAE;;4BAC7BvE,IAAIK,IAAI,iBACP,KAAC3D;gCAAQ2D,MAAML,IAAIK,IAAI;0CAAGL,IAAII,IAAI;+CAElC;;kDACE,KAAC1C;wCAAW6D,SAAQ;kDAASvB,IAAII,IAAI;;kDACrC,KAAC1C;wCAAW6D,SAAQ;wCAAUC,OAAM;kDACjCxB,IAAIQ,UAAU;;;;4BASpB,EAACR,iBAAAA,IAAIS,SAAS,YAAbT,iBAAiB,KAAK,kBACtB,KAACtC;gCAAW6D,SAAQ;gCAAUC,OAAM;0CACjC,CAAC,QAAQ,EAAE,EAACxB,kBAAAA,IAAIS,SAAS,YAAbT,kBAAiB,KAAK,EAAE,eAAe,EAClD,EAACA,kBAAAA,IAAIS,SAAS,YAAbT,kBAAiB,KAAK,MAAM,IAAI,KAAK,KACtC;iCAEF;;;;YAGV;eACI8B,UACAwB,eAAevC,GAAG,CAChB,CAAC,CAAC2D,QAAQL,WAAW,GAA6B,CAAA;oBAChDD,OAAOM;oBACPL;oBACAM,MAAM;oBACNC,OAAO;oBACPC,aAAa;oBACbC,OAAO;oBACPC,aAAa,CAACC,QAAQhF;;4BAAQA;wCAAAA,cAAAA,IAAIY,MAAM,qBAAVZ,WAAY,CAAC0E,OAAO,mBAAI;;oBACtDF,YAAY,CAAC,EAAExE,GAAG,EAAE;4BAAcA;+BAATmD,UAASnD,cAAAA,IAAIY,MAAM,qBAAVZ,WAAY,CAAC0E,OAAO;;gBACxD,CAAA,KAEF,EAAE;SACP,EACD;QAAC5C;KAAQ;IAEX,qBACE,MAACrE;QAAM6D,SAAS;;0BACd,KAAC5D;gBAAW6D,SAAQ;gBAAWC,OAAM;0BAClCE;;YAEFwC,qBACC,KAACxG;gBAAW6D,SAAQ;gBAAQC,OAAM;0BAC/BmC,KAAKsB,MAAM,KAAK,YACb,CAAC,wBAAwB,EAAEtD,KAAK,EAAE,CAAC,GACnC,CAAC,GAAG,EAAEA,KAAK,6BAA6B,EAAEA,KAAK,eAAe,CAAC,GAC/D;+BAGN;;kCACE,KAAC7E;wBACCoI,SAASnI,kBAAkB4G,KAAKwB,IAAI,CAACD,OAAO,EAAE;4BAC5CE,QAAQ1G,uBAAuB0G,MAAM;4BACrCC,SAAS9B;wBACX;wBACA+B,SACE7B,SACI;4BAAC9E;+BAAmCgF,KAAKwB,IAAI,CAACG,OAAO;yBAAC,GACtD3B,KAAKwB,IAAI,CAACG,OAAO;;kCAGzB,KAACtI;wBACCuI,cAAY7D;wBACZuC,MAAMA;wBACNE,SAASA;wBACTqB,UAAU,CAACxF,MAAmBA,IAAIC,EAAE;wBACpCwF,WAAWvI;wBACX,4DAA4D;wBAC5D,kCAAkC;wBAClCwI,cAAc,IAAM;wBACpBC,SAAShC,KAAKsB,MAAM,KAAK;wBACzBW,YAAW;wBACXC,aAAarC,WAAWqC,WAAW;wBACnCC,qBAAqBtC,WAAWsC,mBAAmB;wBACnDC,WAAW;wBACXC,mBAAmB;wBACnBC,oBAAoB;wBACpBC,UAAU;wBACVC,aAAa,CAAC,GAAG,EAAExE,KAAK,kCAAkC,CAAC;;kCAE7D,KAAC9E;wBACC8G,MAAMA,KAAKA,IAAI;wBACfyC,UAAUzC,KAAKyC,QAAQ;wBACvBC,UAAUpC,KAAKvD,MAAM;wBACrB4F,SAAS3C,KAAK2C,OAAO;wBACrBC,cAAc5C,KAAK6C,OAAO;wBAC1BC,kBAAkB9C,KAAK+C,WAAW;;;;;;AAM9C;AAEA3H,uBAAuBmE,WAAW,GAAG;AAErC,eAAenE,uBAAsB"}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
18
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
19
19
|
import { PageHeaderRecord, pluginDocsHelp } from "@aglyn/aglyn";
|
|
20
|
+
import { pluginRecordHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
|
|
20
21
|
import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
|
|
21
22
|
import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
|
|
22
23
|
import { Alert, Box, Button, Chip, Divider, Stack, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
|
|
@@ -33,7 +34,7 @@ import { emailSendTimeMs } from "@aglyn/shared-ui-email-campaigns/model/email-re
|
|
|
33
34
|
import { campaignSendDisplay, campaignSendProgress } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
|
|
34
35
|
import { campaignSendDoc, campaignSendReportDoc } from "./campaign-queries.js";
|
|
35
36
|
import { orgSiteHubPath, useMarketingOrgId, useMarketingOrgMount } from "./marketing-org-mount.js";
|
|
36
|
-
import {
|
|
37
|
+
import { useRecordRouteContext } from "./record-route-context.js";
|
|
37
38
|
/**
|
|
38
39
|
* The help affordance, hoisted so BOTH headers carry it.
|
|
39
40
|
*
|
|
@@ -132,8 +133,8 @@ import { useEmailsHubPath } from "./use-emails-hub-path.js";
|
|
|
132
133
|
*/ export function CampaignReportCard(props) {
|
|
133
134
|
var _ref, _ref1;
|
|
134
135
|
const { hostId, campaignId, basePath } = props;
|
|
135
|
-
// The
|
|
136
|
-
const
|
|
136
|
+
// The scope the message's own page is asked in: it belongs to the Emails page.
|
|
137
|
+
const routeContext = useRecordRouteContext();
|
|
137
138
|
const orgMount = useMarketingOrgMount();
|
|
138
139
|
const { orgId } = useMarketingOrgId(hostId);
|
|
139
140
|
const firestore = useFirestore();
|
|
@@ -188,7 +189,8 @@ import { useEmailsHubPath } from "./use-emails-hub-path.js";
|
|
|
188
189
|
* Where this send's links lead. The message's page is on the Emails page —
|
|
189
190
|
* the site's under a site, the organization's over the org; the
|
|
190
191
|
* conversions list is always the sending site's.
|
|
191
|
-
*/ const
|
|
192
|
+
*/ const messageHrefOf = (id)=>routeContext ? pluginRecordHref('emailMessage', routeContext, id) : null;
|
|
193
|
+
const messageHref = messageHrefOf(campaignId);
|
|
192
194
|
const conversionsHub = orgMount ? orgSiteHubPath(orgMount, String((_ref = campaign == null ? void 0 : campaign.hostId) != null ? _ref : ''), 'marketing') : basePath;
|
|
193
195
|
const report = campaignReport(campaign == null ? void 0 : campaign.stats);
|
|
194
196
|
const linkReport = campaignLinkReport(links);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-report-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Box,\n Button,\n Chip,\n Divider,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport {\n campaignLinkReport,\n campaignReport,\n type CampaignLinkRollup,\n type CampaignStats,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-report'\nimport {\n campaignRevenueReport,\n type CampaignRevenueRollup,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-revenue'\nimport {\n campaignConversionsReport,\n type CampaignConversionsRollup,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions'\n/*\n * The three renderers every email report shares. Imported rather than kept\n * here, so \"a rate prints its denominator\" is one implementation and not a\n * convention each new card has to remember.\n */\nimport {\n Figure,\n MoneyFigure,\n MoneyPerUnitRow,\n percent,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n emailSendTimeMs,\n} from '@aglyn/shared-ui-email-campaigns/model/email-record'\nimport {\n campaignSendDisplay,\n campaignSendProgress,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport { campaignSendDoc, campaignSendReportDoc } from './campaign-queries'\nimport {\n orgSiteHubPath,\n useMarketingOrgId,\n useMarketingOrgMount,\n} from './marketing-org-mount'\nimport { useEmailsHubPath } from './use-emails-hub-path'\n\n/**\n * The help affordance, hoisted so BOTH headers carry it.\n *\n * The unreadable-campaign branch below renders its own `CardDisplay`, and a\n * header without help is a header a reader has no way out of — which is\n * exactly the state that branch describes.\n */\nconst reportDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'What one campaign did: delivery, opens, clicks, bounces, complaints, ' +\n 'unsubscribes and the revenue it was credited with, each with the ' +\n 'population it is measured against.',\n})\n\nexport interface CampaignReportCardProps {\n /** The site, or `null` on the org Marketing hub. */\n hostId: string | null\n campaignId: string\n /** The marketing hub URL, for the way back to the campaigns list. */\n basePath: string\n}\n\n/**\n * ONE CAMPAIGN, MEASURED — and every denominator on screen.\n *\n * ## What was missing\n *\n * Opens and clicks have been recorded on the campaign document since AGL-268\n * and displayed as two words in a history row: `12 opens · 3 clicks`. Nothing\n * anywhere showed a rate, a delivery count, a bounce, a complaint or an\n * unsubscribe, and the populations the send itself measured — how large the\n * audience was, how many the consent rule refused, how many were already\n * suppressed — were computed on every send and written nowhere.\n *\n * ## The rule this screen is built around\n *\n * **Every rate names its denominator, in the same breath as the number.**\n *\n * That is not decoration. An open rate over `sent` and an open rate over\n * `delivered` are different numbers that share a label, and the difference\n * between them is exactly the mail that bounced — so a report that showed one\n * while the reader assumed the other would be wrong by however bad the\n * address list is, which is the case where being wrong matters most. Worse,\n * \"click rate\" is used in the wild for two unrelated quantities — clicks over\n * delivered, and clicks over openers — and they differ by a factor of three\n * or four on ordinary mail.\n *\n * So the arithmetic is not done here at all. `campaign-report.ts` returns\n * each rate as a triple carrying `denominatorLabel`, this file renders that\n * label, and a rate whose denominator is zero or unrecorded comes back `null`\n * and is drawn as \"—\" with a caveat naming the reason. There is no path\n * through this component that prints a percentage without saying what it is a\n * percentage of.\n *\n * ## What the page is about comes before how the mail did\n *\n * A campaign of one email is the ordinary shape, and this card is the page it\n * gets: the URL names a send, so there is no container above it and no list of\n * emails to draw. That collapse is honest, but it decided what the page was\n * ABOUT — delivery, engagement and rates are mechanics of a message, and a\n * page that led with them at length read as a mail report wearing the word\n * campaign.\n *\n * So the outcomes lead: what it caused, what it earned, where it sent people.\n * Two of those headings are the ones `campaign-reach-sections.tsx` puts at the\n * top of a campaign holding SEVERAL emails, and they are the same words here\n * deliberately — a merchant who opens a campaign of one and a campaign of six\n * is asking the same question and should not have to learn two vocabularies to\n * find the answer. The email's own figures follow, under a row that names the\n * message they belong to.\n *\n * Nothing about the reads changed with the order. The conversions, revenue and\n * destination figures were already on this page and already came from the four\n * documents below; they were simply underneath the mail.\n *\n * ## Three documents, whatever the campaign's size\n *\n * The whole screen is one campaign document plus one link-rollup document\n * plus one revenue-rollup document. All three are single-document listens, so\n * the cost does not move with the audience — a report over a 50,000-recipient\n * send reads the same three documents as one over ten. Aggregating the\n * per-recipient delivery log on mount would have been the obvious\n * implementation and would have read one document per recipient, every time\n * anybody opened the page; joining orders to campaigns at read time would\n * have been the same mistake one collection along, which is why the join is\n * done once at the sale and stored.\n *\n * ## No per-recipient view here, deliberately\n *\n * Every number on this screen is an aggregate that carries no address, and\n * that is what makes it three single-document reads. A per-recipient list is a\n * different read and a different question, so it lives where it is asked:\n * the design report's recipients table, which resolves the campaigns from the\n * design on a server that has already checked the reader's site role, and\n * returns only rows tagged with that site.\n *\n * ## The same four documents at the org level\n *\n * A send and its rollups are the org's, so the org hub reads the same four\n * documents. The one link that leads to a SITE fact — the conversions list,\n * which is that site's visitors — goes to the site the send was sent as.\n *\n * The other per-recipient view — the staff delivery log on the user detail\n * page — spans every site on the install and is behind a staff claim that\n * records who looked. Neither reaches this card: adding a recipient list here\n * would put an unbounded read behind a widget whose whole cost model is that\n * it has none.\n */\nexport function CampaignReportCard(props: CampaignReportCardProps) {\n const { hostId, campaignId, basePath } = props\n // The sibling hub: the message's own page belongs to the Emails console.\n const emailsHub = useEmailsHubPath()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(hostId)\n const firestore = useFirestore()\n\n const { data: campaign, status } = useFirestoreDoc<\n Record<string, unknown> & { stats?: CampaignStats }\n >(\n () => (orgId ? campaignSendDoc(firestore, orgId, campaignId) : null),\n [firestore, orgId, campaignId],\n )\n /*\n * The link rollup, its own document rather than a field on the campaign.\n *\n * A map of destinations grows with the campaign's content and a campaign\n * document is read by the history list, the glance widget and the send\n * path; putting an unbounded map on it would make every one of those reads\n * larger. Split, the rollup is read by exactly the screen that renders it.\n */\n const { data: links } = useFirestoreDoc<CampaignLinkRollup>(\n () =>\n orgId ? campaignSendReportDoc(firestore, orgId, campaignId, 'links') : null,\n [firestore, orgId, campaignId],\n )\n /*\n * The revenue rollup, a third single-document listen.\n *\n * Its own document beside the link rollup rather than a field on the\n * campaign, for the reason the link rollup is: the campaign document is\n * read by the history list, the glance widget and the send path, and a map\n * that grows with the campaign's sales would enlarge every one of those\n * reads. The cost model in this file's header is unchanged in the way that\n * matters — the page still reads a fixed number of documents whatever the\n * audience, and no per-order or per-recipient read exists anywhere on it.\n */\n const { data: revenue } = useFirestoreDoc<CampaignRevenueRollup>(\n () =>\n orgId\n ? campaignSendReportDoc(firestore, orgId, campaignId, 'revenue')\n : null,\n [firestore, orgId, campaignId],\n )\n\n /*\n * The conversions rollup, a fourth single-document listen.\n *\n * Beside `reports/revenue` and read the same way, because it is the same\n * join answering the other half of the question: revenue is what a campaign\n * EARNED from people who named themselves at checkout, and this is what it\n * CAUSED among people who were anonymous until the moment they submitted,\n * signed up or booked. The cost model in this file's header is unchanged in\n * the way that matters — a fixed number of documents whatever the audience,\n * and no per-conversion read anywhere on the page.\n */\n const { data: conversions } = useFirestoreDoc<CampaignConversionsRollup>(\n () =>\n orgId\n ? campaignSendReportDoc(firestore, orgId, campaignId, 'conversions')\n : null,\n [firestore, orgId, campaignId],\n )\n /*\n * Where this send's links lead. The message's page is on the Emails page —\n * the site's under a site, the organization's over the org; the\n * conversions list is always the sending site's.\n */\n const messageHref = emailsHub ? `${emailsHub}/messages/${campaignId}` : null\n const conversionsHub = orgMount\n ? orgSiteHubPath(orgMount, String(campaign?.hostId ?? ''), 'marketing')\n : basePath\n\n const report = campaignReport(campaign?.stats)\n const linkReport = campaignLinkReport(links)\n const revenueReport = campaignRevenueReport({\n rollup: revenue,\n /*\n * The DENOMINATOR, handed over from the same document read at the same\n * instant rather than re-read here. `report.delivered` is `null` when no\n * delivery event was ever recorded, and it is passed as `null` so the\n * revenue figures over it are withheld for exactly the reason every other\n * rate over it is.\n */\n delivered: report.delivered,\n midFlight: campaign\n ? campaignSendProgress(campaign as never).state === 'sending'\n : false,\n })\n /*\n * No denominator is handed in, unlike the revenue report. A conversion RATE\n * over delivered messages would repeat the defect the revenue section\n * refuses for orders — one visitor can submit two forms, so the quotient\n * passes 100% without anything being wrong — and counting distinct people\n * instead needs a document per person per campaign, which is the\n * per-recipient read this screen's cost model exists to refuse.\n */\n const conversionsReport = campaignConversionsReport({ rollup: conversions })\n const subject = String(campaign?.subject ?? 'Campaign')\n const sendTimeMs = campaign ? emailSendTimeMs(campaign) : 0\n\n const backButton = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'All campaigns'}\n </Button>\n )\n\n // Nothing to say until the read has had its chance to answer.\n if (!campaign && (status === 'loading' || !orgId)) return null\n\n if (!campaign) {\n return (\n <CardDisplay\n header={'Campaign report'}\n help={reportDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: backButton }}\n >\n {/*\n * Not \"no data\". A campaign that cannot be read is a different\n * situation from one with no engagement, and rendering an empty\n * report for the first is how somebody comes to believe a campaign\n * they sent reached nobody.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This campaign could not be loaded. It may have been deleted.'}\n </Typography>\n </CardDisplay>\n )\n }\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Campaign report'}\n help={reportDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: backButton }}\n >\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n\n {/*==========================================\n * WHAT THIS CAMPAIGN CAUSED — the page's first section, and the\n * heading a campaign of several emails leads with too.\n *\n * The same join as the revenue below, credited under the same rule,\n * answering it for people who were anonymous until the moment being\n * counted: they arrived from a campaign link, browsed, and only\n * became somebody when they submitted a form, signed up or booked.\n *\n * THE FOUR FIGURES ARE NEVER ADDED. One person filling in one form\n * writes a submission, a contact and a lead, so a total would count\n * that visit three times — and it would look like a bigger version\n * of a real number, which is why the model carries no total for this\n * JSX to reach for and the caveat says so in words above them.\n *\n * They are laid out in a row of independent `Figure`s rather than in\n * a table with a footer, because a footer row is where a reader\n * expects the sum to be.\n *=========================================*/}\n <Section title=\"What it caused\">\n {conversionsReport.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {conversionsReport.any ? (\n <Stack spacing={1}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n {conversionsReport.kinds.map((entry) => (\n <Figure\n key={entry.kind}\n label={entry.label}\n value={entry.value}\n note={entry.note}\n />\n ))}\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Credited ${conversionsReport.model === 'last-click' ? 'to the last campaign whose link the visitor clicked' : `under the ${conversionsReport.model} model`}, ` +\n `within ${conversionsReport.windowDays} days of that click. ` +\n 'Somebody who converted without ever following a campaign ' +\n 'link is credited to no campaign at all — nothing is ' +\n 'inferred from a referrer — so these are a floor rather ' +\n 'than everything this campaign influenced. The ones ' +\n 'credited to nobody are counted under Conversions in the ' +\n 'marketing console.'}\n </Typography>\n {/*\n THE RECORDS BEHIND THE FIGURES. The rollup says how many; the\n list says which, and it is the only place the uncredited half\n is counted. A link rather than a table here — the records are\n a paged read and this page's whole cost model is a fixed\n number of documents whatever the audience.\n */}\n {conversionsHub ? (\n <Box>\n <Button\n component={AppLink as any}\n {...({\n componentVariant: 'naked',\n nativeButton: false,\n } as any)}\n href={`${conversionsHub}/conversions/${campaignId}`}\n size=\"small\"\n color=\"primary\"\n >\n {'See these conversions'}\n </Button>\n </Box>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {conversionsReport.recorded\n ? 'Nothing has been credited to this campaign yet.'\n : 'No conversions have been attributed to this campaign. A ' +\n 'form submission, lead, contact or booking is credited to ' +\n 'the last campaign whose link the visitor clicked, within ' +\n `${conversionsReport.windowDays} days — a campaign sent ` +\n 'before that was recorded will never show a figure here.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT THIS CAMPAIGN EARNED.\n *\n * The merchant's second question, and the one every compared\n * product answers by reconstructing revenue probabilistically\n * because it does not own the order. Here it is a join: the click\n * and the order are rows in one database, so there is no tracking\n * script, no catalog sync and nothing estimated except the model.\n *\n * THE MODEL IS ON SCREEN, in a sentence, above the money. A revenue\n * figure whose rule the reader cannot state is worse than no\n * figure, because they will use it anyway — and the two things they\n * have to know to use it are which campaign gets the credit when\n * several touched the buyer, and how long a click stays creditable.\n *\n * NET LEADS. Gross and refunded are both shown beside it, because\n * \"this campaign made $4,000\" and \"this campaign made $4,000 of\n * which $3,100 came back\" are different facts about whether to send\n * another one.\n *=========================================*/}\n <Section title=\"Revenue\">\n {revenueReport.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {revenueReport.currencies.length ? (\n <Stack spacing={3}>\n {revenueReport.currencies.map((entry) => (\n <Stack key={entry.currency} spacing={1}>\n {/*\n * The currency is a HEADING when there is more than one,\n * and absent when there is one. A per-currency label on a\n * report that only ever shows dollars is noise; the moment\n * there are two, the reader has to be able to see at a\n * glance that the blocks are not addable — which is also\n * what the caveat above says in words.\n */}\n {revenueReport.multiCurrency ? (\n <Typography variant=\"subtitle2\">\n {entry.currency.toUpperCase()}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <MoneyFigure\n label=\"Net revenue\"\n cents={entry.netCents}\n currency={entry.currency}\n note=\"after refunds\"\n />\n <MoneyFigure\n label=\"Gross revenue\"\n cents={entry.grossCents}\n currency={entry.currency}\n note=\"as charged\"\n />\n <MoneyFigure\n label=\"Refunded\"\n cents={entry.refundedCents}\n currency={entry.currency}\n note=\"handed back\"\n />\n <Figure\n label=\"Orders\"\n value={entry.orders}\n note=\"credited to this campaign\"\n />\n <Figure\n label=\"Fully refunded\"\n value={entry.refundedOrders}\n note=\"of those orders\"\n />\n </Stack>\n <MoneyPerUnitRow\n label=\"Net revenue per delivered message\"\n figure={entry.netPerDelivered}\n />\n </Stack>\n ))}\n {/*\n * NO CONVERSION RATE, and this is where a reader would look\n * for one. Orders over delivered is not a rate: one buyer can\n * place two orders, so the quotient passes 100% without\n * anything being wrong — the same defect that keeps `opens`\n * out of the open-rate numerator two sections above. Counting\n * distinct buyers instead would need a document per person per\n * campaign, which is the per-recipient read this screen's cost\n * model exists to refuse. The order count is shown as a count.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Credited ${revenueReport.model === 'last-click' ? 'to the last campaign whose link the buyer clicked' : `under the ${revenueReport.model} model`}, ` +\n `within ${revenueReport.windowDays} days of that click. ` +\n 'Clicks only — an open is not treated as evidence that ' +\n 'anybody read the email. An order placed by somebody who ' +\n 'never clicked, or who checked out without giving an ' +\n 'address, is credited to no campaign, so this is a floor ' +\n 'rather than every sale this campaign influenced.'}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {revenueReport.recorded\n ? 'No orders have been credited to this campaign.'\n : 'No revenue has been attributed to this campaign. Orders ' +\n 'are credited to the last campaign whose link the buyer ' +\n `clicked, within ${revenueReport.windowDays} days — a ` +\n 'campaign sent before that was recorded, or one on a site ' +\n 'with no store, will never show a figure here.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n <Section title=\"Where it sent people\">\n {/*\n The heading a campaign of several emails carries over the same\n question, so the two shapes of campaign page read alike. A\n campaign owns no page — this is where its mail was actually\n followed to, which is the only account of it the data holds.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages this campaign’s links were followed to.'}\n </Typography>\n {linkReport.rows.length ? (\n <>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n <TableCell align=\"right\">{'Share'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {linkReport.rows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {row.clicks.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.share\n ? `${percent(row.share.value)} of ${row.share.denominator.toLocaleString()} ${row.share.denominatorLabel}`\n : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n {/*\n * The normalisation, stated rather than left for somebody to\n * discover from a total that does not add up. Two links to the\n * same page distinguished only by their UTM tags are one row\n * here, and a merchant comparing this table with the campaign\n * they built has to be told that before they conclude a link\n * is missing.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Counted by address and path — query strings are dropped, so ' +\n 'two links to the same page with different tracking ' +\n 'parameters count as one row.'}\n </Typography>\n {linkReport.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${linkReport.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived without a destination and are not in this table.'}\n </Typography>\n ) : null}\n {linkReport.overflowClicks ? (\n <Alert severity=\"info\">\n {`This campaign has more distinct destinations than the ` +\n `rollup keeps. ${linkReport.overflowClicks.toLocaleString()} ` +\n 'clicks landed on links past that limit and are counted ' +\n 'in the click total above but not in this table.'}\n </Alert>\n ) : null}\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.clicks\n ? 'Clicks were recorded for this campaign, but none of them ' +\n 'carried a destination, so there is nothing to break down ' +\n 'by link.'\n : 'No link clicks have been recorded for this campaign.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n {/*==========================================\n * THE EMAIL THE FIGURES BELOW CAME FROM — and the hinge between the\n * two halves of this page.\n *\n * This screen is reached at `/marketing/campaigns/{sendId}`, which\n * is a CAMPAIGN url resolving to a send — the fall-through that keeps\n * every unsubscribe footer and every pasted report link working. So\n * a reader arrives from the campaigns table, where the row carries a\n * \"Single send\" chip, and everything under this row is a fact about\n * a message rather than about the campaign.\n *\n * Figures with no visible source read as a campaign holding no\n * emails while reporting real numbers, which is a contradiction\n * rather than a shortage of detail. Naming the one email — and\n * saying it IS the campaign, which is what the chip already claims\n * and what `campaignListRows` already does at read time — is what\n * makes the page answer the question it raises.\n *\n * One row, from the document this card already read: no query, no\n * second listen, and the two-document cost the header describes is\n * unchanged.\n *=========================================*/}\n <Section title=\"The email in this campaign\">\n <Stack spacing={1}>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The MESSAGE's own page, which the Emails console owns — hence\n the sibling hub rather than this surface's `basePath`. Plain\n text until that hub resolves: a link with no destination is\n worse than none, and the subject is what the reader came for\n either way.\n */}\n {messageHref ? (\n <AppLink href={messageHref}>{subject}</AppLink>\n ) : (\n <Typography variant=\"body2\">{subject}</Typography>\n )}\n {/*\n What the email is DOING, not the field it stores. One\n delivering an audience larger than one batch is written back\n as `scheduled` between runs, so a chip rendering the status\n said \"Scheduled\" at the head of a report of five hundred\n deliveries.\n */}\n <Chip\n size=\"small\"\n label={campaignSendDisplay(campaign as never).label}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label=\"Single send\"\n title={\n 'One email, sent on its own rather than as part of a ' +\n 'campaign of several. Its report and its unsubscribe links ' +\n 'are unchanged.'\n }\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {sendTimeMs\n ? new Date(sendTimeMs).toLocaleString()\n : 'not recorded'}\n </Typography>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign is one email, so the sections above are what ' +\n 'that one email caused. What follows is the mail itself — ' +\n 'how it was delivered and how it was read. Open the message ' +\n 'for its body and the people it reached.'}\n </Typography>\n </Stack>\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT HAPPENED TO THE MAIL.\n *\n * Counts first and rates second, in that order and not the other\n * way round, because a rate is only readable once you know what it\n * was taken over — and because the counts are the part that is\n * always true. Every rate below can be absent; none of these can.\n *=========================================*/}\n <Section title=\"Delivery\">\n <Stack direction=\"row\" spacing={4} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Addressed\"\n value={report.recipients}\n note=\"after the per-send cap\"\n />\n <Figure\n label=\"Sent\"\n value={report.sent}\n note=\"accepted by the provider\"\n />\n <Figure\n label=\"Delivered\"\n value={report.delivered}\n note=\"accepted by the receiving server\"\n />\n <Figure label=\"Bounced\" value={report.bounced} note=\"of sent\" />\n <Figure\n label=\"Marked as spam\"\n value={report.complained}\n note=\"of delivered\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Engagement\">\n {/*\n * BOTH the event count and the distinct count, side by side.\n *\n * `Opens` is every open event — one reader opening four times is\n * four — and it is the number this product has always shown. It\n * cannot be a rate numerator: divided by anything it exceeds 100%\n * the moment somebody reads an email twice. `Readers who opened`\n * is the distinct count, and it is what the rate below divides.\n * Showing only one of them would either hide activity or invite a\n * rate nobody can defend, so both are here with different names.\n */}\n <Stack direction=\"row\" spacing={4} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Opens\"\n value={report.opens}\n note=\"every open, repeats included\"\n />\n <Figure\n label=\"Readers who opened\"\n value={report.uniqueOpens}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Clicks\"\n value={report.clicks}\n note=\"every click, repeats included\"\n />\n <Figure\n label=\"Readers who clicked\"\n value={report.uniqueClicks}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Unsubscribed\"\n value={report.unsubscribes}\n note=\"through this campaign's link\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Rates\">\n <Stack spacing={1}>\n <RateRow label=\"Delivery rate\" rate={report.rates.delivery} />\n <RateRow label=\"Open rate\" rate={report.rates.open} />\n <RateRow label=\"Click rate\" rate={report.rates.click} />\n <RateRow\n label=\"Click-to-open rate\"\n rate={report.rates.clickToOpen}\n />\n <RateRow label=\"Bounce rate\" rate={report.rates.bounce} />\n <RateRow label=\"Complaint rate\" rate={report.rates.complaint} />\n <RateRow\n label=\"Unsubscribe rate\"\n rate={report.rates.unsubscribe}\n />\n </Stack>\n </Section>\n\n {report.populations.length ? (\n <>\n <Divider />\n <Section title=\"Who this went to\">\n {/*\n * The populations the SEND measured, recorded at send time.\n *\n * Not recomputed here, and the note under the heading says so:\n * consent records change and addresses get suppressed, so\n * asking the list today produces a number that is true of the\n * list and false of the campaign.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Measured when this campaign was sent, and stored as it was ' +\n 'then. These figures describe the send, not the audience ' +\n 'as it stands today.'}\n </Typography>\n <ScrollTable size=\"small\">\n <TableBody>\n {report.populations.map((population) => (\n <TableRow key={population.id}>\n <TableCell>{population.label}</TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {population.count.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`of ${population.of.toLocaleString()} ${population.ofLabel}`}\n </Typography>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the send; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord\n title={\n campaign ? String(campaign.subject || '') || campaignId : undefined\n }\n />\n {card}\n </>\n )\n}\nCampaignReportCard.displayName = 'CampaignReportCard'\n\nexport default CampaignReportCard\n"],"names":["PageHeaderRecord","pluginDocsHelp","AppLink","CardDisplay","ScrollTable","Alert","Box","Button","Chip","Divider","Stack","TableBody","TableCell","TableHead","TableRow","Typography","useFirestore","useFirestoreDoc","campaignLinkReport","campaignReport","campaignRevenueReport","campaignConversionsReport","Figure","MoneyFigure","MoneyPerUnitRow","percent","RateRow","Section","emailSendTimeMs","campaignSendDisplay","campaignSendProgress","campaignSendDoc","campaignSendReportDoc","orgSiteHubPath","useMarketingOrgId","useMarketingOrgMount","useEmailsHubPath","reportDocsHelp","anchor","excerpt","CampaignReportCard","props","hostId","campaignId","basePath","emailsHub","orgMount","orgId","firestore","data","campaign","status","links","revenue","conversions","messageHref","conversionsHub","String","report","stats","linkReport","revenueReport","rollup","delivered","midFlight","state","conversionsReport","subject","sendTimeMs","backButton","component","componentVariant","nativeButton","href","size","color","header","help","contentGutterX","contentGutterY","HeaderProps","action","variant","card","spacing","caveats","map","caveat","severity","message","id","title","any","direction","useFlexGap","sx","flexWrap","kinds","entry","label","value","note","kind","model","windowDays","recorded","currencies","length","multiCurrency","currency","toUpperCase","cents","netCents","grossCents","refundedCents","orders","refundedOrders","figure","netPerDelivered","rows","align","row","wordBreak","url","fontWeight","clicks","toLocaleString","share","denominator","denominatorLabel","unattributedClicks","overflowClicks","alignItems","Date","recipients","sent","bounced","complained","opens","uniqueOpens","uniqueClicks","unsubscribes","rate","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","populations","population","count","of","ofLabel","undefined","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-report-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { PageHeaderRecord, pluginDocsHelp } from '@aglyn/aglyn'\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Box,\n Button,\n Chip,\n Divider,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport {\n campaignLinkReport,\n campaignReport,\n type CampaignLinkRollup,\n type CampaignStats,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-report'\nimport {\n campaignRevenueReport,\n type CampaignRevenueRollup,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-revenue'\nimport {\n campaignConversionsReport,\n type CampaignConversionsRollup,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions'\n/*\n * The three renderers every email report shares. Imported rather than kept\n * here, so \"a rate prints its denominator\" is one implementation and not a\n * convention each new card has to remember.\n */\nimport {\n Figure,\n MoneyFigure,\n MoneyPerUnitRow,\n percent,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n emailSendTimeMs,\n} from '@aglyn/shared-ui-email-campaigns/model/email-record'\nimport {\n campaignSendDisplay,\n campaignSendProgress,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport { campaignSendDoc, campaignSendReportDoc } from './campaign-queries'\nimport {\n orgSiteHubPath,\n useMarketingOrgId,\n useMarketingOrgMount,\n} from './marketing-org-mount'\nimport { useRecordRouteContext } from './record-route-context'\n\n/**\n * The help affordance, hoisted so BOTH headers carry it.\n *\n * The unreadable-campaign branch below renders its own `CardDisplay`, and a\n * header without help is a header a reader has no way out of — which is\n * exactly the state that branch describes.\n */\nconst reportDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'What one campaign did: delivery, opens, clicks, bounces, complaints, ' +\n 'unsubscribes and the revenue it was credited with, each with the ' +\n 'population it is measured against.',\n})\n\nexport interface CampaignReportCardProps {\n /** The site, or `null` on the org Marketing hub. */\n hostId: string | null\n campaignId: string\n /** The marketing hub URL, for the way back to the campaigns list. */\n basePath: string\n}\n\n/**\n * ONE CAMPAIGN, MEASURED — and every denominator on screen.\n *\n * ## What was missing\n *\n * Opens and clicks have been recorded on the campaign document since AGL-268\n * and displayed as two words in a history row: `12 opens · 3 clicks`. Nothing\n * anywhere showed a rate, a delivery count, a bounce, a complaint or an\n * unsubscribe, and the populations the send itself measured — how large the\n * audience was, how many the consent rule refused, how many were already\n * suppressed — were computed on every send and written nowhere.\n *\n * ## The rule this screen is built around\n *\n * **Every rate names its denominator, in the same breath as the number.**\n *\n * That is not decoration. An open rate over `sent` and an open rate over\n * `delivered` are different numbers that share a label, and the difference\n * between them is exactly the mail that bounced — so a report that showed one\n * while the reader assumed the other would be wrong by however bad the\n * address list is, which is the case where being wrong matters most. Worse,\n * \"click rate\" is used in the wild for two unrelated quantities — clicks over\n * delivered, and clicks over openers — and they differ by a factor of three\n * or four on ordinary mail.\n *\n * So the arithmetic is not done here at all. `campaign-report.ts` returns\n * each rate as a triple carrying `denominatorLabel`, this file renders that\n * label, and a rate whose denominator is zero or unrecorded comes back `null`\n * and is drawn as \"—\" with a caveat naming the reason. There is no path\n * through this component that prints a percentage without saying what it is a\n * percentage of.\n *\n * ## What the page is about comes before how the mail did\n *\n * A campaign of one email is the ordinary shape, and this card is the page it\n * gets: the URL names a send, so there is no container above it and no list of\n * emails to draw. That collapse is honest, but it decided what the page was\n * ABOUT — delivery, engagement and rates are mechanics of a message, and a\n * page that led with them at length read as a mail report wearing the word\n * campaign.\n *\n * So the outcomes lead: what it caused, what it earned, where it sent people.\n * Two of those headings are the ones `campaign-reach-sections.tsx` puts at the\n * top of a campaign holding SEVERAL emails, and they are the same words here\n * deliberately — a merchant who opens a campaign of one and a campaign of six\n * is asking the same question and should not have to learn two vocabularies to\n * find the answer. The email's own figures follow, under a row that names the\n * message they belong to.\n *\n * Nothing about the reads changed with the order. The conversions, revenue and\n * destination figures were already on this page and already came from the four\n * documents below; they were simply underneath the mail.\n *\n * ## Three documents, whatever the campaign's size\n *\n * The whole screen is one campaign document plus one link-rollup document\n * plus one revenue-rollup document. All three are single-document listens, so\n * the cost does not move with the audience — a report over a 50,000-recipient\n * send reads the same three documents as one over ten. Aggregating the\n * per-recipient delivery log on mount would have been the obvious\n * implementation and would have read one document per recipient, every time\n * anybody opened the page; joining orders to campaigns at read time would\n * have been the same mistake one collection along, which is why the join is\n * done once at the sale and stored.\n *\n * ## No per-recipient view here, deliberately\n *\n * Every number on this screen is an aggregate that carries no address, and\n * that is what makes it three single-document reads. A per-recipient list is a\n * different read and a different question, so it lives where it is asked:\n * the design report's recipients table, which resolves the campaigns from the\n * design on a server that has already checked the reader's site role, and\n * returns only rows tagged with that site.\n *\n * ## The same four documents at the org level\n *\n * A send and its rollups are the org's, so the org hub reads the same four\n * documents. The one link that leads to a SITE fact — the conversions list,\n * which is that site's visitors — goes to the site the send was sent as.\n *\n * The other per-recipient view — the staff delivery log on the user detail\n * page — spans every site on the install and is behind a staff claim that\n * records who looked. Neither reaches this card: adding a recipient list here\n * would put an unbounded read behind a widget whose whole cost model is that\n * it has none.\n */\nexport function CampaignReportCard(props: CampaignReportCardProps) {\n const { hostId, campaignId, basePath } = props\n // The scope the message's own page is asked in: it belongs to the Emails page.\n const routeContext = useRecordRouteContext()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(hostId)\n const firestore = useFirestore()\n\n const { data: campaign, status } = useFirestoreDoc<\n Record<string, unknown> & { stats?: CampaignStats }\n >(\n () => (orgId ? campaignSendDoc(firestore, orgId, campaignId) : null),\n [firestore, orgId, campaignId],\n )\n /*\n * The link rollup, its own document rather than a field on the campaign.\n *\n * A map of destinations grows with the campaign's content and a campaign\n * document is read by the history list, the glance widget and the send\n * path; putting an unbounded map on it would make every one of those reads\n * larger. Split, the rollup is read by exactly the screen that renders it.\n */\n const { data: links } = useFirestoreDoc<CampaignLinkRollup>(\n () =>\n orgId ? campaignSendReportDoc(firestore, orgId, campaignId, 'links') : null,\n [firestore, orgId, campaignId],\n )\n /*\n * The revenue rollup, a third single-document listen.\n *\n * Its own document beside the link rollup rather than a field on the\n * campaign, for the reason the link rollup is: the campaign document is\n * read by the history list, the glance widget and the send path, and a map\n * that grows with the campaign's sales would enlarge every one of those\n * reads. The cost model in this file's header is unchanged in the way that\n * matters — the page still reads a fixed number of documents whatever the\n * audience, and no per-order or per-recipient read exists anywhere on it.\n */\n const { data: revenue } = useFirestoreDoc<CampaignRevenueRollup>(\n () =>\n orgId\n ? campaignSendReportDoc(firestore, orgId, campaignId, 'revenue')\n : null,\n [firestore, orgId, campaignId],\n )\n\n /*\n * The conversions rollup, a fourth single-document listen.\n *\n * Beside `reports/revenue` and read the same way, because it is the same\n * join answering the other half of the question: revenue is what a campaign\n * EARNED from people who named themselves at checkout, and this is what it\n * CAUSED among people who were anonymous until the moment they submitted,\n * signed up or booked. The cost model in this file's header is unchanged in\n * the way that matters — a fixed number of documents whatever the audience,\n * and no per-conversion read anywhere on the page.\n */\n const { data: conversions } = useFirestoreDoc<CampaignConversionsRollup>(\n () =>\n orgId\n ? campaignSendReportDoc(firestore, orgId, campaignId, 'conversions')\n : null,\n [firestore, orgId, campaignId],\n )\n /*\n * Where this send's links lead. The message's page is on the Emails page —\n * the site's under a site, the organization's over the org; the\n * conversions list is always the sending site's.\n */\n const messageHrefOf = (id: string): string | null =>\n routeContext ? pluginRecordHref('emailMessage', routeContext, id) : null\n const messageHref = messageHrefOf(campaignId)\n const conversionsHub = orgMount\n ? orgSiteHubPath(orgMount, String(campaign?.hostId ?? ''), 'marketing')\n : basePath\n\n const report = campaignReport(campaign?.stats)\n const linkReport = campaignLinkReport(links)\n const revenueReport = campaignRevenueReport({\n rollup: revenue,\n /*\n * The DENOMINATOR, handed over from the same document read at the same\n * instant rather than re-read here. `report.delivered` is `null` when no\n * delivery event was ever recorded, and it is passed as `null` so the\n * revenue figures over it are withheld for exactly the reason every other\n * rate over it is.\n */\n delivered: report.delivered,\n midFlight: campaign\n ? campaignSendProgress(campaign as never).state === 'sending'\n : false,\n })\n /*\n * No denominator is handed in, unlike the revenue report. A conversion RATE\n * over delivered messages would repeat the defect the revenue section\n * refuses for orders — one visitor can submit two forms, so the quotient\n * passes 100% without anything being wrong — and counting distinct people\n * instead needs a document per person per campaign, which is the\n * per-recipient read this screen's cost model exists to refuse.\n */\n const conversionsReport = campaignConversionsReport({ rollup: conversions })\n const subject = String(campaign?.subject ?? 'Campaign')\n const sendTimeMs = campaign ? emailSendTimeMs(campaign) : 0\n\n const backButton = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'All campaigns'}\n </Button>\n )\n\n // Nothing to say until the read has had its chance to answer.\n if (!campaign && (status === 'loading' || !orgId)) return null\n\n if (!campaign) {\n return (\n <CardDisplay\n header={'Campaign report'}\n help={reportDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: backButton }}\n >\n {/*\n * Not \"no data\". A campaign that cannot be read is a different\n * situation from one with no engagement, and rendering an empty\n * report for the first is how somebody comes to believe a campaign\n * they sent reached nobody.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This campaign could not be loaded. It may have been deleted.'}\n </Typography>\n </CardDisplay>\n )\n }\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Campaign report'}\n help={reportDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: backButton }}\n >\n <Stack spacing={3}>\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n\n {/*==========================================\n * WHAT THIS CAMPAIGN CAUSED — the page's first section, and the\n * heading a campaign of several emails leads with too.\n *\n * The same join as the revenue below, credited under the same rule,\n * answering it for people who were anonymous until the moment being\n * counted: they arrived from a campaign link, browsed, and only\n * became somebody when they submitted a form, signed up or booked.\n *\n * THE FOUR FIGURES ARE NEVER ADDED. One person filling in one form\n * writes a submission, a contact and a lead, so a total would count\n * that visit three times — and it would look like a bigger version\n * of a real number, which is why the model carries no total for this\n * JSX to reach for and the caveat says so in words above them.\n *\n * They are laid out in a row of independent `Figure`s rather than in\n * a table with a footer, because a footer row is where a reader\n * expects the sum to be.\n *=========================================*/}\n <Section title=\"What it caused\">\n {conversionsReport.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {conversionsReport.any ? (\n <Stack spacing={1}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n {conversionsReport.kinds.map((entry) => (\n <Figure\n key={entry.kind}\n label={entry.label}\n value={entry.value}\n note={entry.note}\n />\n ))}\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Credited ${conversionsReport.model === 'last-click' ? 'to the last campaign whose link the visitor clicked' : `under the ${conversionsReport.model} model`}, ` +\n `within ${conversionsReport.windowDays} days of that click. ` +\n 'Somebody who converted without ever following a campaign ' +\n 'link is credited to no campaign at all — nothing is ' +\n 'inferred from a referrer — so these are a floor rather ' +\n 'than everything this campaign influenced. The ones ' +\n 'credited to nobody are counted under Conversions in the ' +\n 'marketing console.'}\n </Typography>\n {/*\n THE RECORDS BEHIND THE FIGURES. The rollup says how many; the\n list says which, and it is the only place the uncredited half\n is counted. A link rather than a table here — the records are\n a paged read and this page's whole cost model is a fixed\n number of documents whatever the audience.\n */}\n {conversionsHub ? (\n <Box>\n <Button\n component={AppLink as any}\n {...({\n componentVariant: 'naked',\n nativeButton: false,\n } as any)}\n href={`${conversionsHub}/conversions/${campaignId}`}\n size=\"small\"\n color=\"primary\"\n >\n {'See these conversions'}\n </Button>\n </Box>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {conversionsReport.recorded\n ? 'Nothing has been credited to this campaign yet.'\n : 'No conversions have been attributed to this campaign. A ' +\n 'form submission, lead, contact or booking is credited to ' +\n 'the last campaign whose link the visitor clicked, within ' +\n `${conversionsReport.windowDays} days — a campaign sent ` +\n 'before that was recorded will never show a figure here.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT THIS CAMPAIGN EARNED.\n *\n * The merchant's second question, and the one every compared\n * product answers by reconstructing revenue probabilistically\n * because it does not own the order. Here it is a join: the click\n * and the order are rows in one database, so there is no tracking\n * script, no catalog sync and nothing estimated except the model.\n *\n * THE MODEL IS ON SCREEN, in a sentence, above the money. A revenue\n * figure whose rule the reader cannot state is worse than no\n * figure, because they will use it anyway — and the two things they\n * have to know to use it are which campaign gets the credit when\n * several touched the buyer, and how long a click stays creditable.\n *\n * NET LEADS. Gross and refunded are both shown beside it, because\n * \"this campaign made $4,000\" and \"this campaign made $4,000 of\n * which $3,100 came back\" are different facts about whether to send\n * another one.\n *=========================================*/}\n <Section title=\"Revenue\">\n {revenueReport.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n {revenueReport.currencies.length ? (\n <Stack spacing={3}>\n {revenueReport.currencies.map((entry) => (\n <Stack key={entry.currency} spacing={1}>\n {/*\n * The currency is a HEADING when there is more than one,\n * and absent when there is one. A per-currency label on a\n * report that only ever shows dollars is noise; the moment\n * there are two, the reader has to be able to see at a\n * glance that the blocks are not addable — which is also\n * what the caveat above says in words.\n */}\n {revenueReport.multiCurrency ? (\n <Typography variant=\"subtitle2\">\n {entry.currency.toUpperCase()}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <MoneyFigure\n label=\"Net revenue\"\n cents={entry.netCents}\n currency={entry.currency}\n note=\"after refunds\"\n />\n <MoneyFigure\n label=\"Gross revenue\"\n cents={entry.grossCents}\n currency={entry.currency}\n note=\"as charged\"\n />\n <MoneyFigure\n label=\"Refunded\"\n cents={entry.refundedCents}\n currency={entry.currency}\n note=\"handed back\"\n />\n <Figure\n label=\"Orders\"\n value={entry.orders}\n note=\"credited to this campaign\"\n />\n <Figure\n label=\"Fully refunded\"\n value={entry.refundedOrders}\n note=\"of those orders\"\n />\n </Stack>\n <MoneyPerUnitRow\n label=\"Net revenue per delivered message\"\n figure={entry.netPerDelivered}\n />\n </Stack>\n ))}\n {/*\n * NO CONVERSION RATE, and this is where a reader would look\n * for one. Orders over delivered is not a rate: one buyer can\n * place two orders, so the quotient passes 100% without\n * anything being wrong — the same defect that keeps `opens`\n * out of the open-rate numerator two sections above. Counting\n * distinct buyers instead would need a document per person per\n * campaign, which is the per-recipient read this screen's cost\n * model exists to refuse. The order count is shown as a count.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Credited ${revenueReport.model === 'last-click' ? 'to the last campaign whose link the buyer clicked' : `under the ${revenueReport.model} model`}, ` +\n `within ${revenueReport.windowDays} days of that click. ` +\n 'Clicks only — an open is not treated as evidence that ' +\n 'anybody read the email. An order placed by somebody who ' +\n 'never clicked, or who checked out without giving an ' +\n 'address, is credited to no campaign, so this is a floor ' +\n 'rather than every sale this campaign influenced.'}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {revenueReport.recorded\n ? 'No orders have been credited to this campaign.'\n : 'No revenue has been attributed to this campaign. Orders ' +\n 'are credited to the last campaign whose link the buyer ' +\n `clicked, within ${revenueReport.windowDays} days — a ` +\n 'campaign sent before that was recorded, or one on a site ' +\n 'with no store, will never show a figure here.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n <Section title=\"Where it sent people\">\n {/*\n The heading a campaign of several emails carries over the same\n question, so the two shapes of campaign page read alike. A\n campaign owns no page — this is where its mail was actually\n followed to, which is the only account of it the data holds.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages this campaign’s links were followed to.'}\n </Typography>\n {linkReport.rows.length ? (\n <>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Destination'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n <TableCell align=\"right\">{'Share'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {linkReport.rows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {row.url}\n </TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {row.clicks.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {row.share\n ? `${percent(row.share.value)} of ${row.share.denominator.toLocaleString()} ${row.share.denominatorLabel}`\n : '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n {/*\n * The normalisation, stated rather than left for somebody to\n * discover from a total that does not add up. Two links to the\n * same page distinguished only by their UTM tags are one row\n * here, and a merchant comparing this table with the campaign\n * they built has to be told that before they conclude a link\n * is missing.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Counted by address and path — query strings are dropped, so ' +\n 'two links to the same page with different tracking ' +\n 'parameters count as one row.'}\n </Typography>\n {linkReport.unattributedClicks ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${linkReport.unattributedClicks.toLocaleString()} clicks ` +\n 'arrived without a destination and are not in this table.'}\n </Typography>\n ) : null}\n {linkReport.overflowClicks ? (\n <Alert severity=\"info\">\n {`This campaign has more distinct destinations than the ` +\n `rollup keeps. ${linkReport.overflowClicks.toLocaleString()} ` +\n 'clicks landed on links past that limit and are counted ' +\n 'in the click total above but not in this table.'}\n </Alert>\n ) : null}\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {report.clicks\n ? 'Clicks were recorded for this campaign, but none of them ' +\n 'carried a destination, so there is nothing to break down ' +\n 'by link.'\n : 'No link clicks have been recorded for this campaign.'}\n </Typography>\n )}\n </Section>\n\n <Divider />\n\n {/*==========================================\n * THE EMAIL THE FIGURES BELOW CAME FROM — and the hinge between the\n * two halves of this page.\n *\n * This screen is reached at `/marketing/campaigns/{sendId}`, which\n * is a CAMPAIGN url resolving to a send — the fall-through that keeps\n * every unsubscribe footer and every pasted report link working. So\n * a reader arrives from the campaigns table, where the row carries a\n * \"Single send\" chip, and everything under this row is a fact about\n * a message rather than about the campaign.\n *\n * Figures with no visible source read as a campaign holding no\n * emails while reporting real numbers, which is a contradiction\n * rather than a shortage of detail. Naming the one email — and\n * saying it IS the campaign, which is what the chip already claims\n * and what `campaignListRows` already does at read time — is what\n * makes the page answer the question it raises.\n *\n * One row, from the document this card already read: no query, no\n * second listen, and the two-document cost the header describes is\n * unchanged.\n *=========================================*/}\n <Section title=\"The email in this campaign\">\n <Stack spacing={1}>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The MESSAGE's own page, which the Emails console owns — hence\n the sibling hub rather than this surface's `basePath`. Plain\n text until that hub resolves: a link with no destination is\n worse than none, and the subject is what the reader came for\n either way.\n */}\n {messageHref ? (\n <AppLink href={messageHref}>{subject}</AppLink>\n ) : (\n <Typography variant=\"body2\">{subject}</Typography>\n )}\n {/*\n What the email is DOING, not the field it stores. One\n delivering an audience larger than one batch is written back\n as `scheduled` between runs, so a chip rendering the status\n said \"Scheduled\" at the head of a report of five hundred\n deliveries.\n */}\n <Chip\n size=\"small\"\n label={campaignSendDisplay(campaign as never).label}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label=\"Single send\"\n title={\n 'One email, sent on its own rather than as part of a ' +\n 'campaign of several. Its report and its unsubscribe links ' +\n 'are unchanged.'\n }\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {sendTimeMs\n ? new Date(sendTimeMs).toLocaleString()\n : 'not recorded'}\n </Typography>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign is one email, so the sections above are what ' +\n 'that one email caused. What follows is the mail itself — ' +\n 'how it was delivered and how it was read. Open the message ' +\n 'for its body and the people it reached.'}\n </Typography>\n </Stack>\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT HAPPENED TO THE MAIL.\n *\n * Counts first and rates second, in that order and not the other\n * way round, because a rate is only readable once you know what it\n * was taken over — and because the counts are the part that is\n * always true. Every rate below can be absent; none of these can.\n *=========================================*/}\n <Section title=\"Delivery\">\n <Stack direction=\"row\" spacing={4} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Addressed\"\n value={report.recipients}\n note=\"after the per-send cap\"\n />\n <Figure\n label=\"Sent\"\n value={report.sent}\n note=\"accepted by the provider\"\n />\n <Figure\n label=\"Delivered\"\n value={report.delivered}\n note=\"accepted by the receiving server\"\n />\n <Figure label=\"Bounced\" value={report.bounced} note=\"of sent\" />\n <Figure\n label=\"Marked as spam\"\n value={report.complained}\n note=\"of delivered\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Engagement\">\n {/*\n * BOTH the event count and the distinct count, side by side.\n *\n * `Opens` is every open event — one reader opening four times is\n * four — and it is the number this product has always shown. It\n * cannot be a rate numerator: divided by anything it exceeds 100%\n * the moment somebody reads an email twice. `Readers who opened`\n * is the distinct count, and it is what the rate below divides.\n * Showing only one of them would either hide activity or invite a\n * rate nobody can defend, so both are here with different names.\n */}\n <Stack direction=\"row\" spacing={4} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Opens\"\n value={report.opens}\n note=\"every open, repeats included\"\n />\n <Figure\n label=\"Readers who opened\"\n value={report.uniqueOpens}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Clicks\"\n value={report.clicks}\n note=\"every click, repeats included\"\n />\n <Figure\n label=\"Readers who clicked\"\n value={report.uniqueClicks}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Unsubscribed\"\n value={report.unsubscribes}\n note=\"through this campaign's link\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Rates\">\n <Stack spacing={1}>\n <RateRow label=\"Delivery rate\" rate={report.rates.delivery} />\n <RateRow label=\"Open rate\" rate={report.rates.open} />\n <RateRow label=\"Click rate\" rate={report.rates.click} />\n <RateRow\n label=\"Click-to-open rate\"\n rate={report.rates.clickToOpen}\n />\n <RateRow label=\"Bounce rate\" rate={report.rates.bounce} />\n <RateRow label=\"Complaint rate\" rate={report.rates.complaint} />\n <RateRow\n label=\"Unsubscribe rate\"\n rate={report.rates.unsubscribe}\n />\n </Stack>\n </Section>\n\n {report.populations.length ? (\n <>\n <Divider />\n <Section title=\"Who this went to\">\n {/*\n * The populations the SEND measured, recorded at send time.\n *\n * Not recomputed here, and the note under the heading says so:\n * consent records change and addresses get suppressed, so\n * asking the list today produces a number that is true of the\n * list and false of the campaign.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Measured when this campaign was sent, and stored as it was ' +\n 'then. These figures describe the send, not the audience ' +\n 'as it stands today.'}\n </Typography>\n <ScrollTable size=\"small\">\n <TableBody>\n {report.populations.map((population) => (\n <TableRow key={population.id}>\n <TableCell>{population.label}</TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {population.count.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`of ${population.of.toLocaleString()} ${population.ofLabel}`}\n </Typography>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the send; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord\n title={\n campaign ? String(campaign.subject || '') || campaignId : undefined\n }\n />\n {card}\n </>\n )\n}\nCampaignReportCard.displayName = 'CampaignReportCard'\n\nexport default CampaignReportCard\n"],"names":["PageHeaderRecord","pluginDocsHelp","pluginRecordHref","AppLink","CardDisplay","ScrollTable","Alert","Box","Button","Chip","Divider","Stack","TableBody","TableCell","TableHead","TableRow","Typography","useFirestore","useFirestoreDoc","campaignLinkReport","campaignReport","campaignRevenueReport","campaignConversionsReport","Figure","MoneyFigure","MoneyPerUnitRow","percent","RateRow","Section","emailSendTimeMs","campaignSendDisplay","campaignSendProgress","campaignSendDoc","campaignSendReportDoc","orgSiteHubPath","useMarketingOrgId","useMarketingOrgMount","useRecordRouteContext","reportDocsHelp","anchor","excerpt","CampaignReportCard","props","hostId","campaignId","basePath","routeContext","orgMount","orgId","firestore","data","campaign","status","links","revenue","conversions","messageHrefOf","id","messageHref","conversionsHub","String","report","stats","linkReport","revenueReport","rollup","delivered","midFlight","state","conversionsReport","subject","sendTimeMs","backButton","component","componentVariant","nativeButton","href","size","color","header","help","contentGutterX","contentGutterY","HeaderProps","action","variant","card","spacing","caveats","map","caveat","severity","message","title","any","direction","useFlexGap","sx","flexWrap","kinds","entry","label","value","note","kind","model","windowDays","recorded","currencies","length","multiCurrency","currency","toUpperCase","cents","netCents","grossCents","refundedCents","orders","refundedOrders","figure","netPerDelivered","rows","align","row","wordBreak","url","fontWeight","clicks","toLocaleString","share","denominator","denominatorLabel","unattributedClicks","overflowClicks","alignItems","Date","recipients","sent","bounced","complained","opens","uniqueOpens","uniqueClicks","unsubscribes","rate","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","populations","population","count","of","ofLabel","undefined","displayName"],"mappings":"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"}
|
|
@@ -475,7 +475,7 @@ const NO_CLAUSES = {
|
|
|
475
475
|
*/ const unfinished = row.rollup.scheduled + row.rollup.sending;
|
|
476
476
|
const agreed = await confirm({
|
|
477
477
|
title: `Delete ${row.name}?`,
|
|
478
|
-
description: (emails ? `The ${emails.toLocaleString()} ` + `${emails === 1 ? 'email' : 'emails'} in it are kept — each ` + 'keeps its own report and its unsubscribe links, and they stay ' + 'in this list as single sends. ' : 'It holds no emails. ') + (unfinished ? `${unfinished.toLocaleString()} of them ${unfinished === 1 ? 'is' : 'are'} still going out or still due, and deleting the campaign does ` + 'not stop that — the sender picks each one up from its own ' + 'record. Stop a send from its own page. ' : '') + 'Any
|
|
478
|
+
description: (emails ? `The ${emails.toLocaleString()} ` + `${emails === 1 ? 'email' : 'emails'} in it are kept — each ` + 'keeps its own report and its unsubscribe links, and they stay ' + 'in this list as single sends. ' : 'It holds no emails. ') + (unfinished ? `${unfinished.toLocaleString()} of them ${unfinished === 1 ? 'is' : 'are'} still going out or still due, and deleting the campaign does ` + 'not stop that — the sender picks each one up from its own ' + 'record. Stop a send from its own page. ' : '') + 'Any pages, forms and contacts assigned to it come out of it and ' + 'are otherwise untouched. Only the campaign itself goes.',
|
|
479
479
|
confirmationText: 'Delete campaign'
|
|
480
480
|
}).then(()=>true).catch(()=>false);
|
|
481
481
|
if (!agreed) return;
|