@aglyn/plugins-marketing 1.0.0-beta.199 → 1.0.0-beta.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-composer.js +14 -6
  3. package/src/lib/components/campaign-composer.js.map +1 -1
  4. package/src/lib/components/campaign-conversions-card.js +6 -0
  5. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  6. package/src/lib/components/campaign-detail-card.js +107 -37
  7. package/src/lib/components/campaign-detail-card.js.map +1 -1
  8. package/src/lib/components/campaign-members-section.d.ts +16 -4
  9. package/src/lib/components/campaign-members-section.js +171 -105
  10. package/src/lib/components/campaign-members-section.js.map +1 -1
  11. package/src/lib/components/campaign-queries.d.ts +11 -0
  12. package/src/lib/components/campaign-queries.js +13 -0
  13. package/src/lib/components/campaign-queries.js.map +1 -1
  14. package/src/lib/components/campaigns-card.d.ts +8 -6
  15. package/src/lib/components/campaigns-card.js +296 -128
  16. package/src/lib/components/campaigns-card.js.map +1 -1
  17. package/src/lib/components/email-compose-card.js +4 -3
  18. package/src/lib/components/email-compose-card.js.map +1 -1
  19. package/src/lib/components/email-detail.js +16 -5
  20. package/src/lib/components/email-detail.js.map +1 -1
  21. package/src/lib/components/email-recipients-card.js +3 -1
  22. package/src/lib/components/email-recipients-card.js.map +1 -1
  23. package/src/lib/components/emails-list-card.d.ts +11 -15
  24. package/src/lib/components/emails-list-card.js +142 -143
  25. package/src/lib/components/emails-list-card.js.map +1 -1
  26. package/src/lib/components/host-experiments-card.component.js +110 -78
  27. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  28. package/src/lib/components/org-experiments-card.js +7 -3
  29. package/src/lib/components/org-experiments-card.js.map +1 -1
  30. package/src/lib/model/campaign-list-query.d.ts +99 -0
  31. package/src/lib/model/campaign-list-query.js +373 -0
  32. package/src/lib/model/campaign-list-query.js.map +1 -0
  33. package/src/lib/model/campaign-members-query.d.ts +37 -0
  34. package/src/lib/model/campaign-members-query.js +120 -0
  35. package/src/lib/model/campaign-members-query.js.map +1 -0
  36. package/src/lib/model/experiment-list-query.d.ts +22 -0
  37. package/src/lib/model/experiment-list-query.js +85 -0
  38. package/src/lib/model/experiment-list-query.js.map +1 -0
  39. package/src/lib/server/campaign-form-flags.d.ts +35 -0
  40. package/src/lib/server/campaign-form-flags.js +52 -0
  41. package/src/lib/server/campaign-form-flags.js.map +1 -0
  42. package/src/lib/server/campaign-manage.d.ts +1 -1
  43. package/src/lib/server/campaign-manage.js +42 -20
  44. package/src/lib/server/campaign-manage.js.map +1 -1
  45. package/src/lib/server/campaign-send.d.ts +42 -1
  46. package/src/lib/server/campaign-send.js +247 -16
  47. package/src/lib/server/campaign-send.js.map +1 -1
  48. package/src/lib/server/email-events.js +13 -1
  49. package/src/lib/server/email-events.js.map +1 -1
  50. package/src/lib/components/email-send-filter.d.ts +0 -32
  51. package/src/lib/components/email-send-filter.js +0 -55
  52. package/src/lib/components/email-send-filter.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-queries.ts"],"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\nimport { hostScopeToken, scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n orgCampaignSendsPath,\n orgCampaignSequenceReportsPath,\n orgEmailCampaignsPath,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport {\n collection,\n doc,\n query,\n where,\n type DocumentReference,\n type Firestore,\n type Query,\n} from 'firebase/firestore'\n\n/**\n * WHERE A CAMPAIGN AND ITS SENDS ARE READ FROM, at either level of the hub.\n *\n * Both collections belong to the organization: containers at\n * `orgs/{orgId}/emailCampaigns`, sends at `orgs/{orgId}/campaigns`, each\n * send's rollups under it at `…/reports/{name}`. Spelled once here so no card\n * rebuilds a path from memory, and so the one rule that makes a site hub's\n * read legal is in one place:\n *\n * **A site hub's LIST reads are filtered by scope.** The rules let an\n * org-wide member read every document and a site collaborator only those\n * whose `visibleTo` names a scope they hold. A query the rules cannot prove\n * document by document is refused whole, so a site hub asks exactly for what\n * that site may see — a container placed on every site (`'org'`) or on this\n * one (`'host:{id}'`), and a send sent AS this site. An org-wide member loses\n * nothing by the filter at a site: those are the campaigns the site shows.\n *\n * The ORG hub is admitted only to org-wide members, so its reads are\n * unfiltered.\n *\n * One `array-contains-any` per query is Firestore's limit, so no caller adds\n * a second array clause to these; per-document reads need no filter at all.\n */\n\n/**\n * `orgs/{orgId}/emailCampaigns`, narrowed to one site's placements when\n * `hostId` names a site; `null` until the org is known.\n */\nexport function campaignContainersQuery(\n firestore: Firestore,\n orgId: string | null,\n hostId: string | null,\n): Query | null {\n if (!orgId) return null\n const containers = collection(firestore, ...orgEmailCampaignsPath(orgId))\n return hostId\n ? query(\n containers,\n where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)),\n )\n : containers\n}\n\n/**\n * `orgs/{orgId}/campaigns` — the sends — narrowed to the ones sent as one\n * site when `hostId` names a site; `null` until the org is known.\n */\nexport function campaignSendsQuery(\n firestore: Firestore,\n orgId: string | null,\n hostId: string | null,\n): Query | null {\n if (!orgId) return null\n const sends = collection(firestore, ...orgCampaignSendsPath(orgId))\n return hostId\n ? query(\n sends,\n where('visibleTo', 'array-contains-any', [hostScopeToken(hostId)]),\n )\n : sends\n}\n\n/** One container, `orgs/{orgId}/emailCampaigns/{id}`. */\nexport function campaignContainerDoc(\n firestore: Firestore,\n orgId: string,\n campaignId: string,\n): DocumentReference {\n return doc(firestore, ...orgEmailCampaignsPath(orgId), campaignId)\n}\n\n/** One send, `orgs/{orgId}/campaigns/{sendId}`. */\nexport function campaignSendDoc(\n firestore: Firestore,\n orgId: string,\n sendId: string,\n): DocumentReference {\n return doc(firestore, ...orgCampaignSendsPath(orgId), sendId)\n}\n\n/** One of a send's rollups, `orgs/{orgId}/campaigns/{sendId}/reports/{name}`. */\nexport function campaignSendReportDoc(\n firestore: Firestore,\n orgId: string,\n sendId: string,\n report: 'links' | 'revenue' | 'conversions' | 'reached',\n): DocumentReference {\n return doc(\n firestore,\n ...orgCampaignSendsPath(orgId),\n sendId,\n 'reports',\n report,\n )\n}\n\n/** A container's sequence rollup, `orgs/{orgId}/campaignSequenceReports/{id}`. */\nexport function campaignSequenceReportDoc(\n firestore: Firestore,\n orgId: string,\n campaignId: string,\n): DocumentReference {\n return doc(firestore, ...orgCampaignSequenceReportsPath(orgId), campaignId)\n}\n"],"names":["hostScopeToken","scopeTokensForHost","orgCampaignSendsPath","orgCampaignSequenceReportsPath","orgEmailCampaignsPath","collection","doc","query","where","campaignContainersQuery","firestore","orgId","hostId","containers","campaignSendsQuery","sends","campaignContainerDoc","campaignId","campaignSendDoc","sendId","campaignSendReportDoc","report","campaignSequenceReportDoc"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,cAAc,EAAEC,kBAAkB,QAAQ,sCAAqC;AACxF,SACEC,oBAAoB,EACpBC,8BAA8B,EAC9BC,qBAAqB,QAChB,4DAA2D;AAClE,SACEC,UAAU,EACVC,GAAG,EACHC,KAAK,EACLC,KAAK,QAIA,qBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED;;;CAGC,GACD,OAAO,SAASC,wBACdC,SAAoB,EACpBC,KAAoB,EACpBC,MAAqB;IAErB,IAAI,CAACD,OAAO,OAAO;IACnB,MAAME,aAAaR,WAAWK,cAAcN,sBAAsBO;IAClE,OAAOC,SACHL,MACEM,YACAL,MAAM,aAAa,sBAAsBP,mBAAmBW,YAE9DC;AACN;AAEA;;;CAGC,GACD,OAAO,SAASC,mBACdJ,SAAoB,EACpBC,KAAoB,EACpBC,MAAqB;IAErB,IAAI,CAACD,OAAO,OAAO;IACnB,MAAMI,QAAQV,WAAWK,cAAcR,qBAAqBS;IAC5D,OAAOC,SACHL,MACEQ,OACAP,MAAM,aAAa,sBAAsB;QAACR,eAAeY;KAAQ,KAEnEG;AACN;AAEA,uDAAuD,GACvD,OAAO,SAASC,qBACdN,SAAoB,EACpBC,KAAa,EACbM,UAAkB;IAElB,OAAOX,IAAII,cAAcN,sBAAsBO,QAAQM;AACzD;AAEA,iDAAiD,GACjD,OAAO,SAASC,gBACdR,SAAoB,EACpBC,KAAa,EACbQ,MAAc;IAEd,OAAOb,IAAII,cAAcR,qBAAqBS,QAAQQ;AACxD;AAEA,+EAA+E,GAC/E,OAAO,SAASC,sBACdV,SAAoB,EACpBC,KAAa,EACbQ,MAAc,EACdE,MAAuD;IAEvD,OAAOf,IACLI,cACGR,qBAAqBS,QACxBQ,QACA,WACAE;AAEJ;AAEA,gFAAgF,GAChF,OAAO,SAASC,0BACdZ,SAAoB,EACpBC,KAAa,EACbM,UAAkB;IAElB,OAAOX,IAAII,cAAcP,+BAA+BQ,QAAQM;AAClE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-queries.ts"],"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\nimport { hostScopeToken, scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n orgCampaignSendsPath,\n orgCampaignSequenceReportsPath,\n orgEmailCampaignsPath,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport {\n collection,\n doc,\n query,\n where,\n type DocumentReference,\n type Firestore,\n type Query,\n} from 'firebase/firestore'\n\n/**\n * WHERE A CAMPAIGN AND ITS SENDS ARE READ FROM, at either level of the hub.\n *\n * Both collections belong to the organization: containers at\n * `orgs/{orgId}/emailCampaigns`, sends at `orgs/{orgId}/campaigns`, each\n * send's rollups under it at `…/reports/{name}`. Spelled once here so no card\n * rebuilds a path from memory, and so the one rule that makes a site hub's\n * read legal is in one place:\n *\n * **A site hub's LIST reads are filtered by scope.** The rules let an\n * org-wide member read every document and a site collaborator only those\n * whose `visibleTo` names a scope they hold. A query the rules cannot prove\n * document by document is refused whole, so a site hub asks exactly for what\n * that site may see — a container placed on every site (`'org'`) or on this\n * one (`'host:{id}'`), and a send sent AS this site. An org-wide member loses\n * nothing by the filter at a site: those are the campaigns the site shows.\n *\n * The ORG hub is admitted only to org-wide members, so its reads are\n * unfiltered.\n *\n * One `array-contains-any` per query is Firestore's limit, so no caller adds\n * a second array clause to these; per-document reads need no filter at all.\n */\n\n/**\n * `orgs/{orgId}/emailCampaigns`, narrowed to one site's placements when\n * `hostId` names a site; `null` until the org is known.\n */\nexport function campaignContainersQuery(\n firestore: Firestore,\n orgId: string | null,\n hostId: string | null,\n): Query | null {\n if (!orgId) return null\n const containers = collection(firestore, ...orgEmailCampaignsPath(orgId))\n return hostId\n ? query(\n containers,\n where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)),\n )\n : containers\n}\n\n/**\n * `orgs/{orgId}/campaigns` — the sends — narrowed to the ones sent as one\n * site when `hostId` names a site; `null` until the org is known.\n */\nexport function campaignSendsQuery(\n firestore: Firestore,\n orgId: string | null,\n hostId: string | null,\n): Query | null {\n if (!orgId) return null\n const sends = collection(firestore, ...orgCampaignSendsPath(orgId))\n return hostId\n ? query(\n sends,\n where('visibleTo', 'array-contains-any', [hostScopeToken(hostId)]),\n )\n : sends\n}\n\n/**\n * The two collections UNSCOPED, for a paged list that puts its scope on its\n * query plan (AGL-3321): `campaignSendsScope` / `campaignContainersScope` in\n * `model/campaign-list-query.ts` carry the site's clause there, beside the\n * list's own. A site hub's sends are asked `hostId == {site}` rather than by\n * `visibleTo`, so search keeps the query's one array clause; the rules\n * admit that read for the site's collaborators too.\n */\nexport function campaignSendsCollection(\n firestore: Firestore,\n orgId: string | null,\n): Query | null {\n return orgId ? collection(firestore, ...orgCampaignSendsPath(orgId)) : null\n}\n\n/** `orgs/{orgId}/emailCampaigns`, unscoped — see {@link campaignSendsCollection}. */\nexport function campaignContainersCollection(\n firestore: Firestore,\n orgId: string | null,\n): Query | null {\n return orgId ? collection(firestore, ...orgEmailCampaignsPath(orgId)) : null\n}\n\n/** One container, `orgs/{orgId}/emailCampaigns/{id}`. */\nexport function campaignContainerDoc(\n firestore: Firestore,\n orgId: string,\n campaignId: string,\n): DocumentReference {\n return doc(firestore, ...orgEmailCampaignsPath(orgId), campaignId)\n}\n\n/** One send, `orgs/{orgId}/campaigns/{sendId}`. */\nexport function campaignSendDoc(\n firestore: Firestore,\n orgId: string,\n sendId: string,\n): DocumentReference {\n return doc(firestore, ...orgCampaignSendsPath(orgId), sendId)\n}\n\n/** One of a send's rollups, `orgs/{orgId}/campaigns/{sendId}/reports/{name}`. */\nexport function campaignSendReportDoc(\n firestore: Firestore,\n orgId: string,\n sendId: string,\n report: 'links' | 'revenue' | 'conversions' | 'reached',\n): DocumentReference {\n return doc(\n firestore,\n ...orgCampaignSendsPath(orgId),\n sendId,\n 'reports',\n report,\n )\n}\n\n/** A container's sequence rollup, `orgs/{orgId}/campaignSequenceReports/{id}`. */\nexport function campaignSequenceReportDoc(\n firestore: Firestore,\n orgId: string,\n campaignId: string,\n): DocumentReference {\n return doc(firestore, ...orgCampaignSequenceReportsPath(orgId), campaignId)\n}\n"],"names":["hostScopeToken","scopeTokensForHost","orgCampaignSendsPath","orgCampaignSequenceReportsPath","orgEmailCampaignsPath","collection","doc","query","where","campaignContainersQuery","firestore","orgId","hostId","containers","campaignSendsQuery","sends","campaignSendsCollection","campaignContainersCollection","campaignContainerDoc","campaignId","campaignSendDoc","sendId","campaignSendReportDoc","report","campaignSequenceReportDoc"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,cAAc,EAAEC,kBAAkB,QAAQ,sCAAqC;AACxF,SACEC,oBAAoB,EACpBC,8BAA8B,EAC9BC,qBAAqB,QAChB,4DAA2D;AAClE,SACEC,UAAU,EACVC,GAAG,EACHC,KAAK,EACLC,KAAK,QAIA,qBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED;;;CAGC,GACD,OAAO,SAASC,wBACdC,SAAoB,EACpBC,KAAoB,EACpBC,MAAqB;IAErB,IAAI,CAACD,OAAO,OAAO;IACnB,MAAME,aAAaR,WAAWK,cAAcN,sBAAsBO;IAClE,OAAOC,SACHL,MACEM,YACAL,MAAM,aAAa,sBAAsBP,mBAAmBW,YAE9DC;AACN;AAEA;;;CAGC,GACD,OAAO,SAASC,mBACdJ,SAAoB,EACpBC,KAAoB,EACpBC,MAAqB;IAErB,IAAI,CAACD,OAAO,OAAO;IACnB,MAAMI,QAAQV,WAAWK,cAAcR,qBAAqBS;IAC5D,OAAOC,SACHL,MACEQ,OACAP,MAAM,aAAa,sBAAsB;QAACR,eAAeY;KAAQ,KAEnEG;AACN;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,wBACdN,SAAoB,EACpBC,KAAoB;IAEpB,OAAOA,QAAQN,WAAWK,cAAcR,qBAAqBS,UAAU;AACzE;AAEA,mFAAmF,GACnF,OAAO,SAASM,6BACdP,SAAoB,EACpBC,KAAoB;IAEpB,OAAOA,QAAQN,WAAWK,cAAcN,sBAAsBO,UAAU;AAC1E;AAEA,uDAAuD,GACvD,OAAO,SAASO,qBACdR,SAAoB,EACpBC,KAAa,EACbQ,UAAkB;IAElB,OAAOb,IAAII,cAAcN,sBAAsBO,QAAQQ;AACzD;AAEA,iDAAiD,GACjD,OAAO,SAASC,gBACdV,SAAoB,EACpBC,KAAa,EACbU,MAAc;IAEd,OAAOf,IAAII,cAAcR,qBAAqBS,QAAQU;AACxD;AAEA,+EAA+E,GAC/E,OAAO,SAASC,sBACdZ,SAAoB,EACpBC,KAAa,EACbU,MAAc,EACdE,MAAuD;IAEvD,OAAOjB,IACLI,cACGR,qBAAqBS,QACxBU,QACA,WACAE;AAEJ;AAEA,gFAAgF,GAChF,OAAO,SAASC,0BACdd,SAAoB,EACpBC,KAAa,EACbQ,UAAkB;IAElB,OAAOb,IAAII,cAAcP,+BAA+BQ,QAAQQ;AAClE"}
@@ -6,12 +6,14 @@
6
6
  * messages. Composing is not here: a message belongs to a campaign, and the
7
7
  * campaign's own page is where one is written.
8
8
  *
9
- * The rows come from two collections, and the second is why this reads
10
- * without a migration. `emailCampaigns` holds the containers.
11
- * `campaigns` holds every send, including thousands written before containers
12
- * existed; those carry no container id, and `campaignListRows` presents each
13
- * as a campaign of one rather than dropping it. Nothing is rewritten, so no
14
- * unsubscribe link and no pasted report URL stops resolving.
9
+ * The rows come from two collections. `emailCampaigns` holds the
10
+ * containers. `campaigns` holds every send, including thousands written
11
+ * before containers existed; those carry no container (`emailCampaignId`
12
+ * null), and `campaignListRows` presents each as a campaign of one rather
13
+ * than dropping it. The two are two queries, so the table lists one kind at a
14
+ * time — Campaigns or Single sends — each paged by its own query (AGL-3321).
15
+ * No send is re-filed, so no unsubscribe link and no pasted report URL stops
16
+ * resolving.
15
17
  *
16
18
  * ## At two levels
17
19
  *
@@ -22,32 +22,38 @@ import { buildRoute, createResourceUid, pluginDocsHelp, Route } from "@aglyn/agl
22
22
  * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks
23
23
  * would otherwise lose them, and neither is a hook.
24
24
  */ import { ceilingedWindow, collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
25
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
25
26
  import { mdiDeleteOutline, mdiEyeOutline } from "@aglyn/shared-data-mdi";
26
27
  import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
27
28
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
29
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
30
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
28
31
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
29
- import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
30
- import { useListRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-rows-filter";
32
+ import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
33
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
34
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
31
35
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
32
- import { Alert, Button, Chip, Stack, Typography } from "@mui/material";
33
- import { collection, limit, query, setDoc } from "firebase/firestore";
36
+ import { Alert, Button, Chip, Stack, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
37
+ import { collection, limit, query, setDoc, where } from "firebase/firestore";
34
38
  import { useCallback, useMemo, useState } from "react";
35
39
  import { useRouter } from "next/navigation";
36
40
  import { useConsoleHostRoute, useFirestore, useFirestoreCollection, useUser } from "@aglyn/tenant-feature-instance";
37
41
  import { campaignListRows, campaignSiteIds, campaignVisibleTo } from "@aglyn/shared-ui-email-campaigns/model";
38
42
  import { CreateArtifactDrawer } from "@aglyn/shared-ui-jsx-forms";
39
- import { campaignContainerDoc, campaignContainersQuery, campaignSendsQuery } from "./campaign-queries.js";
43
+ import { CAMPAIGN_SITE_SEARCH_NOTICE, SINGLE_SENDS_BASE, campaignContainerSearchFields, campaignContainersListQuery, campaignContainersScope, campaignSendsScope, campaignSingleSendsListQuery, campaignSiteSearchClause } from "../model/campaign-list-query.js";
44
+ import { campaignContainerDoc, campaignContainersCollection, campaignSendsCollection } from "./campaign-queries.js";
40
45
  import { orgSiteName, orgSiteOptions, topicCatalogHostId, useMarketingOrgId, useMarketingOrgMount } from "./marketing-org-mount.js";
41
46
  import { useCampaignManageApi } from "./use-campaign-send-api.js";
42
47
  import { useCampaignTopicOptions } from "./use-campaign-topic-options.js";
43
48
  /**
44
- * How many sends the list reads.
49
+ * How many emails one read of a page's campaigns covers, for their figures.
45
50
  *
46
- * A CEILING, not a page size — see the query, which explains why this list
47
- * cannot be paged by the server until a send carries one date field every
48
- * writer stamps.
49
- */ const CAMPAIGN_CEILING = 30;
50
- /** How many campaign containers the list reads. */ const CONTAINER_CEILING = 50;
51
+ * A ceiling on the ROLLUP read, not on the list: the list is paged by its
52
+ * own query, and this reads the emails of the campaigns on the page on
53
+ * screen, thirty campaigns to a read (`in` takes thirty ids), so the largest
54
+ * page is two reads of this many. Past it, the figures say so.
55
+ */ const ROLLUP_CEILING = 50;
56
+ /** Firestore's cap on an `in` clause's values. */ const IN_LIMIT = 30;
51
57
  const formatDay = (ms)=>ms === null ? '' : new Date(ms).toLocaleDateString();
52
58
  /** A rolled-up figure, or an em dash where nothing recorded one. */ const figure = (value)=>value.value === null ? '—' : value.value.toLocaleString();
53
59
  const WINDOW_LABEL = {
@@ -57,44 +63,29 @@ const WINDOW_LABEL = {
57
63
  ended: 'Ended'
58
64
  };
59
65
  /*
60
- * What the campaigns grid's Filters panel offers (AGL-3317). The card reads
61
- * every campaign under its ceiling, so the panel and the search answer over
62
- * all of them. A campaign's lists are ids, matched member by member.
63
- */ const CAMPAIGN_FILTER_FIELDS = [
64
- inMemoryListField('name', 'text'),
65
- inMemoryListField('windowState', 'select'),
66
- _extends({}, inMemoryListField('listIds', 'select'), {
67
- tokensPath: 'listIds',
68
- verbatimTokens: true
69
- }),
70
- inMemoryListField('kind', 'select'),
71
- inMemoryListField('sitesLabel', 'text')
72
- ];
73
- const CAMPAIGN_FILTER_HEADERS = {
74
- name: 'Campaign',
75
- windowState: 'Window',
76
- listIds: 'Lists',
77
- kind: 'Kind',
78
- sitesLabel: 'Sites'
79
- };
80
- const CAMPAIGN_KIND_OPTIONS = [
81
- {
82
- value: 'campaign',
83
- label: 'Campaign'
66
+ * What the campaigns grid's Filters panel offers, each clause and the search
67
+ * on the list's Firestore query (AGL-3321, `campaignContainersListQuery` and
68
+ * `campaignSingleSendsListQuery`). The Window column is derived from a
69
+ * campaign's dates against the clock, which no query can ask, so it is not
70
+ * offered; the one date range is Created, the list's own order.
71
+ */ const CAMPAIGN_FILTER_HEADERS = {
72
+ campaign: {
73
+ name: 'Campaign',
74
+ listIds: 'Lists',
75
+ sitesLabel: 'Sites',
76
+ createdAtMs: 'Created'
84
77
  },
85
- {
86
- value: 'single',
87
- label: 'Single send'
78
+ // A single send has no container; its `name` field filters by the subject.
79
+ single: {
80
+ name: 'Subject',
81
+ sitesLabel: 'Sites',
82
+ createdAtMs: 'Created'
88
83
  }
89
- ];
90
- const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
91
- value: state,
92
- label: WINDOW_LABEL[state]
93
- }));
94
- /** What the quick search reads on a campaign row. */ const CAMPAIGN_SEARCH_FIELDS = [
95
- 'name',
96
- 'sitesLabel'
97
- ];
84
+ };
85
+ const NO_CLAUSES = {
86
+ campaign: [],
87
+ single: []
88
+ };
98
89
  /**
99
90
  * THE CAMPAIGNS LIST.
100
91
  *
@@ -103,12 +94,14 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
103
94
  * messages. Composing is not here: a message belongs to a campaign, and the
104
95
  * campaign's own page is where one is written.
105
96
  *
106
- * The rows come from two collections, and the second is why this reads
107
- * without a migration. `emailCampaigns` holds the containers.
108
- * `campaigns` holds every send, including thousands written before containers
109
- * existed; those carry no container id, and `campaignListRows` presents each
110
- * as a campaign of one rather than dropping it. Nothing is rewritten, so no
111
- * unsubscribe link and no pasted report URL stops resolving.
97
+ * The rows come from two collections. `emailCampaigns` holds the
98
+ * containers. `campaigns` holds every send, including thousands written
99
+ * before containers existed; those carry no container (`emailCampaignId`
100
+ * null), and `campaignListRows` presents each as a campaign of one rather
101
+ * than dropping it. The two are two queries, so the table lists one kind at a
102
+ * time — Campaigns or Single sends — each paged by its own query (AGL-3321).
103
+ * No send is re-filed, so no unsubscribe link and no pasted report URL stops
104
+ * resolving.
112
105
  *
113
106
  * ## At two levels
114
107
  *
@@ -134,42 +127,133 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
134
127
  const { orgId } = useMarketingOrgId(hostId);
135
128
  // A container is the org's, so deleting one from the org hub names the org.
136
129
  const manageApi = useCampaignManageApi(hostId, orgId);
137
- /*
138
- * ORDERED AND CEILINGED, and not orderable on any DATE.
130
+ /*==========================================
131
+ * TWO KINDS OF ROW, EACH ITS OWN QUERY (AGL-3321).
139
132
  *
140
- * No field here is on every send: a sent one is written `{status:'sent',
141
- * sentAt}` and a scheduled one `{status:'scheduled', sendAtMs}`, by the two
142
- * branches of `campaign-send.ts`, and there is no `createdAt` at all.
143
- * `orderBy` on either would not mis-sort the list, it would DROP half of it.
133
+ * A campaign is a CONTAINER (`emailCampaigns`); a single send is an email
134
+ * filed under none (`campaigns` where `emailCampaignId == null`). The two
135
+ * live in different collections, and one Firestore query reads one
136
+ * collection — so a table that merged them could page neither, and every
137
+ * filter would be matched over whatever window of each it happened to
138
+ * hold. The toggle above the table picks the kind, and the table shows a
139
+ * page of that kind's query: newest first on `createdAtMs`, every clause
140
+ * and the search word on the query, paged by it. Same table, same
141
+ * columns; a single send still reads as a campaign of one.
144
142
  *
145
- * A bare `limit(30)` is answered in DOCUMENT-ID order, so the window is
146
- * thirty sends chosen by id and then sorted by date, which reads as the most
147
- * recent thirty and is not. `collectionCeiling` returns that same thirty —
148
- * document-id order is what the bare cap already gave — but it says so,
149
- * which is what stops the next edit reaching for `sentAt`, and it probes one
150
- * past the ceiling so the reader is told the history is longer.
151
- */ const { data: sendDocs } = useFirestoreCollection(()=>{
152
- const sends = campaignSendsQuery(firestore, orgId, hostId);
153
- return sends ? collectionCeiling(sends, CAMPAIGN_CEILING) : null;
154
- }, [
143
+ * Each kind keeps its own clauses (their Sites values differ: a campaign
144
+ * is asked by scope token, a send by the site it was sent as); the search
145
+ * words are the reader's and carry across.
146
+ *=========================================*/ const [kind, setKind] = useState('campaign');
147
+ const [clausesByKind, setClausesByKind] = useState(NO_CLAUSES);
148
+ const [searchWords, setSearchWords] = useState([]);
149
+ const setKindClauses = useCallback((next)=>setClausesByKind((current)=>_extends({}, current, {
150
+ [kind]: next
151
+ })), [
152
+ kind
153
+ ]);
154
+ const gridFilter = useListGridFilter({
155
+ clauses: clausesByKind[kind],
156
+ onChange: setKindClauses,
157
+ selectFields: [
158
+ 'listIds',
159
+ 'sitesLabel'
160
+ ],
161
+ search: {
162
+ words: searchWords,
163
+ onChange: setSearchWords
164
+ }
165
+ });
166
+ const containerDeclaration = campaignContainersListQuery(!hostId);
167
+ const singleDeclaration = campaignSingleSendsListQuery(!hostId);
168
+ const declaration = kind === 'campaign' ? containerDeclaration : singleDeclaration;
169
+ const filterHeaders = CAMPAIGN_FILTER_HEADERS[kind];
170
+ /*
171
+ * A site's campaigns are searched by the START of the name: a range beside
172
+ * the site's scope clause, the one array clause the rules can prove for a
173
+ * site collaborator (see `campaign-list-query.ts`). The org hub keeps the
174
+ * word search.
175
+ */ const siteSearch = hostId && kind === 'campaign' ? campaignSiteSearchClause(gridFilter.searchWords) : null;
176
+ const containerPage = useListQuery({
177
+ collection: kind === 'campaign' ? campaignContainersCollection(firestore, orgId) : null,
178
+ declaration: containerDeclaration,
179
+ request: {
180
+ clauses: kind === 'campaign' ? siteSearch ? [
181
+ siteSearch,
182
+ ...gridFilter.clauses
183
+ ] : gridFilter.clauses : [],
184
+ search: hostId ? [] : gridFilter.searchWords,
185
+ base: campaignContainersScope(hostId)
186
+ },
187
+ deps: [
188
+ firestore,
189
+ orgId,
190
+ hostId,
191
+ kind
192
+ ],
193
+ idField: '$id'
194
+ });
195
+ const singlePage = useListQuery({
196
+ collection: kind === 'single' ? campaignSendsCollection(firestore, orgId) : null,
197
+ declaration: singleDeclaration,
198
+ request: {
199
+ clauses: kind === 'single' ? gridFilter.clauses : [],
200
+ search: gridFilter.searchWords,
201
+ base: [
202
+ SINGLE_SENDS_BASE,
203
+ ...campaignSendsScope(hostId)
204
+ ]
205
+ },
206
+ deps: [
207
+ firestore,
208
+ orgId,
209
+ hostId,
210
+ kind
211
+ ],
212
+ idField: '$id'
213
+ });
214
+ const page = kind === 'campaign' ? containerPage : singlePage;
215
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
216
+ /*
217
+ * THE FIGURES OF THE CAMPAIGNS ON THE PAGE ON SCREEN.
218
+ *
219
+ * A container stores no counters; its row sums the emails filed under it.
220
+ * Those are read for the page's campaigns only — `emailCampaignId in` the
221
+ * ids on screen, thirty to a read, and under a site the ones sent as it —
222
+ * and never used to filter: the page is already the query's answer. Past
223
+ * `ROLLUP_CEILING` emails a read stops, and the table says the figures
224
+ * are partial.
225
+ */ const pageIds = useMemo(()=>kind === 'campaign' ? containerPage.rows.map((row)=>String(row.$id)) : [], [
226
+ kind,
227
+ containerPage.rows
228
+ ]);
229
+ const rollupRead = (ids)=>()=>{
230
+ const sends = campaignSendsCollection(firestore, orgId);
231
+ if (!sends || !ids.length) return null;
232
+ return collectionCeiling(query(sends, where('emailCampaignId', 'in', ids), ...hostId ? [
233
+ where('hostId', '==', hostId)
234
+ ] : []), ROLLUP_CEILING);
235
+ };
236
+ const firstIds = pageIds.slice(0, IN_LIMIT);
237
+ const restIds = pageIds.slice(IN_LIMIT, IN_LIMIT * 2);
238
+ const { data: firstSendDocs } = useFirestoreCollection(rollupRead(firstIds), [
155
239
  firestore,
156
240
  orgId,
157
- hostId
241
+ hostId,
242
+ firstIds.join(',')
158
243
  ], {
159
244
  idField: '$id'
160
245
  });
161
- const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow(sendDocs, CAMPAIGN_CEILING);
162
- const { data: campaignDocs } = useFirestoreCollection(()=>{
163
- const containers = campaignContainersQuery(firestore, orgId, hostId);
164
- return containers ? collectionCeiling(containers, CONTAINER_CEILING) : null;
165
- }, [
246
+ const { data: restSendDocs } = useFirestoreCollection(rollupRead(restIds), [
166
247
  firestore,
167
248
  orgId,
168
- hostId
249
+ hostId,
250
+ restIds.join(',')
169
251
  ], {
170
252
  idField: '$id'
171
253
  });
172
- const { rows: readCampaigns, truncated: campaignsTruncated } = ceilingedWindow(campaignDocs, CONTAINER_CEILING);
254
+ const firstSends = ceilingedWindow(firstSendDocs, ROLLUP_CEILING);
255
+ const restSends = ceilingedWindow(restSendDocs, ROLLUP_CEILING);
256
+ const rollupTruncated = firstSends.truncated || restSends.truncated;
173
257
  // Org email lists, so a row can name what it is aimed at rather than
174
258
  // showing ids.
175
259
  const { data: listDocs } = useFirestoreCollection(()=>orgId ? query(collection(firestore, 'orgs', orgId, 'lists'), limit(50)) : null, [
@@ -216,40 +300,66 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
216
300
  }), [
217
301
  listDocs
218
302
  ]);
219
- const rows = useMemo(()=>{
220
- const campaigns = readCampaigns.filter((campaign)=>!campaign.deletedAt);
221
- const byId = new Map(campaigns.map((campaign)=>[
222
- campaign.$id,
223
- campaign
303
+ /*
304
+ * The page's rows, in the QUERY's order. `campaignListRows` builds each
305
+ * row — a container with its emails summed, or a single send as a
306
+ * campaign of one — and sorts by date, which is not this list's order, so
307
+ * the page is put back in the order the query returned it.
308
+ */ const rows = useMemo(()=>{
309
+ const read = page.rows;
310
+ const order = new Map(read.map((row, at)=>[
311
+ String(row.$id),
312
+ at
224
313
  ]));
225
- return campaignListRows(campaigns, readSends, Date.now()).map((row)=>_extends({}, row, {
314
+ const built = kind === 'campaign' ? campaignListRows(read, [
315
+ ...firstSends.rows,
316
+ ...restSends.rows
317
+ ], Date.now()) : campaignListRows([], read, Date.now());
318
+ const byId = new Map(read.map((row)=>[
319
+ String(row.$id),
320
+ row
321
+ ]));
322
+ return built.filter((row)=>order.has(row.id)).sort((a, b)=>{
323
+ var _order_get, _order_get1;
324
+ return ((_order_get = order.get(a.id)) != null ? _order_get : 0) - ((_order_get1 = order.get(b.id)) != null ? _order_get1 : 0);
325
+ }).map((row)=>_extends({}, row, {
226
326
  $id: row.id,
227
- kind: row.legacy ? 'single' : 'campaign',
228
327
  sitesLabel: orgMount ? campaignSitesLabel(orgMount, row, byId.get(row.id)) : ''
229
328
  }));
230
329
  }, [
231
- readCampaigns,
232
- readSends,
330
+ kind,
331
+ page.rows,
332
+ firstSends.rows,
333
+ restSends.rows,
233
334
  orgMount
234
335
  ]);
235
- const filterOptions = useMemo(()=>({
236
- windowState: CAMPAIGN_WINDOW_OPTIONS,
336
+ /*
337
+ * The choices the picked fields offer. A campaign's Sites are asked by
338
+ * scope token — `org` is placed on every site — and a single send's by
339
+ * the site it was sent as.
340
+ */ const filterOptions = useMemo(()=>{
341
+ if (!orgMount) return {};
342
+ const sites = orgSiteOptions(orgMount);
343
+ return kind === 'campaign' ? {
237
344
  listIds: listOptions,
238
- kind: CAMPAIGN_KIND_OPTIONS
239
- }), [
240
- listOptions
241
- ]);
242
- // Sites is a column on the org hub only; under a site it would say one thing.
243
- const filterFields = useMemo(()=>orgMount ? CAMPAIGN_FILTER_FIELDS : CAMPAIGN_FILTER_FIELDS.filter((field)=>field.column !== 'sitesLabel'), [
345
+ sitesLabel: [
346
+ {
347
+ value: 'org',
348
+ label: 'Every site'
349
+ },
350
+ ...sites.map((site)=>({
351
+ value: `host:${site.value}`,
352
+ label: site.label
353
+ }))
354
+ ]
355
+ } : {
356
+ sitesLabel: sites
357
+ };
358
+ }, [
359
+ kind,
360
+ listOptions,
244
361
  orgMount
245
362
  ]);
246
- const listFilter = useListRowsFilter({
247
- rows,
248
- fields: filterFields,
249
- options: filterOptions,
250
- headers: CAMPAIGN_FILTER_HEADERS,
251
- search: CAMPAIGN_SEARCH_FIELDS
252
- });
253
363
  const [createError, setCreateError] = useState(null);
254
364
  const [creating, setCreating] = useState(false);
255
365
  /** The campaign a delete is in flight for, so its row menu can say so. */ const [deletingId, setDeletingId] = useState('');
@@ -286,11 +396,19 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
286
396
  setCreating(true);
287
397
  setCreateError(null);
288
398
  const id = createResourceUid();
399
+ /*
400
+ * Where it is placed. From a site's hub, that site — the creator is
401
+ * standing on it, and a campaign that appeared on every sibling site
402
+ * would be a decision nobody made. From the org hub, the sites picked,
403
+ * and every site when none are.
404
+ */ const visibleTo = campaignVisibleTo(hostId ? [
405
+ hostId
406
+ ] : Array.isArray(values.siteIds) ? values.siteIds.map(String) : null);
289
407
  try {
290
408
  var _ref;
291
409
  await setDoc(campaignContainerDoc(firestore, orgId, id), _extends({
292
410
  name
293
- }, Number.isFinite(startAtMs) && startAtMs !== null ? {
411
+ }, campaignContainerSearchFields(name), Number.isFinite(startAtMs) && startAtMs !== null ? {
294
412
  startAtMs
295
413
  } : {}, Number.isFinite(endAtMs) && endAtMs !== null ? {
296
414
  endAtMs
@@ -299,14 +417,7 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
299
417
  }, values.topicId ? {
300
418
  topicId: String(values.topicId)
301
419
  } : {}, {
302
- /*
303
- * Where it is placed. From a site's hub, that site — the creator is
304
- * standing on it, and a campaign that appeared on every sibling
305
- * site would be a decision nobody made. From the org hub, the sites
306
- * picked, and every site when none are.
307
- */ visibleTo: campaignVisibleTo(hostId ? [
308
- hostId
309
- ] : Array.isArray(values.siteIds) ? values.siteIds.map(String) : null),
420
+ visibleTo,
310
421
  createdAtMs: Date.now(),
311
422
  createdBy: String((_ref = user == null ? void 0 : user.uid) != null ? _ref : '')
312
423
  }));
@@ -403,7 +514,7 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
403
514
  const columns = useMemo(()=>[
404
515
  {
405
516
  field: 'name',
406
- headerName: 'Campaign',
517
+ headerName: filterHeaders.name,
407
518
  flex: 1,
408
519
  minWidth: 180,
409
520
  renderCell: ({ row })=>/*#__PURE__*/ _jsxs(Stack, {
@@ -661,8 +772,10 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
661
772
  campaignHref,
662
773
  deletingId,
663
774
  handleDelete,
664
- orgMount
775
+ orgMount,
776
+ filterHeaders
665
777
  ]);
778
+ /** Nothing of this kind stored at all — not a filter that matched nothing. */ const none = page.status !== 'loading' && !rows.length && !filtering && page.page === 0;
666
779
  return /*#__PURE__*/ _jsxs(CardDisplay, {
667
780
  header: 'Campaigns',
668
781
  help: pluginDocsHelp('emailCampaigns', {
@@ -687,35 +800,90 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
687
800
  /*#__PURE__*/ _jsxs(Stack, {
688
801
  spacing: 1.5,
689
802
  children: [
690
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, listFilter.chipsProps)),
691
- /*#__PURE__*/ _jsx(ListTable, _extends({
692
- rows: listFilter.rows,
693
- columns: listFilter.filterColumns(columns),
694
- onOpen: (id)=>openCampaign(id)
695
- }, listFilter.gridProps, {
803
+ /*#__PURE__*/ _jsxs(ToggleButtonGroup, {
804
+ exclusive: true,
805
+ size: "small",
806
+ value: kind,
807
+ onChange: (_event, next)=>{
808
+ if (next) setKind(next);
809
+ },
810
+ "aria-label": "Which rows the table lists",
811
+ sx: {
812
+ alignSelf: 'flex-start'
813
+ },
814
+ children: [
815
+ /*#__PURE__*/ _jsx(ToggleButton, {
816
+ value: "campaign",
817
+ children: 'Campaigns'
818
+ }),
819
+ /*#__PURE__*/ _jsx(ToggleButton, {
820
+ value: "single",
821
+ children: 'Single sends'
822
+ })
823
+ ]
824
+ }),
825
+ /*#__PURE__*/ _jsx(ListFilterChips, {
826
+ fields: declaration.fields,
827
+ headers: filterHeaders,
828
+ clauses: gridFilter.clauses,
829
+ onChange: gridFilter.setClauses,
830
+ options: filterOptions
831
+ }),
832
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
833
+ refused: listQueryRefusals(page.plan.refused, {
834
+ fields: declaration.fields,
835
+ headers: filterHeaders,
836
+ options: filterOptions
837
+ }),
838
+ notices: siteSearch ? [
839
+ CAMPAIGN_SITE_SEARCH_NOTICE,
840
+ ...page.plan.notices
841
+ ] : page.plan.notices
842
+ }),
843
+ /*#__PURE__*/ _jsx(ListTable, {
844
+ rows: rows,
845
+ columns: listFilterGridColumns(columns, declaration.fields, filterOptions, filterHeaders),
846
+ onOpen: (id)=>openCampaign(id),
847
+ loading: page.status === 'loading',
848
+ /*
849
+ * The panel and the search are the grid's; every clause and the
850
+ * search word are on the list's query (AGL-3321), and the grid
851
+ * neither filters nor sorts the page it is handed.
852
+ */ filterMode: "server",
853
+ filterModel: gridFilter.filterModel,
854
+ onFilterModelChange: gridFilter.onFilterModelChange,
855
+ quickFilter: true,
856
+ disableColumnSorting: true,
857
+ hideFooter: true,
696
858
  initialState: {
697
859
  columns: {
698
- columnVisibilityModel: {
699
- kind: false
700
- }
860
+ columnVisibilityModel: hiddenFilterVisibility(declaration.fields, columns.map((column)=>column.field))
701
861
  }
702
862
  },
703
- noRowsLabel: rows.length ? 'No campaigns match these filters' : 'No campaigns yet',
704
- noRowsDescription: rows.length ? undefined : 'A campaign groups the emails you send to a set of lists.',
705
- noRowsAction: rows.length ? undefined : /*#__PURE__*/ _jsx(Button, {
863
+ noRowsLabel: none ? kind === 'campaign' ? 'No campaigns yet' : 'No single sends' : kind === 'campaign' ? 'No campaigns match these filters' : 'No single sends match these filters',
864
+ noRowsDescription: none ? kind === 'campaign' ? 'A campaign groups the emails you send to a set of lists.' : 'An email filed under no campaign is listed here.' : undefined,
865
+ noRowsAction: none && kind === 'campaign' ? /*#__PURE__*/ _jsx(Button, {
706
866
  variant: "contained",
707
867
  onClick: ()=>setCreateOpen(true),
708
868
  children: 'Create campaign'
709
- })
710
- })),
869
+ }) : undefined
870
+ }),
871
+ /*#__PURE__*/ _jsx(ListPagination, {
872
+ page: page.page,
873
+ pageSize: page.pageSize,
874
+ rowCount: rows.length,
875
+ hasMore: page.hasMore,
876
+ onPageChange: page.setPage,
877
+ onPageSizeChange: page.setPageSize
878
+ }),
711
879
  /*#__PURE__*/ _jsx(Typography, {
712
880
  variant: "caption",
713
881
  color: "text.secondary",
714
882
  children: 'These figures measure each campaign’s mail. What a campaign ' + 'caused — the form submissions, leads, contacts and bookings ' + 'credited to it — and the pages it sent people to are on the ' + 'campaign’s own page.'
715
883
  }),
716
- sendsTruncated || campaignsTruncated ? /*#__PURE__*/ _jsx(Alert, {
884
+ rollupTruncated ? /*#__PURE__*/ _jsx(Alert, {
717
885
  severity: "info",
718
- children: `Showing the most recent ${CONTAINER_CEILING} campaigns and ` + `${CAMPAIGN_CEILING} sends. ${hostId ? 'This site' : 'This organization'} has more — the ones ` + 'listed are not necessarily the most recent, because a send ' + 'carries no date field that every writer stamps.'
886
+ children: `The campaigns on this page hold more than ${ROLLUP_CEILING} ` + 'emails between them, so their figures count the first ' + `${ROLLUP_CEILING} read.`
719
887
  }) : null
720
888
  ]
721
889
  }),