@aglyn/plugins-marketing 1.0.0-beta.172 → 1.0.0-beta.174

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 (79) hide show
  1. package/README.md +2 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-detail-card.d.ts +12 -1
  4. package/src/lib/components/campaign-detail-card.js +146 -35
  5. package/src/lib/components/campaign-detail-card.js.map +1 -1
  6. package/src/lib/components/campaign-edit-drawer.d.ts +13 -0
  7. package/src/lib/components/campaign-edit-drawer.js +56 -4
  8. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  9. package/src/lib/components/campaign-glance-card.component.js +10 -2
  10. package/src/lib/components/campaign-glance-card.component.js.map +1 -1
  11. package/src/lib/components/campaign-members-section.d.ts +6 -0
  12. package/src/lib/components/campaign-members-section.js +2 -2
  13. package/src/lib/components/campaign-members-section.js.map +1 -1
  14. package/src/lib/components/campaign-queries.d.ts +58 -0
  15. package/src/lib/components/campaign-queries.js +72 -0
  16. package/src/lib/components/campaign-queries.js.map +1 -0
  17. package/src/lib/components/campaign-reach-sections.d.ts +21 -7
  18. package/src/lib/components/campaign-reach-sections.js +20 -17
  19. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  20. package/src/lib/components/campaign-report-card.d.ts +8 -1
  21. package/src/lib/components/campaign-report-card.js +33 -16
  22. package/src/lib/components/campaign-report-card.js.map +1 -1
  23. package/src/lib/components/campaigns-card.d.ts +10 -1
  24. package/src/lib/components/campaigns-card.js +102 -19
  25. package/src/lib/components/campaigns-card.js.map +1 -1
  26. package/src/lib/components/email-compose-card.d.ts +3 -2
  27. package/src/lib/components/email-compose-card.js +18 -7
  28. package/src/lib/components/email-compose-card.js.map +1 -1
  29. package/src/lib/components/email-detail.d.ts +11 -2
  30. package/src/lib/components/email-detail.js +50 -23
  31. package/src/lib/components/email-detail.js.map +1 -1
  32. package/src/lib/components/emails-list-card.d.ts +10 -1
  33. package/src/lib/components/emails-list-card.js +129 -23
  34. package/src/lib/components/emails-list-card.js.map +1 -1
  35. package/src/lib/components/host-marketing-summary-card.component.js +9 -1
  36. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -1
  37. package/src/lib/components/marketing-console-page.js +81 -4
  38. package/src/lib/components/marketing-console-page.js.map +1 -1
  39. package/src/lib/components/marketing-console-sections.d.ts +16 -1
  40. package/src/lib/components/marketing-console-sections.js +25 -2
  41. package/src/lib/components/marketing-console-sections.js.map +1 -1
  42. package/src/lib/components/marketing-org-mount.d.ts +71 -0
  43. package/src/lib/components/marketing-org-mount.js +84 -0
  44. package/src/lib/components/marketing-org-mount.js.map +1 -0
  45. package/src/lib/components/use-campaign-send-api.d.ts +2 -2
  46. package/src/lib/components/use-campaign-send-api.js +22 -6
  47. package/src/lib/components/use-campaign-send-api.js.map +1 -1
  48. package/src/lib/components/use-emails-hub-path.d.ts +13 -3
  49. package/src/lib/components/use-emails-hub-path.js +25 -4
  50. package/src/lib/components/use-emails-hub-path.js.map +1 -1
  51. package/src/lib/components/use-marketing-hub-path.d.ts +19 -16
  52. package/src/lib/components/use-marketing-hub-path.js +6 -32
  53. package/src/lib/components/use-marketing-hub-path.js.map +1 -1
  54. package/src/lib/model/marketing-record-routes.js +11 -4
  55. package/src/lib/model/marketing-record-routes.js.map +1 -1
  56. package/src/lib/plugin.js +31 -1
  57. package/src/lib/plugin.js.map +1 -1
  58. package/src/lib/server/campaign-manage.d.ts +31 -2
  59. package/src/lib/server/campaign-manage.js +365 -137
  60. package/src/lib/server/campaign-manage.js.map +1 -1
  61. package/src/lib/server/campaign-org-refs.d.ts +45 -0
  62. package/src/lib/server/campaign-org-refs.js +83 -0
  63. package/src/lib/server/campaign-org-refs.js.map +1 -0
  64. package/src/lib/server/campaign-process-scheduled.d.ts +32 -2
  65. package/src/lib/server/campaign-process-scheduled.js +79 -9
  66. package/src/lib/server/campaign-process-scheduled.js.map +1 -1
  67. package/src/lib/server/campaign-recipients.js +18 -4
  68. package/src/lib/server/campaign-recipients.js.map +1 -1
  69. package/src/lib/server/campaign-send.js +139 -55
  70. package/src/lib/server/campaign-send.js.map +1 -1
  71. package/src/lib/server/email-campaign-reach.d.ts +6 -5
  72. package/src/lib/server/email-campaign-reach.js +14 -14
  73. package/src/lib/server/email-campaign-reach.js.map +1 -1
  74. package/src/lib/server/email-events.js +48 -17
  75. package/src/lib/server/email-events.js.map +1 -1
  76. package/src/lib/server/marketing-figures.js +12 -1
  77. package/src/lib/server/marketing-figures.js.map +1 -1
  78. package/src/lib/server.js +7 -3
  79. package/src/lib/server.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-glance-card.component.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, pluginDocsHelp, Route } from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Button, Stack, Typography } from '@mui/material'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useParams } from 'next/navigation'\nimport { useMemo } from 'react'\nimport { useFirestore, useFirestoreCollection } from '@aglyn/tenant-feature-instance'\n\n/**\n * How many campaigns the card reads to find the last SENT one.\n *\n * A ceiling on a search, not a page: the newest send is what the card is\n * about, and a site whose thirty most recent campaigns are all drafts simply\n * has nothing to show — the card renders nothing at all in that case, which\n * is the same answer it gives a site that has never sent.\n */\nconst SEARCH_CEILING = 30\n\n/**\n * Last campaign at a glance (AGL-353): sent, opens and clicks for the most\n * recent send.\n *\n * Registered by the marketing plugin rather than imported by the dashboard.\n * Campaigns are managed from the Marketing page, so a workspace with the\n * marketing plugin switched off has no campaigns page to open and the card\n * could only ever be blank there. The header links to the surface that owns\n * the history for the same reason: it is the one this widget's own plugin\n * guarantees exists.\n */\nexport function CampaignGlanceCard(props: { hostId: string }) {\n const { hostId } = props\n const firestore = useFirestore()\n const { orgSlug, host } = useParams<{ orgSlug: string; host: string }>()\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'campaigns'),\n limit(SEARCH_CEILING),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const lastSent = useMemo(\n () =>\n [...(campaignDocs ?? [])]\n .filter((campaign: any) => campaign.status === 'sent')\n .sort(\n (a: any, b: any) =>\n (b.sentAt?.seconds ?? 0) - (a.sentAt?.seconds ?? 0),\n )[0],\n [campaignDocs],\n )\n\n if (!lastSent) return null\n\n return (\n <CardDisplay\n header={'Last campaign'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: '#opens--clicks',\n excerpt:\n 'Sent, opens, and clicks for your most recent campaign — open ' +\n 'Campaigns for the full history.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${buildRoute(Route.HOST_PLUGIN, {\n orgSlug,\n host,\n pluginSlug: 'marketing',\n })}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'Campaigns'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1}>\n <Typography variant=\"body2\" noWrap>\n {lastSent.subject}\n </Typography>\n <Stack direction=\"row\" spacing={3}>\n <Stack>\n <Typography variant=\"h6\">{lastSent.stats?.sent ?? 0}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Sent'}\n </Typography>\n </Stack>\n <Stack>\n <Typography variant=\"h6\">{lastSent.stats?.opens ?? 0}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Opens'}\n </Typography>\n </Stack>\n <Stack>\n <Typography variant=\"h6\">{lastSent.stats?.clicks ?? 0}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Clicks'}\n </Typography>\n </Stack>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nCampaignGlanceCard.displayName = 'CampaignGlanceCard'\n\nexport default CampaignGlanceCard\n"],"names":["buildRoute","pluginDocsHelp","Route","AppLink","CardDisplay","Button","Stack","Typography","collection","limit","query","useParams","useMemo","useFirestore","useFirestoreCollection","SEARCH_CEILING","CampaignGlanceCard","props","lastSent","hostId","firestore","orgSlug","host","data","campaignDocs","idField","filter","campaign","status","sort","a","b","sentAt","seconds","header","help","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","HOST_PLUGIN","pluginSlug","size","color","spacing","variant","noWrap","subject","direction","stats","sent","opens","clicks","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SAASA,UAAU,EAAEC,cAAc,EAAEC,KAAK,QAAQ,eAAc;AAChE,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,MAAM,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACzD,SAASC,UAAU,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7D,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,YAAY,EAAEC,sBAAsB,QAAQ,iCAAgC;AAErF;;;;;;;CAOC,GACD,MAAMC,iBAAiB;AAEvB;;;;;;;;;;CAUC,GACD,OAAO,SAASC,mBAAmBC,KAAyB;;QA6DtBC,iBAMAA,kBAMAA;IAxEpC,MAAM,EAAEC,MAAM,EAAE,GAAGF;IACnB,MAAMG,YAAYP;IAClB,MAAM,EAAEQ,OAAO,EAAEC,IAAI,EAAE,GAAGX;IAC1B,MAAM,EAAEY,MAAMC,YAAY,EAAE,GAAGV,uBAC7B,IACEJ,MACEF,WAAWY,WAAW,SAASD,QAAQ,cACvCV,MAAMM,kBAEV;QAACK;QAAWD;KAAO,EACnB;QAAEM,SAAS;IAAM;IAEnB,MAAMP,WAAWN,QACf,IACE;eAAKY,uBAAAA,eAAgB,EAAE;SAAE,CACtBE,MAAM,CAAC,CAACC,WAAkBA,SAASC,MAAM,KAAK,QAC9CC,IAAI,CACH,CAACC,GAAQC;;gBACNA,WAA2BD;mBAA5B,UAACC,YAAAA,EAAEC,MAAM,qBAARD,UAAUE,OAAO,mBAAI,gBAAMH,YAAAA,EAAEE,MAAM,qBAARF,UAAUG,OAAO,oBAAI;UACpD,CAAC,EAAE,EACR;QAACT;KAAa;IAGhB,IAAI,CAACN,UAAU,OAAO;IAEtB,qBACE,KAACd;QACC8B,QAAQ;QACRC,MAAMlC,eAAe,kBAAkB;YACrCmC,QAAQ;YACRC,SACE,kEACA;QACJ;QACAC,cAAc;QACdC,cAAc;QACdC,aAAa;YACXC,sBACE,KAACpC;gBACCqC,WAAWvC;eACN;gBAAEwC,kBAAkB;gBAASC,cAAc;YAAM;gBACtDC,MAAM,GAAG7C,WAAWE,MAAM4C,WAAW,EAAE;oBACrCzB;oBACAC;oBACAyB,YAAY;gBACd,GAAG,UAAU,CAAC;gBACdC,MAAK;gBACLC,OAAM;0BAEL;;QAGP;kBAEA,cAAA,MAAC3C;YAAM4C,SAAS;;8BACd,KAAC3C;oBAAW4C,SAAQ;oBAAQC,MAAM;8BAC/BlC,SAASmC,OAAO;;8BAEnB,MAAC/C;oBAAMgD,WAAU;oBAAMJ,SAAS;;sCAC9B,MAAC5C;;8CACC,KAACC;oCAAW4C,SAAQ;uDAAMjC,kBAAAA,SAASqC,KAAK,qBAAdrC,gBAAgBsC,IAAI,mBAAI;;8CAClD,KAACjD;oCAAW4C,SAAQ;oCAAUF,OAAM;8CACjC;;;;sCAGL,MAAC3C;;8CACC,KAACC;oCAAW4C,SAAQ;wDAAMjC,mBAAAA,SAASqC,KAAK,qBAAdrC,iBAAgBuC,KAAK,oBAAI;;8CACnD,KAAClD;oCAAW4C,SAAQ;oCAAUF,OAAM;8CACjC;;;;sCAGL,MAAC3C;;8CACC,KAACC;oCAAW4C,SAAQ;wDAAMjC,mBAAAA,SAASqC,KAAK,qBAAdrC,iBAAgBwC,MAAM,oBAAI;;8CACpD,KAACnD;oCAAW4C,SAAQ;oCAAUF,OAAM;8CACjC;;;;;;;;;AAOf;AACAjC,mBAAmB2C,WAAW,GAAG;AAEjC,eAAe3C,mBAAkB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-glance-card.component.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, pluginDocsHelp, Route } from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Button, Stack, Typography } from '@mui/material'\nimport { limit, query } from 'firebase/firestore'\nimport { useParams } from 'next/navigation'\nimport { useMemo } from 'react'\nimport { useFirestore, useFirestoreCollection } from '@aglyn/tenant-feature-instance'\nimport { campaignSendsQuery } from './campaign-queries'\nimport { useMarketingOrgId } from './marketing-org-mount'\n\n/**\n * How many campaigns the card reads to find the last SENT one.\n *\n * A ceiling on a search, not a page: the newest send is what the card is\n * about, and a site whose thirty most recent campaigns are all drafts simply\n * has nothing to show — the card renders nothing at all in that case, which\n * is the same answer it gives a site that has never sent.\n */\nconst SEARCH_CEILING = 30\n\n/**\n * Last campaign at a glance (AGL-353): sent, opens and clicks for the most\n * recent send.\n *\n * Registered by the marketing plugin rather than imported by the dashboard.\n * Campaigns are managed from the Marketing page, so a workspace with the\n * marketing plugin switched off has no campaigns page to open and the card\n * could only ever be blank there. The header links to the surface that owns\n * the history for the same reason: it is the one this widget's own plugin\n * guarantees exists.\n */\nexport function CampaignGlanceCard(props: { hostId: string }) {\n const { hostId } = props\n const firestore = useFirestore()\n const { orgSlug, host } = useParams<{ orgSlug: string; host: string }>()\n const { orgId } = useMarketingOrgId(hostId)\n // The org's sends, narrowed to the ones this site sent.\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () => {\n const sends = campaignSendsQuery(firestore, orgId, hostId)\n return sends ? query(sends, limit(SEARCH_CEILING)) : null\n },\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const lastSent = useMemo(\n () =>\n [...(campaignDocs ?? [])]\n .filter((campaign: any) => campaign.status === 'sent')\n .sort(\n (a: any, b: any) =>\n (b.sentAt?.seconds ?? 0) - (a.sentAt?.seconds ?? 0),\n )[0],\n [campaignDocs],\n )\n\n if (!lastSent) return null\n\n return (\n <CardDisplay\n header={'Last campaign'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: '#opens--clicks',\n excerpt:\n 'Sent, opens, and clicks for your most recent campaign — open ' +\n 'Campaigns for the full history.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${buildRoute(Route.HOST_PLUGIN, {\n orgSlug,\n host,\n pluginSlug: 'marketing',\n })}/campaigns`}\n size=\"small\"\n color=\"primary\"\n >\n {'Campaigns'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1}>\n <Typography variant=\"body2\" noWrap>\n {lastSent.subject}\n </Typography>\n <Stack direction=\"row\" spacing={3}>\n <Stack>\n <Typography variant=\"h6\">{lastSent.stats?.sent ?? 0}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Sent'}\n </Typography>\n </Stack>\n <Stack>\n <Typography variant=\"h6\">{lastSent.stats?.opens ?? 0}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Opens'}\n </Typography>\n </Stack>\n <Stack>\n <Typography variant=\"h6\">{lastSent.stats?.clicks ?? 0}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Clicks'}\n </Typography>\n </Stack>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nCampaignGlanceCard.displayName = 'CampaignGlanceCard'\n\nexport default CampaignGlanceCard\n"],"names":["buildRoute","pluginDocsHelp","Route","AppLink","CardDisplay","Button","Stack","Typography","limit","query","useParams","useMemo","useFirestore","useFirestoreCollection","campaignSendsQuery","useMarketingOrgId","SEARCH_CEILING","CampaignGlanceCard","props","lastSent","hostId","firestore","orgSlug","host","orgId","data","campaignDocs","sends","idField","filter","campaign","status","sort","a","b","sentAt","seconds","header","help","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","HOST_PLUGIN","pluginSlug","size","color","spacing","variant","noWrap","subject","direction","stats","sent","opens","clicks","displayName"],"mappings":"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"}
@@ -1,5 +1,11 @@
1
1
  export interface CampaignMembersSectionProps {
2
2
  hostId: string;
3
+ /**
4
+ * The site's name, when the section is one of several — the org hub draws
5
+ * one per site the campaign is placed on, and each heading says whose
6
+ * forms and screens it lists.
7
+ */
8
+ siteName?: string;
3
9
  campaignId: string;
4
10
  /** The campaign's first day, when it has one. */
5
11
  startAtMs?: number | null;
@@ -118,7 +118,7 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
118
118
  * would cost a scan of the collection the customer is billed on.
119
119
  */ export function CampaignMembersSection(props) {
120
120
  var _range_from, _range_to;
121
- const { hostId, campaignId, startAtMs, endAtMs } = props;
121
+ const { hostId, siteName, campaignId, startAtMs, endAtMs } = props;
122
122
  const firestore = useFirestore();
123
123
  const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId);
124
124
  const memberQuery = (collectionName)=>()=>query(collection(firestore, 'hosts', hostId, collectionName), where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId), orderBy(documentId()), limit(MEMBER_CEILING + 1));
@@ -224,7 +224,7 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
224
224
  host
225
225
  }) : null;
226
226
  return /*#__PURE__*/ _jsx(Section, {
227
- title: "Assigned to this campaign",
227
+ title: siteName ? `Assigned to this campaign on ${siteName}` : 'Assigned to this campaign',
228
228
  children: /*#__PURE__*/ _jsxs(Stack, {
229
229
  spacing: 2,
230
230
  children: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-members-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n buildRoute,\n CAMPAIGN_MEMBERSHIP_FIELD,\n readCampaignIds,\n Route,\n type FormStats,\n type FormStatsTotals,\n} from '@aglyn/aglyn'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignFormsRollup,\n campaignFormTotals,\n campaignPeriodRange,\n isWindowedRange,\n type CampaignFormsRollup,\n} from '../model/campaign-membership-figures'\n\n/**\n * How many members of one kind the section enumerates.\n *\n * One document past it is asked for, so \"this campaign holds more than are\n * listed\" is a fact rather than a guess — the same probe the emails table on\n * this page makes.\n */\nconst MEMBER_CEILING = 25\n\nexport interface CampaignMembersSectionProps {\n hostId: string\n campaignId: string\n /** The campaign's first day, when it has one. */\n startAtMs?: number | null\n /** The campaign's last day, when it has one. */\n endAtMs?: number | null\n}\n\n/**\n * The rows a reader may act on: the window, minus what has been deleted.\n *\n * The soft delete is filtered HERE and not in the query. `deletedAt` is\n * written only when a record is removed, so `where('deletedAt', '==', null)`\n * would match nothing at all — Firestore's equality matches documents that\n * HAVE the field — and every live record would vanish from the campaign. The\n * campaigns table filters its own soft-deleted containers the same way.\n */\nfunction live(\n docs: Array<Record<string, unknown>> | undefined,\n): Array<Record<string, unknown>> {\n return (docs ?? [])\n .slice(0, MEMBER_CEILING)\n .filter((row) => !row['deletedAt'])\n}\n\n/** One row, whatever collection it came out of. */\ninterface MemberRow {\n id: string\n name: string\n href: string | null\n /** Why there is no link, when there is none. */\n hrefReason?: string\n /** The member's own counters, for a kind that carries them. */\n totals?: FormStatsTotals\n /** Campaigns the member is filed under, this one included. */\n campaigns?: number\n}\n\n/**\n * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,\n * and what those forms have collected.\n *\n * ## Declared, and therefore different from every section above it\n *\n * The conversions, revenue and destinations sections are EVIDENCE: they join\n * on the campaign's send ids and report what visitors did BECAUSE of this\n * campaign's mail. This one is a statement the merchant made — a screen or a\n * form carries this campaign's id in {@link CAMPAIGN_MEMBERSHIP_FIELD} — plus\n * the counters those records carry on their own documents.\n *\n * The distinction survives into the wording, because the arithmetic will not\n * enforce it. A form's submissions are the form's; filing the form under a\n * campaign does not make them the campaign's, and a heading that said\n * \"this campaign produced 40 leads\" over a number meaning \"the forms filed\n * here have 40 leads\" would be a worse page than one showing nothing. So the\n * figures are labeled as what the forms HOLD, they sit in their own block\n * under their own disclaimer, and no total on this page adds them to an\n * attributed one.\n *\n * ## Three ways the forms' counters could mislead, each answered\n *\n * 1. **They are lifetime.** `stats.submissions` has counted since the form\n * existed, including every submission from before anybody filed it here.\n * Where the campaign carries dates, the figures are summed from the\n * monthly `stats.periods` series over those months instead, and the\n * caption says which of the two a reader is looking at. Months are whole,\n * so a campaign starting mid-month includes that month entire — stated,\n * not rounded away.\n * 2. **A form can be in twenty campaigns.** `campaignIds` is an array, so\n * the same submissions count toward every campaign the form is filed\n * under. Rows say so and the total refuses to present itself as\n * exclusive.\n * 3. **A counter nobody wrote is not a zero.** `stats.leads` is incremented\n * only for a form whose `routing.lead` is set, and `stats.views` only by\n * the analytics beacon. Both reach the shared `Figure`, which draws an em\n * dash and the words \"not recorded\" rather than a confident 0.\n *\n * ## Screens carry no counter, and this section will not invent one\n *\n * A form's counters ride on the form document, so the query this section\n * already makes pays for them. A screen's traffic does not: it is one\n * document per screen per day in `hosts/{hostId}/screenAnalytics`, with no\n * lifetime total anywhere, so a views column here would cost a range read\n * across screens times days on every open of this page — and the display of\n * those figures is a paid entitlement this section does not resolve. The\n * screens block names the absence and links to the surface that does measure\n * it, which is a smaller lie than a number nobody can stand behind.\n *\n * ## The join is `array-contains`, on the member's own document\n *\n * A campaign holds no member list. Each record names the campaigns it is in,\n * which is what makes deleting one record leave nothing behind and what lets\n * a record's own page draw its campaigns without a query. The query here is\n * the other direction of the same field, served by Firestore's automatic\n * single-field index.\n *\n * `orderBy(documentId())`, like every other bounded list in this console: a\n * form carries `updatedAt` only if some writer stamped one, and ordering on a\n * field a writer may omit does not mis-sort the list, it drops rows from it.\n *\n * ## Contacts are named here and NOT listed, and the reason is a query limit\n *\n * A contact is org-scoped and shared between the sites that captured it, so\n * every client read of the collection has to carry\n * `visibleTo array-contains-any` — that is what makes the read provable\n * per-document under the rules. Firestore permits ONE array clause per query,\n * and that is it: there is no client query that filters contacts by campaign\n * as well, so there is no pre-filtered address to link to either. The link\n * offered goes to the Contacts page and says it arrives unfiltered; the\n * assignment is real, is set from the contact's own drawer, and a count of it\n * would cost a scan of the collection the customer is billed on.\n */\nexport function CampaignMembersSection(props: CampaignMembersSectionProps) {\n const { hostId, campaignId, startAtMs, endAtMs } = props\n const firestore = useFirestore()\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n\n const memberQuery = (collectionName: string) => () =>\n query(\n collection(firestore, 'hosts', hostId, collectionName),\n where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId),\n orderBy(documentId()),\n limit(MEMBER_CEILING + 1),\n )\n\n const { data: screenDocs, status: screensStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('screens'), [firestore, hostId, campaignId], {\n idField: '$id',\n })\n const { data: formDocs, status: formsStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('forms'), [firestore, hostId, campaignId], { idField: '$id' })\n\n /*\n * The months the campaign's dates cover, or nothing where it has neither.\n * Derived from props the detail card already holds, so asking what a\n * figure covers costs no read.\n */\n const range = useMemo(\n () => campaignPeriodRange({ startAtMs, endAtMs }),\n [startAtMs, endAtMs],\n )\n const windowed = isWindowedRange(range)\n\n const screens = useMemo<MemberRow[]>(() => {\n return live(screenDocs).map((row) => {\n const id = String(row['$id'])\n const versionId = String(row['versionId'] ?? '')\n return {\n id,\n name: String(row['displayName'] ?? id),\n /*\n * A screen with no saved version has no detail address to build —\n * the route carries a version — so it stays plain text rather than\n * linking somewhere that 404s. The screens list applies the same\n * guard to its own name column.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : null,\n hrefReason: versionId\n ? 'This site’s console URL has not resolved yet'\n : 'This screen has no saved version yet',\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [screenDocs, orgSlug, host])\n\n /*\n * The counters ride on the documents the membership query already returned,\n * so every figure below is paid for by a read this section was making\n * anyway. Nothing here opens a second listener.\n */\n const forms = useMemo<MemberRow[]>(() => {\n return live(formDocs).map((row) => {\n const id = String(row['$id'])\n return {\n id,\n name: String(row['displayName'] ?? id),\n href:\n orgSlug && host\n ? buildRoute(Route.FORM_DETAILS, { orgSlug, host, formId: id })\n : null,\n hrefReason: 'This site’s console URL has not resolved yet',\n totals: campaignFormTotals(row['stats'] as FormStats | undefined, range),\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [formDocs, orgSlug, host, range])\n\n const rollup = useMemo(\n () =>\n campaignFormsRollup(\n forms.map((row) => ({\n totals: row.totals as FormStatsTotals,\n campaigns: row.campaigns ?? 1,\n })),\n ),\n [forms],\n )\n\n const screensTruncated = (screenDocs ?? []).length > MEMBER_CEILING\n const formsTruncated = (formDocs ?? []).length > MEMBER_CEILING\n const settled = screensStatus !== 'loading' && formsStatus !== 'loading'\n\n const analyticsHref =\n orgSlug && host\n ? buildRoute(Route.HOST_ANALYTICS, { orgSlug, host })\n : null\n const contactsHref =\n orgSlug && host ? buildRoute(Route.HOST_CONTACTS, { orgSlug, host }) : null\n\n return (\n <Section title=\"Assigned to this campaign\">\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages and forms somebody put in this campaign. Assignment is ' +\n 'a grouping — what the campaign was credited with is measured ' +\n 'above, from the links its emails carried.'}\n </Typography>\n <MemberTable\n heading=\"Screens\"\n noun=\"screen\"\n rows={screens}\n truncated={screensTruncated}\n settled={settled}\n />\n {/*\n The absence, named where a reader would look for the number.\n\n A screen's traffic is not on the screen document — it is a day doc\n per screen in `screenAnalytics` — so a views column here would be a\n read across screens times days on every open, for figures whose\n display is a paid entitlement this section does not resolve. The\n page that does both is linked instead.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Page views are not shown here. A screen keeps no running total ' +\n 'on its own record — traffic is stored a day at a time — so ' +\n 'counting it would be a fresh read of the site’s history every ' +\n 'time this page opens. '}\n {analyticsHref ? (\n <AppLink href={analyticsHref}>\n {'The site’s analytics measures it by screen.'}\n </AppLink>\n ) : (\n 'The site’s analytics page measures it by screen.'\n )}\n </Typography>\n <MemberTable\n heading=\"Forms\"\n noun=\"form\"\n rows={forms}\n truncated={formsTruncated}\n settled={settled}\n figures\n />\n {/*\n THE MEMBERSHIP TOTAL, fenced off from the attributed ones above.\n\n Same `Figure` primitive as the mail rollup, deliberately different\n words: it says what the forms HOLD, and the caption beneath refuses\n the reading a merchant will reach for first.\n */}\n {forms.length ? (\n <FormsHoldings\n rollup={rollup}\n windowed={windowed}\n from={range.from ?? null}\n to={range.to ?? null}\n />\n ) : null}\n {/*\n The people, named rather than omitted.\n\n A contact CAN be filed under a campaign and this page cannot list\n them — see the module comment for the query rule that decides it.\n Leaving the heading out entirely would read as \"contacts cannot be\n assigned\", which is the one thing it does not mean.\n */}\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Contacts'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Contacts are filed under a campaign from the contact’s own ' +\n 'panel on the Contacts page. They are not listed or counted ' +\n 'here: a contact belongs to the organization rather than to ' +\n 'this site, so the query that reads them is already spending ' +\n 'its one array filter on which sites may see the person. '}\n {contactsHref ? (\n <AppLink href={contactsHref}>{'Open Contacts'}</AppLink>\n ) : null}\n {contactsHref\n ? ' — it opens unfiltered, because that same rule leaves no ' +\n 'campaign filter for the address to carry.'\n : ''}\n </Typography>\n </Stack>\n </Stack>\n </Section>\n )\n}\n\n/**\n * What the campaign's forms have collected — and what that is NOT.\n *\n * Two sentences do the whole job of keeping this apart from the attributed\n * figures, and both are load-bearing:\n *\n * - The first refuses causation outright. A merchant reading a number under\n * a campaign's heading will assume the campaign caused it unless told\n * otherwise in the same breath.\n * - The second says which months the number covers. A lifetime total under a\n * campaign that ran for six weeks is the same lie one step quieter.\n *\n * `shared` is stated as a count rather than folded into the arithmetic. There\n * is no honest way to divide a submission between two campaigns a form is\n * filed under, so the total is left whole and the overlap is disclosed.\n */\nfunction FormsHoldings(props: {\n rollup: CampaignFormsRollup\n windowed: boolean\n from: string | null\n to: string | null\n}) {\n const { rollup, windowed, from, to } = props\n const note = (recorded: number) =>\n recorded < rollup.members\n ? `across ${recorded} of ${rollup.members} forms`\n : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`\n const span = windowed\n ? `the campaign’s months (${from ?? 'the first month recorded'} to ${\n to ?? 'now'\n })`\n : 'each form’s whole history'\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'What these forms hold'}\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Views\"\n value={rollup.views.value}\n note={note(rollup.views.recorded)}\n />\n <Figure\n label=\"Started\"\n value={rollup.starts.value}\n note={note(rollup.starts.recorded)}\n />\n <Figure\n label=\"Submissions\"\n value={rollup.submissions.value}\n note={note(rollup.submissions.recorded)}\n />\n <Figure\n label=\"Leads\"\n value={rollup.leads.value}\n note={note(rollup.leads.recorded)}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These are the forms’ own counters, not this campaign’s results. ' +\n 'A submission is counted here because somebody filed the form ' +\n 'under this campaign, whether or not this campaign’s mail sent ' +\n `the visitor. Counted over ${span}.`}\n </Typography>\n {windowed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whole calendar months: a campaign that started or ended mid-' +\n 'month takes that month entire, and a form counting nothing in ' +\n 'those months adds nothing here even where its lifetime total ' +\n 'is large.'}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign has no dates, so nothing narrows the figures to ' +\n 'it. They are lifetime totals and include everything from ' +\n 'before the forms were put in this campaign. Give the campaign ' +\n 'a start and an end to count only its months.'}\n </Typography>\n )}\n {rollup.shared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${rollup.shared} of these ${rollup.members} forms ${\n rollup.shared === 1 ? 'is' : 'are'\n } filed under another campaign too, so the same submissions are ` +\n 'counted there as well. This total is not exclusive to this ' +\n 'campaign.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nFormsHoldings.displayName = 'FormsHoldings'\n\n/** A counter as a cell: an em dash where nothing was recorded, never a zero. */\nfunction statCell(value: number | null | undefined) {\n return value === null || value === undefined ? '—' : value.toLocaleString()\n}\n\n/** The counters a form carries on its own document, in the order they are read. */\nconst MEMBER_FIGURES = [\n ['views', 'Views'],\n ['starts', 'Started'],\n ['submissions', 'Submissions'],\n ['leads', 'Leads'],\n] as const\n\n/**\n * One kind's members, as a record list in the shared grid.\n *\n * The rows are a window this component already holds — the same arrangement\n * the campaign's emails list beside it uses, and for the same reason: the\n * ceiling bounds the read, and the grid's footer lets a reader walk what came\n * back without the card deciding how many rows fit.\n *\n * `figures` adds the counter columns for a kind that carries them on its own\n * document. It is a prop rather than two lists because the row grammar — the\n * name cell, the reason a member has no link, the footer — is the part that\n * has to stay identical between screens and forms.\n */\nfunction MemberTable(props: {\n heading: string\n noun: string\n rows: MemberRow[]\n truncated: boolean\n settled: boolean\n figures?: boolean\n}) {\n const { heading, noun, rows, truncated, settled, figures } = props\n const columns = useMemo<GridColDef<MemberRow>[]>(\n () => [\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack sx={{ py: 1, minWidth: 0 }}>\n {row.href ? (\n <AppLink href={row.href}>{row.name}</AppLink>\n ) : (\n <>\n <Typography variant=\"body2\">{row.name}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.hrefReason}\n </Typography>\n </>\n )}\n {/*\n The overlap, on the row that causes it. A reader comparing two\n campaigns has to be able to see which rows they have in common\n without opening either.\n */}\n {(row.campaigns ?? 1) > 1 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Also in ${(row.campaigns ?? 1) - 1} other campaign${\n (row.campaigns ?? 1) - 1 === 1 ? '' : 's'\n }`}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n ...(figures\n ? MEMBER_FIGURES.map(\n ([figure, headerName]): GridColDef<MemberRow> => ({\n field: figure,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 120,\n valueGetter: (_value, row) => row.totals?.[figure] ?? null,\n renderCell: ({ row }) => statCell(row.totals?.[figure]),\n }),\n )\n : []),\n ],\n [figures],\n )\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {heading}\n </Typography>\n {truncated ? (\n <Alert severity=\"info\">\n {`More than ${MEMBER_CEILING} ${noun}s are in this campaign. The ` +\n `first ${MEMBER_CEILING} are listed, in document order.`}\n </Alert>\n ) : null}\n {rows.length ? (\n <ListTable\n aria-label={heading}\n rows={rows}\n columns={columns}\n getRowId={(row: MemberRow) => row.id}\n rowHeight={TABLE_ROW_HEIGHT}\n // A member's name carries why it has no link, and the other\n // campaigns it is in, beneath it.\n getRowHeight={() => 'auto'}\n />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? `No ${noun} is in this campaign. Open a ${noun} and pick this ` +\n 'campaign on its own page to put it in one.'\n : `Reading this campaign’s ${noun}s…`}\n </Typography>\n )}\n </Stack>\n )\n}\n\nCampaignMembersSection.displayName = 'CampaignMembersSection'\n\nexport default CampaignMembersSection\n"],"names":["AppLink","Figure","Section","ListTable","TABLE_ROW_HEIGHT","buildRoute","CAMPAIGN_MEMBERSHIP_FIELD","readCampaignIds","Route","Alert","Stack","Typography","collection","documentId","limit","orderBy","query","where","useMemo","useConsoleHostRoute","useFirestore","useFirestoreCollection","campaignFormsRollup","campaignFormTotals","campaignPeriodRange","isWindowedRange","MEMBER_CEILING","live","docs","slice","filter","row","CampaignMembersSection","props","range","hostId","campaignId","startAtMs","endAtMs","firestore","orgSlug","subdomain","host","memberQuery","collectionName","data","screenDocs","status","screensStatus","idField","formDocs","formsStatus","windowed","screens","map","id","String","versionId","name","href","SCREEN_DETAILS","screenId","hrefReason","campaigns","length","forms","FORM_DETAILS","formId","totals","rollup","screensTruncated","formsTruncated","settled","analyticsHref","HOST_ANALYTICS","contactsHref","HOST_CONTACTS","title","spacing","variant","color","MemberTable","heading","noun","rows","truncated","figures","FormsHoldings","from","to","note","recorded","members","span","direction","sx","flexWrap","label","value","views","starts","submissions","leads","shared","displayName","statCell","undefined","toLocaleString","MEMBER_FIGURES","columns","field","headerName","flex","minWidth","renderCell","py","figure","type","align","headerAlign","width","valueGetter","_value","severity","aria-label","getRowId","rowHeight","getRowHeight"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-members-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n buildRoute,\n CAMPAIGN_MEMBERSHIP_FIELD,\n readCampaignIds,\n Route,\n type FormStats,\n type FormStatsTotals,\n} from '@aglyn/aglyn'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignFormsRollup,\n campaignFormTotals,\n campaignPeriodRange,\n isWindowedRange,\n type CampaignFormsRollup,\n} from '../model/campaign-membership-figures'\n\n/**\n * How many members of one kind the section enumerates.\n *\n * One document past it is asked for, so \"this campaign holds more than are\n * listed\" is a fact rather than a guess — the same probe the emails table on\n * this page makes.\n */\nconst MEMBER_CEILING = 25\n\nexport interface CampaignMembersSectionProps {\n hostId: string\n /**\n * The site's name, when the section is one of several — the org hub draws\n * one per site the campaign is placed on, and each heading says whose\n * forms and screens it lists.\n */\n siteName?: string\n campaignId: string\n /** The campaign's first day, when it has one. */\n startAtMs?: number | null\n /** The campaign's last day, when it has one. */\n endAtMs?: number | null\n}\n\n/**\n * The rows a reader may act on: the window, minus what has been deleted.\n *\n * The soft delete is filtered HERE and not in the query. `deletedAt` is\n * written only when a record is removed, so `where('deletedAt', '==', null)`\n * would match nothing at all — Firestore's equality matches documents that\n * HAVE the field — and every live record would vanish from the campaign. The\n * campaigns table filters its own soft-deleted containers the same way.\n */\nfunction live(\n docs: Array<Record<string, unknown>> | undefined,\n): Array<Record<string, unknown>> {\n return (docs ?? [])\n .slice(0, MEMBER_CEILING)\n .filter((row) => !row['deletedAt'])\n}\n\n/** One row, whatever collection it came out of. */\ninterface MemberRow {\n id: string\n name: string\n href: string | null\n /** Why there is no link, when there is none. */\n hrefReason?: string\n /** The member's own counters, for a kind that carries them. */\n totals?: FormStatsTotals\n /** Campaigns the member is filed under, this one included. */\n campaigns?: number\n}\n\n/**\n * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,\n * and what those forms have collected.\n *\n * ## Declared, and therefore different from every section above it\n *\n * The conversions, revenue and destinations sections are EVIDENCE: they join\n * on the campaign's send ids and report what visitors did BECAUSE of this\n * campaign's mail. This one is a statement the merchant made — a screen or a\n * form carries this campaign's id in {@link CAMPAIGN_MEMBERSHIP_FIELD} — plus\n * the counters those records carry on their own documents.\n *\n * The distinction survives into the wording, because the arithmetic will not\n * enforce it. A form's submissions are the form's; filing the form under a\n * campaign does not make them the campaign's, and a heading that said\n * \"this campaign produced 40 leads\" over a number meaning \"the forms filed\n * here have 40 leads\" would be a worse page than one showing nothing. So the\n * figures are labeled as what the forms HOLD, they sit in their own block\n * under their own disclaimer, and no total on this page adds them to an\n * attributed one.\n *\n * ## Three ways the forms' counters could mislead, each answered\n *\n * 1. **They are lifetime.** `stats.submissions` has counted since the form\n * existed, including every submission from before anybody filed it here.\n * Where the campaign carries dates, the figures are summed from the\n * monthly `stats.periods` series over those months instead, and the\n * caption says which of the two a reader is looking at. Months are whole,\n * so a campaign starting mid-month includes that month entire — stated,\n * not rounded away.\n * 2. **A form can be in twenty campaigns.** `campaignIds` is an array, so\n * the same submissions count toward every campaign the form is filed\n * under. Rows say so and the total refuses to present itself as\n * exclusive.\n * 3. **A counter nobody wrote is not a zero.** `stats.leads` is incremented\n * only for a form whose `routing.lead` is set, and `stats.views` only by\n * the analytics beacon. Both reach the shared `Figure`, which draws an em\n * dash and the words \"not recorded\" rather than a confident 0.\n *\n * ## Screens carry no counter, and this section will not invent one\n *\n * A form's counters ride on the form document, so the query this section\n * already makes pays for them. A screen's traffic does not: it is one\n * document per screen per day in `hosts/{hostId}/screenAnalytics`, with no\n * lifetime total anywhere, so a views column here would cost a range read\n * across screens times days on every open of this page — and the display of\n * those figures is a paid entitlement this section does not resolve. The\n * screens block names the absence and links to the surface that does measure\n * it, which is a smaller lie than a number nobody can stand behind.\n *\n * ## The join is `array-contains`, on the member's own document\n *\n * A campaign holds no member list. Each record names the campaigns it is in,\n * which is what makes deleting one record leave nothing behind and what lets\n * a record's own page draw its campaigns without a query. The query here is\n * the other direction of the same field, served by Firestore's automatic\n * single-field index.\n *\n * `orderBy(documentId())`, like every other bounded list in this console: a\n * form carries `updatedAt` only if some writer stamped one, and ordering on a\n * field a writer may omit does not mis-sort the list, it drops rows from it.\n *\n * ## Contacts are named here and NOT listed, and the reason is a query limit\n *\n * A contact is org-scoped and shared between the sites that captured it, so\n * every client read of the collection has to carry\n * `visibleTo array-contains-any` — that is what makes the read provable\n * per-document under the rules. Firestore permits ONE array clause per query,\n * and that is it: there is no client query that filters contacts by campaign\n * as well, so there is no pre-filtered address to link to either. The link\n * offered goes to the Contacts page and says it arrives unfiltered; the\n * assignment is real, is set from the contact's own drawer, and a count of it\n * would cost a scan of the collection the customer is billed on.\n */\nexport function CampaignMembersSection(props: CampaignMembersSectionProps) {\n const { hostId, siteName, campaignId, startAtMs, endAtMs } = props\n const firestore = useFirestore()\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n\n const memberQuery = (collectionName: string) => () =>\n query(\n collection(firestore, 'hosts', hostId, collectionName),\n where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId),\n orderBy(documentId()),\n limit(MEMBER_CEILING + 1),\n )\n\n const { data: screenDocs, status: screensStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('screens'), [firestore, hostId, campaignId], {\n idField: '$id',\n })\n const { data: formDocs, status: formsStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('forms'), [firestore, hostId, campaignId], { idField: '$id' })\n\n /*\n * The months the campaign's dates cover, or nothing where it has neither.\n * Derived from props the detail card already holds, so asking what a\n * figure covers costs no read.\n */\n const range = useMemo(\n () => campaignPeriodRange({ startAtMs, endAtMs }),\n [startAtMs, endAtMs],\n )\n const windowed = isWindowedRange(range)\n\n const screens = useMemo<MemberRow[]>(() => {\n return live(screenDocs).map((row) => {\n const id = String(row['$id'])\n const versionId = String(row['versionId'] ?? '')\n return {\n id,\n name: String(row['displayName'] ?? id),\n /*\n * A screen with no saved version has no detail address to build —\n * the route carries a version — so it stays plain text rather than\n * linking somewhere that 404s. The screens list applies the same\n * guard to its own name column.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : null,\n hrefReason: versionId\n ? 'This site’s console URL has not resolved yet'\n : 'This screen has no saved version yet',\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [screenDocs, orgSlug, host])\n\n /*\n * The counters ride on the documents the membership query already returned,\n * so every figure below is paid for by a read this section was making\n * anyway. Nothing here opens a second listener.\n */\n const forms = useMemo<MemberRow[]>(() => {\n return live(formDocs).map((row) => {\n const id = String(row['$id'])\n return {\n id,\n name: String(row['displayName'] ?? id),\n href:\n orgSlug && host\n ? buildRoute(Route.FORM_DETAILS, { orgSlug, host, formId: id })\n : null,\n hrefReason: 'This site’s console URL has not resolved yet',\n totals: campaignFormTotals(row['stats'] as FormStats | undefined, range),\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [formDocs, orgSlug, host, range])\n\n const rollup = useMemo(\n () =>\n campaignFormsRollup(\n forms.map((row) => ({\n totals: row.totals as FormStatsTotals,\n campaigns: row.campaigns ?? 1,\n })),\n ),\n [forms],\n )\n\n const screensTruncated = (screenDocs ?? []).length > MEMBER_CEILING\n const formsTruncated = (formDocs ?? []).length > MEMBER_CEILING\n const settled = screensStatus !== 'loading' && formsStatus !== 'loading'\n\n const analyticsHref =\n orgSlug && host\n ? buildRoute(Route.HOST_ANALYTICS, { orgSlug, host })\n : null\n const contactsHref =\n orgSlug && host ? buildRoute(Route.HOST_CONTACTS, { orgSlug, host }) : null\n\n return (\n <Section\n title={\n siteName\n ? `Assigned to this campaign on ${siteName}`\n : 'Assigned to this campaign'\n }\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages and forms somebody put in this campaign. Assignment is ' +\n 'a grouping — what the campaign was credited with is measured ' +\n 'above, from the links its emails carried.'}\n </Typography>\n <MemberTable\n heading=\"Screens\"\n noun=\"screen\"\n rows={screens}\n truncated={screensTruncated}\n settled={settled}\n />\n {/*\n The absence, named where a reader would look for the number.\n\n A screen's traffic is not on the screen document — it is a day doc\n per screen in `screenAnalytics` — so a views column here would be a\n read across screens times days on every open, for figures whose\n display is a paid entitlement this section does not resolve. The\n page that does both is linked instead.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Page views are not shown here. A screen keeps no running total ' +\n 'on its own record — traffic is stored a day at a time — so ' +\n 'counting it would be a fresh read of the site’s history every ' +\n 'time this page opens. '}\n {analyticsHref ? (\n <AppLink href={analyticsHref}>\n {'The site’s analytics measures it by screen.'}\n </AppLink>\n ) : (\n 'The site’s analytics page measures it by screen.'\n )}\n </Typography>\n <MemberTable\n heading=\"Forms\"\n noun=\"form\"\n rows={forms}\n truncated={formsTruncated}\n settled={settled}\n figures\n />\n {/*\n THE MEMBERSHIP TOTAL, fenced off from the attributed ones above.\n\n Same `Figure` primitive as the mail rollup, deliberately different\n words: it says what the forms HOLD, and the caption beneath refuses\n the reading a merchant will reach for first.\n */}\n {forms.length ? (\n <FormsHoldings\n rollup={rollup}\n windowed={windowed}\n from={range.from ?? null}\n to={range.to ?? null}\n />\n ) : null}\n {/*\n The people, named rather than omitted.\n\n A contact CAN be filed under a campaign and this page cannot list\n them — see the module comment for the query rule that decides it.\n Leaving the heading out entirely would read as \"contacts cannot be\n assigned\", which is the one thing it does not mean.\n */}\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Contacts'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Contacts are filed under a campaign from the contact’s own ' +\n 'panel on the Contacts page. They are not listed or counted ' +\n 'here: a contact belongs to the organization rather than to ' +\n 'this site, so the query that reads them is already spending ' +\n 'its one array filter on which sites may see the person. '}\n {contactsHref ? (\n <AppLink href={contactsHref}>{'Open Contacts'}</AppLink>\n ) : null}\n {contactsHref\n ? ' — it opens unfiltered, because that same rule leaves no ' +\n 'campaign filter for the address to carry.'\n : ''}\n </Typography>\n </Stack>\n </Stack>\n </Section>\n )\n}\n\n/**\n * What the campaign's forms have collected — and what that is NOT.\n *\n * Two sentences do the whole job of keeping this apart from the attributed\n * figures, and both are load-bearing:\n *\n * - The first refuses causation outright. A merchant reading a number under\n * a campaign's heading will assume the campaign caused it unless told\n * otherwise in the same breath.\n * - The second says which months the number covers. A lifetime total under a\n * campaign that ran for six weeks is the same lie one step quieter.\n *\n * `shared` is stated as a count rather than folded into the arithmetic. There\n * is no honest way to divide a submission between two campaigns a form is\n * filed under, so the total is left whole and the overlap is disclosed.\n */\nfunction FormsHoldings(props: {\n rollup: CampaignFormsRollup\n windowed: boolean\n from: string | null\n to: string | null\n}) {\n const { rollup, windowed, from, to } = props\n const note = (recorded: number) =>\n recorded < rollup.members\n ? `across ${recorded} of ${rollup.members} forms`\n : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`\n const span = windowed\n ? `the campaign’s months (${from ?? 'the first month recorded'} to ${\n to ?? 'now'\n })`\n : 'each form’s whole history'\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'What these forms hold'}\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Views\"\n value={rollup.views.value}\n note={note(rollup.views.recorded)}\n />\n <Figure\n label=\"Started\"\n value={rollup.starts.value}\n note={note(rollup.starts.recorded)}\n />\n <Figure\n label=\"Submissions\"\n value={rollup.submissions.value}\n note={note(rollup.submissions.recorded)}\n />\n <Figure\n label=\"Leads\"\n value={rollup.leads.value}\n note={note(rollup.leads.recorded)}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These are the forms’ own counters, not this campaign’s results. ' +\n 'A submission is counted here because somebody filed the form ' +\n 'under this campaign, whether or not this campaign’s mail sent ' +\n `the visitor. Counted over ${span}.`}\n </Typography>\n {windowed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whole calendar months: a campaign that started or ended mid-' +\n 'month takes that month entire, and a form counting nothing in ' +\n 'those months adds nothing here even where its lifetime total ' +\n 'is large.'}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign has no dates, so nothing narrows the figures to ' +\n 'it. They are lifetime totals and include everything from ' +\n 'before the forms were put in this campaign. Give the campaign ' +\n 'a start and an end to count only its months.'}\n </Typography>\n )}\n {rollup.shared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${rollup.shared} of these ${rollup.members} forms ${\n rollup.shared === 1 ? 'is' : 'are'\n } filed under another campaign too, so the same submissions are ` +\n 'counted there as well. This total is not exclusive to this ' +\n 'campaign.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nFormsHoldings.displayName = 'FormsHoldings'\n\n/** A counter as a cell: an em dash where nothing was recorded, never a zero. */\nfunction statCell(value: number | null | undefined) {\n return value === null || value === undefined ? '—' : value.toLocaleString()\n}\n\n/** The counters a form carries on its own document, in the order they are read. */\nconst MEMBER_FIGURES = [\n ['views', 'Views'],\n ['starts', 'Started'],\n ['submissions', 'Submissions'],\n ['leads', 'Leads'],\n] as const\n\n/**\n * One kind's members, as a record list in the shared grid.\n *\n * The rows are a window this component already holds — the same arrangement\n * the campaign's emails list beside it uses, and for the same reason: the\n * ceiling bounds the read, and the grid's footer lets a reader walk what came\n * back without the card deciding how many rows fit.\n *\n * `figures` adds the counter columns for a kind that carries them on its own\n * document. It is a prop rather than two lists because the row grammar — the\n * name cell, the reason a member has no link, the footer — is the part that\n * has to stay identical between screens and forms.\n */\nfunction MemberTable(props: {\n heading: string\n noun: string\n rows: MemberRow[]\n truncated: boolean\n settled: boolean\n figures?: boolean\n}) {\n const { heading, noun, rows, truncated, settled, figures } = props\n const columns = useMemo<GridColDef<MemberRow>[]>(\n () => [\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack sx={{ py: 1, minWidth: 0 }}>\n {row.href ? (\n <AppLink href={row.href}>{row.name}</AppLink>\n ) : (\n <>\n <Typography variant=\"body2\">{row.name}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.hrefReason}\n </Typography>\n </>\n )}\n {/*\n The overlap, on the row that causes it. A reader comparing two\n campaigns has to be able to see which rows they have in common\n without opening either.\n */}\n {(row.campaigns ?? 1) > 1 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Also in ${(row.campaigns ?? 1) - 1} other campaign${\n (row.campaigns ?? 1) - 1 === 1 ? '' : 's'\n }`}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n ...(figures\n ? MEMBER_FIGURES.map(\n ([figure, headerName]): GridColDef<MemberRow> => ({\n field: figure,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 120,\n valueGetter: (_value, row) => row.totals?.[figure] ?? null,\n renderCell: ({ row }) => statCell(row.totals?.[figure]),\n }),\n )\n : []),\n ],\n [figures],\n )\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {heading}\n </Typography>\n {truncated ? (\n <Alert severity=\"info\">\n {`More than ${MEMBER_CEILING} ${noun}s are in this campaign. The ` +\n `first ${MEMBER_CEILING} are listed, in document order.`}\n </Alert>\n ) : null}\n {rows.length ? (\n <ListTable\n aria-label={heading}\n rows={rows}\n columns={columns}\n getRowId={(row: MemberRow) => row.id}\n rowHeight={TABLE_ROW_HEIGHT}\n // A member's name carries why it has no link, and the other\n // campaigns it is in, beneath it.\n getRowHeight={() => 'auto'}\n />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? `No ${noun} is in this campaign. Open a ${noun} and pick this ` +\n 'campaign on its own page to put it in one.'\n : `Reading this campaign’s ${noun}s…`}\n </Typography>\n )}\n </Stack>\n )\n}\n\nCampaignMembersSection.displayName = 'CampaignMembersSection'\n\nexport default CampaignMembersSection\n"],"names":["AppLink","Figure","Section","ListTable","TABLE_ROW_HEIGHT","buildRoute","CAMPAIGN_MEMBERSHIP_FIELD","readCampaignIds","Route","Alert","Stack","Typography","collection","documentId","limit","orderBy","query","where","useMemo","useConsoleHostRoute","useFirestore","useFirestoreCollection","campaignFormsRollup","campaignFormTotals","campaignPeriodRange","isWindowedRange","MEMBER_CEILING","live","docs","slice","filter","row","CampaignMembersSection","props","range","hostId","siteName","campaignId","startAtMs","endAtMs","firestore","orgSlug","subdomain","host","memberQuery","collectionName","data","screenDocs","status","screensStatus","idField","formDocs","formsStatus","windowed","screens","map","id","String","versionId","name","href","SCREEN_DETAILS","screenId","hrefReason","campaigns","length","forms","FORM_DETAILS","formId","totals","rollup","screensTruncated","formsTruncated","settled","analyticsHref","HOST_ANALYTICS","contactsHref","HOST_CONTACTS","title","spacing","variant","color","MemberTable","heading","noun","rows","truncated","figures","FormsHoldings","from","to","note","recorded","members","span","direction","sx","flexWrap","label","value","views","starts","submissions","leads","shared","displayName","statCell","undefined","toLocaleString","MEMBER_FIGURES","columns","field","headerName","flex","minWidth","renderCell","py","figure","type","align","headerAlign","width","valueGetter","_value","severity","aria-label","getRowId","rowHeight","getRowHeight"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,OAAO,QAAQ,uBAAsB;AAC9C,SACEC,MAAM,EACNC,OAAO,QACF,6DAA4D;AACnE,SAASC,SAAS,QAAQ,uDAAsD;AAChF,SAASC,gBAAgB,QAAQ,8CAA6C;AAC9E,SACEC,UAAU,EACVC,yBAAyB,EACzBC,eAAe,EACfC,KAAK,QAGA,eAAc;AACrB,SAASC,KAAK,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AAExD,SACEC,UAAU,EACVC,UAAU,EACVC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,OAAO,QAAQ,QAAO;AAC/B,SACEC,mBAAmB,EACnBC,YAAY,EACZC,sBAAsB,QACjB,iCAAgC;AACvC,SACEC,mBAAmB,EACnBC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,QAEV,0CAAsC;AAE7C;;;;;;CAMC,GACD,MAAMC,iBAAiB;AAiBvB;;;;;;;;CAQC,GACD,SAASC,KACPC,IAAgD;IAEhD,OAAO,CAACA,eAAAA,OAAQ,EAAE,EACfC,KAAK,CAAC,GAAGH,gBACTI,MAAM,CAAC,CAACC,MAAQ,CAACA,GAAG,CAAC,YAAY;AACtC;AAeA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyEC,GACD,OAAO,SAASC,uBAAuBC,KAAkC;QAyKvDC,aACFA;IAzKd,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,UAAU,EAAEC,SAAS,EAAEC,OAAO,EAAE,GAAGN;IAC7D,MAAMO,YAAYpB;IAClB,MAAM,EAAEqB,OAAO,EAAEC,WAAWC,IAAI,EAAE,GAAGxB,oBAAoBgB;IAEzD,MAAMS,cAAc,CAACC,iBAA2B,IAC9C7B,MACEJ,WAAW4B,WAAW,SAASL,QAAQU,iBACvC5B,MAAMX,2BAA2B,kBAAkB+B,aACnDtB,QAAQF,eACRC,MAAMY,iBAAiB;IAG3B,MAAM,EAAEoB,MAAMC,UAAU,EAAEC,QAAQC,aAAa,EAAE,GAAG5B,uBAElDuB,YAAY,YAAY;QAACJ;QAAWL;QAAQE;KAAW,EAAE;QACzDa,SAAS;IACX;IACA,MAAM,EAAEJ,MAAMK,QAAQ,EAAEH,QAAQI,WAAW,EAAE,GAAG/B,uBAE9CuB,YAAY,UAAU;QAACJ;QAAWL;QAAQE;KAAW,EAAE;QAAEa,SAAS;IAAM;IAE1E;;;;GAIC,GACD,MAAMhB,QAAQhB,QACZ,IAAMM,oBAAoB;YAAEc;YAAWC;QAAQ,IAC/C;QAACD;QAAWC;KAAQ;IAEtB,MAAMc,WAAW5B,gBAAgBS;IAEjC,MAAMoB,UAAUpC,QAAqB;QACnC,OAAOS,KAAKoB,YAAYQ,GAAG,CAAC,CAACxB;gBAEFA,gBAGVA;YAJf,MAAMyB,KAAKC,OAAO1B,GAAG,CAAC,MAAM;YAC5B,MAAM2B,YAAYD,QAAO1B,iBAAAA,GAAG,CAAC,YAAY,YAAhBA,iBAAoB;YAC7C,OAAO;gBACLyB;gBACAG,MAAMF,QAAO1B,mBAAAA,GAAG,CAAC,cAAc,YAAlBA,mBAAsByB;gBACnC;;;;;SAKC,GACDI,MACEF,aAAajB,WAAWE,OACpBtC,WAAWG,MAAMqD,cAAc,EAAE;oBAC/BpB;oBACAE;oBACAmB,UAAUN;oBACVE;gBACF,KACA;gBACNK,YAAYL,YACR,iDACA;gBACJM,WAAWzD,gBAAgBwB,KAAKkC,MAAM;YACxC;QACF;IACF,GAAG;QAAClB;QAAYN;QAASE;KAAK;IAE9B;;;;GAIC,GACD,MAAMuB,QAAQhD,QAAqB;QACjC,OAAOS,KAAKwB,UAAUI,GAAG,CAAC,CAACxB;gBAIVA;YAHf,MAAMyB,KAAKC,OAAO1B,GAAG,CAAC,MAAM;YAC5B,OAAO;gBACLyB;gBACAG,MAAMF,QAAO1B,mBAAAA,GAAG,CAAC,cAAc,YAAlBA,mBAAsByB;gBACnCI,MACEnB,WAAWE,OACPtC,WAAWG,MAAM2D,YAAY,EAAE;oBAAE1B;oBAASE;oBAAMyB,QAAQZ;gBAAG,KAC3D;gBACNO,YAAY;gBACZM,QAAQ9C,mBAAmBQ,GAAG,CAAC,QAAQ,EAA2BG;gBAClE8B,WAAWzD,gBAAgBwB,KAAKkC,MAAM;YACxC;QACF;IACF,GAAG;QAACd;QAAUV;QAASE;QAAMT;KAAM;IAEnC,MAAMoC,SAASpD,QACb,IACEI,oBACE4C,MAAMX,GAAG,CAAC,CAACxB;gBAEEA;mBAFO;gBAClBsC,QAAQtC,IAAIsC,MAAM;gBAClBL,SAAS,GAAEjC,iBAAAA,IAAIiC,SAAS,YAAbjC,iBAAiB;YAC9B;aAEJ;QAACmC;KAAM;IAGT,MAAMK,mBAAmB,CAACxB,qBAAAA,aAAc,EAAE,EAAEkB,MAAM,GAAGvC;IACrD,MAAM8C,iBAAiB,CAACrB,mBAAAA,WAAY,EAAE,EAAEc,MAAM,GAAGvC;IACjD,MAAM+C,UAAUxB,kBAAkB,aAAaG,gBAAgB;IAE/D,MAAMsB,gBACJjC,WAAWE,OACPtC,WAAWG,MAAMmE,cAAc,EAAE;QAAElC;QAASE;IAAK,KACjD;IACN,MAAMiC,eACJnC,WAAWE,OAAOtC,WAAWG,MAAMqE,aAAa,EAAE;QAAEpC;QAASE;IAAK,KAAK;IAEzE,qBACE,KAACzC;QACC4E,OACE1C,WACI,CAAC,6BAA6B,EAAEA,UAAU,GAC1C;kBAGN,cAAA,MAAC1B;YAAMqE,SAAS;;8BACd,KAACpE;oBAAWqE,SAAQ;oBAAQC,OAAM;8BAC/B,sEACC,kEACA;;8BAEJ,KAACC;oBACCC,SAAQ;oBACRC,MAAK;oBACLC,MAAM/B;oBACNgC,WAAWf;oBACXE,SAASA;;8BAWX,MAAC9D;oBAAWqE,SAAQ;oBAAUC,OAAM;;wBACjC,oEACC,gEACA,mEACA;wBACDP,8BACC,KAAC1E;4BAAQ4D,MAAMc;sCACZ;6BAGH;;;8BAGJ,KAACQ;oBACCC,SAAQ;oBACRC,MAAK;oBACLC,MAAMnB;oBACNoB,WAAWd;oBACXC,SAASA;oBACTc,OAAO;;gBASRrB,MAAMD,MAAM,iBACX,KAACuB;oBACClB,QAAQA;oBACRjB,UAAUA;oBACVoC,IAAI,GAAEvD,cAAAA,MAAMuD,IAAI,YAAVvD,cAAc;oBACpBwD,EAAE,GAAExD,YAAAA,MAAMwD,EAAE,YAARxD,YAAY;qBAEhB;8BASJ,MAACxB;oBAAMqE,SAAS;;sCACd,KAACpE;4BAAWqE,SAAQ;4BAAWC,OAAM;sCAClC;;sCAEH,MAACtE;4BAAWqE,SAAQ;4BAAQC,OAAM;;gCAC/B,gEACC,gEACA,gEACA,iEACA;gCACDL,6BACC,KAAC5E;oCAAQ4D,MAAMgB;8CAAe;qCAC5B;gCACHA,eACG,8DACA,8CACA;;;;;;;;AAMhB;AAEA;;;;;;;;;;;;;;;CAeC,GACD,SAASY,cAAcvD,KAKtB;IACC,MAAM,EAAEqC,MAAM,EAAEjB,QAAQ,EAAEoC,IAAI,EAAEC,EAAE,EAAE,GAAGzD;IACvC,MAAM0D,OAAO,CAACC,WACZA,WAAWtB,OAAOuB,OAAO,GACrB,CAAC,OAAO,EAAED,SAAS,IAAI,EAAEtB,OAAOuB,OAAO,CAAC,MAAM,CAAC,GAC/C,CAAC,OAAO,EAAEvB,OAAOuB,OAAO,CAAC,KAAK,EAAEvB,OAAOuB,OAAO,KAAK,IAAI,KAAK,KAAK;IACvE,MAAMC,OAAOzC,WACT,CAAC,uBAAuB,EAAEoC,eAAAA,OAAQ,2BAA2B,IAAI,EAC/DC,aAAAA,KAAM,MACP,CAAC,CAAC,GACH;IACJ,qBACE,MAAChF;QAAMqE,SAAS;;0BACd,KAACpE;gBAAWqE,SAAQ;gBAAWC,OAAM;0BAClC;;0BAEH,MAACvE;gBAAMqF,WAAU;gBAAMhB,SAAS;gBAAGiB,IAAI;oBAAEC,UAAU;gBAAO;;kCACxD,KAAChG;wBACCiG,OAAM;wBACNC,OAAO7B,OAAO8B,KAAK,CAACD,KAAK;wBACzBR,MAAMA,KAAKrB,OAAO8B,KAAK,CAACR,QAAQ;;kCAElC,KAAC3F;wBACCiG,OAAM;wBACNC,OAAO7B,OAAO+B,MAAM,CAACF,KAAK;wBAC1BR,MAAMA,KAAKrB,OAAO+B,MAAM,CAACT,QAAQ;;kCAEnC,KAAC3F;wBACCiG,OAAM;wBACNC,OAAO7B,OAAOgC,WAAW,CAACH,KAAK;wBAC/BR,MAAMA,KAAKrB,OAAOgC,WAAW,CAACV,QAAQ;;kCAExC,KAAC3F;wBACCiG,OAAM;wBACNC,OAAO7B,OAAOiC,KAAK,CAACJ,KAAK;wBACzBR,MAAMA,KAAKrB,OAAOiC,KAAK,CAACX,QAAQ;;;;0BAGpC,KAACjF;gBAAWqE,SAAQ;gBAAUC,OAAM;0BACjC,qEACC,kEACA,mEACA,CAAC,0BAA0B,EAAEa,KAAK,CAAC,CAAC;;YAEvCzC,yBACC,KAAC1C;gBAAWqE,SAAQ;gBAAUC,OAAM;0BACjC,iEACC,mEACA,kEACA;+BAGJ,KAACtE;gBAAWqE,SAAQ;gBAAUC,OAAM;0BACjC,mEACC,8DACA,mEACA;;YAGLX,OAAOkC,MAAM,iBACZ,KAAC7F;gBAAWqE,SAAQ;gBAAUC,OAAM;0BACjC,GAAGX,OAAOkC,MAAM,CAAC,UAAU,EAAElC,OAAOuB,OAAO,CAAC,OAAO,EAClDvB,OAAOkC,MAAM,KAAK,IAAI,OAAO,MAC9B,+DAA+D,CAAC,GAC/D,gEACA;iBAEF;;;AAGV;AACAhB,cAAciB,WAAW,GAAG;AAE5B,8EAA8E,GAC9E,SAASC,SAASP,KAAgC;IAChD,OAAOA,UAAU,QAAQA,UAAUQ,YAAY,MAAMR,MAAMS,cAAc;AAC3E;AAEA,iFAAiF,GACjF,MAAMC,iBAAiB;IACrB;QAAC;QAAS;KAAQ;IAClB;QAAC;QAAU;KAAU;IACrB;QAAC;QAAe;KAAc;IAC9B;QAAC;QAAS;KAAQ;CACnB;AAED;;;;;;;;;;;;CAYC,GACD,SAAS3B,YAAYjD,KAOpB;IACC,MAAM,EAAEkD,OAAO,EAAEC,IAAI,EAAEC,IAAI,EAAEC,SAAS,EAAEb,OAAO,EAAEc,OAAO,EAAE,GAAGtD;IAC7D,MAAM6E,UAAU5F,QACd,IAAM;YACJ;gBACE6F,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEpF,GAAG,EAAE;wBAiBdA,gBAEeA,iBACVA;yCAnBT,MAACrB;wBAAMsF,IAAI;4BAAEoB,IAAI;4BAAGF,UAAU;wBAAE;;4BAC7BnF,IAAI6B,IAAI,iBACP,KAAC5D;gCAAQ4D,MAAM7B,IAAI6B,IAAI;0CAAG7B,IAAI4B,IAAI;+CAElC;;kDACE,KAAChD;wCAAWqE,SAAQ;kDAASjD,IAAI4B,IAAI;;kDACrC,KAAChD;wCAAWqE,SAAQ;wCAAUC,OAAM;kDACjClD,IAAIgC,UAAU;;;;4BASpB,EAAChC,iBAAAA,IAAIiC,SAAS,YAAbjC,iBAAiB,KAAK,kBACtB,KAACpB;gCAAWqE,SAAQ;gCAAUC,OAAM;0CACjC,CAAC,QAAQ,EAAE,EAAClD,kBAAAA,IAAIiC,SAAS,YAAbjC,kBAAiB,KAAK,EAAE,eAAe,EAClD,EAACA,kBAAAA,IAAIiC,SAAS,YAAbjC,kBAAiB,KAAK,MAAM,IAAI,KAAK,KACtC;iCAEF;;;;YAGV;eACIwD,UACAsB,eAAetD,GAAG,CAChB,CAAC,CAAC8D,QAAQL,WAAW,GAA6B,CAAA;oBAChDD,OAAOM;oBACPL;oBACAM,MAAM;oBACNC,OAAO;oBACPC,aAAa;oBACbC,OAAO;oBACPC,aAAa,CAACC,QAAQ5F;;4BAAQA;wCAAAA,cAAAA,IAAIsC,MAAM,qBAAVtC,WAAY,CAACsF,OAAO,mBAAI;;oBACtDF,YAAY,CAAC,EAAEpF,GAAG,EAAE;4BAAcA;+BAAT2E,UAAS3E,cAAAA,IAAIsC,MAAM,qBAAVtC,WAAY,CAACsF,OAAO;;gBACxD,CAAA,KAEF,EAAE;SACP,EACD;QAAC9B;KAAQ;IAEX,qBACE,MAAC7E;QAAMqE,SAAS;;0BACd,KAACpE;gBAAWqE,SAAQ;gBAAWC,OAAM;0BAClCE;;YAEFG,0BACC,KAAC7E;gBAAMmH,UAAS;0BACb,CAAC,UAAU,EAAElG,eAAe,CAAC,EAAE0D,KAAK,4BAA4B,CAAC,GAChE,CAAC,MAAM,EAAE1D,eAAe,+BAA+B,CAAC;iBAE1D;YACH2D,KAAKpB,MAAM,iBACV,KAAC9D;gBACC0H,cAAY1C;gBACZE,MAAMA;gBACNyB,SAASA;gBACTgB,UAAU,CAAC/F,MAAmBA,IAAIyB,EAAE;gBACpCuE,WAAW3H;gBACX,4DAA4D;gBAC5D,kCAAkC;gBAClC4H,cAAc,IAAM;+BAGtB,KAACrH;gBAAWqE,SAAQ;gBAAQC,OAAM;0BAC/BR,UACG,CAAC,GAAG,EAAEW,KAAK,6BAA6B,EAAEA,KAAK,eAAe,CAAC,GAC/D,+CACA,CAAC,wBAAwB,EAAEA,KAAK,EAAE,CAAC;;;;AAKjD;AAEApD,uBAAuByE,WAAW,GAAG;AAErC,eAAezE,uBAAsB"}
@@ -0,0 +1,58 @@
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 DocumentReference, type Firestore, type Query } from 'firebase/firestore';
18
+ /**
19
+ * WHERE A CAMPAIGN AND ITS SENDS ARE READ FROM, at either level of the hub.
20
+ *
21
+ * Both collections belong to the organization: containers at
22
+ * `orgs/{orgId}/emailCampaigns`, sends at `orgs/{orgId}/campaigns`, each
23
+ * send's rollups under it at `…/reports/{name}`. Spelled once here so no card
24
+ * rebuilds a path from memory, and so the one rule that makes a site hub's
25
+ * read legal is in one place:
26
+ *
27
+ * **A site hub's LIST reads are filtered by scope.** The rules let an
28
+ * org-wide member read every document and a site collaborator only those
29
+ * whose `visibleTo` names a scope they hold. A query the rules cannot prove
30
+ * document by document is refused whole, so a site hub asks exactly for what
31
+ * that site may see — a container placed on every site (`'org'`) or on this
32
+ * one (`'host:{id}'`), and a send sent AS this site. An org-wide member loses
33
+ * nothing by the filter at a site: those are the campaigns the site shows.
34
+ *
35
+ * The ORG hub is admitted only to org-wide members, so its reads are
36
+ * unfiltered.
37
+ *
38
+ * One `array-contains-any` per query is Firestore's limit, so no caller adds
39
+ * a second array clause to these; per-document reads need no filter at all.
40
+ */
41
+ /**
42
+ * `orgs/{orgId}/emailCampaigns`, narrowed to one site's placements when
43
+ * `hostId` names a site; `null` until the org is known.
44
+ */
45
+ export declare function campaignContainersQuery(firestore: Firestore, orgId: string | null, hostId: string | null): Query | null;
46
+ /**
47
+ * `orgs/{orgId}/campaigns` — the sends — narrowed to the ones sent as one
48
+ * site when `hostId` names a site; `null` until the org is known.
49
+ */
50
+ export declare function campaignSendsQuery(firestore: Firestore, orgId: string | null, hostId: string | null): Query | null;
51
+ /** One container, `orgs/{orgId}/emailCampaigns/{id}`. */
52
+ export declare function campaignContainerDoc(firestore: Firestore, orgId: string, campaignId: string): DocumentReference;
53
+ /** One send, `orgs/{orgId}/campaigns/{sendId}`. */
54
+ export declare function campaignSendDoc(firestore: Firestore, orgId: string, sendId: string): DocumentReference;
55
+ /** One of a send's rollups, `orgs/{orgId}/campaigns/{sendId}/reports/{name}`. */
56
+ export declare function campaignSendReportDoc(firestore: Firestore, orgId: string, sendId: string, report: 'links' | 'revenue' | 'conversions' | 'reached'): DocumentReference;
57
+ /** A container's sequence rollup, `orgs/{orgId}/campaignSequenceReports/{id}`. */
58
+ export declare function campaignSequenceReportDoc(firestore: Firestore, orgId: string, campaignId: string): DocumentReference;
@@ -0,0 +1,72 @@
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
+ */ import { hostScopeToken, scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
17
+ import { orgCampaignSendsPath, orgCampaignSequenceReportsPath, orgEmailCampaignsPath } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
18
+ import { collection, doc, query, where } from "firebase/firestore";
19
+ /**
20
+ * WHERE A CAMPAIGN AND ITS SENDS ARE READ FROM, at either level of the hub.
21
+ *
22
+ * Both collections belong to the organization: containers at
23
+ * `orgs/{orgId}/emailCampaigns`, sends at `orgs/{orgId}/campaigns`, each
24
+ * send's rollups under it at `…/reports/{name}`. Spelled once here so no card
25
+ * rebuilds a path from memory, and so the one rule that makes a site hub's
26
+ * read legal is in one place:
27
+ *
28
+ * **A site hub's LIST reads are filtered by scope.** The rules let an
29
+ * org-wide member read every document and a site collaborator only those
30
+ * whose `visibleTo` names a scope they hold. A query the rules cannot prove
31
+ * document by document is refused whole, so a site hub asks exactly for what
32
+ * that site may see — a container placed on every site (`'org'`) or on this
33
+ * one (`'host:{id}'`), and a send sent AS this site. An org-wide member loses
34
+ * nothing by the filter at a site: those are the campaigns the site shows.
35
+ *
36
+ * The ORG hub is admitted only to org-wide members, so its reads are
37
+ * unfiltered.
38
+ *
39
+ * One `array-contains-any` per query is Firestore's limit, so no caller adds
40
+ * a second array clause to these; per-document reads need no filter at all.
41
+ */ /**
42
+ * `orgs/{orgId}/emailCampaigns`, narrowed to one site's placements when
43
+ * `hostId` names a site; `null` until the org is known.
44
+ */ export function campaignContainersQuery(firestore, orgId, hostId) {
45
+ if (!orgId) return null;
46
+ const containers = collection(firestore, ...orgEmailCampaignsPath(orgId));
47
+ return hostId ? query(containers, where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))) : containers;
48
+ }
49
+ /**
50
+ * `orgs/{orgId}/campaigns` — the sends — narrowed to the ones sent as one
51
+ * site when `hostId` names a site; `null` until the org is known.
52
+ */ export function campaignSendsQuery(firestore, orgId, hostId) {
53
+ if (!orgId) return null;
54
+ const sends = collection(firestore, ...orgCampaignSendsPath(orgId));
55
+ return hostId ? query(sends, where('visibleTo', 'array-contains-any', [
56
+ hostScopeToken(hostId)
57
+ ])) : sends;
58
+ }
59
+ /** One container, `orgs/{orgId}/emailCampaigns/{id}`. */ export function campaignContainerDoc(firestore, orgId, campaignId) {
60
+ return doc(firestore, ...orgEmailCampaignsPath(orgId), campaignId);
61
+ }
62
+ /** One send, `orgs/{orgId}/campaigns/{sendId}`. */ export function campaignSendDoc(firestore, orgId, sendId) {
63
+ return doc(firestore, ...orgCampaignSendsPath(orgId), sendId);
64
+ }
65
+ /** One of a send's rollups, `orgs/{orgId}/campaigns/{sendId}/reports/{name}`. */ export function campaignSendReportDoc(firestore, orgId, sendId, report) {
66
+ return doc(firestore, ...orgCampaignSendsPath(orgId), sendId, 'reports', report);
67
+ }
68
+ /** A container's sequence rollup, `orgs/{orgId}/campaignSequenceReports/{id}`. */ export function campaignSequenceReportDoc(firestore, orgId, campaignId) {
69
+ return doc(firestore, ...orgCampaignSequenceReportsPath(orgId), campaignId);
70
+ }
71
+
72
+ //# sourceMappingURL=campaign-queries.js.map
@@ -0,0 +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,5 +1,9 @@
1
1
  export interface CampaignReachProps {
2
- hostId: string;
2
+ /**
3
+ * The org whose sends the rollups sit under, `orgs/{orgId}/campaigns/{id}/
4
+ * reports/*`; nothing is read until it is known.
5
+ */
6
+ orgId: string | null;
3
7
  /**
4
8
  * The campaign's emails, by send id — the handle both collections join on.
5
9
  *
@@ -33,9 +37,18 @@ export interface CampaignReachProps {
33
37
  * three times and would look exactly like a bigger number, so there is no
34
38
  * total here and the note under the figures says why.
35
39
  */
36
- export declare function CampaignConversionsSection(props: CampaignReachProps & {
37
- /** The marketing hub URL, for the site-wide conversions list. */
38
- basePath: string;
40
+ export declare function CampaignConversionsSection(props: Omit<CampaignReachProps, 'orgId'> & {
41
+ /**
42
+ * The site whose conversions are counted. Conversions are a site's
43
+ * visitors and are recorded under it, so on the org hub the caller picks
44
+ * one.
45
+ */
46
+ hostId: string;
47
+ /**
48
+ * That site's marketing hub URL, for its conversions list; `null` hides
49
+ * the link rather than pointing it nowhere.
50
+ */
51
+ basePath: string | null;
39
52
  }): import("react").JSX.Element;
40
53
  export declare namespace CampaignConversionsSection {
41
54
  var displayName: string;
@@ -140,8 +153,9 @@ export declare namespace CampaignDestinationsSection {
140
153
  *
141
154
  * A sequence joins a campaign from its own document, and the Outreach
142
155
  * runtime credits what each enrollment produced to the campaign's one
143
- * sequences report — `campaignSequenceReports/{campaignId}` on the host,
144
- * server-only like the conversions rollup. One keyed read, joined on the
156
+ * sequences report — `campaignSequenceReports/{campaignId}` on the org,
157
+ * server-only like the conversions rollup, and readable by org-wide members
158
+ * alone: a site collaborator is told it could not be read. One keyed read, joined on the
145
159
  * CONTAINER's id rather than on send ids: a sequence's emails are not the
146
160
  * campaign's sends, so the join the three sections above make does not
147
161
  * exist for it, and the report is written under the campaign directly.
@@ -150,7 +164,7 @@ export declare namespace CampaignDestinationsSection {
150
164
  * and says so in words rather than as five dashes.
151
165
  */
152
166
  export declare function CampaignSequencesSection(props: {
153
- hostId: string;
167
+ orgId: string | null;
154
168
  campaignId: string;
155
169
  }): import("react").JSX.Element;
156
170
  export declare namespace CampaignSequencesSection {
@@ -22,10 +22,11 @@ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.
22
22
  import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
23
23
  import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
24
24
  import { Alert, Button, Stack, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
25
- import { collection, doc, getCountFromServer, getDoc, query, where } from "firebase/firestore";
25
+ import { collection, getCountFromServer, getDoc, query, where } from "firebase/firestore";
26
26
  import { useCallback, useEffect, useMemo, useState } from "react";
27
27
  import { useFirestore } from "@aglyn/tenant-feature-instance";
28
- import { campaignRevenueAcrossSends, campaignSequencesReport, CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION, EMAIL_ATTRIBUTION_WINDOW_DAYS } from "@aglyn/shared-ui-email-campaigns/model";
28
+ import { campaignRevenueAcrossSends, campaignSequencesReport, CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, EMAIL_ATTRIBUTION_WINDOW_DAYS } from "@aglyn/shared-ui-email-campaigns/model";
29
+ import { campaignSendReportDoc, campaignSequenceReportDoc } from "./campaign-queries.js";
29
30
  /**
30
31
  * A CAMPAIGN BEYOND ITS MAIL — what it caused, what it earned, and where it
31
32
  * sent people.
@@ -195,7 +196,7 @@ import { campaignRevenueAcrossSends, campaignSequencesReport, CAMPAIGN_CONVERSIO
195
196
  }) : null
196
197
  ]
197
198
  }),
198
- /*#__PURE__*/ _jsx(Stack, {
199
+ basePath ? /*#__PURE__*/ _jsx(Stack, {
199
200
  direction: "row",
200
201
  children: /*#__PURE__*/ _jsx(Button, _extends({
201
202
  component: AppLink
@@ -208,7 +209,7 @@ import { campaignRevenueAcrossSends, campaignSequencesReport, CAMPAIGN_CONVERSIO
208
209
  color: "primary",
209
210
  children: 'All conversions'
210
211
  }))
211
- })
212
+ }) : null
212
213
  ]
213
214
  });
214
215
  }
@@ -287,7 +288,7 @@ CampaignConversionsSection.displayName = 'CampaignConversionsSection';
287
288
  * standing beside it, because a stale total under a fresh error is the one
288
289
  * arrangement that looks authoritative and is not.
289
290
  */ export function CampaignRevenueSection(props) {
290
- const { hostId, sendIds, truncated } = props;
291
+ const { orgId, sendIds, truncated } = props;
291
292
  const firestore = useFirestore();
292
293
  const key = idsKey(sendIds);
293
294
  const [busy, setBusy] = useState(false);
@@ -304,11 +305,11 @@ CampaignConversionsSection.displayName = 'CampaignConversionsSection';
304
305
  const load = useCallback(async ()=>{
305
306
  if (busy) return;
306
307
  const ids = key ? key.split(',') : [];
307
- if (!ids.length) return;
308
+ if (!ids.length || !orgId) return;
308
309
  setBusy(true);
309
310
  setError(null);
310
311
  try {
311
- const snapshots = await Promise.all(ids.map((id)=>getDoc(doc(firestore, 'hosts', hostId, 'campaigns', id, 'reports', 'revenue'))));
312
+ const snapshots = await Promise.all(ids.map((id)=>getDoc(campaignSendReportDoc(firestore, orgId, id, 'revenue'))));
312
313
  /*
313
314
  * `data()` is `undefined` on a document that does not exist, and it is
314
315
  * handed on as `undefined` rather than as an empty rollup: the merge
@@ -329,7 +330,7 @@ CampaignConversionsSection.displayName = 'CampaignConversionsSection';
329
330
  }, [
330
331
  busy,
331
332
  firestore,
332
- hostId,
333
+ orgId,
333
334
  key
334
335
  ]);
335
336
  const count = key ? key.split(',').length : 0;
@@ -483,7 +484,7 @@ CampaignRevenueSection.displayName = 'CampaignRevenueSection';
483
484
  * Clicks are EVENTS. One reader clicking twice counts twice, so a destination
484
485
  * total is not a count of people.
485
486
  */ export function CampaignDestinationsSection(props) {
486
- const { hostId, sendIds, truncated } = props;
487
+ const { orgId, sendIds, truncated } = props;
487
488
  const firestore = useFirestore();
488
489
  const key = idsKey(sendIds);
489
490
  const [busy, setBusy] = useState(false);
@@ -516,11 +517,11 @@ CampaignRevenueSection.displayName = 'CampaignRevenueSection';
516
517
  const load = useCallback(async ()=>{
517
518
  if (busy) return;
518
519
  const ids = key ? key.split(',') : [];
519
- if (!ids.length) return;
520
+ if (!ids.length || !orgId) return;
520
521
  setBusy(true);
521
522
  setError(null);
522
523
  try {
523
- const snapshots = await Promise.all(ids.map((id)=>getDoc(doc(firestore, 'hosts', hostId, 'campaigns', id, 'reports', 'links'))));
524
+ const snapshots = await Promise.all(ids.map((id)=>getDoc(campaignSendReportDoc(firestore, orgId, id, 'links'))));
524
525
  const byUrl = new Map();
525
526
  let overflowClicks = 0;
526
527
  let unattributedClicks = 0;
@@ -564,7 +565,7 @@ CampaignRevenueSection.displayName = 'CampaignRevenueSection';
564
565
  }, [
565
566
  busy,
566
567
  firestore,
567
- hostId,
568
+ orgId,
568
569
  key
569
570
  ]);
570
571
  const count = key ? key.split(',').length : 0;
@@ -695,8 +696,9 @@ CampaignDestinationsSection.displayName = 'CampaignDestinationsSection';
695
696
  *
696
697
  * A sequence joins a campaign from its own document, and the Outreach
697
698
  * runtime credits what each enrollment produced to the campaign's one
698
- * sequences report — `campaignSequenceReports/{campaignId}` on the host,
699
- * server-only like the conversions rollup. One keyed read, joined on the
699
+ * sequences report — `campaignSequenceReports/{campaignId}` on the org,
700
+ * server-only like the conversions rollup, and readable by org-wide members
701
+ * alone: a site collaborator is told it could not be read. One keyed read, joined on the
700
702
  * CONTAINER's id rather than on send ids: a sequence's emails are not the
701
703
  * campaign's sends, so the join the three sections above make does not
702
704
  * exist for it, and the report is written under the campaign directly.
@@ -704,7 +706,7 @@ CampaignDestinationsSection.displayName = 'CampaignDestinationsSection';
704
706
  * Absent is not zero: a campaign no sequence was ever in has no document,
705
707
  * and says so in words rather than as five dashes.
706
708
  */ export function CampaignSequencesSection(props) {
707
- const { hostId, campaignId } = props;
709
+ const { orgId, campaignId } = props;
708
710
  const firestore = useFirestore();
709
711
  const [rollup, setRollup] = useState(null);
710
712
  const [failed, setFailed] = useState(false);
@@ -712,7 +714,8 @@ CampaignDestinationsSection.displayName = 'CampaignDestinationsSection';
712
714
  let active = true;
713
715
  setRollup(null);
714
716
  setFailed(false);
715
- getDoc(doc(firestore, 'hosts', hostId, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION, campaignId)).then((snapshot)=>{
717
+ if (!orgId) return undefined;
718
+ getDoc(campaignSequenceReportDoc(firestore, orgId, campaignId)).then((snapshot)=>{
716
719
  var _snapshot_data;
717
720
  if (active) setRollup((_snapshot_data = snapshot.data()) != null ? _snapshot_data : undefined);
718
721
  }).catch(()=>{
@@ -724,7 +727,7 @@ CampaignDestinationsSection.displayName = 'CampaignDestinationsSection';
724
727
  };
725
728
  }, [
726
729
  firestore,
727
- hostId,
730
+ orgId,
728
731
  campaignId
729
732
  ]);
730
733
  const report = rollup === null ? null : campaignSequencesReport(rollup);