@aglyn/plugins-marketing 1.0.0-beta.187 → 1.0.0-beta.188
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -15
- package/src/lib/components/campaign-detail-card.js +37 -4
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaigns-card.js +78 -8
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/email-recipients-card.js +74 -29
- package/src/lib/components/email-recipients-card.js.map +1 -1
- package/src/lib/components/email-send-filter.d.ts +32 -0
- package/src/lib/components/email-send-filter.js +55 -0
- package/src/lib/components/email-send-filter.js.map +1 -0
- package/src/lib/components/emails-list-card.js +75 -7
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +71 -17
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaigns-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 { buildRoute, createResourceUid, pluginDocsHelp, Route } from '@aglyn/aglyn'\n/*\n * The MODULE, not the barrel, for the two PURE helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { mdiDeleteOutline, mdiEyeOutline } from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport { collection, limit, query, setDoc } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignListRows,\n campaignSiteIds,\n campaignVisibleTo,\n type CampaignAggregate,\n type CampaignListRow,\n type CampaignSend,\n type EmailCampaign,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport {\n campaignContainerDoc,\n campaignContainersQuery,\n campaignSendsQuery,\n} from './campaign-queries'\nimport {\n orgSiteName,\n orgSiteOptions,\n topicCatalogHostId,\n useMarketingOrgId,\n useMarketingOrgMount,\n type MarketingOrgMount,\n} from './marketing-org-mount'\nimport { useCampaignManageApi } from './use-campaign-send-api'\nimport { useCampaignTopicOptions } from './use-campaign-topic-options'\n\n/**\n * How many sends the list reads.\n *\n * A CEILING, not a page size — see the query, which explains why this list\n * cannot be paged by the server until a send carries one date field every\n * writer stamps.\n */\nconst CAMPAIGN_CEILING = 30\n\n/** How many campaign containers the list reads. */\nconst CONTAINER_CEILING = 50\n\nconst formatDay = (ms: number | null): string =>\n ms === null ? '' : new Date(ms).toLocaleDateString()\n\n/** A rolled-up figure, or an em dash where nothing recorded one. */\nconst figure = (value: CampaignAggregate): string =>\n value.value === null ? '—' : value.value.toLocaleString()\n\nconst WINDOW_LABEL: Record<CampaignListRow['windowState'], string> = {\n undated: 'No dates',\n upcoming: 'Upcoming',\n running: 'Running',\n ended: 'Ended',\n}\n\n/**\n * THE CAMPAIGNS LIST.\n *\n * A campaign is a container — a name, a window, the lists it is aimed at, and\n * the emails sent inside it — so this is a table of campaigns and not of\n * messages. Composing is not here: a message belongs to a campaign, and the\n * campaign's own page is where one is written.\n *\n * The rows come from two collections, and the second is why this reads\n * without a migration. `emailCampaigns` holds the containers.\n * `campaigns` holds every send, including thousands written before containers\n * existed; those carry no container id, and `campaignListRows` presents each\n * as a campaign of one rather than dropping it. Nothing is rewritten, so no\n * unsubscribe link and no pasted report URL stops resolving.\n *\n * ## At two levels\n *\n * Both collections are the organization's. Under a site the card lists the\n * campaigns PLACED on that site and the sends sent AS it (see\n * `campaign-queries.ts` for why those filters are what make the read legal);\n * on the org hub, with `hostId` null, it lists every campaign with the sites\n * each one is placed on, and a new campaign is placed where its creator says.\n */\nexport function HostCampaignsCard(props: {\n /** The site, or `null` on the org Marketing hub. */\n hostId: string | null\n /**\n * The marketing hub URL, when the caller already has it.\n *\n * Resolved from the host route when it does not: the inbox console page\n * embeds this card on a tab of its own, and its `basePath` names the INBOX\n * hub. Deriving one hub's URL from another's by string surgery is the kind\n * of link that breaks silently when a surface moves.\n */\n basePath?: string\n}) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { orgSlug, subdomain } = useConsoleHostRoute(hostId)\n const hubPath =\n basePath ??\n (orgSlug && subdomain\n ? buildRoute(Route.HOST_PLUGIN, {\n orgSlug,\n host: subdomain,\n pluginSlug: 'marketing',\n })\n : null)\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(hostId)\n // A container is the org's, so deleting one from the org hub names the org.\n const manageApi = useCampaignManageApi(hostId, orgId)\n\n /*\n * ORDERED AND CEILINGED, and not orderable on any DATE.\n *\n * No field here is on every send: a sent one is written `{status:'sent',\n * sentAt}` and a scheduled one `{status:'scheduled', sendAtMs}`, by the two\n * branches of `campaign-send.ts`, and there is no `createdAt` at all.\n * `orderBy` on either would not mis-sort the list, it would DROP half of it.\n *\n * A bare `limit(30)` is answered in DOCUMENT-ID order, so the window is\n * thirty sends chosen by id and then sorted by date, which reads as the most\n * recent thirty and is not. `collectionCeiling` returns that same thirty —\n * document-id order is what the bare cap already gave — but it says so,\n * which is what stops the next edit reaching for `sentAt`, and it probes one\n * past the ceiling so the reader is told the history is longer.\n */\n const { data: sendDocs } = useFirestoreCollection<any>(\n () => {\n const sends = campaignSendsQuery(firestore, orgId, hostId)\n return sends ? collectionCeiling(sends, CAMPAIGN_CEILING) : null\n },\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow<any>(\n sendDocs,\n CAMPAIGN_CEILING,\n )\n\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () => {\n const containers = campaignContainersQuery(firestore, orgId, hostId)\n return containers ? collectionCeiling(containers, CONTAINER_CEILING) : null\n },\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const { rows: readCampaigns, truncated: campaignsTruncated } =\n ceilingedWindow<any>(campaignDocs, CONTAINER_CEILING)\n\n // Org email lists, so a row can name what it is aimed at rather than\n // showing ids.\n const { data: listDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(collection(firestore, 'orgs', orgId, 'lists'), limit(50))\n : null,\n [firestore, orgId],\n { idField: '$id' },\n )\n const [createOpen, setCreateOpen] = useState(false)\n /*\n * The org's topic catalog, read only while the CREATE DRAWER is open.\n *\n * It fills one picker in that drawer and is drawn nowhere in the table, so\n * a reader who came to look at their campaigns must not pay for it — the\n * same rule the emails list already applies to the campaigns it offers its\n * own drawer, and the one the audiences section applies to a list's\n * members. It is the largest read on this section at 200 documents, and it\n * is the only one nothing on screen needs.\n */\n const { topics, source: topicSource } = useCampaignTopicOptions(\n topicCatalogHostId(hostId, orgMount),\n { enabled: createOpen },\n )\n const topicOptions = useMemo(\n () =>\n topics.map((topic) => ({\n value: topic.id,\n label: topic.name,\n })),\n [topics],\n )\n\n const listNames = useMemo(() => {\n const names = new Map<string, string>()\n for (const list of listDocs ?? []) {\n names.set(String(list.$id), String(list.name ?? list.$id))\n }\n return names\n }, [listDocs])\n\n const listOptions = useMemo(\n () =>\n (listDocs ?? []).map((list: any) => ({\n value: String(list.$id),\n label: String(list.name ?? list.$id),\n })),\n [listDocs],\n )\n\n const rows = useMemo(() => {\n const campaigns = (readCampaigns as EmailCampaign[]).filter(\n (campaign: any) => !campaign.deletedAt,\n )\n const byId = new Map(campaigns.map((campaign) => [campaign.$id, campaign]))\n return campaignListRows(\n campaigns,\n readSends as CampaignSend[],\n Date.now(),\n ).map((row) => ({\n ...row,\n $id: row.id,\n sitesLabel: orgMount\n ? campaignSitesLabel(orgMount, row, byId.get(row.id))\n : '',\n }))\n }, [readCampaigns, readSends, orgMount])\n\n const [createError, setCreateError] = useState<string | null>(null)\n const [creating, setCreating] = useState(false)\n /** The campaign a delete is in flight for, so its row menu can say so. */\n const [deletingId, setDeletingId] = useState('')\n\n const openCampaign = useCallback(\n (id: string) => {\n if (hubPath) void router.push(`${hubPath}/campaigns/${id}`)\n },\n [hubPath, router],\n )\n\n const handleCreate = useCallback(\n async (values: Record<string, any>) => {\n if (creating) return\n /*\n * `displayName` is the shared drawer's own field, and every artifact\n * create in the console collects a name under that key. The campaign\n * stores it as `name` — the container has one name and no second\n * internal label — so the mapping happens here rather than by giving\n * this one drawer a field the others do not have.\n */\n const name = String(values.displayName ?? '').trim()\n if (!name) return\n const startAtMs = values.startAt ? Date.parse(String(values.startAt)) : null\n const endAtMs = values.endAt ? Date.parse(String(values.endAt)) : null\n // Refused here rather than stored and rendered as a campaign that ends\n // before it starts, which no window state describes.\n if (startAtMs !== null && endAtMs !== null && endAtMs < startAtMs) {\n return setCreateError('The end date is before the start date')\n }\n /*\n * Held until the org is known. A container has one home, and a write\n * that raced the site's org lookup would have nowhere to go.\n */\n if (!orgId) {\n return setCreateError('This workspace is still loading — try again')\n }\n setCreating(true)\n setCreateError(null)\n const id = createResourceUid()\n try {\n await setDoc(campaignContainerDoc(firestore, orgId, id), {\n name,\n ...(Number.isFinite(startAtMs) && startAtMs !== null\n ? { startAtMs }\n : {}),\n ...(Number.isFinite(endAtMs) && endAtMs !== null ? { endAtMs } : {}),\n listIds: Array.isArray(values.listIds)\n ? values.listIds.map(String)\n : [],\n ...(values.topicId ? { topicId: String(values.topicId) } : {}),\n /*\n * Where it is placed. From a site's hub, that site — the creator is\n * standing on it, and a campaign that appeared on every sibling\n * site would be a decision nobody made. From the org hub, the sites\n * picked, and every site when none are.\n */\n visibleTo: campaignVisibleTo(\n hostId\n ? [hostId]\n : Array.isArray(values.siteIds)\n ? values.siteIds.map(String)\n : null,\n ),\n createdAtMs: Date.now(),\n createdBy: String((user as any)?.uid ?? ''),\n })\n setCreateOpen(false)\n openCampaign(id)\n } catch (error) {\n console.error(error)\n setCreateError('The campaign could not be created')\n enqueueSnackbar('Could not create the campaign', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setCreating(false)\n }\n },\n [creating, firestore, hostId, orgId, user, enqueueSnackbar, openCampaign],\n )\n\n const campaignHref = useCallback(\n (id: string) => (hubPath ? `${hubPath}/campaigns/${id}` : null),\n [hubPath],\n )\n\n /*==========================================\n * DELETING A CAMPAIGN FROM ITS ROW.\n *\n * The container goes and its emails stay — the route detaches them first,\n * so each one keeps its id, its report and its unsubscribe links, and\n * reappears in this very table as a \"Single send\". That is not a\n * convenience: a send id is inside the HMAC of every unsubscribe footer\n * already delivered, so a delete that took the sends with it would break\n * opt-outs that must go on working.\n *\n * The confirmation names the count and says what the delete does NOT do.\n * Deleting a campaign cancels nothing — a scheduled email inside it still\n * goes out — and a merchant who reads the button as \"stop this campaign\"\n * has to be told before they press it.\n *\n * EDITING is deliberately not offered here. A record is edited on its own\n * page in this console; the campaign's page carries the edit drawer, and\n * this row's first entry is the way to it.\n *=========================================*/\n const handleDelete = useCallback(\n async (row: CampaignListRow) => {\n if (deletingId) return\n const emails = row.sends.length\n /*\n * Emails with mail still to go: waiting for their time, AND part way\n * through an audience larger than one batch. Counting only `scheduled`\n * would miss the case that needs the warning most — an email delivering\n * right now is stored as `scheduled` too, and `rollup.scheduled` is the\n * narrower reading that leaves it out.\n */\n const unfinished = row.rollup.scheduled + row.rollup.sending\n const agreed = await confirm({\n title: `Delete ${row.name}?`,\n description:\n (emails\n ? `The ${emails.toLocaleString()} ` +\n `${emails === 1 ? 'email' : 'emails'} in it are kept — each ` +\n 'keeps its own report and its unsubscribe links, and they stay ' +\n 'in this list as single sends. '\n : 'It holds no emails. ') +\n (unfinished\n ? `${unfinished.toLocaleString()} of them ${\n unfinished === 1 ? 'is' : 'are'\n } still going out or still due, and deleting the campaign does ` +\n 'not stop that — the sender picks each one up from its own ' +\n 'record. Stop a send from its own page. '\n : '') +\n 'Any screens, forms and contacts assigned to it come out of it and ' +\n 'are otherwise untouched. Only the campaign itself goes.',\n confirmationText: 'Delete campaign',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDeletingId(row.id)\n try {\n const { response, payload } = await manageApi({\n action: 'deleteCampaign',\n campaignId: row.id,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The campaign could not be deleted',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Campaign deleted', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The campaign could not be deleted', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDeletingId('')\n }\n },\n [confirm, deletingId, enqueueSnackbar, manageApi],\n )\n\n const columns = useMemo(\n () => [\n {\n field: 'name',\n headerName: 'Campaign',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => (\n <Stack\n direction=\"row\"\n spacing={1}\n /*\n `minWidth: 0` on the row AND on the name: a flex child will not\n shrink below its content without it, so the chip beside it is\n what gets clipped instead of the name being ellipsed.\n */\n sx={{ alignItems: 'center', minWidth: 0, width: 1 }}\n >\n {/*\n The name is a real anchor as well as the row being clickable, so\n a campaign can be middle-clicked into a tab or copied out of the\n context menu — affordances a row handler cannot offer. The grid's\n own row click fires too, so the link stops the event rather than\n pushing the same route twice.\n */}\n {campaignHref(row.id) ? (\n <AppLink\n href={campaignHref(row.id) as string}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n style={{ minWidth: 0 }}\n >\n <Typography variant=\"body2\" noWrap>\n {row.name}\n </Typography>\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap sx={{ minWidth: 0 }}>\n {row.name}\n </Typography>\n )}\n {row.legacy ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label=\"Single send\"\n // A label that reads \"Single sen\" is worse than a shorter name.\n sx={{ flexShrink: 0 }}\n title={\n 'Sent before campaigns grouped their emails. Its report ' +\n 'and its unsubscribe links are unchanged.'\n }\n />\n ) : null}\n </Stack>\n ),\n },\n /*\n WHERE IT IS PLACED, on the org hub only. Under a site every row is on\n that site by construction, so the column would say one thing thirty\n times.\n */\n ...(orgMount\n ? [\n {\n field: 'sitesLabel',\n headerName: 'Sites',\n width: 170,\n sortable: false,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.sitesLabel}\n </Typography>\n ),\n },\n ]\n : []),\n {\n field: 'windowState',\n headerName: 'Window',\n width: 170,\n renderCell: ({ row }: any) => {\n const start = formatDay(row.startAtMs)\n const end = formatDay(row.endAtMs)\n const range =\n start && end && start !== end\n ? `${start} – ${end}`\n : start || end || ''\n return (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {WINDOW_LABEL[row.windowState as CampaignListRow['windowState']]}\n </Typography>\n {range ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {range}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n field: 'listIds',\n headerName: 'Lists',\n width: 140,\n sortable: false,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.listIds.length\n ? row.listIds\n .map((id: string) => listNames.get(id) ?? id)\n .join(', ')\n : '—'}\n </Typography>\n ),\n },\n /*\n THE FOUR COUNTS, RIGHT-ALIGNED HEAD AND BODY.\n A figure is read by its last digit, so a column of them lines up on\n the right or it does not line up at all. `headerAlign` has to be said\n as well as `align`: a grid column defaults its header to the column\n type's alignment rather than to the cell's, which is how these came to\n sit with left headers over figures nobody could compare down the page.\n\n EVERY ONE OF THEM MEASURES THE MAIL, and the headings say so. What a\n campaign CAUSED — the submissions, leads, contacts and bookings\n credited to it — is not a column here, because there is no figure to\n read: a container has no conversion counter of its own, so a column\n would be an aggregation per row for a campaign and a subcollection\n document per row for a single send, on a list that costs two queries\n however many campaigns it holds. Headings that named the campaign over\n figures that all described its mail were the cheaper mistake, so the\n headings name the mail and the note under the table says where the\n outcome figures are.\n */\n {\n field: 'emails',\n headerName: 'Emails',\n width: 128,\n align: 'right',\n headerAlign: 'right',\n /*\n THE COUNT, AND WHAT IS STILL HAPPENING TO IT.\n\n `sending` is called out apart from `scheduled` because the two are\n opposite readings of the same stored status: an email delivering an\n audience larger than one batch sits as `scheduled` between runs, and\n rolling it in with the ones that have mailed nobody reports a\n campaign that is half delivered as a campaign that has not started.\n\n The note sits UNDER the figure rather than beside it — the column\n would otherwise have to be wide enough for the longest sentence, and\n the figure is what the column is read for.\n */\n renderCell: ({ row }: any) => {\n const { sends, sending, scheduled } = row.rollup\n const note = [\n sending ? `${sending} sending` : '',\n scheduled ? `${scheduled} scheduled` : '',\n ]\n .filter(Boolean)\n .join(', ')\n return (\n <Stack sx={{ alignItems: 'flex-end', minWidth: 0, width: 1 }}>\n <Typography variant=\"body2\">{String(sends)}</Typography>\n {note ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {note}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n field: 'sent',\n headerName: 'Mail sent',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.sent)}</Typography>\n ),\n },\n {\n field: 'opens',\n headerName: 'Mail opens',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.opens)}</Typography>\n ),\n },\n {\n field: 'clicks',\n headerName: 'Mail clicks',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.clicks)}</Typography>\n ),\n },\n /*\n THE TRAILING CLUSTER, through the grid's own actions column.\n `listActionsColumn` is what every other grid list in the console\n renders, so the reader cannot tell which family of table they are\n standing in — the hand-rolled tables on this surface reach the same\n `RowActionsMenu` from the other direction.\n\n Opening the campaign is restated here even though the row click does\n the same thing, for the reason the screens table restates its own: the\n menu is where a row's actions are NAMED, and an action absent from it\n reads as an action the row does not have.\n\n A \"Single send\" row is a SEND with no container — the table adopts it\n so a merchant's history is not missing — and there is no container\n under it to delete. Its entry is shown disabled with that reason\n rather than hidden, because a control that vanishes and one that does\n not apply look identical from the outside.\n */\n listActionsColumn(\n (row: any) => (\n <ListRowActions\n label={String(row.name ?? row.id)}\n items={[\n {\n key: 'details',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row.id) ?? undefined,\n disabled: !campaignHref(row.id),\n disabledReason: campaignHref(row.id)\n ? undefined\n : 'This site’s console URL has not resolved yet',\n },\n {\n key: 'delete',\n label: 'Delete campaign',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: row.legacy || Boolean(deletingId),\n disabledReason: row.legacy\n ? 'This is a single send, not a campaign. Open it to ' +\n 'manage the email itself.'\n : 'A campaign is being deleted',\n onClick: () => void handleDelete(row as CampaignListRow),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ],\n [listNames, campaignHref, deletingId, handleDelete, orgMount],\n )\n\n return (\n <CardDisplay\n header={'Campaigns'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: '#campaigns-group-emails',\n excerpt:\n 'A campaign groups the emails you send to a set of lists over a ' +\n 'window of dates. Open one to write and send an email inside it.',\n })}\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={creating}\n onClick={() => {\n setCreateError(null)\n setCreateOpen(true)\n }}\n >\n {creating ? 'Creating…' : 'Create campaign'}\n </Button>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n <ListTable\n rows={rows}\n columns={columns as any}\n onOpen={(id) => openCampaign(id)}\n noRowsLabel=\"No campaigns yet\"\n noRowsDescription=\"A campaign groups the emails you send to a set of lists.\"\n noRowsAction={\n <Button variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'Create campaign'}\n </Button>\n }\n />\n {/*\n WHAT THIS TABLE MEASURES, said rather than inferred from the column\n names. Every figure in it is a fact about the campaign's mail, and a\n reader comparing rows is comparing mailings — the conversions and the\n destinations credited to a campaign are on the campaign's own page,\n because counting them for every row is a read per row on a list whose\n whole cost is two queries.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These figures measure each campaign’s mail. What a campaign ' +\n 'caused — the form submissions, leads, contacts and bookings ' +\n 'credited to it — and the pages it sent people to are on the ' +\n 'campaign’s own page.'}\n </Typography>\n {sendsTruncated || campaignsTruncated ? (\n <Alert severity=\"info\">\n {`Showing the most recent ${CONTAINER_CEILING} campaigns and ` +\n `${CAMPAIGN_CEILING} sends. ${\n hostId ? 'This site' : 'This organization'\n } has more — the ones ` +\n 'listed are not necessarily the most recent, because a send ' +\n 'carries no date field that every writer stamps.'}\n </Alert>\n ) : null}\n </Stack>\n {/*\n The console's own create drawer, from the shared library rather than a\n second one that looks like it. What a campaign collects beyond a name\n is its window, the lists it is aimed at and the stream its emails open\n on; the description box is left off because the container stores none,\n and a field the writer discards is worse than one never offered.\n */}\n {/* Draws nothing: the zone the topic catalog's owner answers through. */}\n {topicSource}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create campaign\"\n submitLabel=\"Create campaign\"\n includeDescription={false}\n onSubmit={handleCreate}\n extraFields={campaignFields(\n listOptions,\n topicOptions,\n orgMount ? orgSiteOptions(orgMount) : null,\n )}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {createError}\n </Alert>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n/**\n * What a campaign collects beyond its name.\n *\n * Data-driven-forms field descriptors, built from the org's own lists and\n * topics so the drawer offers what this workspace actually has. Dates are\n * plain `date` inputs: a campaign window is a day at each end, and a time of\n * day would be a precision the model does not carry.\n */\nfunction campaignFields(\n lists: Array<{ value: string; label: string }>,\n topics: Array<{ value: string; label: string }>,\n /** The org's sites, on the org hub only — a site hub places on itself. */\n sites: Array<{ value: string; label: string }> | null,\n): any[] {\n return [\n ...(sites\n ? [\n {\n component: 'select',\n name: 'siteIds',\n label: 'Sites',\n multiple: true,\n initialValue: [],\n helperText:\n 'The sites this campaign is offered on. Leave empty for every site.',\n disableDefaultOption: true,\n options: sites,\n },\n ]\n : []),\n {\n component: 'text-field',\n name: 'startAt',\n label: 'Starts',\n type: 'date',\n helperText: 'When the campaign window opens',\n },\n {\n component: 'text-field',\n name: 'endAt',\n label: 'Ends',\n type: 'date',\n helperText: 'Leave empty for an open-ended campaign',\n InputLabelProps: { shrink: true },\n },\n {\n component: 'select',\n name: 'listIds',\n label: 'Lists',\n multiple: true,\n initialValue: [],\n // A campaign with no list is legitimate: its emails can go to leads, to\n // site members, or to a segment. The lists are what it is AIMED at.\n helperText: 'The lists this campaign is aimed at',\n disableDefaultOption: true,\n options: lists,\n },\n {\n component: 'select',\n name: 'topicId',\n label: 'Topic',\n helperText: 'The stream its emails open on — each one can change it',\n disableDefaultOption: true,\n options: topics,\n },\n ]\n}\n\n/**\n * The Sites cell: where a campaign is offered.\n *\n * A container reads its `visibleTo`; a single send, which has no container,\n * is on the one site it was sent as. An empty placement is said rather than\n * left blank, because a blank cell reads as \"every site\" and it means none.\n */\nfunction campaignSitesLabel(\n mount: MarketingOrgMount,\n row: CampaignListRow,\n container: EmailCampaign | undefined,\n): string {\n if (row.legacy) return orgSiteName(mount, row.sends[0]?.hostId) || '—'\n const ids = campaignSiteIds(container)\n if (ids === null) return 'Every site'\n if (!ids.length) return 'No site'\n return ids.map((id) => orgSiteName(mount, id)).join(', ')\n}\n\nHostCampaignsCard.displayName = 'HostCampaignsCard'\n\nexport default HostCampaignsCard\n"],"names":["buildRoute","createResourceUid","pluginDocsHelp","Route","ceilingedWindow","collectionCeiling","mdiDeleteOutline","mdiEyeOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListRowActions","ListTable","listActionsColumn","useSnackbar","Alert","Button","Chip","Stack","Typography","collection","limit","query","setDoc","useCallback","useMemo","useState","useRouter","useConsoleHostRoute","useFirestore","useFirestoreCollection","useUser","campaignListRows","campaignSiteIds","campaignVisibleTo","CreateArtifactDrawer","campaignContainerDoc","campaignContainersQuery","campaignSendsQuery","orgSiteName","orgSiteOptions","topicCatalogHostId","useMarketingOrgId","useMarketingOrgMount","useCampaignManageApi","useCampaignTopicOptions","CAMPAIGN_CEILING","CONTAINER_CEILING","formatDay","ms","Date","toLocaleDateString","figure","value","toLocaleString","WINDOW_LABEL","undated","upcoming","running","ended","HostCampaignsCard","props","hostId","basePath","firestore","router","orgSlug","subdomain","hubPath","HOST_PLUGIN","host","pluginSlug","data","user","enqueueSnackbar","confirm","orgMount","orgId","manageApi","sendDocs","sends","idField","rows","readSends","truncated","sendsTruncated","campaignDocs","containers","readCampaigns","campaignsTruncated","listDocs","createOpen","setCreateOpen","topics","source","topicSource","enabled","topicOptions","map","topic","id","label","name","listNames","names","Map","list","set","String","$id","listOptions","campaigns","filter","campaign","deletedAt","byId","now","row","sitesLabel","campaignSitesLabel","get","createError","setCreateError","creating","setCreating","deletingId","setDeletingId","openCampaign","push","handleCreate","values","displayName","trim","startAtMs","startAt","parse","endAtMs","endAt","Number","isFinite","listIds","Array","isArray","topicId","visibleTo","siteIds","createdAtMs","createdBy","uid","error","console","variant","allowDuplicate","campaignHref","handleDelete","emails","length","unfinished","rollup","scheduled","sending","agreed","title","description","confirmationText","then","catch","response","payload","action","campaignId","ok","persist","columns","field","headerName","flex","minWidth","renderCell","direction","spacing","sx","alignItems","width","href","onClick","event","stopPropagation","style","noWrap","legacy","size","flexShrink","sortable","start","end","range","windowState","color","join","align","headerAlign","note","Boolean","sent","opens","clicks","items","key","icon","path","undefined","disabled","disabledReason","destructive","header","help","anchor","excerpt","HeaderProps","contentGutterX","contentGutterY","onOpen","noRowsLabel","noRowsDescription","noRowsAction","severity","open","onClose","submitLabel","includeDescription","onSubmit","extraFields","campaignFields","errorSlot","mt","mb","lists","sites","component","multiple","initialValue","helperText","disableDefaultOption","options","type","InputLabelProps","shrink","mount","container","ids"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaigns-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 { buildRoute, createResourceUid, pluginDocsHelp, Route } from '@aglyn/aglyn'\n/*\n * The MODULE, not the barrel, for the two PURE helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { mdiDeleteOutline, mdiEyeOutline } from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { inMemoryListField } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { useListRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-rows-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport { collection, limit, query, setDoc } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignListRows,\n campaignSiteIds,\n campaignVisibleTo,\n type CampaignAggregate,\n type CampaignListRow,\n type CampaignSend,\n type EmailCampaign,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport {\n campaignContainerDoc,\n campaignContainersQuery,\n campaignSendsQuery,\n} from './campaign-queries'\nimport {\n orgSiteName,\n orgSiteOptions,\n topicCatalogHostId,\n useMarketingOrgId,\n useMarketingOrgMount,\n type MarketingOrgMount,\n} from './marketing-org-mount'\nimport { useCampaignManageApi } from './use-campaign-send-api'\nimport { useCampaignTopicOptions } from './use-campaign-topic-options'\n\n/**\n * How many sends the list reads.\n *\n * A CEILING, not a page size — see the query, which explains why this list\n * cannot be paged by the server until a send carries one date field every\n * writer stamps.\n */\nconst CAMPAIGN_CEILING = 30\n\n/** How many campaign containers the list reads. */\nconst CONTAINER_CEILING = 50\n\nconst formatDay = (ms: number | null): string =>\n ms === null ? '' : new Date(ms).toLocaleDateString()\n\n/** A rolled-up figure, or an em dash where nothing recorded one. */\nconst figure = (value: CampaignAggregate): string =>\n value.value === null ? '—' : value.value.toLocaleString()\n\nconst WINDOW_LABEL: Record<CampaignListRow['windowState'], string> = {\n undated: 'No dates',\n upcoming: 'Upcoming',\n running: 'Running',\n ended: 'Ended',\n}\n\n/*\n * What the campaigns grid's Filters panel offers (AGL-3317). The card reads\n * every campaign under its ceiling, so the panel and the search answer over\n * all of them. A campaign's lists are ids, matched member by member.\n */\nconst CAMPAIGN_FILTER_FIELDS = [\n inMemoryListField('name', 'text'),\n inMemoryListField('windowState', 'select'),\n { ...inMemoryListField('listIds', 'select'), tokensPath: 'listIds', verbatimTokens: true },\n inMemoryListField('kind', 'select'),\n inMemoryListField('sitesLabel', 'text'),\n]\nconst CAMPAIGN_FILTER_HEADERS: Readonly<Record<string, string>> = {\n name: 'Campaign',\n windowState: 'Window',\n listIds: 'Lists',\n kind: 'Kind',\n sitesLabel: 'Sites',\n}\nconst CAMPAIGN_KIND_OPTIONS = [\n { value: 'campaign', label: 'Campaign' },\n { value: 'single', label: 'Single send' },\n]\nconst CAMPAIGN_WINDOW_OPTIONS = (\n Object.keys(WINDOW_LABEL) as Array<CampaignListRow['windowState']>\n).map((state) => ({ value: state, label: WINDOW_LABEL[state] }))\n/** What the quick search reads on a campaign row. */\nconst CAMPAIGN_SEARCH_FIELDS = ['name', 'sitesLabel'] as const\n\n/**\n * THE CAMPAIGNS LIST.\n *\n * A campaign is a container — a name, a window, the lists it is aimed at, and\n * the emails sent inside it — so this is a table of campaigns and not of\n * messages. Composing is not here: a message belongs to a campaign, and the\n * campaign's own page is where one is written.\n *\n * The rows come from two collections, and the second is why this reads\n * without a migration. `emailCampaigns` holds the containers.\n * `campaigns` holds every send, including thousands written before containers\n * existed; those carry no container id, and `campaignListRows` presents each\n * as a campaign of one rather than dropping it. Nothing is rewritten, so no\n * unsubscribe link and no pasted report URL stops resolving.\n *\n * ## At two levels\n *\n * Both collections are the organization's. Under a site the card lists the\n * campaigns PLACED on that site and the sends sent AS it (see\n * `campaign-queries.ts` for why those filters are what make the read legal);\n * on the org hub, with `hostId` null, it lists every campaign with the sites\n * each one is placed on, and a new campaign is placed where its creator says.\n */\nexport function HostCampaignsCard(props: {\n /** The site, or `null` on the org Marketing hub. */\n hostId: string | null\n /**\n * The marketing hub URL, when the caller already has it.\n *\n * Resolved from the host route when it does not: the inbox console page\n * embeds this card on a tab of its own, and its `basePath` names the INBOX\n * hub. Deriving one hub's URL from another's by string surgery is the kind\n * of link that breaks silently when a surface moves.\n */\n basePath?: string\n}) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { orgSlug, subdomain } = useConsoleHostRoute(hostId)\n const hubPath =\n basePath ??\n (orgSlug && subdomain\n ? buildRoute(Route.HOST_PLUGIN, {\n orgSlug,\n host: subdomain,\n pluginSlug: 'marketing',\n })\n : null)\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(hostId)\n // A container is the org's, so deleting one from the org hub names the org.\n const manageApi = useCampaignManageApi(hostId, orgId)\n\n /*\n * ORDERED AND CEILINGED, and not orderable on any DATE.\n *\n * No field here is on every send: a sent one is written `{status:'sent',\n * sentAt}` and a scheduled one `{status:'scheduled', sendAtMs}`, by the two\n * branches of `campaign-send.ts`, and there is no `createdAt` at all.\n * `orderBy` on either would not mis-sort the list, it would DROP half of it.\n *\n * A bare `limit(30)` is answered in DOCUMENT-ID order, so the window is\n * thirty sends chosen by id and then sorted by date, which reads as the most\n * recent thirty and is not. `collectionCeiling` returns that same thirty —\n * document-id order is what the bare cap already gave — but it says so,\n * which is what stops the next edit reaching for `sentAt`, and it probes one\n * past the ceiling so the reader is told the history is longer.\n */\n const { data: sendDocs } = useFirestoreCollection<any>(\n () => {\n const sends = campaignSendsQuery(firestore, orgId, hostId)\n return sends ? collectionCeiling(sends, CAMPAIGN_CEILING) : null\n },\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow<any>(\n sendDocs,\n CAMPAIGN_CEILING,\n )\n\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () => {\n const containers = campaignContainersQuery(firestore, orgId, hostId)\n return containers ? collectionCeiling(containers, CONTAINER_CEILING) : null\n },\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const { rows: readCampaigns, truncated: campaignsTruncated } =\n ceilingedWindow<any>(campaignDocs, CONTAINER_CEILING)\n\n // Org email lists, so a row can name what it is aimed at rather than\n // showing ids.\n const { data: listDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(collection(firestore, 'orgs', orgId, 'lists'), limit(50))\n : null,\n [firestore, orgId],\n { idField: '$id' },\n )\n const [createOpen, setCreateOpen] = useState(false)\n /*\n * The org's topic catalog, read only while the CREATE DRAWER is open.\n *\n * It fills one picker in that drawer and is drawn nowhere in the table, so\n * a reader who came to look at their campaigns must not pay for it — the\n * same rule the emails list already applies to the campaigns it offers its\n * own drawer, and the one the audiences section applies to a list's\n * members. It is the largest read on this section at 200 documents, and it\n * is the only one nothing on screen needs.\n */\n const { topics, source: topicSource } = useCampaignTopicOptions(\n topicCatalogHostId(hostId, orgMount),\n { enabled: createOpen },\n )\n const topicOptions = useMemo(\n () =>\n topics.map((topic) => ({\n value: topic.id,\n label: topic.name,\n })),\n [topics],\n )\n\n const listNames = useMemo(() => {\n const names = new Map<string, string>()\n for (const list of listDocs ?? []) {\n names.set(String(list.$id), String(list.name ?? list.$id))\n }\n return names\n }, [listDocs])\n\n const listOptions = useMemo(\n () =>\n (listDocs ?? []).map((list: any) => ({\n value: String(list.$id),\n label: String(list.name ?? list.$id),\n })),\n [listDocs],\n )\n\n const rows = useMemo(() => {\n const campaigns = (readCampaigns as EmailCampaign[]).filter(\n (campaign: any) => !campaign.deletedAt,\n )\n const byId = new Map(campaigns.map((campaign) => [campaign.$id, campaign]))\n return campaignListRows(\n campaigns,\n readSends as CampaignSend[],\n Date.now(),\n ).map((row) => ({\n ...row,\n $id: row.id,\n kind: row.legacy ? 'single' : 'campaign',\n sitesLabel: orgMount\n ? campaignSitesLabel(orgMount, row, byId.get(row.id))\n : '',\n }))\n }, [readCampaigns, readSends, orgMount])\n\n const filterOptions = useMemo(\n () => ({\n windowState: CAMPAIGN_WINDOW_OPTIONS,\n listIds: listOptions,\n kind: CAMPAIGN_KIND_OPTIONS,\n }),\n [listOptions],\n )\n // Sites is a column on the org hub only; under a site it would say one thing.\n const filterFields = useMemo(\n () =>\n orgMount\n ? CAMPAIGN_FILTER_FIELDS\n : CAMPAIGN_FILTER_FIELDS.filter((field) => field.column !== 'sitesLabel'),\n [orgMount],\n )\n const listFilter = useListRowsFilter({\n rows,\n fields: filterFields,\n options: filterOptions,\n headers: CAMPAIGN_FILTER_HEADERS,\n search: CAMPAIGN_SEARCH_FIELDS,\n })\n\n const [createError, setCreateError] = useState<string | null>(null)\n const [creating, setCreating] = useState(false)\n /** The campaign a delete is in flight for, so its row menu can say so. */\n const [deletingId, setDeletingId] = useState('')\n\n const openCampaign = useCallback(\n (id: string) => {\n if (hubPath) void router.push(`${hubPath}/campaigns/${id}`)\n },\n [hubPath, router],\n )\n\n const handleCreate = useCallback(\n async (values: Record<string, any>) => {\n if (creating) return\n /*\n * `displayName` is the shared drawer's own field, and every artifact\n * create in the console collects a name under that key. The campaign\n * stores it as `name` — the container has one name and no second\n * internal label — so the mapping happens here rather than by giving\n * this one drawer a field the others do not have.\n */\n const name = String(values.displayName ?? '').trim()\n if (!name) return\n const startAtMs = values.startAt ? Date.parse(String(values.startAt)) : null\n const endAtMs = values.endAt ? Date.parse(String(values.endAt)) : null\n // Refused here rather than stored and rendered as a campaign that ends\n // before it starts, which no window state describes.\n if (startAtMs !== null && endAtMs !== null && endAtMs < startAtMs) {\n return setCreateError('The end date is before the start date')\n }\n /*\n * Held until the org is known. A container has one home, and a write\n * that raced the site's org lookup would have nowhere to go.\n */\n if (!orgId) {\n return setCreateError('This workspace is still loading — try again')\n }\n setCreating(true)\n setCreateError(null)\n const id = createResourceUid()\n try {\n await setDoc(campaignContainerDoc(firestore, orgId, id), {\n name,\n ...(Number.isFinite(startAtMs) && startAtMs !== null\n ? { startAtMs }\n : {}),\n ...(Number.isFinite(endAtMs) && endAtMs !== null ? { endAtMs } : {}),\n listIds: Array.isArray(values.listIds)\n ? values.listIds.map(String)\n : [],\n ...(values.topicId ? { topicId: String(values.topicId) } : {}),\n /*\n * Where it is placed. From a site's hub, that site — the creator is\n * standing on it, and a campaign that appeared on every sibling\n * site would be a decision nobody made. From the org hub, the sites\n * picked, and every site when none are.\n */\n visibleTo: campaignVisibleTo(\n hostId\n ? [hostId]\n : Array.isArray(values.siteIds)\n ? values.siteIds.map(String)\n : null,\n ),\n createdAtMs: Date.now(),\n createdBy: String((user as any)?.uid ?? ''),\n })\n setCreateOpen(false)\n openCampaign(id)\n } catch (error) {\n console.error(error)\n setCreateError('The campaign could not be created')\n enqueueSnackbar('Could not create the campaign', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setCreating(false)\n }\n },\n [creating, firestore, hostId, orgId, user, enqueueSnackbar, openCampaign],\n )\n\n const campaignHref = useCallback(\n (id: string) => (hubPath ? `${hubPath}/campaigns/${id}` : null),\n [hubPath],\n )\n\n /*==========================================\n * DELETING A CAMPAIGN FROM ITS ROW.\n *\n * The container goes and its emails stay — the route detaches them first,\n * so each one keeps its id, its report and its unsubscribe links, and\n * reappears in this very table as a \"Single send\". That is not a\n * convenience: a send id is inside the HMAC of every unsubscribe footer\n * already delivered, so a delete that took the sends with it would break\n * opt-outs that must go on working.\n *\n * The confirmation names the count and says what the delete does NOT do.\n * Deleting a campaign cancels nothing — a scheduled email inside it still\n * goes out — and a merchant who reads the button as \"stop this campaign\"\n * has to be told before they press it.\n *\n * EDITING is deliberately not offered here. A record is edited on its own\n * page in this console; the campaign's page carries the edit drawer, and\n * this row's first entry is the way to it.\n *=========================================*/\n const handleDelete = useCallback(\n async (row: CampaignListRow) => {\n if (deletingId) return\n const emails = row.sends.length\n /*\n * Emails with mail still to go: waiting for their time, AND part way\n * through an audience larger than one batch. Counting only `scheduled`\n * would miss the case that needs the warning most — an email delivering\n * right now is stored as `scheduled` too, and `rollup.scheduled` is the\n * narrower reading that leaves it out.\n */\n const unfinished = row.rollup.scheduled + row.rollup.sending\n const agreed = await confirm({\n title: `Delete ${row.name}?`,\n description:\n (emails\n ? `The ${emails.toLocaleString()} ` +\n `${emails === 1 ? 'email' : 'emails'} in it are kept — each ` +\n 'keeps its own report and its unsubscribe links, and they stay ' +\n 'in this list as single sends. '\n : 'It holds no emails. ') +\n (unfinished\n ? `${unfinished.toLocaleString()} of them ${\n unfinished === 1 ? 'is' : 'are'\n } still going out or still due, and deleting the campaign does ` +\n 'not stop that — the sender picks each one up from its own ' +\n 'record. Stop a send from its own page. '\n : '') +\n 'Any screens, forms and contacts assigned to it come out of it and ' +\n 'are otherwise untouched. Only the campaign itself goes.',\n confirmationText: 'Delete campaign',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDeletingId(row.id)\n try {\n const { response, payload } = await manageApi({\n action: 'deleteCampaign',\n campaignId: row.id,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The campaign could not be deleted',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Campaign deleted', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The campaign could not be deleted', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDeletingId('')\n }\n },\n [confirm, deletingId, enqueueSnackbar, manageApi],\n )\n\n const columns = useMemo(\n () => [\n {\n field: 'name',\n headerName: 'Campaign',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => (\n <Stack\n direction=\"row\"\n spacing={1}\n /*\n `minWidth: 0` on the row AND on the name: a flex child will not\n shrink below its content without it, so the chip beside it is\n what gets clipped instead of the name being ellipsed.\n */\n sx={{ alignItems: 'center', minWidth: 0, width: 1 }}\n >\n {/*\n The name is a real anchor as well as the row being clickable, so\n a campaign can be middle-clicked into a tab or copied out of the\n context menu — affordances a row handler cannot offer. The grid's\n own row click fires too, so the link stops the event rather than\n pushing the same route twice.\n */}\n {campaignHref(row.id) ? (\n <AppLink\n href={campaignHref(row.id) as string}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n style={{ minWidth: 0 }}\n >\n <Typography variant=\"body2\" noWrap>\n {row.name}\n </Typography>\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap sx={{ minWidth: 0 }}>\n {row.name}\n </Typography>\n )}\n {row.legacy ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label=\"Single send\"\n // A label that reads \"Single sen\" is worse than a shorter name.\n sx={{ flexShrink: 0 }}\n title={\n 'Sent before campaigns grouped their emails. Its report ' +\n 'and its unsubscribe links are unchanged.'\n }\n />\n ) : null}\n </Stack>\n ),\n },\n /*\n WHERE IT IS PLACED, on the org hub only. Under a site every row is on\n that site by construction, so the column would say one thing thirty\n times.\n */\n ...(orgMount\n ? [\n {\n field: 'sitesLabel',\n headerName: 'Sites',\n width: 170,\n sortable: false,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.sitesLabel}\n </Typography>\n ),\n },\n ]\n : []),\n {\n field: 'windowState',\n headerName: 'Window',\n width: 170,\n renderCell: ({ row }: any) => {\n const start = formatDay(row.startAtMs)\n const end = formatDay(row.endAtMs)\n const range =\n start && end && start !== end\n ? `${start} – ${end}`\n : start || end || ''\n return (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {WINDOW_LABEL[row.windowState as CampaignListRow['windowState']]}\n </Typography>\n {range ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {range}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n field: 'listIds',\n headerName: 'Lists',\n width: 140,\n sortable: false,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.listIds.length\n ? row.listIds\n .map((id: string) => listNames.get(id) ?? id)\n .join(', ')\n : '—'}\n </Typography>\n ),\n },\n /*\n THE FOUR COUNTS, RIGHT-ALIGNED HEAD AND BODY.\n A figure is read by its last digit, so a column of them lines up on\n the right or it does not line up at all. `headerAlign` has to be said\n as well as `align`: a grid column defaults its header to the column\n type's alignment rather than to the cell's, which is how these came to\n sit with left headers over figures nobody could compare down the page.\n\n EVERY ONE OF THEM MEASURES THE MAIL, and the headings say so. What a\n campaign CAUSED — the submissions, leads, contacts and bookings\n credited to it — is not a column here, because there is no figure to\n read: a container has no conversion counter of its own, so a column\n would be an aggregation per row for a campaign and a subcollection\n document per row for a single send, on a list that costs two queries\n however many campaigns it holds. Headings that named the campaign over\n figures that all described its mail were the cheaper mistake, so the\n headings name the mail and the note under the table says where the\n outcome figures are.\n */\n {\n field: 'emails',\n headerName: 'Emails',\n width: 128,\n align: 'right',\n headerAlign: 'right',\n /*\n THE COUNT, AND WHAT IS STILL HAPPENING TO IT.\n\n `sending` is called out apart from `scheduled` because the two are\n opposite readings of the same stored status: an email delivering an\n audience larger than one batch sits as `scheduled` between runs, and\n rolling it in with the ones that have mailed nobody reports a\n campaign that is half delivered as a campaign that has not started.\n\n The note sits UNDER the figure rather than beside it — the column\n would otherwise have to be wide enough for the longest sentence, and\n the figure is what the column is read for.\n */\n renderCell: ({ row }: any) => {\n const { sends, sending, scheduled } = row.rollup\n const note = [\n sending ? `${sending} sending` : '',\n scheduled ? `${scheduled} scheduled` : '',\n ]\n .filter(Boolean)\n .join(', ')\n return (\n <Stack sx={{ alignItems: 'flex-end', minWidth: 0, width: 1 }}>\n <Typography variant=\"body2\">{String(sends)}</Typography>\n {note ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {note}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n field: 'sent',\n headerName: 'Mail sent',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.sent)}</Typography>\n ),\n },\n {\n field: 'opens',\n headerName: 'Mail opens',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.opens)}</Typography>\n ),\n },\n {\n field: 'clicks',\n headerName: 'Mail clicks',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.clicks)}</Typography>\n ),\n },\n /*\n THE TRAILING CLUSTER, through the grid's own actions column.\n `listActionsColumn` is what every other grid list in the console\n renders, so the reader cannot tell which family of table they are\n standing in — the hand-rolled tables on this surface reach the same\n `RowActionsMenu` from the other direction.\n\n Opening the campaign is restated here even though the row click does\n the same thing, for the reason the screens table restates its own: the\n menu is where a row's actions are NAMED, and an action absent from it\n reads as an action the row does not have.\n\n A \"Single send\" row is a SEND with no container — the table adopts it\n so a merchant's history is not missing — and there is no container\n under it to delete. Its entry is shown disabled with that reason\n rather than hidden, because a control that vanishes and one that does\n not apply look identical from the outside.\n */\n listActionsColumn(\n (row: any) => (\n <ListRowActions\n label={String(row.name ?? row.id)}\n items={[\n {\n key: 'details',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row.id) ?? undefined,\n disabled: !campaignHref(row.id),\n disabledReason: campaignHref(row.id)\n ? undefined\n : 'This site’s console URL has not resolved yet',\n },\n {\n key: 'delete',\n label: 'Delete campaign',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: row.legacy || Boolean(deletingId),\n disabledReason: row.legacy\n ? 'This is a single send, not a campaign. Open it to ' +\n 'manage the email itself.'\n : 'A campaign is being deleted',\n onClick: () => void handleDelete(row as CampaignListRow),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ],\n [listNames, campaignHref, deletingId, handleDelete, orgMount],\n )\n\n return (\n <CardDisplay\n header={'Campaigns'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: '#campaigns-group-emails',\n excerpt:\n 'A campaign groups the emails you send to a set of lists over a ' +\n 'window of dates. Open one to write and send an email inside it.',\n })}\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={creating}\n onClick={() => {\n setCreateError(null)\n setCreateOpen(true)\n }}\n >\n {creating ? 'Creating…' : 'Create campaign'}\n </Button>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n <ListFilterChips {...listFilter.chipsProps} />\n <ListTable\n rows={listFilter.rows}\n columns={listFilter.filterColumns(columns as any)}\n onOpen={(id) => openCampaign(id)}\n // The panel and the search are the grid's; the card answers them\n // over every campaign it read (AGL-3317).\n {...listFilter.gridProps}\n initialState={{ columns: { columnVisibilityModel: { kind: false } } }}\n noRowsLabel={rows.length ? 'No campaigns match these filters' : 'No campaigns yet'}\n noRowsDescription={\n rows.length ? undefined : 'A campaign groups the emails you send to a set of lists.'\n }\n noRowsAction={\n rows.length ? undefined : (\n <Button variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'Create campaign'}\n </Button>\n )\n }\n />\n {/*\n WHAT THIS TABLE MEASURES, said rather than inferred from the column\n names. Every figure in it is a fact about the campaign's mail, and a\n reader comparing rows is comparing mailings — the conversions and the\n destinations credited to a campaign are on the campaign's own page,\n because counting them for every row is a read per row on a list whose\n whole cost is two queries.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These figures measure each campaign’s mail. What a campaign ' +\n 'caused — the form submissions, leads, contacts and bookings ' +\n 'credited to it — and the pages it sent people to are on the ' +\n 'campaign’s own page.'}\n </Typography>\n {sendsTruncated || campaignsTruncated ? (\n <Alert severity=\"info\">\n {`Showing the most recent ${CONTAINER_CEILING} campaigns and ` +\n `${CAMPAIGN_CEILING} sends. ${\n hostId ? 'This site' : 'This organization'\n } has more — the ones ` +\n 'listed are not necessarily the most recent, because a send ' +\n 'carries no date field that every writer stamps.'}\n </Alert>\n ) : null}\n </Stack>\n {/*\n The console's own create drawer, from the shared library rather than a\n second one that looks like it. What a campaign collects beyond a name\n is its window, the lists it is aimed at and the stream its emails open\n on; the description box is left off because the container stores none,\n and a field the writer discards is worse than one never offered.\n */}\n {/* Draws nothing: the zone the topic catalog's owner answers through. */}\n {topicSource}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create campaign\"\n submitLabel=\"Create campaign\"\n includeDescription={false}\n onSubmit={handleCreate}\n extraFields={campaignFields(\n listOptions,\n topicOptions,\n orgMount ? orgSiteOptions(orgMount) : null,\n )}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {createError}\n </Alert>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n/**\n * What a campaign collects beyond its name.\n *\n * Data-driven-forms field descriptors, built from the org's own lists and\n * topics so the drawer offers what this workspace actually has. Dates are\n * plain `date` inputs: a campaign window is a day at each end, and a time of\n * day would be a precision the model does not carry.\n */\nfunction campaignFields(\n lists: Array<{ value: string; label: string }>,\n topics: Array<{ value: string; label: string }>,\n /** The org's sites, on the org hub only — a site hub places on itself. */\n sites: Array<{ value: string; label: string }> | null,\n): any[] {\n return [\n ...(sites\n ? [\n {\n component: 'select',\n name: 'siteIds',\n label: 'Sites',\n multiple: true,\n initialValue: [],\n helperText:\n 'The sites this campaign is offered on. Leave empty for every site.',\n disableDefaultOption: true,\n options: sites,\n },\n ]\n : []),\n {\n component: 'text-field',\n name: 'startAt',\n label: 'Starts',\n type: 'date',\n helperText: 'When the campaign window opens',\n },\n {\n component: 'text-field',\n name: 'endAt',\n label: 'Ends',\n type: 'date',\n helperText: 'Leave empty for an open-ended campaign',\n InputLabelProps: { shrink: true },\n },\n {\n component: 'select',\n name: 'listIds',\n label: 'Lists',\n multiple: true,\n initialValue: [],\n // A campaign with no list is legitimate: its emails can go to leads, to\n // site members, or to a segment. The lists are what it is AIMED at.\n helperText: 'The lists this campaign is aimed at',\n disableDefaultOption: true,\n options: lists,\n },\n {\n component: 'select',\n name: 'topicId',\n label: 'Topic',\n helperText: 'The stream its emails open on — each one can change it',\n disableDefaultOption: true,\n options: topics,\n },\n ]\n}\n\n/**\n * The Sites cell: where a campaign is offered.\n *\n * A container reads its `visibleTo`; a single send, which has no container,\n * is on the one site it was sent as. An empty placement is said rather than\n * left blank, because a blank cell reads as \"every site\" and it means none.\n */\nfunction campaignSitesLabel(\n mount: MarketingOrgMount,\n row: CampaignListRow,\n container: EmailCampaign | undefined,\n): string {\n if (row.legacy) return orgSiteName(mount, row.sends[0]?.hostId) || '—'\n const ids = campaignSiteIds(container)\n if (ids === null) return 'Every site'\n if (!ids.length) return 'No site'\n return ids.map((id) => orgSiteName(mount, id)).join(', ')\n}\n\nHostCampaignsCard.displayName = 'HostCampaignsCard'\n\nexport default HostCampaignsCard\n"],"names":["buildRoute","createResourceUid","pluginDocsHelp","Route","ceilingedWindow","collectionCeiling","mdiDeleteOutline","mdiEyeOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListRowActions","ListTable","listActionsColumn","inMemoryListField","useListRowsFilter","useSnackbar","Alert","Button","Chip","Stack","Typography","collection","limit","query","setDoc","useCallback","useMemo","useState","useRouter","useConsoleHostRoute","useFirestore","useFirestoreCollection","useUser","campaignListRows","campaignSiteIds","campaignVisibleTo","CreateArtifactDrawer","campaignContainerDoc","campaignContainersQuery","campaignSendsQuery","orgSiteName","orgSiteOptions","topicCatalogHostId","useMarketingOrgId","useMarketingOrgMount","useCampaignManageApi","useCampaignTopicOptions","CAMPAIGN_CEILING","CONTAINER_CEILING","formatDay","ms","Date","toLocaleDateString","figure","value","toLocaleString","WINDOW_LABEL","undated","upcoming","running","ended","CAMPAIGN_FILTER_FIELDS","tokensPath","verbatimTokens","CAMPAIGN_FILTER_HEADERS","name","windowState","listIds","kind","sitesLabel","CAMPAIGN_KIND_OPTIONS","label","CAMPAIGN_WINDOW_OPTIONS","Object","keys","map","state","CAMPAIGN_SEARCH_FIELDS","HostCampaignsCard","props","hostId","basePath","firestore","router","orgSlug","subdomain","hubPath","HOST_PLUGIN","host","pluginSlug","data","user","enqueueSnackbar","confirm","orgMount","orgId","manageApi","sendDocs","sends","idField","rows","readSends","truncated","sendsTruncated","campaignDocs","containers","readCampaigns","campaignsTruncated","listDocs","createOpen","setCreateOpen","topics","source","topicSource","enabled","topicOptions","topic","id","listNames","names","Map","list","set","String","$id","listOptions","campaigns","filter","campaign","deletedAt","byId","now","row","legacy","campaignSitesLabel","get","filterOptions","filterFields","field","column","listFilter","fields","options","headers","search","createError","setCreateError","creating","setCreating","deletingId","setDeletingId","openCampaign","push","handleCreate","values","displayName","trim","startAtMs","startAt","parse","endAtMs","endAt","Number","isFinite","Array","isArray","topicId","visibleTo","siteIds","createdAtMs","createdBy","uid","error","console","variant","allowDuplicate","campaignHref","handleDelete","emails","length","unfinished","rollup","scheduled","sending","agreed","title","description","confirmationText","then","catch","response","payload","action","campaignId","ok","persist","columns","headerName","flex","minWidth","renderCell","direction","spacing","sx","alignItems","width","href","onClick","event","stopPropagation","style","noWrap","size","flexShrink","sortable","start","end","range","color","join","align","headerAlign","note","Boolean","sent","opens","clicks","items","key","icon","path","undefined","disabled","disabledReason","destructive","header","help","anchor","excerpt","HeaderProps","contentGutterX","contentGutterY","chipsProps","filterColumns","onOpen","gridProps","initialState","columnVisibilityModel","noRowsLabel","noRowsDescription","noRowsAction","severity","open","onClose","submitLabel","includeDescription","onSubmit","extraFields","campaignFields","errorSlot","mt","mb","lists","sites","component","multiple","initialValue","helperText","disableDefaultOption","type","InputLabelProps","shrink","mount","container","ids"],"mappings":"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"}
|
|
@@ -19,17 +19,49 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
19
19
|
import { pluginDocsHelp } from "@aglyn/aglyn";
|
|
20
20
|
import { CardDisplay } from "@aglyn/shared-ui-jsx";
|
|
21
21
|
import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
|
|
22
|
+
import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
|
|
22
23
|
import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component";
|
|
24
|
+
import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
|
|
25
|
+
import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
|
|
23
26
|
import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
24
27
|
import { useUser } from "@aglyn/tenant-feature-instance";
|
|
25
28
|
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
26
|
-
import { Alert, Chip,
|
|
27
|
-
import {
|
|
29
|
+
import { Alert, Chip, Stack, Typography } from "@mui/material";
|
|
30
|
+
import { useEffect, useMemo, useState } from "react";
|
|
28
31
|
/** The server's page size. Fixed, so the reader is not offered a choice. */ const PAGE_SIZE = 25;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
/*
|
|
33
|
+
* What the recipients grid's Filters panel offers (AGL-3317): the one filter
|
|
34
|
+
* the route serves, as a hidden select column. The route answers it over the
|
|
35
|
+
* whole delivery log, one cursor page at a time, and serves nothing else —
|
|
36
|
+
* so no other column filters, and there is no quick search, which could only
|
|
37
|
+
* ever narrow the page on screen.
|
|
38
|
+
*/ const RECIPIENT_FILTER_FIELDS = [
|
|
39
|
+
{
|
|
40
|
+
column: 'engagement',
|
|
41
|
+
kind: 'exact',
|
|
42
|
+
path: 'engagement',
|
|
43
|
+
operators: [
|
|
44
|
+
'equals'
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
const RECIPIENT_FILTER_HEADERS = {
|
|
49
|
+
engagement: 'Engagement'
|
|
50
|
+
};
|
|
51
|
+
const RECIPIENT_FILTER_OPTIONS = {
|
|
52
|
+
engagement: [
|
|
53
|
+
{
|
|
54
|
+
value: 'opened',
|
|
55
|
+
label: 'Opened it'
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
value: 'clicked',
|
|
59
|
+
label: 'Clicked something'
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
};
|
|
63
|
+
const RECIPIENT_HIDDEN_COLUMNS = {
|
|
64
|
+
engagement: false
|
|
33
65
|
};
|
|
34
66
|
const recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
35
67
|
anchor: '#opens--clicks',
|
|
@@ -67,9 +99,17 @@ const recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
67
99
|
* re-walking forward from the beginning.
|
|
68
100
|
*/ export function EmailRecipientsCard(props) {
|
|
69
101
|
var _cursors_page;
|
|
102
|
+
var _gridFilter_clauses_find;
|
|
70
103
|
const { hostId, screenId, emailId } = props;
|
|
71
104
|
const { data: user } = useUser();
|
|
72
|
-
const
|
|
105
|
+
const gridFilter = useListGridFilter({
|
|
106
|
+
selectFields: [
|
|
107
|
+
'engagement'
|
|
108
|
+
],
|
|
109
|
+
single: true
|
|
110
|
+
});
|
|
111
|
+
const asked = (_gridFilter_clauses_find = gridFilter.clauses.find((clause)=>clause.field === 'engagement')) == null ? void 0 : _gridFilter_clauses_find.value;
|
|
112
|
+
const filter = asked === 'opened' || asked === 'clicked' ? asked : 'all';
|
|
73
113
|
const [page, setPage] = useState(0);
|
|
74
114
|
/** Cursor for each page. Index 0 is always `null` — the first page. */ const [cursors, setCursors] = useState([
|
|
75
115
|
null
|
|
@@ -167,13 +207,14 @@ const recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
167
207
|
]);
|
|
168
208
|
// A new filter is a new result set, so the cursors collected under the old
|
|
169
209
|
// one describe positions in a query that no longer exists.
|
|
170
|
-
const
|
|
171
|
-
|
|
210
|
+
const [askedFilter, setAskedFilter] = useState(filter);
|
|
211
|
+
if (askedFilter !== filter) {
|
|
212
|
+
setAskedFilter(filter);
|
|
172
213
|
setPage(0);
|
|
173
214
|
setCursors([
|
|
174
215
|
null
|
|
175
216
|
]);
|
|
176
|
-
}
|
|
217
|
+
}
|
|
177
218
|
const columns = useMemo(()=>[
|
|
178
219
|
{
|
|
179
220
|
field: 'to',
|
|
@@ -260,6 +301,9 @@ const recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
260
301
|
], [
|
|
261
302
|
emailId
|
|
262
303
|
]);
|
|
304
|
+
const filterColumns = useMemo(()=>listFilterGridColumns(columns, RECIPIENT_FILTER_FIELDS, RECIPIENT_FILTER_OPTIONS, RECIPIENT_FILTER_HEADERS), [
|
|
305
|
+
columns
|
|
306
|
+
]);
|
|
263
307
|
return /*#__PURE__*/ _jsx(CardDisplay, {
|
|
264
308
|
header: 'Recipients',
|
|
265
309
|
help: recipientsDocsHelp,
|
|
@@ -273,20 +317,12 @@ const recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
273
317
|
color: "text.secondary",
|
|
274
318
|
children: (emailId ? 'One row per address this email was sent to, newest first. ' : 'One row per message sent from this template, newest first. ') + 'Opens are counted by a tracking pixel a mail client may block ' + 'or pre-fetch, so an absent open is weaker evidence than a click.'
|
|
275
319
|
}),
|
|
276
|
-
/*#__PURE__*/ _jsx(
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
sx: {
|
|
283
|
-
alignSelf: 'flex-start',
|
|
284
|
-
minWidth: 260
|
|
285
|
-
},
|
|
286
|
-
children: Object.keys(FILTER_LABELS).map((key)=>/*#__PURE__*/ _jsx(MenuItem, {
|
|
287
|
-
value: key,
|
|
288
|
-
children: FILTER_LABELS[key]
|
|
289
|
-
}, key))
|
|
320
|
+
/*#__PURE__*/ _jsx(ListFilterChips, {
|
|
321
|
+
fields: RECIPIENT_FILTER_FIELDS,
|
|
322
|
+
headers: RECIPIENT_FILTER_HEADERS,
|
|
323
|
+
clauses: gridFilter.clauses,
|
|
324
|
+
onChange: gridFilter.setClauses,
|
|
325
|
+
options: RECIPIENT_FILTER_OPTIONS
|
|
290
326
|
}),
|
|
291
327
|
failure ? /*#__PURE__*/ _jsx(Alert, {
|
|
292
328
|
severity: "warning",
|
|
@@ -296,14 +332,15 @@ const recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
296
332
|
severity: "info",
|
|
297
333
|
children: 'This template has been used by more emails than one read can ' + `span. These rows cover its ${campaignsRead} most recent ` + `emails; ${campaignsOmitted} older ` + `${campaignsOmitted === 1 ? 'email is' : 'emails are'} ` + 'not included.'
|
|
298
334
|
}) : null,
|
|
299
|
-
rows.length === 0 && !loading && !failure ? /*#__PURE__*/ _jsx(Typography, {
|
|
335
|
+
rows.length === 0 && !loading && !failure && filter === 'all' ? /*#__PURE__*/ _jsx(Typography, {
|
|
300
336
|
variant: "body2",
|
|
301
337
|
color: "text.secondary",
|
|
302
|
-
children:
|
|
338
|
+
children: 'No delivery records have been kept for this yet.'
|
|
303
339
|
}) : /*#__PURE__*/ _jsx(ListTable, {
|
|
304
340
|
"aria-label": "Recipients",
|
|
305
341
|
rows: rows,
|
|
306
|
-
columns:
|
|
342
|
+
columns: filterColumns,
|
|
343
|
+
loading: loading,
|
|
307
344
|
getRowId: (row)=>row.messageId,
|
|
308
345
|
rowHeight: TABLE_ROW_HEIGHT,
|
|
309
346
|
// A recipient carries the links they followed beneath the
|
|
@@ -311,9 +348,17 @@ const recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
|
311
348
|
getRowHeight: ()=>'auto',
|
|
312
349
|
// One page of a cursor feed, turned by the footer below: the grid
|
|
313
350
|
// neither slices it nor filters the page and calls that the log.
|
|
351
|
+
// Its panel's one filter is the route's (AGL-3317).
|
|
314
352
|
hideFooter: true,
|
|
315
|
-
|
|
316
|
-
|
|
353
|
+
filterMode: "server",
|
|
354
|
+
filterModel: gridFilter.filterModel,
|
|
355
|
+
onFilterModelChange: gridFilter.onFilterModelChange,
|
|
356
|
+
initialState: {
|
|
357
|
+
columns: {
|
|
358
|
+
columnVisibilityModel: RECIPIENT_HIDDEN_COLUMNS
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
noRowsLabel: "Nobody in the delivery log matches that yet"
|
|
317
362
|
}),
|
|
318
363
|
/*#__PURE__*/ _jsx(ListPagination, {
|
|
319
364
|
page: page,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/email-recipients-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useUser } from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n Alert,\n Chip,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\n\n/** The server's page size. Fixed, so the reader is not offered a choice. */\nconst PAGE_SIZE = 25\n\n/** Which recipients the table asks for. Matches the route's own vocabulary. */\ntype EngagementFilter = 'all' | 'opened' | 'clicked'\n\nconst FILTER_LABELS: Record<EngagementFilter, string> = {\n all: 'Everyone this was sent to',\n opened: 'Opened it',\n clicked: 'Clicked something',\n}\n\ninterface RecipientRow {\n messageId: string\n to: string\n subject: string | null\n campaignId: string | null\n status: string\n openCount: number\n clickCount: number\n clickedLinks: string[]\n firstSeenAtMs: number\n lastEventAtMs: number\n}\n\nconst recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#opens--clicks',\n excerpt:\n 'The people an email reached, and which of them opened it or clicked ' +\n 'a link — read from the per-recipient delivery log.',\n})\n\nexport interface EmailRecipientsCardProps {\n hostId: string\n /**\n * Read every message built from this template.\n *\n * Exactly one of `screenId` and `emailId` is passed. The route takes the\n * SCOPE rather than a list of message ids because a caller who could name\n * ids could name another site's, and the narrower input is the one that\n * cannot be got wrong later.\n */\n screenId?: string\n /** Read one message. */\n emailId?: string\n}\n\n/**\n * WHO, not how many.\n *\n * ## Why this is a fetch and not a listen\n *\n * Every other card on this page reads Firestore directly. This one cannot:\n * the per-recipient delivery log lives at `emailDeliveries/{sha256(address)}`\n * — one platform-level collection holding every site's mail and every\n * transactional message besides — and the query that narrows it to this\n * site's campaigns is a collection-group query whose scoping predicate is\n * part of the query. A security rule cannot require a `where` clause, so\n * there is no rule that would admit this read and refuse the same read\n * without the filter. The narrowing happens on the server, behind the same\n * site role the send path requires.\n *\n * ## Why it is its own card\n *\n * A separate card is a separate mount, and this is the only read on either\n * detail page whose cost grows with how much mail there has been. It asks\n * once on mount and once per page turn — never on a poll — so a reader who\n * came for the preview and the totals above pays for one page of this and\n * nothing more.\n *\n * ## The cursor, and why \"Previous\" keeps a stack\n *\n * The route pages forward with an opaque cursor, which is the only shape a\n * Firestore query offers without counting the whole result first. Going back\n * therefore means remembering where each page started, so the cursors are\n * kept in a list indexed by page — `null` for the first — rather than\n * re-walking forward from the beginning.\n */\nexport function EmailRecipientsCard(props: EmailRecipientsCardProps) {\n const { hostId, screenId, emailId } = props\n const { data: user } = useUser()\n\n const [filter, setFilter] = useState<EngagementFilter>('all')\n const [page, setPage] = useState(0)\n /** Cursor for each page. Index 0 is always `null` — the first page. */\n const [cursors, setCursors] = useState<(string | null)[]>([null])\n const [rows, setRows] = useState<RecipientRow[]>([])\n const [hasMore, setHasMore] = useState(false)\n const [loading, setLoading] = useState(true)\n const [failure, setFailure] = useState<string | null>(null)\n const [campaignsOmitted, setCampaignsOmitted] = useState(0)\n const [campaignsRead, setCampaignsRead] = useState(0)\n\n const cursor = cursors[page] ?? null\n\n useEffect(() => {\n if (!user) return undefined\n let active = true\n setLoading(true)\n setFailure(null)\n void (async () => {\n try {\n const response = await authorizedFetch(\n user,\n '/api/campaigns/recipients',\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n ...(screenId ? { screenId } : {}),\n ...(emailId ? { emailId } : {}),\n filter,\n cursor,\n }),\n },\n )\n const payload = await response.json().catch(() => ({}))\n if (!active) return\n if (!response.ok) {\n setRows([])\n setHasMore(false)\n setFailure(String(payload?.error ?? 'Could not read recipients'))\n return\n }\n /*\n * A read that FAILED is not a campaign nobody opened.\n *\n * The route answers 200 with `lookupFailed` for a delivery-log read\n * that could not run — a missing collection-group index is the likely\n * one — because the rest of the payload is still true. Rendering that\n * as an empty table is how a merchant concludes their campaign\n * reached nobody, which is the one wrong answer this table can give.\n */\n if (payload?.lookupFailed) {\n setRows([])\n setHasMore(false)\n setFailure(\n 'The delivery log could not be read, so this table is empty for ' +\n 'a reason that has nothing to do with your campaign. The ' +\n 'numbers above come from the campaigns themselves and are ' +\n 'unaffected.',\n )\n return\n }\n const nextRows: RecipientRow[] = Array.isArray(payload?.rows)\n ? payload.rows\n : []\n setRows(nextRows)\n setCampaignsOmitted(Number(payload?.campaignsOmitted ?? 0))\n setCampaignsRead(Number(payload?.campaignsRead ?? 0))\n const nextCursor: string | null = payload?.cursor ?? null\n setHasMore(Boolean(nextCursor))\n if (nextCursor) {\n setCursors((existing) => {\n if (existing[page + 1] === nextCursor) return existing\n const next = existing.slice(0, page + 1)\n next[page + 1] = nextCursor\n return next\n })\n }\n } catch (error) {\n console.error(error)\n if (active) {\n setRows([])\n setHasMore(false)\n setFailure('Could not read recipients')\n }\n } finally {\n if (active) setLoading(false)\n }\n })()\n return () => {\n active = false\n }\n }, [user, hostId, screenId, emailId, filter, cursor, page])\n\n // A new filter is a new result set, so the cursors collected under the old\n // one describe positions in a query that no longer exists.\n const handleFilter = useCallback((next: EngagementFilter) => {\n setFilter(next)\n setPage(0)\n setCursors([null])\n }, [])\n\n const columns = useMemo<GridColDef<RecipientRow>[]>(\n () => [\n {\n field: 'to',\n headerName: 'Recipient',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack spacing={0.5} sx={{ py: 1, minWidth: 0, wordBreak: 'break-all' }}>\n <Typography variant=\"body2\">{row.to}</Typography>\n {/*\n * WHICH links, under the person who followed them. The\n * per-campaign link rollup counts destinations and names nobody;\n * this is the other half of the same question, and it is the half\n * a merchant asks when they want to know who to call.\n */}\n {row.clickedLinks.map((link) => (\n <Typography key={link} variant=\"caption\" color=\"text.secondary\">\n {link}\n </Typography>\n ))}\n </Stack>\n ),\n },\n ...(emailId\n ? []\n : [\n {\n field: 'subject',\n headerName: 'Email',\n flex: 1,\n minWidth: 180,\n valueGetter: (_value: unknown, row: RecipientRow) => row.subject ?? '—',\n } satisfies GridColDef<RecipientRow>,\n ]),\n {\n field: 'status',\n headerName: 'State',\n width: 130,\n renderCell: ({ row }) => <Chip size=\"small\" label={row.status} />,\n },\n {\n field: 'openCount',\n headerName: 'Opens',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n renderCell: ({ row }) => (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {row.openCount.toLocaleString()}\n </Typography>\n ),\n },\n {\n field: 'clickCount',\n headerName: 'Clicks',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n renderCell: ({ row }) => (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {row.clickCount.toLocaleString()}\n </Typography>\n ),\n },\n {\n field: 'lastEventAtMs',\n headerName: 'Last event',\n width: 190,\n renderCell: ({ row }) =>\n row.lastEventAtMs ? new Date(row.lastEventAtMs).toLocaleString() : '—',\n },\n ],\n [emailId],\n )\n\n return (\n <CardDisplay\n header={'Recipients'}\n help={recipientsDocsHelp}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {(emailId\n ? 'One row per address this email was sent to, newest first. '\n : 'One row per message sent from this template, newest first. ') +\n 'Opens are counted by a tracking pixel a mail client may block ' +\n 'or pre-fetch, so an absent open is weaker evidence than a click.'}\n </Typography>\n\n <TextField\n select\n size=\"small\"\n label=\"Show\"\n value={filter}\n onChange={(event) =>\n handleFilter(event.target.value as EngagementFilter)\n }\n sx={{ alignSelf: 'flex-start', minWidth: 260 }}\n >\n {(Object.keys(FILTER_LABELS) as EngagementFilter[]).map((key) => (\n <MenuItem key={key} value={key}>\n {FILTER_LABELS[key]}\n </MenuItem>\n ))}\n </TextField>\n\n {failure ? <Alert severity=\"warning\">{failure}</Alert> : null}\n\n {campaignsOmitted ? (\n <Alert severity=\"info\">\n {'This template has been used by more emails than one read can ' +\n `span. These rows cover its ${campaignsRead} most recent ` +\n `emails; ${campaignsOmitted} older ` +\n `${campaignsOmitted === 1 ? 'email is' : 'emails are'} ` +\n 'not included.'}\n </Alert>\n ) : null}\n\n {rows.length === 0 && !loading && !failure ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {filter === 'all'\n ? 'No delivery records have been kept for this yet.'\n : 'Nobody in the delivery log matches that yet.'}\n </Typography>\n ) : (\n <ListTable\n aria-label=\"Recipients\"\n rows={rows}\n columns={columns}\n getRowId={(row: RecipientRow) => row.messageId}\n rowHeight={TABLE_ROW_HEIGHT}\n // A recipient carries the links they followed beneath the\n // address, so a row is as tall as its list of links.\n getRowHeight={() => 'auto'}\n // One page of a cursor feed, turned by the footer below: the grid\n // neither slices it nor filters the page and calls that the log.\n hideFooter\n disableColumnFilter\n quickFilter={false}\n />\n )}\n\n {/*\n * A cursor feed knows whether ANOTHER page exists and never how many\n * rows there are in total, so `hasMore` is passed and `count` is not\n * — the control renders \"1–25 of more than 25\" rather than inventing\n * a total nobody paid to count.\n */}\n <ListPagination\n page={page}\n pageSize={PAGE_SIZE}\n rowCount={rows.length}\n hasMore={hasMore}\n disabled={loading}\n onPageChange={setPage}\n />\n\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Up to ten distinct destinations are kept per recipient, so a ' +\n 'reader who clicked more links than that has the rest counted ' +\n 'but not listed.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nEmailRecipientsCard.displayName = 'EmailRecipientsCard'\n\nexport default EmailRecipientsCard\n"],"names":["pluginDocsHelp","CardDisplay","ListPagination","ListTable","TABLE_ROW_HEIGHT","useUser","authorizedFetch","Alert","Chip","MenuItem","Stack","TextField","Typography","useCallback","useEffect","useMemo","useState","PAGE_SIZE","FILTER_LABELS","all","opened","clicked","recipientsDocsHelp","anchor","excerpt","EmailRecipientsCard","props","cursors","hostId","screenId","emailId","data","user","filter","setFilter","page","setPage","setCursors","rows","setRows","hasMore","setHasMore","loading","setLoading","failure","setFailure","campaignsOmitted","setCampaignsOmitted","campaignsRead","setCampaignsRead","cursor","undefined","active","response","method","headers","body","JSON","stringify","payload","json","catch","ok","String","error","lookupFailed","nextRows","Array","isArray","Number","nextCursor","Boolean","existing","next","slice","console","handleFilter","columns","field","headerName","flex","minWidth","renderCell","row","spacing","sx","py","wordBreak","variant","to","clickedLinks","map","link","color","valueGetter","_value","subject","width","size","label","status","type","align","headerAlign","fontWeight","openCount","toLocaleString","clickCount","lastEventAtMs","Date","header","help","contentGutterX","contentGutterY","select","value","onChange","event","target","alignSelf","Object","keys","key","severity","length","aria-label","getRowId","messageId","rowHeight","getRowHeight","hideFooter","disableColumnFilter","quickFilter","pageSize","rowCount","disabled","onPageChange","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/email-recipients-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\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 { useUser } from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { Alert, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { useEffect, useMemo, useState } from 'react'\n\n/** The server's page size. Fixed, so the reader is not offered a choice. */\nconst PAGE_SIZE = 25\n\n/** Which recipients the table asks for. Matches the route's own vocabulary. */\ntype EngagementFilter = 'all' | 'opened' | 'clicked'\n\n/*\n * What the recipients grid's Filters panel offers (AGL-3317): the one filter\n * the route serves, as a hidden select column. The route answers it over the\n * whole delivery log, one cursor page at a time, and serves nothing else —\n * so no other column filters, and there is no quick search, which could only\n * ever narrow the page on screen.\n */\nconst RECIPIENT_FILTER_FIELDS: readonly ListFilterField[] = [\n { column: 'engagement', kind: 'exact', path: 'engagement', operators: ['equals'] },\n]\nconst RECIPIENT_FILTER_HEADERS: Readonly<Record<string, string>> = {\n engagement: 'Engagement',\n}\nconst RECIPIENT_FILTER_OPTIONS = {\n engagement: [\n { value: 'opened', label: 'Opened it' },\n { value: 'clicked', label: 'Clicked something' },\n ],\n}\nconst RECIPIENT_HIDDEN_COLUMNS = { engagement: false }\n\ninterface RecipientRow {\n messageId: string\n to: string\n subject: string | null\n campaignId: string | null\n status: string\n openCount: number\n clickCount: number\n clickedLinks: string[]\n firstSeenAtMs: number\n lastEventAtMs: number\n}\n\nconst recipientsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#opens--clicks',\n excerpt:\n 'The people an email reached, and which of them opened it or clicked ' +\n 'a link — read from the per-recipient delivery log.',\n})\n\nexport interface EmailRecipientsCardProps {\n hostId: string\n /**\n * Read every message built from this template.\n *\n * Exactly one of `screenId` and `emailId` is passed. The route takes the\n * SCOPE rather than a list of message ids because a caller who could name\n * ids could name another site's, and the narrower input is the one that\n * cannot be got wrong later.\n */\n screenId?: string\n /** Read one message. */\n emailId?: string\n}\n\n/**\n * WHO, not how many.\n *\n * ## Why this is a fetch and not a listen\n *\n * Every other card on this page reads Firestore directly. This one cannot:\n * the per-recipient delivery log lives at `emailDeliveries/{sha256(address)}`\n * — one platform-level collection holding every site's mail and every\n * transactional message besides — and the query that narrows it to this\n * site's campaigns is a collection-group query whose scoping predicate is\n * part of the query. A security rule cannot require a `where` clause, so\n * there is no rule that would admit this read and refuse the same read\n * without the filter. The narrowing happens on the server, behind the same\n * site role the send path requires.\n *\n * ## Why it is its own card\n *\n * A separate card is a separate mount, and this is the only read on either\n * detail page whose cost grows with how much mail there has been. It asks\n * once on mount and once per page turn — never on a poll — so a reader who\n * came for the preview and the totals above pays for one page of this and\n * nothing more.\n *\n * ## The cursor, and why \"Previous\" keeps a stack\n *\n * The route pages forward with an opaque cursor, which is the only shape a\n * Firestore query offers without counting the whole result first. Going back\n * therefore means remembering where each page started, so the cursors are\n * kept in a list indexed by page — `null` for the first — rather than\n * re-walking forward from the beginning.\n */\nexport function EmailRecipientsCard(props: EmailRecipientsCardProps) {\n const { hostId, screenId, emailId } = props\n const { data: user } = useUser()\n\n const gridFilter = useListGridFilter({ selectFields: ['engagement'], single: true })\n const asked = gridFilter.clauses.find((clause) => clause.field === 'engagement')?.value\n const filter: EngagementFilter =\n asked === 'opened' || asked === 'clicked' ? asked : 'all'\n const [page, setPage] = useState(0)\n /** Cursor for each page. Index 0 is always `null` — the first page. */\n const [cursors, setCursors] = useState<(string | null)[]>([null])\n const [rows, setRows] = useState<RecipientRow[]>([])\n const [hasMore, setHasMore] = useState(false)\n const [loading, setLoading] = useState(true)\n const [failure, setFailure] = useState<string | null>(null)\n const [campaignsOmitted, setCampaignsOmitted] = useState(0)\n const [campaignsRead, setCampaignsRead] = useState(0)\n\n const cursor = cursors[page] ?? null\n\n useEffect(() => {\n if (!user) return undefined\n let active = true\n setLoading(true)\n setFailure(null)\n void (async () => {\n try {\n const response = await authorizedFetch(\n user,\n '/api/campaigns/recipients',\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n ...(screenId ? { screenId } : {}),\n ...(emailId ? { emailId } : {}),\n filter,\n cursor,\n }),\n },\n )\n const payload = await response.json().catch(() => ({}))\n if (!active) return\n if (!response.ok) {\n setRows([])\n setHasMore(false)\n setFailure(String(payload?.error ?? 'Could not read recipients'))\n return\n }\n /*\n * A read that FAILED is not a campaign nobody opened.\n *\n * The route answers 200 with `lookupFailed` for a delivery-log read\n * that could not run — a missing collection-group index is the likely\n * one — because the rest of the payload is still true. Rendering that\n * as an empty table is how a merchant concludes their campaign\n * reached nobody, which is the one wrong answer this table can give.\n */\n if (payload?.lookupFailed) {\n setRows([])\n setHasMore(false)\n setFailure(\n 'The delivery log could not be read, so this table is empty for ' +\n 'a reason that has nothing to do with your campaign. The ' +\n 'numbers above come from the campaigns themselves and are ' +\n 'unaffected.',\n )\n return\n }\n const nextRows: RecipientRow[] = Array.isArray(payload?.rows)\n ? payload.rows\n : []\n setRows(nextRows)\n setCampaignsOmitted(Number(payload?.campaignsOmitted ?? 0))\n setCampaignsRead(Number(payload?.campaignsRead ?? 0))\n const nextCursor: string | null = payload?.cursor ?? null\n setHasMore(Boolean(nextCursor))\n if (nextCursor) {\n setCursors((existing) => {\n if (existing[page + 1] === nextCursor) return existing\n const next = existing.slice(0, page + 1)\n next[page + 1] = nextCursor\n return next\n })\n }\n } catch (error) {\n console.error(error)\n if (active) {\n setRows([])\n setHasMore(false)\n setFailure('Could not read recipients')\n }\n } finally {\n if (active) setLoading(false)\n }\n })()\n return () => {\n active = false\n }\n }, [user, hostId, screenId, emailId, filter, cursor, page])\n\n // A new filter is a new result set, so the cursors collected under the old\n // one describe positions in a query that no longer exists.\n const [askedFilter, setAskedFilter] = useState<EngagementFilter>(filter)\n if (askedFilter !== filter) {\n setAskedFilter(filter)\n setPage(0)\n setCursors([null])\n }\n\n const columns = useMemo<GridColDef<RecipientRow>[]>(\n () => [\n {\n field: 'to',\n headerName: 'Recipient',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack spacing={0.5} sx={{ py: 1, minWidth: 0, wordBreak: 'break-all' }}>\n <Typography variant=\"body2\">{row.to}</Typography>\n {/*\n * WHICH links, under the person who followed them. The\n * per-campaign link rollup counts destinations and names nobody;\n * this is the other half of the same question, and it is the half\n * a merchant asks when they want to know who to call.\n */}\n {row.clickedLinks.map((link) => (\n <Typography key={link} variant=\"caption\" color=\"text.secondary\">\n {link}\n </Typography>\n ))}\n </Stack>\n ),\n },\n ...(emailId\n ? []\n : [\n {\n field: 'subject',\n headerName: 'Email',\n flex: 1,\n minWidth: 180,\n valueGetter: (_value: unknown, row: RecipientRow) => row.subject ?? '—',\n } satisfies GridColDef<RecipientRow>,\n ]),\n {\n field: 'status',\n headerName: 'State',\n width: 130,\n renderCell: ({ row }) => <Chip size=\"small\" label={row.status} />,\n },\n {\n field: 'openCount',\n headerName: 'Opens',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n renderCell: ({ row }) => (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {row.openCount.toLocaleString()}\n </Typography>\n ),\n },\n {\n field: 'clickCount',\n headerName: 'Clicks',\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 100,\n renderCell: ({ row }) => (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {row.clickCount.toLocaleString()}\n </Typography>\n ),\n },\n {\n field: 'lastEventAtMs',\n headerName: 'Last event',\n width: 190,\n renderCell: ({ row }) =>\n row.lastEventAtMs ? new Date(row.lastEventAtMs).toLocaleString() : '—',\n },\n ],\n [emailId],\n )\n const filterColumns = useMemo(\n () =>\n listFilterGridColumns(\n columns as GridColDef[],\n RECIPIENT_FILTER_FIELDS,\n RECIPIENT_FILTER_OPTIONS,\n RECIPIENT_FILTER_HEADERS,\n ),\n [columns],\n )\n\n return (\n <CardDisplay\n header={'Recipients'}\n help={recipientsDocsHelp}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {(emailId\n ? 'One row per address this email was sent to, newest first. '\n : 'One row per message sent from this template, newest first. ') +\n 'Opens are counted by a tracking pixel a mail client may block ' +\n 'or pre-fetch, so an absent open is weaker evidence than a click.'}\n </Typography>\n\n <ListFilterChips\n fields={RECIPIENT_FILTER_FIELDS}\n headers={RECIPIENT_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={RECIPIENT_FILTER_OPTIONS}\n />\n\n {failure ? <Alert severity=\"warning\">{failure}</Alert> : null}\n\n {campaignsOmitted ? (\n <Alert severity=\"info\">\n {'This template has been used by more emails than one read can ' +\n `span. These rows cover its ${campaignsRead} most recent ` +\n `emails; ${campaignsOmitted} older ` +\n `${campaignsOmitted === 1 ? 'email is' : 'emails are'} ` +\n 'not included.'}\n </Alert>\n ) : null}\n\n {rows.length === 0 && !loading && !failure && filter === 'all' ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No delivery records have been kept for this yet.'}\n </Typography>\n ) : (\n <ListTable\n aria-label=\"Recipients\"\n rows={rows}\n columns={filterColumns}\n loading={loading}\n getRowId={(row: RecipientRow) => row.messageId}\n rowHeight={TABLE_ROW_HEIGHT}\n // A recipient carries the links they followed beneath the\n // address, so a row is as tall as its list of links.\n getRowHeight={() => 'auto'}\n // One page of a cursor feed, turned by the footer below: the grid\n // neither slices it nor filters the page and calls that the log.\n // Its panel's one filter is the route's (AGL-3317).\n hideFooter\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n initialState={{ columns: { columnVisibilityModel: RECIPIENT_HIDDEN_COLUMNS } }}\n noRowsLabel=\"Nobody in the delivery log matches that yet\"\n />\n )}\n\n {/*\n * A cursor feed knows whether ANOTHER page exists and never how many\n * rows there are in total, so `hasMore` is passed and `count` is not\n * — the control renders \"1–25 of more than 25\" rather than inventing\n * a total nobody paid to count.\n */}\n <ListPagination\n page={page}\n pageSize={PAGE_SIZE}\n rowCount={rows.length}\n hasMore={hasMore}\n disabled={loading}\n onPageChange={setPage}\n />\n\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Up to ten distinct destinations are kept per recipient, so a ' +\n 'reader who clicked more links than that has the rest counted ' +\n 'but not listed.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nEmailRecipientsCard.displayName = 'EmailRecipientsCard'\n\nexport default EmailRecipientsCard\n"],"names":["pluginDocsHelp","CardDisplay","ListPagination","ListFilterChips","ListTable","listFilterGridColumns","useListGridFilter","TABLE_ROW_HEIGHT","useUser","authorizedFetch","Alert","Chip","Stack","Typography","useEffect","useMemo","useState","PAGE_SIZE","RECIPIENT_FILTER_FIELDS","column","kind","path","operators","RECIPIENT_FILTER_HEADERS","engagement","RECIPIENT_FILTER_OPTIONS","value","label","RECIPIENT_HIDDEN_COLUMNS","recipientsDocsHelp","anchor","excerpt","EmailRecipientsCard","props","cursors","gridFilter","hostId","screenId","emailId","data","user","selectFields","single","asked","clauses","find","clause","field","filter","page","setPage","setCursors","rows","setRows","hasMore","setHasMore","loading","setLoading","failure","setFailure","campaignsOmitted","setCampaignsOmitted","campaignsRead","setCampaignsRead","cursor","undefined","active","response","method","headers","body","JSON","stringify","payload","json","catch","ok","String","error","lookupFailed","nextRows","Array","isArray","Number","nextCursor","Boolean","existing","next","slice","console","askedFilter","setAskedFilter","columns","headerName","flex","minWidth","renderCell","row","spacing","sx","py","wordBreak","variant","to","clickedLinks","map","link","color","valueGetter","_value","subject","width","size","status","type","align","headerAlign","fontWeight","openCount","toLocaleString","clickCount","lastEventAtMs","Date","filterColumns","header","help","contentGutterX","contentGutterY","fields","onChange","setClauses","options","severity","length","aria-label","getRowId","messageId","rowHeight","getRowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","initialState","columnVisibilityModel","noRowsLabel","pageSize","rowCount","disabled","onPageChange","displayName"],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type CampaignSendDisplayState } from '@aglyn/shared-ui-email-campaigns/model';
|
|
18
|
+
export declare const EMAIL_STATE_OPTIONS: ReadonlyArray<{
|
|
19
|
+
value: CampaignSendDisplayState;
|
|
20
|
+
label: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare const EMAIL_SUBJECT_FILTER_FIELD: import("@aglyn/shared-ui-jsx/src/lib/const/list-filter").ListFilterField;
|
|
23
|
+
export declare const EMAIL_STATE_FILTER_FIELD: import("@aglyn/shared-ui-jsx/src/lib/const/list-filter").ListFilterField;
|
|
24
|
+
/** The filter values an email row carries beside its stored fields. */
|
|
25
|
+
export declare function emailFilterValues(email: {
|
|
26
|
+
$id?: string;
|
|
27
|
+
subject?: string | null;
|
|
28
|
+
status?: string;
|
|
29
|
+
}): {
|
|
30
|
+
subjectText: string;
|
|
31
|
+
stateKey: CampaignSendDisplayState;
|
|
32
|
+
};
|