@aglyn/plugins-marketing 1.0.0-beta.143

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 (183) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +56 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
  8. package/src/lib/components/announcement-bar-card.component.js +292 -0
  9. package/src/lib/components/announcement-bar-card.component.js.map +1 -0
  10. package/src/lib/components/campaign-composer.d.ts +84 -0
  11. package/src/lib/components/campaign-composer.js +1643 -0
  12. package/src/lib/components/campaign-composer.js.map +1 -0
  13. package/src/lib/components/campaign-conversions-card.d.ts +22 -0
  14. package/src/lib/components/campaign-conversions-card.js +642 -0
  15. package/src/lib/components/campaign-conversions-card.js.map +1 -0
  16. package/src/lib/components/campaign-detail-card.d.ts +55 -0
  17. package/src/lib/components/campaign-detail-card.js +771 -0
  18. package/src/lib/components/campaign-detail-card.js.map +1 -0
  19. package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
  20. package/src/lib/components/campaign-edit-drawer.js +252 -0
  21. package/src/lib/components/campaign-edit-drawer.js.map +1 -0
  22. package/src/lib/components/campaign-email-zones.d.ts +111 -0
  23. package/src/lib/components/campaign-email-zones.js +54 -0
  24. package/src/lib/components/campaign-email-zones.js.map +1 -0
  25. package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
  26. package/src/lib/components/campaign-glance-card.component.js +151 -0
  27. package/src/lib/components/campaign-glance-card.component.js.map +1 -0
  28. package/src/lib/components/campaign-members-section.d.ts +87 -0
  29. package/src/lib/components/campaign-members-section.js +499 -0
  30. package/src/lib/components/campaign-members-section.js.map +1 -0
  31. package/src/lib/components/campaign-reach-sections.d.ts +137 -0
  32. package/src/lib/components/campaign-reach-sections.js +694 -0
  33. package/src/lib/components/campaign-reach-sections.js.map +1 -0
  34. package/src/lib/components/campaign-report-card.d.ts +91 -0
  35. package/src/lib/components/campaign-report-card.js +667 -0
  36. package/src/lib/components/campaign-report-card.js.map +1 -0
  37. package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
  38. package/src/lib/components/campaign-test-send-drawer.js +238 -0
  39. package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
  40. package/src/lib/components/campaigns-card.d.ts +31 -0
  41. package/src/lib/components/campaigns-card.js +667 -0
  42. package/src/lib/components/campaigns-card.js.map +1 -0
  43. package/src/lib/components/conversion-attribution.component.d.ts +62 -0
  44. package/src/lib/components/conversion-attribution.component.js +127 -0
  45. package/src/lib/components/conversion-attribution.component.js.map +1 -0
  46. package/src/lib/components/email-compose-card.d.ts +12 -0
  47. package/src/lib/components/email-compose-card.js +157 -0
  48. package/src/lib/components/email-compose-card.js.map +1 -0
  49. package/src/lib/components/email-detail.d.ts +40 -0
  50. package/src/lib/components/email-detail.js +1180 -0
  51. package/src/lib/components/email-detail.js.map +1 -0
  52. package/src/lib/components/email-edit-drawer.d.ts +54 -0
  53. package/src/lib/components/email-edit-drawer.js +172 -0
  54. package/src/lib/components/email-edit-drawer.js.map +1 -0
  55. package/src/lib/components/email-messages-widget.d.ts +22 -0
  56. package/src/lib/components/email-messages-widget.js +49 -0
  57. package/src/lib/components/email-messages-widget.js.map +1 -0
  58. package/src/lib/components/email-recipients-card.d.ts +50 -0
  59. package/src/lib/components/email-recipients-card.js +338 -0
  60. package/src/lib/components/email-recipients-card.js.map +1 -0
  61. package/src/lib/components/emails-list-card.d.ts +36 -0
  62. package/src/lib/components/emails-list-card.js +544 -0
  63. package/src/lib/components/emails-list-card.js.map +1 -0
  64. package/src/lib/components/experiment-zones.d.ts +78 -0
  65. package/src/lib/components/experiment-zones.js +65 -0
  66. package/src/lib/components/experiment-zones.js.map +1 -0
  67. package/src/lib/components/host-experiments-card.component.d.ts +17 -0
  68. package/src/lib/components/host-experiments-card.component.js +887 -0
  69. package/src/lib/components/host-experiments-card.component.js.map +1 -0
  70. package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
  71. package/src/lib/components/host-marketing-summary-card.component.js +161 -0
  72. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
  73. package/src/lib/components/host-overlays-card.component.d.ts +19 -0
  74. package/src/lib/components/host-overlays-card.component.js +783 -0
  75. package/src/lib/components/host-overlays-card.component.js.map +1 -0
  76. package/src/lib/components/marketing-console-page.d.ts +28 -0
  77. package/src/lib/components/marketing-console-page.js +166 -0
  78. package/src/lib/components/marketing-console-page.js.map +1 -0
  79. package/src/lib/components/marketing-console-sections.d.ts +34 -0
  80. package/src/lib/components/marketing-console-sections.js +77 -0
  81. package/src/lib/components/marketing-console-sections.js.map +1 -0
  82. package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
  83. package/src/lib/components/overlay-stats-row.component.js +78 -0
  84. package/src/lib/components/overlay-stats-row.component.js.map +1 -0
  85. package/src/lib/components/popup-card.component.d.ts +18 -0
  86. package/src/lib/components/popup-card.component.js +462 -0
  87. package/src/lib/components/popup-card.component.js.map +1 -0
  88. package/src/lib/components/record-attribution-widget.d.ts +35 -0
  89. package/src/lib/components/record-attribution-widget.js +33 -0
  90. package/src/lib/components/record-attribution-widget.js.map +1 -0
  91. package/src/lib/components/site-runtime.d.ts +9 -0
  92. package/src/lib/components/site-runtime.js +1081 -0
  93. package/src/lib/components/site-runtime.js.map +1 -0
  94. package/src/lib/components/use-campaign-send-api.d.ts +35 -0
  95. package/src/lib/components/use-campaign-send-api.js +99 -0
  96. package/src/lib/components/use-campaign-send-api.js.map +1 -0
  97. package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
  98. package/src/lib/components/use-campaign-topic-options.js +44 -0
  99. package/src/lib/components/use-campaign-topic-options.js.map +1 -0
  100. package/src/lib/components/use-emails-hub-path.d.ts +39 -0
  101. package/src/lib/components/use-emails-hub-path.js +70 -0
  102. package/src/lib/components/use-emails-hub-path.js.map +1 -0
  103. package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
  104. package/src/lib/components/use-marketing-hub-path.js +50 -0
  105. package/src/lib/components/use-marketing-hub-path.js.map +1 -0
  106. package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
  107. package/src/lib/components/use-overlay-copy-editor.js +110 -0
  108. package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
  109. package/src/lib/constants/bundle-common.d.ts +8 -0
  110. package/src/lib/constants/bundle-common.js +9 -0
  111. package/src/lib/constants/bundle-common.js.map +1 -0
  112. package/src/lib/model/campaign-membership-figures.d.ts +109 -0
  113. package/src/lib/model/campaign-membership-figures.js +113 -0
  114. package/src/lib/model/campaign-membership-figures.js.map +1 -0
  115. package/src/lib/model/compile-client-automations.d.ts +65 -0
  116. package/src/lib/model/compile-client-automations.js +95 -0
  117. package/src/lib/model/compile-client-automations.js.map +1 -0
  118. package/src/lib/model/experiments.d.ts +130 -0
  119. package/src/lib/model/experiments.js +186 -0
  120. package/src/lib/model/experiments.js.map +1 -0
  121. package/src/lib/model/index.d.ts +26 -0
  122. package/src/lib/model/index.js +26 -0
  123. package/src/lib/model/index.js.map +1 -0
  124. package/src/lib/model/marketing-record-routes.d.ts +6 -0
  125. package/src/lib/model/marketing-record-routes.js +46 -0
  126. package/src/lib/model/marketing-record-routes.js.map +1 -0
  127. package/src/lib/model/overlay-copy.d.ts +62 -0
  128. package/src/lib/model/overlay-copy.js +78 -0
  129. package/src/lib/model/overlay-copy.js.map +1 -0
  130. package/src/lib/model/overlays.d.ts +113 -0
  131. package/src/lib/model/overlays.js +93 -0
  132. package/src/lib/model/overlays.js.map +1 -0
  133. package/src/lib/model/site-contract.d.ts +100 -0
  134. package/src/lib/model/site-contract.js +23 -0
  135. package/src/lib/model/site-contract.js.map +1 -0
  136. package/src/lib/plugin.d.ts +27 -0
  137. package/src/lib/plugin.js +168 -0
  138. package/src/lib/plugin.js.map +1 -0
  139. package/src/lib/preview-props.d.ts +31 -0
  140. package/src/lib/preview-props.js +54 -0
  141. package/src/lib/preview-props.js.map +1 -0
  142. package/src/lib/server/campaign-manage.d.ts +62 -0
  143. package/src/lib/server/campaign-manage.js +829 -0
  144. package/src/lib/server/campaign-manage.js.map +1 -0
  145. package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
  146. package/src/lib/server/campaign-process-scheduled.js +198 -0
  147. package/src/lib/server/campaign-process-scheduled.js.map +1 -0
  148. package/src/lib/server/campaign-recipients.d.ts +55 -0
  149. package/src/lib/server/campaign-recipients.js +219 -0
  150. package/src/lib/server/campaign-recipients.js.map +1 -0
  151. package/src/lib/server/campaign-send.d.ts +487 -0
  152. package/src/lib/server/campaign-send.js +3157 -0
  153. package/src/lib/server/campaign-send.js.map +1 -0
  154. package/src/lib/server/email-campaign-reach.d.ts +174 -0
  155. package/src/lib/server/email-campaign-reach.js +254 -0
  156. package/src/lib/server/email-campaign-reach.js.map +1 -0
  157. package/src/lib/server/email-events.d.ts +27 -0
  158. package/src/lib/server/email-events.js +714 -0
  159. package/src/lib/server/email-events.js.map +1 -0
  160. package/src/lib/server/get-client-automations.d.ts +60 -0
  161. package/src/lib/server/get-client-automations.js +87 -0
  162. package/src/lib/server/get-client-automations.js.map +1 -0
  163. package/src/lib/server/get-overlays.d.ts +28 -0
  164. package/src/lib/server/get-overlays.js +65 -0
  165. package/src/lib/server/get-overlays.js.map +1 -0
  166. package/src/lib/server/get-screen-experiments.d.ts +42 -0
  167. package/src/lib/server/get-screen-experiments.js +82 -0
  168. package/src/lib/server/get-screen-experiments.js.map +1 -0
  169. package/src/lib/server/lists-materialize.d.ts +18 -0
  170. package/src/lib/server/lists-materialize.js +130 -0
  171. package/src/lib/server/lists-materialize.js.map +1 -0
  172. package/src/lib/server/marketing-figures.d.ts +37 -0
  173. package/src/lib/server/marketing-figures.js +248 -0
  174. package/src/lib/server/marketing-figures.js.map +1 -0
  175. package/src/lib/server/site-page-enricher.d.ts +26 -0
  176. package/src/lib/server/site-page-enricher.js +210 -0
  177. package/src/lib/server/site-page-enricher.js.map +1 -0
  178. package/src/lib/server.d.ts +20 -0
  179. package/src/lib/server.js +152 -0
  180. package/src/lib/server.js.map +1 -0
  181. package/src/lib/site.d.ts +22 -0
  182. package/src/lib/site.js +48 -0
  183. package/src/lib/site.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaigns-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { buildRoute, createResourceUid, pluginDocsHelp, Route } from '@aglyn/aglyn'\n/*\n * The MODULE, not the barrel, for the two PURE helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { mdiDeleteOutline, mdiEyeOutline } from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport { collection, doc, limit, query, setDoc } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignListRows,\n type CampaignAggregate,\n type CampaignListRow,\n type CampaignSend,\n type EmailCampaign,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { useCampaignManageApi } from './use-campaign-send-api'\nimport { useCampaignTopicOptions } from './use-campaign-topic-options'\n\n/**\n * How many sends the list reads.\n *\n * A CEILING, not a page size — see the query, which explains why this list\n * cannot be paged by the server until a send carries one date field every\n * writer stamps.\n */\nconst CAMPAIGN_CEILING = 30\n\n/** How many campaign containers the list reads. */\nconst CONTAINER_CEILING = 50\n\nconst formatDay = (ms: number | null): string =>\n ms === null ? '' : new Date(ms).toLocaleDateString()\n\n/** A rolled-up figure, or an em dash where nothing recorded one. */\nconst figure = (value: CampaignAggregate): string =>\n value.value === null ? '—' : value.value.toLocaleString()\n\nconst WINDOW_LABEL: Record<CampaignListRow['windowState'], string> = {\n undated: 'No dates',\n upcoming: 'Upcoming',\n running: 'Running',\n ended: 'Ended',\n}\n\n/**\n * THE CAMPAIGNS LIST.\n *\n * A campaign is a container — a name, a window, the lists it is aimed at, and\n * the emails sent inside it — so this is a table of campaigns and not of\n * messages. Composing is not here: a message belongs to a campaign, and the\n * campaign's own page is where one is written.\n *\n * The rows come from two collections, and the second is why this reads\n * without a migration. `emailCampaigns` holds the containers.\n * `campaigns` holds every send, including thousands written before containers\n * existed; those carry no container id, and `campaignListRows` presents each\n * as a campaign of one rather than dropping it. Nothing is rewritten, so no\n * unsubscribe link and no pasted report URL stops resolving.\n */\nexport function HostCampaignsCard(props: {\n hostId: string\n /**\n * The marketing hub URL, when the caller already has it.\n *\n * Resolved from the host route when it does not: the inbox console page\n * embeds this card on a tab of its own, and its `basePath` names the INBOX\n * hub. Deriving one hub's URL from another's by string surgery is the kind\n * of link that breaks silently when a surface moves.\n */\n basePath?: string\n}) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { orgSlug, subdomain } = useConsoleHostRoute(hostId)\n const hubPath =\n basePath ??\n (orgSlug && subdomain\n ? buildRoute(Route.HOST_PLUGIN, {\n orgSlug,\n host: subdomain,\n pluginSlug: 'marketing',\n })\n : null)\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const manageApi = useCampaignManageApi(hostId)\n const { scope: dataScope } = useOrgDataScope({ hostId })\n\n /*\n * ORDERED AND CEILINGED, and not orderable on any DATE.\n *\n * No field here is on every send: a sent one is written `{status:'sent',\n * sentAt}` and a scheduled one `{status:'scheduled', sendAtMs}`, by the two\n * branches of `campaign-send.ts`, and there is no `createdAt` at all.\n * `orderBy` on either would not mis-sort the list, it would DROP half of it.\n *\n * A bare `limit(30)` is answered in DOCUMENT-ID order, so the window is\n * thirty sends chosen by id and then sorted by date, which reads as the most\n * recent thirty and is not. `collectionCeiling` returns that same thirty —\n * document-id order is what the bare cap already gave — but it says so,\n * which is what stops the next edit reaching for `sentAt`, and it probes one\n * past the ceiling so the reader is told the history is longer.\n */\n const { data: sendDocs } = useFirestoreCollection<any>(\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'campaigns'),\n CAMPAIGN_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow<any>(\n sendDocs,\n CAMPAIGN_CEILING,\n )\n\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'emailCampaigns'),\n CONTAINER_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { rows: readCampaigns, truncated: campaignsTruncated } =\n ceilingedWindow<any>(campaignDocs, CONTAINER_CEILING)\n\n // Org email lists, so a row can name what it is aimed at rather than\n // showing ids.\n const { data: listDocs } = useFirestoreCollection<any>(\n () =>\n dataScope\n ? query(\n collection(firestore, dataScope[0], dataScope[1], 'lists'),\n limit(50),\n )\n : null,\n [firestore, dataScope],\n { idField: '$id' },\n )\n const [createOpen, setCreateOpen] = useState(false)\n /*\n * The org's topic catalog, read only while the CREATE DRAWER is open.\n *\n * It fills one picker in that drawer and is drawn nowhere in the table, so\n * a reader who came to look at their campaigns must not pay for it — the\n * same rule the emails list already applies to the campaigns it offers its\n * own drawer, and the one the audiences section applies to a list's\n * members. It is the largest read on this section at 200 documents, and it\n * is the only one nothing on screen needs.\n */\n const { topics, source: topicSource } = useCampaignTopicOptions(hostId, {\n enabled: createOpen,\n })\n const topicOptions = useMemo(\n () =>\n topics.map((topic) => ({\n value: topic.id,\n label: topic.name,\n })),\n [topics],\n )\n\n const listNames = useMemo(() => {\n const names = new Map<string, string>()\n for (const list of listDocs ?? []) {\n names.set(String(list.$id), String(list.name ?? list.$id))\n }\n return names\n }, [listDocs])\n\n const listOptions = useMemo(\n () =>\n (listDocs ?? []).map((list: any) => ({\n value: String(list.$id),\n label: String(list.name ?? list.$id),\n })),\n [listDocs],\n )\n\n const rows = useMemo(() => {\n const campaigns = (readCampaigns as EmailCampaign[]).filter(\n (campaign: any) => !campaign.deletedAt,\n )\n return campaignListRows(\n campaigns,\n readSends as CampaignSend[],\n Date.now(),\n ).map((row) => ({ ...row, $id: row.id }))\n }, [readCampaigns, readSends])\n\n const [createError, setCreateError] = useState<string | null>(null)\n const [creating, setCreating] = useState(false)\n /** The campaign a delete is in flight for, so its row menu can say so. */\n const [deletingId, setDeletingId] = useState('')\n\n const openCampaign = useCallback(\n (id: string) => {\n if (hubPath) void router.push(`${hubPath}/campaigns/${id}`)\n },\n [hubPath, router],\n )\n\n const handleCreate = useCallback(\n async (values: Record<string, any>) => {\n if (creating) return\n /*\n * `displayName` is the shared drawer's own field, and every artifact\n * create in the console collects a name under that key. The campaign\n * stores it as `name` — the container has one name and no second\n * internal label — so the mapping happens here rather than by giving\n * this one drawer a field the others do not have.\n */\n const name = String(values.displayName ?? '').trim()\n if (!name) return\n const startAtMs = values.startAt ? Date.parse(String(values.startAt)) : null\n const endAtMs = values.endAt ? Date.parse(String(values.endAt)) : null\n // Refused here rather than stored and rendered as a campaign that ends\n // before it starts, which no window state describes.\n if (startAtMs !== null && endAtMs !== null && endAtMs < startAtMs) {\n return setCreateError('The end date is before the start date')\n }\n setCreating(true)\n setCreateError(null)\n const id = createResourceUid()\n try {\n await setDoc(doc(firestore, 'hosts', hostId, 'emailCampaigns', id), {\n name,\n ...(Number.isFinite(startAtMs) && startAtMs !== null\n ? { startAtMs }\n : {}),\n ...(Number.isFinite(endAtMs) && endAtMs !== null ? { endAtMs } : {}),\n listIds: Array.isArray(values.listIds)\n ? values.listIds.map(String)\n : [],\n ...(values.topicId ? { topicId: String(values.topicId) } : {}),\n createdAtMs: Date.now(),\n createdBy: String((user as any)?.uid ?? ''),\n })\n setCreateOpen(false)\n openCampaign(id)\n } catch (error) {\n console.error(error)\n setCreateError('The campaign could not be created')\n enqueueSnackbar('Could not create the campaign', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setCreating(false)\n }\n },\n [creating, firestore, hostId, user, enqueueSnackbar, openCampaign],\n )\n\n const campaignHref = useCallback(\n (id: string) => (hubPath ? `${hubPath}/campaigns/${id}` : null),\n [hubPath],\n )\n\n /*==========================================\n * DELETING A CAMPAIGN FROM ITS ROW.\n *\n * The container goes and its emails stay — the route detaches them first,\n * so each one keeps its id, its report and its unsubscribe links, and\n * reappears in this very table as a \"Single send\". That is not a\n * convenience: a send id is inside the HMAC of every unsubscribe footer\n * already delivered, so a delete that took the sends with it would break\n * opt-outs that must go on working.\n *\n * The confirmation names the count and says what the delete does NOT do.\n * Deleting a campaign cancels nothing — a scheduled email inside it still\n * goes out — and a merchant who reads the button as \"stop this campaign\"\n * has to be told before they press it.\n *\n * EDITING is deliberately not offered here. A record is edited on its own\n * page in this console; the campaign's page carries the edit drawer, and\n * this row's first entry is the way to it.\n *=========================================*/\n const handleDelete = useCallback(\n async (row: CampaignListRow) => {\n if (deletingId) return\n const emails = row.sends.length\n /*\n * Emails with mail still to go: waiting for their time, AND part way\n * through an audience larger than one batch. Counting only `scheduled`\n * would miss the case that needs the warning most — an email delivering\n * right now is stored as `scheduled` too, and `rollup.scheduled` is the\n * narrower reading that leaves it out.\n */\n const unfinished = row.rollup.scheduled + row.rollup.sending\n const agreed = await confirm({\n title: `Delete ${row.name}?`,\n description:\n (emails\n ? `The ${emails.toLocaleString()} ` +\n `${emails === 1 ? 'email' : 'emails'} in it are kept — each ` +\n 'keeps its own report and its unsubscribe links, and they stay ' +\n 'in this list as single sends. '\n : 'It holds no emails. ') +\n (unfinished\n ? `${unfinished.toLocaleString()} of them ${\n unfinished === 1 ? 'is' : 'are'\n } still going out or still due, and deleting the campaign does ` +\n 'not stop that — the sender picks each one up from its own ' +\n 'record. Stop a send from its own page. '\n : '') +\n 'Any screens, forms and contacts assigned to it come out of it and ' +\n 'are otherwise untouched. Only the campaign itself goes.',\n confirmationText: 'Delete campaign',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDeletingId(row.id)\n try {\n const { response, payload } = await manageApi({\n action: 'deleteCampaign',\n campaignId: row.id,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The campaign could not be deleted',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Campaign deleted', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The campaign could not be deleted', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDeletingId('')\n }\n },\n [confirm, deletingId, enqueueSnackbar, manageApi],\n )\n\n const columns = useMemo(\n () => [\n {\n field: 'name',\n headerName: 'Campaign',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => (\n <Stack\n direction=\"row\"\n spacing={1}\n /*\n `minWidth: 0` on the row AND on the name: a flex child will not\n shrink below its content without it, so the chip beside it is\n what gets clipped instead of the name being ellipsed.\n */\n sx={{ alignItems: 'center', minWidth: 0, width: 1 }}\n >\n {/*\n The name is a real anchor as well as the row being clickable, so\n a campaign can be middle-clicked into a tab or copied out of the\n context menu — affordances a row handler cannot offer. The grid's\n own row click fires too, so the link stops the event rather than\n pushing the same route twice.\n */}\n {campaignHref(row.id) ? (\n <AppLink\n href={campaignHref(row.id) as string}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n style={{ minWidth: 0 }}\n >\n <Typography variant=\"body2\" noWrap>\n {row.name}\n </Typography>\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap sx={{ minWidth: 0 }}>\n {row.name}\n </Typography>\n )}\n {row.legacy ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label=\"Single send\"\n // A label that reads \"Single sen\" is worse than a shorter name.\n sx={{ flexShrink: 0 }}\n title={\n 'Sent before campaigns grouped their emails. Its report ' +\n 'and its unsubscribe links are unchanged.'\n }\n />\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'windowState',\n headerName: 'Window',\n width: 170,\n renderCell: ({ row }: any) => {\n const start = formatDay(row.startAtMs)\n const end = formatDay(row.endAtMs)\n const range =\n start && end && start !== end\n ? `${start} – ${end}`\n : start || end || ''\n return (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {WINDOW_LABEL[row.windowState as CampaignListRow['windowState']]}\n </Typography>\n {range ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {range}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n field: 'listIds',\n headerName: 'Lists',\n width: 140,\n sortable: false,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.listIds.length\n ? row.listIds\n .map((id: string) => listNames.get(id) ?? id)\n .join(', ')\n : '—'}\n </Typography>\n ),\n },\n /*\n THE FOUR COUNTS, RIGHT-ALIGNED HEAD AND BODY.\n A figure is read by its last digit, so a column of them lines up on\n the right or it does not line up at all. `headerAlign` has to be said\n as well as `align`: a grid column defaults its header to the column\n type's alignment rather than to the cell's, which is how these came to\n sit with left headers over figures nobody could compare down the page.\n\n EVERY ONE OF THEM MEASURES THE MAIL, and the headings say so. What a\n campaign CAUSED — the submissions, leads, contacts and bookings\n credited to it — is not a column here, because there is no figure to\n read: a container has no conversion counter of its own, so a column\n would be an aggregation per row for a campaign and a subcollection\n document per row for a single send, on a list that costs two queries\n however many campaigns it holds. Headings that named the campaign over\n figures that all described its mail were the cheaper mistake, so the\n headings name the mail and the note under the table says where the\n outcome figures are.\n */\n {\n field: 'emails',\n headerName: 'Emails',\n width: 128,\n align: 'right',\n headerAlign: 'right',\n /*\n THE COUNT, AND WHAT IS STILL HAPPENING TO IT.\n\n `sending` is called out apart from `scheduled` because the two are\n opposite readings of the same stored status: an email delivering an\n audience larger than one batch sits as `scheduled` between runs, and\n rolling it in with the ones that have mailed nobody reports a\n campaign that is half delivered as a campaign that has not started.\n\n The note sits UNDER the figure rather than beside it — the column\n would otherwise have to be wide enough for the longest sentence, and\n the figure is what the column is read for.\n */\n renderCell: ({ row }: any) => {\n const { sends, sending, scheduled } = row.rollup\n const note = [\n sending ? `${sending} sending` : '',\n scheduled ? `${scheduled} scheduled` : '',\n ]\n .filter(Boolean)\n .join(', ')\n return (\n <Stack sx={{ alignItems: 'flex-end', minWidth: 0, width: 1 }}>\n <Typography variant=\"body2\">{String(sends)}</Typography>\n {note ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {note}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n field: 'sent',\n headerName: 'Mail sent',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.sent)}</Typography>\n ),\n },\n {\n field: 'opens',\n headerName: 'Mail opens',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.opens)}</Typography>\n ),\n },\n {\n field: 'clicks',\n headerName: 'Mail clicks',\n width: 112,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">{figure(row.rollup.clicks)}</Typography>\n ),\n },\n /*\n THE TRAILING CLUSTER, through the grid's own actions column.\n `listActionsColumn` is what every other grid list in the console\n renders, so the reader cannot tell which family of table they are\n standing in — the hand-rolled tables on this surface reach the same\n `RowActionsMenu` from the other direction.\n\n Opening the campaign is restated here even though the row click does\n the same thing, for the reason the screens table restates its own: the\n menu is where a row's actions are NAMED, and an action absent from it\n reads as an action the row does not have.\n\n A \"Single send\" row is a SEND with no container — the table adopts it\n so a merchant's history is not missing — and there is no container\n under it to delete. Its entry is shown disabled with that reason\n rather than hidden, because a control that vanishes and one that does\n not apply look identical from the outside.\n */\n listActionsColumn(\n (row: any) => (\n <ListRowActions\n label={String(row.name ?? row.id)}\n items={[\n {\n key: 'details',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row.id) ?? undefined,\n disabled: !campaignHref(row.id),\n disabledReason: campaignHref(row.id)\n ? undefined\n : 'This site’s console URL has not resolved yet',\n },\n {\n key: 'delete',\n label: 'Delete campaign',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: row.legacy || Boolean(deletingId),\n disabledReason: row.legacy\n ? 'This is a single send, not a campaign. Open it to ' +\n 'manage the email itself.'\n : 'A campaign is being deleted',\n onClick: () => void handleDelete(row as CampaignListRow),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ],\n [listNames, campaignHref, deletingId, handleDelete],\n )\n\n return (\n <CardDisplay\n header={'Campaigns'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: '#campaigns-group-emails',\n excerpt:\n 'A campaign groups the emails you send to a set of lists over a ' +\n 'window of dates. Open one to write and send an email inside it.',\n })}\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={creating}\n onClick={() => {\n setCreateError(null)\n setCreateOpen(true)\n }}\n >\n {creating ? 'Creating…' : 'Create campaign'}\n </Button>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n <ListTable\n rows={rows}\n columns={columns as any}\n onOpen={(id) => openCampaign(id)}\n noRowsLabel=\"No campaigns yet\"\n noRowsDescription=\"A campaign groups the emails you send to a set of lists.\"\n noRowsAction={\n <Button variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'Create campaign'}\n </Button>\n }\n />\n {/*\n WHAT THIS TABLE MEASURES, said rather than inferred from the column\n names. Every figure in it is a fact about the campaign's mail, and a\n reader comparing rows is comparing mailings — the conversions and the\n destinations credited to a campaign are on the campaign's own page,\n because counting them for every row is a read per row on a list whose\n whole cost is two queries.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These figures measure each campaign’s mail. What a campaign ' +\n 'caused — the form submissions, leads, contacts and bookings ' +\n 'credited to it — and the pages it sent people to are on the ' +\n 'campaign’s own page.'}\n </Typography>\n {sendsTruncated || campaignsTruncated ? (\n <Alert severity=\"info\">\n {`Showing the most recent ${CONTAINER_CEILING} campaigns and ` +\n `${CAMPAIGN_CEILING} sends. This site has more — the ones ` +\n 'listed are not necessarily the most recent, because a send ' +\n 'carries no date field that every writer stamps.'}\n </Alert>\n ) : null}\n </Stack>\n {/*\n The console's own create drawer, from the shared library rather than a\n second one that looks like it. What a campaign collects beyond a name\n is its window, the lists it is aimed at and the stream its emails open\n on; the description box is left off because the container stores none,\n and a field the writer discards is worse than one never offered.\n */}\n {/* Draws nothing: the zone the topic catalog's owner answers through. */}\n {topicSource}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create campaign\"\n submitLabel=\"Create campaign\"\n includeDescription={false}\n onSubmit={handleCreate}\n extraFields={campaignFields(listOptions, topicOptions)}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {createError}\n </Alert>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n/**\n * What a campaign collects beyond its name.\n *\n * Data-driven-forms field descriptors, built from the org's own lists and\n * topics so the drawer offers what this workspace actually has. Dates are\n * plain `date` inputs: a campaign window is a day at each end, and a time of\n * day would be a precision the model does not carry.\n */\nfunction campaignFields(\n lists: Array<{ value: string; label: string }>,\n topics: Array<{ value: string; label: string }>,\n): any[] {\n return [\n {\n component: 'text-field',\n name: 'startAt',\n label: 'Starts',\n type: 'date',\n helperText: 'When the campaign window opens',\n },\n {\n component: 'text-field',\n name: 'endAt',\n label: 'Ends',\n type: 'date',\n helperText: 'Leave empty for an open-ended campaign',\n InputLabelProps: { shrink: true },\n },\n {\n component: 'select',\n name: 'listIds',\n label: 'Lists',\n multiple: true,\n initialValue: [],\n // A campaign with no list is legitimate: its emails can go to leads, to\n // site members, or to a segment. The lists are what it is AIMED at.\n helperText: 'The lists this campaign is aimed at',\n disableDefaultOption: true,\n options: lists,\n },\n {\n component: 'select',\n name: 'topicId',\n label: 'Topic',\n helperText: 'The stream its emails open on — each one can change it',\n disableDefaultOption: true,\n options: topics,\n },\n ]\n}\n\nHostCampaignsCard.displayName = 'HostCampaignsCard'\n\nexport default HostCampaignsCard\n"],"names":["buildRoute","createResourceUid","pluginDocsHelp","Route","ceilingedWindow","collectionCeiling","mdiDeleteOutline","mdiEyeOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListRowActions","ListTable","listActionsColumn","useSnackbar","Alert","Button","Chip","Stack","Typography","collection","doc","limit","query","setDoc","useCallback","useMemo","useState","useRouter","useConsoleHostRoute","useFirestore","useFirestoreCollection","useOrgDataScope","useUser","campaignListRows","CreateArtifactDrawer","useCampaignManageApi","useCampaignTopicOptions","CAMPAIGN_CEILING","CONTAINER_CEILING","formatDay","ms","Date","toLocaleDateString","figure","value","toLocaleString","WINDOW_LABEL","undated","upcoming","running","ended","HostCampaignsCard","props","hostId","basePath","firestore","router","orgSlug","subdomain","hubPath","HOST_PLUGIN","host","pluginSlug","data","user","enqueueSnackbar","confirm","manageApi","scope","dataScope","sendDocs","idField","rows","readSends","truncated","sendsTruncated","campaignDocs","readCampaigns","campaignsTruncated","listDocs","createOpen","setCreateOpen","topics","source","topicSource","enabled","topicOptions","map","topic","id","label","name","listNames","names","Map","list","set","String","$id","listOptions","campaigns","filter","campaign","deletedAt","now","row","createError","setCreateError","creating","setCreating","deletingId","setDeletingId","openCampaign","push","handleCreate","values","displayName","trim","startAtMs","startAt","parse","endAtMs","endAt","Number","isFinite","listIds","Array","isArray","topicId","createdAtMs","createdBy","uid","error","console","variant","allowDuplicate","campaignHref","handleDelete","emails","sends","length","unfinished","rollup","scheduled","sending","agreed","title","description","confirmationText","then","catch","response","payload","action","campaignId","ok","persist","columns","field","headerName","flex","minWidth","renderCell","direction","spacing","sx","alignItems","width","href","onClick","event","stopPropagation","style","noWrap","legacy","size","flexShrink","start","end","range","windowState","color","sortable","get","join","align","headerAlign","note","Boolean","sent","opens","clicks","items","key","icon","path","undefined","disabled","disabledReason","destructive","header","help","anchor","excerpt","HeaderProps","contentGutterX","contentGutterY","onOpen","noRowsLabel","noRowsDescription","noRowsAction","severity","open","onClose","submitLabel","includeDescription","onSubmit","extraFields","campaignFields","errorSlot","mt","mb","lists","component","type","helperText","InputLabelProps","shrink","multiple","initialValue","disableDefaultOption","options"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SAASA,UAAU,EAAEC,iBAAiB,EAAEC,cAAc,EAAEC,KAAK,QAAQ,eAAc;AACnF;;;;CAIC,GACD,SACEC,eAAe,EACfC,iBAAiB,QACZ,+DAA8D;AACrE,SAASC,gBAAgB,EAAEC,aAAa,QAAQ,yBAAwB;AACxE,SACEC,OAAO,EACPC,WAAW,EACXC,OAAO,EACPC,sBAAsB,QACjB,uBAAsB;AAC7B,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAC7D,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAASC,KAAK,EAAEC,MAAM,EAAEC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACtE,SAASC,UAAU,EAAEC,GAAG,EAAEC,KAAK,EAAEC,KAAK,EAAEC,MAAM,QAAQ,qBAAoB;AAC1E,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SACEC,mBAAmB,EACnBC,YAAY,EACZC,sBAAsB,EACtBC,eAAe,EACfC,OAAO,QACF,iCAAgC;AACvC,SACEC,gBAAgB,QAKX,yCAAwC;AAC/C,SAASC,oBAAoB,QAAQ,6BAA4B;AACjE,SAASC,oBAAoB,QAAQ,6BAAyB;AAC9D,SAASC,uBAAuB,QAAQ,kCAA8B;AAEtE;;;;;;CAMC,GACD,MAAMC,mBAAmB;AAEzB,iDAAiD,GACjD,MAAMC,oBAAoB;AAE1B,MAAMC,YAAY,CAACC,KACjBA,OAAO,OAAO,KAAK,IAAIC,KAAKD,IAAIE,kBAAkB;AAEpD,kEAAkE,GAClE,MAAMC,SAAS,CAACC,QACdA,MAAMA,KAAK,KAAK,OAAO,MAAMA,MAAMA,KAAK,CAACC,cAAc;AAEzD,MAAMC,eAA+D;IACnEC,SAAS;IACTC,UAAU;IACVC,SAAS;IACTC,OAAO;AACT;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,kBAAkBC,KAWjC;IACC,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAE,GAAGF;IAC7B,MAAMG,YAAY1B;IAClB,MAAM2B,SAAS7B;IACf,MAAM,EAAE8B,OAAO,EAAEC,SAAS,EAAE,GAAG9B,oBAAoByB;IACnD,MAAMM,UACJL,mBAAAA,WACCG,WAAWC,YACR5D,WAAWG,MAAM2D,WAAW,EAAE;QAC5BH;QACAI,MAAMH;QACNI,YAAY;IACd,KACA;IACN,MAAM,EAAEC,MAAMC,IAAI,EAAE,GAAGhC;IACvB,MAAM,EAAEiC,eAAe,EAAE,GAAGpD;IAC5B,MAAM,EAAEqD,OAAO,EAAE,GAAGzD;IACpB,MAAM0D,YAAYhC,qBAAqBkB;IACvC,MAAM,EAAEe,OAAOC,SAAS,EAAE,GAAGtC,gBAAgB;QAAEsB;IAAO;IAEtD;;;;;;;;;;;;;;GAcC,GACD,MAAM,EAAEU,MAAMO,QAAQ,EAAE,GAAGxC,uBACzB,IACE3B,kBACEgB,WAAWoC,WAAW,SAASF,QAAQ,cACvChB,mBAEJ;QAACkB;QAAWF;KAAO,EACnB;QAAEkB,SAAS;IAAM;IAEnB,MAAM,EAAEC,MAAMC,SAAS,EAAEC,WAAWC,cAAc,EAAE,GAAGzE,gBACrDoE,UACAjC;IAGF,MAAM,EAAE0B,MAAMa,YAAY,EAAE,GAAG9C,uBAC7B,IACE3B,kBACEgB,WAAWoC,WAAW,SAASF,QAAQ,mBACvCf,oBAEJ;QAACiB;QAAWF;KAAO,EACnB;QAAEkB,SAAS;IAAM;IAEnB,MAAM,EAAEC,MAAMK,aAAa,EAAEH,WAAWI,kBAAkB,EAAE,GAC1D5E,gBAAqB0E,cAActC;IAErC,qEAAqE;IACrE,eAAe;IACf,MAAM,EAAEyB,MAAMgB,QAAQ,EAAE,GAAGjD,uBACzB,IACEuC,YACI/C,MACEH,WAAWoC,WAAWc,SAAS,CAAC,EAAE,EAAEA,SAAS,CAAC,EAAE,EAAE,UAClDhD,MAAM,OAER,MACN;QAACkC;QAAWc;KAAU,EACtB;QAAEE,SAAS;IAAM;IAEnB,MAAM,CAACS,YAAYC,cAAc,GAAGvD,SAAS;IAC7C;;;;;;;;;GASC,GACD,MAAM,EAAEwD,MAAM,EAAEC,QAAQC,WAAW,EAAE,GAAGhD,wBAAwBiB,QAAQ;QACtEgC,SAASL;IACX;IACA,MAAMM,eAAe7D,QACnB,IACEyD,OAAOK,GAAG,CAAC,CAACC,QAAW,CAAA;gBACrB5C,OAAO4C,MAAMC,EAAE;gBACfC,OAAOF,MAAMG,IAAI;YACnB,CAAA,IACF;QAACT;KAAO;IAGV,MAAMU,YAAYnE,QAAQ;QACxB,MAAMoE,QAAQ,IAAIC;QAClB,KAAK,MAAMC,QAAQhB,mBAAAA,WAAY,EAAE,CAAE;gBACEgB;YAAnCF,MAAMG,GAAG,CAACC,OAAOF,KAAKG,GAAG,GAAGD,QAAOF,aAAAA,KAAKJ,IAAI,YAATI,aAAaA,KAAKG,GAAG;QAC1D;QACA,OAAOL;IACT,GAAG;QAACd;KAAS;IAEb,MAAMoB,cAAc1E,QAClB,IACE,CAACsD,mBAAAA,WAAY,EAAE,EAAEQ,GAAG,CAAC,CAACQ;gBAENA;mBAFqB;gBACnCnD,OAAOqD,OAAOF,KAAKG,GAAG;gBACtBR,OAAOO,QAAOF,aAAAA,KAAKJ,IAAI,YAATI,aAAaA,KAAKG,GAAG;YACrC;YACF;QAACnB;KAAS;IAGZ,MAAMP,OAAO/C,QAAQ;QACnB,MAAM2E,YAAY,AAACvB,cAAkCwB,MAAM,CACzD,CAACC,WAAkB,CAACA,SAASC,SAAS;QAExC,OAAOtE,iBACLmE,WACA3B,WACAhC,KAAK+D,GAAG,IACRjB,GAAG,CAAC,CAACkB,MAAS,aAAKA;gBAAKP,KAAKO,IAAIhB,EAAE;;IACvC,GAAG;QAACZ;QAAeJ;KAAU;IAE7B,MAAM,CAACiC,aAAaC,eAAe,GAAGjF,SAAwB;IAC9D,MAAM,CAACkF,UAAUC,YAAY,GAAGnF,SAAS;IACzC,wEAAwE,GACxE,MAAM,CAACoF,YAAYC,cAAc,GAAGrF,SAAS;IAE7C,MAAMsF,eAAexF,YACnB,CAACiE;QACC,IAAI9B,SAAS,KAAKH,OAAOyD,IAAI,CAAC,GAAGtD,QAAQ,WAAW,EAAE8B,IAAI;IAC5D,GACA;QAAC9B;QAASH;KAAO;IAGnB,MAAM0D,eAAe1F,YACnB,OAAO2F;YASeA;QARpB,IAAIP,UAAU;QACd;;;;;;OAMC,GACD,MAAMjB,OAAOM,QAAOkB,sBAAAA,OAAOC,WAAW,YAAlBD,sBAAsB,IAAIE,IAAI;QAClD,IAAI,CAAC1B,MAAM;QACX,MAAM2B,YAAYH,OAAOI,OAAO,GAAG9E,KAAK+E,KAAK,CAACvB,OAAOkB,OAAOI,OAAO,KAAK;QACxE,MAAME,UAAUN,OAAOO,KAAK,GAAGjF,KAAK+E,KAAK,CAACvB,OAAOkB,OAAOO,KAAK,KAAK;QAClE,uEAAuE;QACvE,qDAAqD;QACrD,IAAIJ,cAAc,QAAQG,YAAY,QAAQA,UAAUH,WAAW;YACjE,OAAOX,eAAe;QACxB;QACAE,YAAY;QACZF,eAAe;QACf,MAAMlB,KAAK1F;QACX,IAAI;;YACF,MAAMwB,OAAOH,IAAImC,WAAW,SAASF,QAAQ,kBAAkBoC,KAAK;gBAClEE;eACIgC,OAAOC,QAAQ,CAACN,cAAcA,cAAc,OAC5C;gBAAEA;YAAU,IACZ,CAAC,GACDK,OAAOC,QAAQ,CAACH,YAAYA,YAAY,OAAO;gBAAEA;YAAQ,IAAI,CAAC;gBAClEI,SAASC,MAAMC,OAAO,CAACZ,OAAOU,OAAO,IACjCV,OAAOU,OAAO,CAACtC,GAAG,CAACU,UACnB,EAAE;eACFkB,OAAOa,OAAO,GAAG;gBAAEA,SAAS/B,OAAOkB,OAAOa,OAAO;YAAE,IAAI,CAAC;gBAC5DC,aAAaxF,KAAK+D,GAAG;gBACrB0B,WAAWjC,eAAQjC,wBAAD,AAACA,KAAcmE,GAAG,mBAAI;;YAE1ClD,cAAc;YACd+B,aAAavB;QACf,EAAE,OAAO2C,OAAO;YACdC,QAAQD,KAAK,CAACA;YACdzB,eAAe;YACf1C,gBAAgB,iCAAiC;gBAC/CqE,SAAS;gBACTC,gBAAgB;YAClB;QACF,SAAU;YACR1B,YAAY;QACd;IACF,GACA;QAACD;QAAUrD;QAAWF;QAAQW;QAAMC;QAAiB+C;KAAa;IAGpE,MAAMwB,eAAehH,YACnB,CAACiE,KAAgB9B,UAAU,GAAGA,QAAQ,WAAW,EAAE8B,IAAI,GAAG,MAC1D;QAAC9B;KAAQ;IAGX;;;;;;;;;;;;;;;;;;6CAkB2C,GAC3C,MAAM8E,eAAejH,YACnB,OAAOiF;QACL,IAAIK,YAAY;QAChB,MAAM4B,SAASjC,IAAIkC,KAAK,CAACC,MAAM;QAC/B;;;;;;OAMC,GACD,MAAMC,aAAapC,IAAIqC,MAAM,CAACC,SAAS,GAAGtC,IAAIqC,MAAM,CAACE,OAAO;QAC5D,MAAMC,SAAS,MAAM/E,QAAQ;YAC3BgF,OAAO,CAAC,OAAO,EAAEzC,IAAId,IAAI,CAAC,CAAC,CAAC;YAC5BwD,aACE,AAACT,CAAAA,SACG,CAAC,IAAI,EAAEA,OAAO7F,cAAc,GAAG,CAAC,CAAC,GACjC,GAAG6F,WAAW,IAAI,UAAU,SAAS,uBAAuB,CAAC,GAC7D,mEACA,mCACA,sBAAqB,IACxBG,CAAAA,aACG,GAAGA,WAAWhG,cAAc,GAAG,SAAS,EACtCgG,eAAe,IAAI,OAAO,MAC3B,8DAA8D,CAAC,GAChE,+DACA,4CACA,EAAC,IACL,uEACA;YACFO,kBAAkB;QACpB,GACGC,IAAI,CAAC,IAAM,MACXC,KAAK,CAAC,IAAM;QACf,IAAI,CAACL,QAAQ;QACblC,cAAcN,IAAIhB,EAAE;QACpB,IAAI;YACF,MAAM,EAAE8D,QAAQ,EAAEC,OAAO,EAAE,GAAG,MAAMrF,UAAU;gBAC5CsF,QAAQ;gBACRC,YAAYjD,IAAIhB,EAAE;YACpB;YACA,IAAI,CAAC8D,SAASI,EAAE,EAAE;;gBAChB,OAAO,KAAK1F,wBACVuF,2BAAAA,QAASpB,KAAK,mBAAI,qCAClB;oBAAEE,SAAS;oBAAWC,gBAAgB;gBAAK;YAE/C;YACAtE,gBAAgB,oBAAoB;gBAClCqE,SAAS;gBACTsB,SAAS;YACX;QACF,EAAE,OAAOxB,OAAO;YACdC,QAAQD,KAAK,CAACA;YACdnE,gBAAgB,qCAAqC;gBACnDqE,SAAS;gBACTC,gBAAgB;YAClB;QACF,SAAU;YACRxB,cAAc;QAChB;IACF,GACA;QAAC7C;QAAS4C;QAAY7C;QAAiBE;KAAU;IAGnD,MAAM0F,UAAUpI,QACd,IAAM;YACJ;gBACEqI,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEzD,GAAG,EAAO,iBACvB,MAACxF;wBACCkJ,WAAU;wBACVC,SAAS;wBACT;;;;aAIC,GACDC,IAAI;4BAAEC,YAAY;4BAAUL,UAAU;4BAAGM,OAAO;wBAAE;;4BASjD/B,aAAa/B,IAAIhB,EAAE,kBAClB,KAACnF;gCACCkK,MAAMhC,aAAa/B,IAAIhB,EAAE;gCACzBgF,SAAS,CAACC,QACRA,MAAMC,eAAe;gCAEvBC,OAAO;oCAAEX,UAAU;gCAAE;0CAErB,cAAA,KAAC/I;oCAAWoH,SAAQ;oCAAQuC,MAAM;8CAC/BpE,IAAId,IAAI;;+CAIb,KAACzE;gCAAWoH,SAAQ;gCAAQuC,MAAM;gCAACR,IAAI;oCAAEJ,UAAU;gCAAE;0CAClDxD,IAAId,IAAI;;4BAGZc,IAAIqE,MAAM,iBACT,KAAC9J;gCACC+J,MAAK;gCACLzC,SAAQ;gCACR5C,OAAM;gCACN,gEAAgE;gCAChE2E,IAAI;oCAAEW,YAAY;gCAAE;gCACpB9B,OACE,4DACA;iCAGF;;;YAGV;YACA;gBACEY,OAAO;gBACPC,YAAY;gBACZQ,OAAO;gBACPL,YAAY,CAAC,EAAEzD,GAAG,EAAO;oBACvB,MAAMwE,QAAQ1I,UAAUkE,IAAIa,SAAS;oBACrC,MAAM4D,MAAM3I,UAAUkE,IAAIgB,OAAO;oBACjC,MAAM0D,QACJF,SAASC,OAAOD,UAAUC,MACtB,GAAGD,MAAM,GAAG,EAAEC,KAAK,GACnBD,SAASC,OAAO;oBACtB,qBACE,MAACjK;wBAAMoJ,IAAI;4BAAEJ,UAAU;wBAAE;;0CACvB,KAAC/I;gCAAWoH,SAAQ;gCAAQuC,MAAM;0CAC/B/H,YAAY,CAAC2D,IAAI2E,WAAW,CAAmC;;4BAEjED,sBACC,KAACjK;gCAAWoH,SAAQ;gCAAU+C,OAAM;gCAAiBR,MAAM;0CACxDM;iCAED;;;gBAGV;YACF;YACA;gBACErB,OAAO;gBACPC,YAAY;gBACZQ,OAAO;gBACPe,UAAU;gBACVpB,YAAY,CAAC,EAAEzD,GAAG,EAAO,iBACvB,KAACvF;wBAAWoH,SAAQ;wBAAQuC,MAAM;kCAC/BpE,IAAIoB,OAAO,CAACe,MAAM,GACfnC,IAAIoB,OAAO,CACRtC,GAAG,CAAC,CAACE;gCAAeG;oCAAAA,iBAAAA,UAAU2F,GAAG,CAAC9F,eAAdG,iBAAqBH;2BACzC+F,IAAI,CAAC,QACR;;YAGV;YACA;;;;;;;;;;;;;;;;;;OAkBC,GACD;gBACE1B,OAAO;gBACPC,YAAY;gBACZQ,OAAO;gBACPkB,OAAO;gBACPC,aAAa;gBACb;;;;;;;;;;;;SAYC,GACDxB,YAAY,CAAC,EAAEzD,GAAG,EAAO;oBACvB,MAAM,EAAEkC,KAAK,EAAEK,OAAO,EAAED,SAAS,EAAE,GAAGtC,IAAIqC,MAAM;oBAChD,MAAM6C,OAAO;wBACX3C,UAAU,GAAGA,QAAQ,QAAQ,CAAC,GAAG;wBACjCD,YAAY,GAAGA,UAAU,UAAU,CAAC,GAAG;qBACxC,CACE1C,MAAM,CAACuF,SACPJ,IAAI,CAAC;oBACR,qBACE,MAACvK;wBAAMoJ,IAAI;4BAAEC,YAAY;4BAAYL,UAAU;4BAAGM,OAAO;wBAAE;;0CACzD,KAACrJ;gCAAWoH,SAAQ;0CAASrC,OAAO0C;;4BACnCgD,qBACC,KAACzK;gCAAWoH,SAAQ;gCAAU+C,OAAM;gCAAiBR,MAAM;0CACxDc;iCAED;;;gBAGV;YACF;YACA;gBACE7B,OAAO;gBACPC,YAAY;gBACZQ,OAAO;gBACPkB,OAAO;gBACPC,aAAa;gBACbxB,YAAY,CAAC,EAAEzD,GAAG,EAAO,iBACvB,KAACvF;wBAAWoH,SAAQ;kCAAS3F,OAAO8D,IAAIqC,MAAM,CAAC+C,IAAI;;YAEvD;YACA;gBACE/B,OAAO;gBACPC,YAAY;gBACZQ,OAAO;gBACPkB,OAAO;gBACPC,aAAa;gBACbxB,YAAY,CAAC,EAAEzD,GAAG,EAAO,iBACvB,KAACvF;wBAAWoH,SAAQ;kCAAS3F,OAAO8D,IAAIqC,MAAM,CAACgD,KAAK;;YAExD;YACA;gBACEhC,OAAO;gBACPC,YAAY;gBACZQ,OAAO;gBACPkB,OAAO;gBACPC,aAAa;gBACbxB,YAAY,CAAC,EAAEzD,GAAG,EAAO,iBACvB,KAACvF;wBAAWoH,SAAQ;kCAAS3F,OAAO8D,IAAIqC,MAAM,CAACiD,MAAM;;YAEzD;YACA;;;;;;;;;;;;;;;;;OAiBC,GACDnL,kBACE,CAAC6F;oBAEiBA,WAMJ+B;qCAPZ,KAAC9H;oBACCgF,OAAOO,QAAOQ,YAAAA,IAAId,IAAI,YAARc,YAAYA,IAAIhB,EAAE;oBAChCuG,OAAO;wBACL;4BACEC,KAAK;4BACLvG,OAAO;4BACPwG,oBAAM,KAAC1L;gCAAQ2L,MAAM9L,cAAc8L,IAAI;gCAAEpB,MAAM;;4BAC/CP,IAAI,GAAEhC,gBAAAA,aAAa/B,IAAIhB,EAAE,aAAnB+C,gBAAwB4D;4BAC9BC,UAAU,CAAC7D,aAAa/B,IAAIhB,EAAE;4BAC9B6G,gBAAgB9D,aAAa/B,IAAIhB,EAAE,IAC/B2G,YACA;wBACN;wBACA;4BACEH,KAAK;4BACLvG,OAAO;4BACPwG,oBAAM,KAAC1L;gCAAQ2L,MAAM/L,iBAAiB+L,IAAI;gCAAEpB,MAAM;;4BAClDwB,aAAa;4BACbF,UAAU5F,IAAIqE,MAAM,IAAIc,QAAQ9E;4BAChCwF,gBAAgB7F,IAAIqE,MAAM,GACtB,uDACA,6BACA;4BACJL,SAAS,IAAM,KAAKhC,aAAahC;wBACnC;qBACD;;eAGL;gBAAE8D,OAAO;YAAG;SAEf,EACD;QAAC3E;QAAW4C;QAAc1B;QAAY2B;KAAa;IAGrD,qBACE,MAAClI;QACCiM,QAAQ;QACRC,MAAMzM,eAAe,kBAAkB;YACrC0M,QAAQ;YACRC,SACE,oEACA;QACJ;QACAC,aAAa;YACXnD,sBACE,KAAC1I;gBACCgK,MAAK;gBACLzC,SAAQ;gBACR+D,UAAUzF;gBACV6D,SAAS;oBACP9D,eAAe;oBACf1B,cAAc;gBAChB;0BAEC2B,WAAW,cAAc;;QAGhC;QACAiG,cAAc;QACdC,cAAc;;0BAEd,MAAC7L;gBAAMmJ,SAAS;;kCACd,KAACzJ;wBACC6D,MAAMA;wBACNqF,SAASA;wBACTkD,QAAQ,CAACtH,KAAOuB,aAAavB;wBAC7BuH,aAAY;wBACZC,mBAAkB;wBAClBC,4BACE,KAACnM;4BAAOuH,SAAQ;4BAAYmC,SAAS,IAAMxF,cAAc;sCACtD;;;kCAYP,KAAC/D;wBAAWoH,SAAQ;wBAAU+C,OAAM;kCACjC,iEACC,iEACA,iEACA;;oBAEH1G,kBAAkBG,mCACjB,KAAChE;wBAAMqM,UAAS;kCACb,CAAC,wBAAwB,EAAE7K,kBAAkB,eAAe,CAAC,GAC5D,GAAGD,iBAAiB,sCAAsC,CAAC,GAC3D,gEACA;yBAEF;;;YAUL+C;0BACD,KAAClD;gBACCkL,MAAMpI;gBACNqI,SAAS,IAAMpI,cAAc;gBAC7BiE,OAAM;gBACNoE,aAAY;gBACZC,oBAAoB;gBACpBC,UAAUtG;gBACVuG,aAAaC,eAAevH,aAAab;gBACzCqI,WACEjH,4BACE,KAAC5F;oBAAMqM,UAAS;oBAAQ9C,IAAI;wBAAEuD,IAAI;wBAAGC,IAAI;oBAAE;8BACxCnH;qBAED;;;;AAKd;AACA;;;;;;;CAOC,GACD,SAASgH,eACPI,KAA8C,EAC9C5I,MAA+C;IAE/C,OAAO;QACL;YACE6I,WAAW;YACXpI,MAAM;YACND,OAAO;YACPsI,MAAM;YACNC,YAAY;QACd;QACA;YACEF,WAAW;YACXpI,MAAM;YACND,OAAO;YACPsI,MAAM;YACNC,YAAY;YACZC,iBAAiB;gBAAEC,QAAQ;YAAK;QAClC;QACA;YACEJ,WAAW;YACXpI,MAAM;YACND,OAAO;YACP0I,UAAU;YACVC,cAAc,EAAE;YAChB,wEAAwE;YACxE,oEAAoE;YACpEJ,YAAY;YACZK,sBAAsB;YACtBC,SAAST;QACX;QACA;YACEC,WAAW;YACXpI,MAAM;YACND,OAAO;YACPuI,YAAY;YACZK,sBAAsB;YACtBC,SAASrJ;QACX;KACD;AACH;AAEA/B,kBAAkBiE,WAAW,GAAG;AAEhC,eAAejE,kBAAiB"}
@@ -0,0 +1,62 @@
1
+ import { type CampaignConversionKind } from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions';
2
+ /**
3
+ * WHERE THIS RECORD CAME FROM — attribution on the converted record itself.
4
+ *
5
+ * The campaign's own page answers "what did this campaign cause". This
6
+ * answers the question from the other end, which is the one a person standing
7
+ * in front of a lead actually has: where did THIS come from. Without it the
8
+ * join is invisible to everybody who is not already looking at a campaign.
9
+ *
10
+ * ## One keyed read, and only where the record is opened
11
+ *
12
+ * The record's id IS half the attribution's id — `{kind}:{refId}` — so this
13
+ * is `getDoc` on a known path: no query, no index, nothing that can be
14
+ * truncated, and no listener over a collection. Mounted on a DETAIL surface
15
+ * (a reader dialog, a drawer) rather than in a table row, because a column
16
+ * would multiply that one read by the page size and charge it to every reader
17
+ * who opened the list for another reason entirely.
18
+ *
19
+ * ## Absence is a real answer and is rendered as one
20
+ *
21
+ * The join writes nothing at all for a conversion it cannot credit — no
22
+ * `utm_source=direct` placeholder, no referrer inference, no fallback to
23
+ * whichever campaign ran most recently. So a missing document is the ordinary
24
+ * case and it means "this person arrived without following a campaign link",
25
+ * which this says in words.
26
+ *
27
+ * What it must never render is a campaign with a zero beside it. "Came from
28
+ * no campaign" and "came from this campaign, which produced nothing" are
29
+ * opposite facts, and a component that reached for a default would print the
30
+ * second when it means the first.
31
+ *
32
+ * ## The two channels do not render the same, because they are not the same
33
+ *
34
+ * An EMAIL touch names a campaign document, so the label is a link to it. A
35
+ * WEB touch names `utm_` text a marketer typed into a URL — there is no
36
+ * document at the other end and no page to open — so it is rendered as text.
37
+ * A link that resolves nowhere is worse than no link.
38
+ */
39
+ export interface ConversionAttributionProps {
40
+ hostId: string;
41
+ /** Which identify moment this record is. */
42
+ kind: CampaignConversionKind;
43
+ /** The record's own document id — the `refId` the writer credited. */
44
+ refId: string;
45
+ /**
46
+ * The marketing hub URL, when the caller already holds it.
47
+ *
48
+ * Resolved from the console route when it does not, which is the ordinary
49
+ * case: the surfaces that render this belong to other plugins — the Inbox,
50
+ * Contacts — and they are handed their OWN hub's path. Deriving one hub's
51
+ * URL from another's by string surgery is the link that breaks silently
52
+ * when a surface moves.
53
+ */
54
+ marketingBasePath?: string;
55
+ /** Rendered instead of the "no campaign" sentence, for a compact surface. */
56
+ quiet?: boolean;
57
+ }
58
+ export declare function ConversionAttribution(props: ConversionAttributionProps): import("react").JSX.Element;
59
+ export declare namespace ConversionAttribution {
60
+ var displayName: string;
61
+ }
62
+ export default ConversionAttribution;
@@ -0,0 +1,127 @@
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
+ */ 'use client';
17
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
+ import { AppLink } from "@aglyn/shared-ui-jsx";
19
+ import { Chip, Stack, Typography } from "@mui/material";
20
+ import { doc } from "firebase/firestore";
21
+ import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
22
+ import { useMarketingHubPath } from "./use-emails-hub-path.js";
23
+ import { campaignConversionId, campaignTouchLabel } from "@aglyn/shared-ui-email-campaigns/model/campaign-conversions";
24
+ export function ConversionAttribution(props) {
25
+ var _props_marketingBasePath, _record_convertedAtMs, _record_touchedAtMs, _record_model, _record_windowDays;
26
+ const { hostId, kind, refId, quiet } = props;
27
+ const firestore = useFirestore();
28
+ /*
29
+ * Free — the org slug and the subdomain are already in the URL this console
30
+ * is on, so no host document is resolved to render a link. `null` until the
31
+ * params settle, and the campaign is then NAMED but not linked, which is
32
+ * the rule the web channel follows for the same reason: no link beats one
33
+ * that resolves nowhere.
34
+ */ const resolvedHub = useMarketingHubPath();
35
+ const marketingBasePath = (_props_marketingBasePath = props.marketingBasePath) != null ? _props_marketingBasePath : resolvedHub;
36
+ /*
37
+ * The id is BUILT rather than interpolated, so a ref carrying a slash or a
38
+ * colon answers null here instead of naming a document in another
39
+ * collection. A null builder is how this hook is told not to read at all.
40
+ */ const attributionId = campaignConversionId(kind, refId);
41
+ const { data: record, status } = useFirestoreDoc(()=>attributionId ? doc(firestore, 'hosts', hostId, 'campaignAttributions', attributionId) : null, [
42
+ firestore,
43
+ hostId,
44
+ attributionId
45
+ ]);
46
+ /*
47
+ * An id that could not be built means the record's own id is unusable, so
48
+ * the question was never asked. Drawing the "not credited" sentence here
49
+ * would state a fact nothing checked — the one thing this component must
50
+ * not do — so it draws nothing.
51
+ */ if (!attributionId) return null;
52
+ /*
53
+ * Nothing while the read settles. Rendering "no campaign" first and
54
+ * replacing it a tick later states the opposite of the truth for as long as
55
+ * the reader's eye takes to reach it, and this sits inside a dialog that
56
+ * opens with the read.
57
+ */ if (status === 'loading') return null;
58
+ if (!record) {
59
+ if (quiet) return null;
60
+ return /*#__PURE__*/ _jsxs(Stack, {
61
+ spacing: 0.5,
62
+ children: [
63
+ /*#__PURE__*/ _jsx(Typography, {
64
+ variant: "overline",
65
+ color: "text.secondary",
66
+ children: 'Campaign'
67
+ }),
68
+ /*#__PURE__*/ _jsx(Typography, {
69
+ variant: "body2",
70
+ color: "text.secondary",
71
+ children: 'Not credited to a campaign — this arrived directly, or from a ' + 'link that carried no campaign. Nothing is inferred from a ' + 'referrer and no campaign is credited for being the most recent ' + 'one to run.'
72
+ })
73
+ ]
74
+ });
75
+ }
76
+ const label = campaignTouchLabel(record);
77
+ const converted = Number((_record_convertedAtMs = record.convertedAtMs) != null ? _record_convertedAtMs : 0);
78
+ const touched = Number((_record_touchedAtMs = record.touchedAtMs) != null ? _record_touchedAtMs : 0);
79
+ const isEmail = record.channel === 'email';
80
+ const campaignHref = isEmail && marketingBasePath && record.campaignId ? `${marketingBasePath}/campaigns/${record.campaignId}` : undefined;
81
+ return /*#__PURE__*/ _jsxs(Stack, {
82
+ spacing: 0.5,
83
+ children: [
84
+ /*#__PURE__*/ _jsx(Typography, {
85
+ variant: "overline",
86
+ color: "text.secondary",
87
+ children: 'Campaign'
88
+ }),
89
+ /*#__PURE__*/ _jsxs(Stack, {
90
+ direction: "row",
91
+ spacing: 1,
92
+ useFlexGap: true,
93
+ sx: {
94
+ alignItems: 'center',
95
+ flexWrap: 'wrap'
96
+ },
97
+ children: [
98
+ /*#__PURE__*/ _jsx(Chip, {
99
+ size: "small",
100
+ color: isEmail ? 'primary' : 'default',
101
+ variant: isEmail ? 'filled' : 'outlined',
102
+ label: isEmail ? 'Campaign email' : 'Web link'
103
+ }),
104
+ campaignHref ? /*#__PURE__*/ _jsx(AppLink, {
105
+ href: campaignHref,
106
+ children: label || record.campaignId
107
+ }) : /*#__PURE__*/ _jsx(Typography, {
108
+ variant: "body2",
109
+ sx: {
110
+ fontWeight: 'bold'
111
+ },
112
+ children: label || 'unnamed'
113
+ })
114
+ ]
115
+ }),
116
+ /*#__PURE__*/ _jsx(Typography, {
117
+ variant: "caption",
118
+ color: "text.secondary",
119
+ children: (touched ? `Followed the link on ${new Date(touched).toLocaleString()}` : 'Followed a campaign link') + (converted ? `, converted ${new Date(converted).toLocaleString()}` : '') + `. Credited ${record.model === 'last-click' ? 'to the last campaign whose link they clicked' : `under the ${String((_record_model = record.model) != null ? _record_model : 'recorded')} model`}, within ${Number((_record_windowDays = record.windowDays) != null ? _record_windowDays : 0) || 7} days of that click.`
120
+ })
121
+ ]
122
+ });
123
+ }
124
+ ConversionAttribution.displayName = 'ConversionAttribution';
125
+ export default ConversionAttribution;
126
+
127
+ //# sourceMappingURL=conversion-attribution.component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/conversion-attribution.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { useMarketingHubPath } from './use-emails-hub-path'\nimport {\n campaignConversionId,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions'\n\n/**\n * WHERE THIS RECORD CAME FROM — attribution on the converted record itself.\n *\n * The campaign's own page answers \"what did this campaign cause\". This\n * answers the question from the other end, which is the one a person standing\n * in front of a lead actually has: where did THIS come from. Without it the\n * join is invisible to everybody who is not already looking at a campaign.\n *\n * ## One keyed read, and only where the record is opened\n *\n * The record's id IS half the attribution's id — `{kind}:{refId}` — so this\n * is `getDoc` on a known path: no query, no index, nothing that can be\n * truncated, and no listener over a collection. Mounted on a DETAIL surface\n * (a reader dialog, a drawer) rather than in a table row, because a column\n * would multiply that one read by the page size and charge it to every reader\n * who opened the list for another reason entirely.\n *\n * ## Absence is a real answer and is rendered as one\n *\n * The join writes nothing at all for a conversion it cannot credit — no\n * `utm_source=direct` placeholder, no referrer inference, no fallback to\n * whichever campaign ran most recently. So a missing document is the ordinary\n * case and it means \"this person arrived without following a campaign link\",\n * which this says in words.\n *\n * What it must never render is a campaign with a zero beside it. \"Came from\n * no campaign\" and \"came from this campaign, which produced nothing\" are\n * opposite facts, and a component that reached for a default would print the\n * second when it means the first.\n *\n * ## The two channels do not render the same, because they are not the same\n *\n * An EMAIL touch names a campaign document, so the label is a link to it. A\n * WEB touch names `utm_` text a marketer typed into a URL — there is no\n * document at the other end and no page to open — so it is rendered as text.\n * A link that resolves nowhere is worse than no link.\n */\nexport interface ConversionAttributionProps {\n hostId: string\n /** Which identify moment this record is. */\n kind: CampaignConversionKind\n /** The record's own document id — the `refId` the writer credited. */\n refId: string\n /**\n * The marketing hub URL, when the caller already holds it.\n *\n * Resolved from the console route when it does not, which is the ordinary\n * case: the surfaces that render this belong to other plugins — the Inbox,\n * Contacts — and they are handed their OWN hub's path. Deriving one hub's\n * URL from another's by string surgery is the link that breaks silently\n * when a surface moves.\n */\n marketingBasePath?: string\n /** Rendered instead of the \"no campaign\" sentence, for a compact surface. */\n quiet?: boolean\n}\n\nexport function ConversionAttribution(props: ConversionAttributionProps) {\n const { hostId, kind, refId, quiet } = props\n const firestore = useFirestore()\n /*\n * Free — the org slug and the subdomain are already in the URL this console\n * is on, so no host document is resolved to render a link. `null` until the\n * params settle, and the campaign is then NAMED but not linked, which is\n * the rule the web channel follows for the same reason: no link beats one\n * that resolves nowhere.\n */\n const resolvedHub = useMarketingHubPath()\n const marketingBasePath = props.marketingBasePath ?? resolvedHub\n\n /*\n * The id is BUILT rather than interpolated, so a ref carrying a slash or a\n * colon answers null here instead of naming a document in another\n * collection. A null builder is how this hook is told not to read at all.\n */\n const attributionId = campaignConversionId(kind, refId)\n const { data: record, status } = useFirestoreDoc<CampaignConversionRecord>(\n () =>\n attributionId\n ? doc(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n attributionId,\n )\n : null,\n [firestore, hostId, attributionId],\n )\n\n /*\n * An id that could not be built means the record's own id is unusable, so\n * the question was never asked. Drawing the \"not credited\" sentence here\n * would state a fact nothing checked — the one thing this component must\n * not do — so it draws nothing.\n */\n if (!attributionId) return null\n\n /*\n * Nothing while the read settles. Rendering \"no campaign\" first and\n * replacing it a tick later states the opposite of the truth for as long as\n * the reader's eye takes to reach it, and this sits inside a dialog that\n * opens with the read.\n */\n if (status === 'loading') return null\n\n if (!record) {\n if (quiet) return null\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n {/*\n NOT a zero, and not an empty campaign chip. The sentence says which\n fact this is: nobody was credited, and nothing was guessed.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Not credited to a campaign — this arrived directly, or from a ' +\n 'link that carried no campaign. Nothing is inferred from a ' +\n 'referrer and no campaign is credited for being the most recent ' +\n 'one to run.'}\n </Typography>\n </Stack>\n )\n }\n\n const label = campaignTouchLabel(record)\n const converted = Number(record.convertedAtMs ?? 0)\n const touched = Number(record.touchedAtMs ?? 0)\n const isEmail = record.channel === 'email'\n const campaignHref =\n isEmail && marketingBasePath && record.campaignId\n ? `${marketingBasePath}/campaigns/${record.campaignId}`\n : undefined\n\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The CHANNEL is on screen beside the label, because the two are read\n differently: one names a campaign whose report can be opened, the\n other names text somebody typed into a URL.\n */}\n <Chip\n size=\"small\"\n color={isEmail ? 'primary' : 'default'}\n variant={isEmail ? 'filled' : 'outlined'}\n label={isEmail ? 'Campaign email' : 'Web link'}\n />\n {campaignHref ? (\n <AppLink href={campaignHref}>{label || record.campaignId}</AppLink>\n ) : (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {label || 'unnamed'}\n </Typography>\n )}\n </Stack>\n {/*\n THE RULE, under the claim. A credit whose rule the reader cannot state\n is a credit they will use anyway, and the two things they need are\n which touch wins and how long one stays creditable. `model` and\n `windowDays` come off the RECORD, so a conversion credited under an\n older rule prints the rule it was credited under.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {(touched\n ? `Followed the link on ${new Date(touched).toLocaleString()}`\n : 'Followed a campaign link') +\n (converted\n ? `, converted ${new Date(converted).toLocaleString()}`\n : '') +\n `. Credited ${\n record.model === 'last-click'\n ? 'to the last campaign whose link they clicked'\n : `under the ${String(record.model ?? 'recorded')} model`\n }, within ${Number(record.windowDays ?? 0) || 7} days of that click.`}\n </Typography>\n </Stack>\n )\n}\nConversionAttribution.displayName = 'ConversionAttribution'\n\nexport default ConversionAttribution\n"],"names":["AppLink","Chip","Stack","Typography","doc","useFirestore","useFirestoreDoc","useMarketingHubPath","campaignConversionId","campaignTouchLabel","ConversionAttribution","props","record","hostId","kind","refId","quiet","firestore","resolvedHub","marketingBasePath","attributionId","data","status","spacing","variant","color","label","converted","Number","convertedAtMs","touched","touchedAtMs","isEmail","channel","campaignHref","campaignId","undefined","direction","useFlexGap","sx","alignItems","flexWrap","size","href","fontWeight","Date","toLocaleString","model","String","windowDays","displayName"],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export interface EmailComposeCardProps {
2
+ hostId: string;
3
+ /** The message document under `hosts/{hostId}/campaigns`. */
4
+ emailId: string;
5
+ /** The emails hub URL, for the way back to the message's own page. */
6
+ basePath: string;
7
+ }
8
+ export declare function EmailComposeCard(props: EmailComposeCardProps): import("react").JSX.Element;
9
+ export declare namespace EmailComposeCard {
10
+ var displayName: string;
11
+ }
12
+ export default EmailComposeCard;
@@ -0,0 +1,157 @@
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
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx } from "react/jsx-runtime";
19
+ /**
20
+ * WRITING ONE EMAIL — its own route, and its own card.
21
+ *
22
+ * ## Why it is not on the message's page
23
+ *
24
+ * `/emails/messages/{emailId}` answers "what did this email do": delivery,
25
+ * engagement, every rate over its own denominator, the links that were
26
+ * followed and who it reached. Writing the thing is a different job with a
27
+ * different shape — a form, a preview, and one irreversible button — and
28
+ * carrying both on one route made the reader of a report scroll past a
29
+ * composer and the writer of an email scroll past a table of zeros.
30
+ *
31
+ * So the record's page reports, and `…/edit` writes. It is the grammar this
32
+ * surface already reads by: an audience is `…/audiences/{listId}` and its
33
+ * settings are `…/audiences/{listId}/edit`, and a screen's page and its
34
+ * besigner are two addresses for the same reason. Creating is still a drawer
35
+ * on the list — it collects the name, mints the record and routes here — so no
36
+ * list on this surface carries a form above it.
37
+ *
38
+ * ## Why the card is HERE and not inside the composer
39
+ *
40
+ * The composer is also mounted inside the campaign detail card, which already
41
+ * supplies a card of its own; a `CardDisplay` in the component would nest one
42
+ * inside another there. The surface owns the chrome, exactly as `ListEditCard`
43
+ * owns the chrome around the audience rule fields.
44
+ *
45
+ * ## What may be edited
46
+ *
47
+ * Only an email that is still unsent. The route enforces it too — `schedule`
48
+ * and `draft` answer 409 on a sent or canceled record — and this is the same
49
+ * refusal said before the merchant types rather than after they press Send. An
50
+ * email PART WAY THROUGH a send is stored as `scheduled` between batches, so
51
+ * the stored status alone would let somebody rewrite the copy of a message
52
+ * that is already reaching inboxes; `campaignSendDisplay` is what tells the
53
+ * two apart.
54
+ */ import { pluginDocsHelp } from "@aglyn/aglyn";
55
+ import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
56
+ import { Button, Typography } from "@mui/material";
57
+ import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
58
+ import { doc } from "firebase/firestore";
59
+ import { useRouter } from "next/navigation";
60
+ import { campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
61
+ import CampaignComposer from "./campaign-composer.js";
62
+ const composeDocsHelp = pluginDocsHelp('emailCampaigns', {
63
+ anchor: '#send-a-campaign',
64
+ excerpt: 'Everything one email decides: who it goes to, how it is written, who ' + 'it appears to come from, and how many people it reaches.'
65
+ });
66
+ export function EmailComposeCard(props) {
67
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8, _ref9, _ref10, _ref11, _ref12, _ref13, _ref14;
68
+ const { hostId, emailId, basePath } = props;
69
+ const firestore = useFirestore();
70
+ const router = useRouter();
71
+ const { data: email, status } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'campaigns', emailId), [
72
+ firestore,
73
+ hostId,
74
+ emailId
75
+ ]);
76
+ const detailHref = `${basePath}/messages/${emailId}`;
77
+ const headerActions = /*#__PURE__*/ _jsx(Button, _extends({
78
+ component: AppLink
79
+ }, {
80
+ componentVariant: 'naked',
81
+ nativeButton: false
82
+ }, {
83
+ href: detailHref,
84
+ size: "small",
85
+ color: "primary",
86
+ children: 'Back to the email'
87
+ }));
88
+ const subject = String((_ref = email == null ? void 0 : email.subject) != null ? _ref : '');
89
+ const displayName = String((_ref1 = email == null ? void 0 : email.displayName) != null ? _ref1 : '');
90
+ const state = String((_ref2 = email == null ? void 0 : email.status) != null ? _ref2 : '');
91
+ const display = email ? campaignSendDisplay(email) : null;
92
+ const editable = (state === 'draft' || state === 'scheduled') && (display == null ? void 0 : display.state) !== 'sending';
93
+ /**
94
+ * Why this email cannot be written, in the words that say what to do next.
95
+ *
96
+ * Empty while it still can be. Each case is a different fact about the
97
+ * record and not a variation on "no": one has not loaded, one is in inboxes
98
+ * already, one is being delivered right now.
99
+ */ const refusal = !email ? status === 'loading' ? 'Loading this email…' : 'This email could not be loaded. It may have been deleted.' : editable ? '' : (display == null ? void 0 : display.state) === 'sending' ? 'This email is being sent right now, so its message can no longer ' + 'be changed. Its page reports what it has reached so far.' : state === 'sent' ? 'This email has been sent, so its subject, message and audience ' + 'describe mail that is already in inboxes. Compose a new email ' + 'to say something else.' : 'This email was canceled, so it can no longer be written or put ' + 'back on the schedule. Compose a new email.';
100
+ return /*#__PURE__*/ _jsx(CardDisplay, {
101
+ header: subject || displayName || 'Untitled email',
102
+ subheader: 'Write this email',
103
+ help: composeDocsHelp,
104
+ contentGutterX: true,
105
+ contentGutterY: true,
106
+ HeaderProps: {
107
+ action: headerActions
108
+ },
109
+ children: refusal ? /*#__PURE__*/ _jsx(Typography, {
110
+ variant: "body2",
111
+ color: "text.secondary",
112
+ children: refusal
113
+ }) : /*#__PURE__*/ _jsx(CampaignComposer, {
114
+ hostId: hostId,
115
+ campaignId: emailId,
116
+ emailCampaignId: (email == null ? void 0 : email[CAMPAIGN_SEND_CONTAINER_FIELD]) ? String(email[CAMPAIGN_SEND_CONTAINER_FIELD]) : undefined,
117
+ displayName: displayName || undefined,
118
+ initial: {
119
+ subject,
120
+ body: String((_ref3 = email == null ? void 0 : email.body) != null ? _ref3 : ''),
121
+ fromName: String((_ref4 = email == null ? void 0 : email.fromName) != null ? _ref4 : ''),
122
+ replyTo: String((_ref5 = email == null ? void 0 : email.replyTo) != null ? _ref5 : ''),
123
+ /*
124
+ * The sender this email is set to go out as, so reopening a draft
125
+ * does not silently move it onto whichever sender has become the
126
+ * site's default since it was saved.
127
+ */ senderId: String((_ref6 = email == null ? void 0 : email.senderId) != null ? _ref6 : ''),
128
+ preheader: String((_ref7 = email == null ? void 0 : email.preheader) != null ? _ref7 : ''),
129
+ audience: String((_ref8 = email == null ? void 0 : email.audience) != null ? _ref8 : ''),
130
+ listId: String((_ref9 = email == null ? void 0 : email.listId) != null ? _ref9 : ''),
131
+ segmentId: String((_ref10 = email == null ? void 0 : email.segmentId) != null ? _ref10 : ''),
132
+ topicId: String((_ref11 = email == null ? void 0 : email.topicId) != null ? _ref11 : ''),
133
+ /*
134
+ * Which of the two ways this email is written. The composer
135
+ * resolves the mode from this field through the same function the
136
+ * send path reads, so a designed draft reopens designed.
137
+ */ templateScreenId: String((_ref12 = email == null ? void 0 : email.templateScreenId) != null ? _ref12 : ''),
138
+ /*
139
+ * The plain-text half, and the design version it was written
140
+ * against. Absent means the design generates it — the composer
141
+ * reads that through the same resolver the record's shape is
142
+ * defined by, so a stale override is named rather than guessed at.
143
+ */ plainText: String((_ref13 = email == null ? void 0 : email.plainText) != null ? _ref13 : ''),
144
+ plainTextVersionId: String((_ref14 = email == null ? void 0 : email.plainTextVersionId) != null ? _ref14 : '')
145
+ },
146
+ /*
147
+ * A send or a schedule lands on the record, and the record's page is
148
+ * where its report will be — so the merchant is left looking at what
149
+ * they just did rather than at the form that did it.
150
+ */ onSent: ()=>router.push(detailHref)
151
+ })
152
+ });
153
+ }
154
+ EmailComposeCard.displayName = 'EmailComposeCard';
155
+ export default EmailComposeCard;
156
+
157
+ //# sourceMappingURL=email-compose-card.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/email-compose-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\n/**\n * WRITING ONE EMAIL — its own route, and its own card.\n *\n * ## Why it is not on the message's page\n *\n * `/emails/messages/{emailId}` answers \"what did this email do\": delivery,\n * engagement, every rate over its own denominator, the links that were\n * followed and who it reached. Writing the thing is a different job with a\n * different shape — a form, a preview, and one irreversible button — and\n * carrying both on one route made the reader of a report scroll past a\n * composer and the writer of an email scroll past a table of zeros.\n *\n * So the record's page reports, and `…/edit` writes. It is the grammar this\n * surface already reads by: an audience is `…/audiences/{listId}` and its\n * settings are `…/audiences/{listId}/edit`, and a screen's page and its\n * besigner are two addresses for the same reason. Creating is still a drawer\n * on the list — it collects the name, mints the record and routes here — so no\n * list on this surface carries a form above it.\n *\n * ## Why the card is HERE and not inside the composer\n *\n * The composer is also mounted inside the campaign detail card, which already\n * supplies a card of its own; a `CardDisplay` in the component would nest one\n * inside another there. The surface owns the chrome, exactly as `ListEditCard`\n * owns the chrome around the audience rule fields.\n *\n * ## What may be edited\n *\n * Only an email that is still unsent. The route enforces it too — `schedule`\n * and `draft` answer 409 on a sent or canceled record — and this is the same\n * refusal said before the merchant types rather than after they press Send. An\n * email PART WAY THROUGH a send is stored as `scheduled` between batches, so\n * the stored status alone would let somebody rewrite the copy of a message\n * that is already reaching inboxes; `campaignSendDisplay` is what tells the\n * two apart.\n */\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Button, Typography } from '@mui/material'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { doc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport {\n campaignSendDisplay,\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport CampaignComposer from './campaign-composer'\n\nconst composeDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#send-a-campaign',\n excerpt:\n 'Everything one email decides: who it goes to, how it is written, who ' +\n 'it appears to come from, and how many people it reaches.',\n})\n\nexport interface EmailComposeCardProps {\n hostId: string\n /** The message document under `hosts/{hostId}/campaigns`. */\n emailId: string\n /** The emails hub URL, for the way back to the message's own page. */\n basePath: string\n}\n\nexport function EmailComposeCard(props: EmailComposeCardProps) {\n const { hostId, emailId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n\n const { data: email, status } = useFirestoreDoc<Record<string, any>>(\n () => doc(firestore, 'hosts', hostId, 'campaigns', emailId),\n [firestore, hostId, emailId],\n )\n\n const detailHref = `${basePath}/messages/${emailId}`\n const headerActions = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Back to the email'}\n </Button>\n )\n\n const subject = String(email?.subject ?? '')\n const displayName = String(email?.displayName ?? '')\n const state = String(email?.status ?? '')\n const display = email ? campaignSendDisplay(email as never) : null\n const editable =\n (state === 'draft' || state === 'scheduled') && display?.state !== 'sending'\n\n /**\n * Why this email cannot be written, in the words that say what to do next.\n *\n * Empty while it still can be. Each case is a different fact about the\n * record and not a variation on \"no\": one has not loaded, one is in inboxes\n * already, one is being delivered right now.\n */\n const refusal = !email\n ? status === 'loading'\n ? 'Loading this email…'\n : 'This email could not be loaded. It may have been deleted.'\n : editable\n ? ''\n : display?.state === 'sending'\n ? 'This email is being sent right now, so its message can no longer ' +\n 'be changed. Its page reports what it has reached so far.'\n : state === 'sent'\n ? 'This email has been sent, so its subject, message and audience ' +\n 'describe mail that is already in inboxes. Compose a new email ' +\n 'to say something else.'\n : 'This email was canceled, so it can no longer be written or put ' +\n 'back on the schedule. Compose a new email.'\n\n return (\n <CardDisplay\n header={subject || displayName || 'Untitled email'}\n subheader={'Write this email'}\n help={composeDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n {refusal ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {refusal}\n </Typography>\n ) : (\n <CampaignComposer\n hostId={hostId}\n campaignId={emailId}\n emailCampaignId={\n email?.[CAMPAIGN_SEND_CONTAINER_FIELD]\n ? String(email[CAMPAIGN_SEND_CONTAINER_FIELD])\n : undefined\n }\n displayName={displayName || undefined}\n initial={{\n subject,\n body: String(email?.body ?? ''),\n fromName: String(email?.fromName ?? ''),\n replyTo: String(email?.replyTo ?? ''),\n /*\n * The sender this email is set to go out as, so reopening a draft\n * does not silently move it onto whichever sender has become the\n * site's default since it was saved.\n */\n senderId: String(email?.senderId ?? ''),\n preheader: String(email?.preheader ?? ''),\n audience: String(email?.audience ?? ''),\n listId: String(email?.listId ?? ''),\n segmentId: String(email?.segmentId ?? ''),\n topicId: String(email?.topicId ?? ''),\n /*\n * Which of the two ways this email is written. The composer\n * resolves the mode from this field through the same function the\n * send path reads, so a designed draft reopens designed.\n */\n templateScreenId: String(email?.templateScreenId ?? ''),\n /*\n * The plain-text half, and the design version it was written\n * against. Absent means the design generates it — the composer\n * reads that through the same resolver the record's shape is\n * defined by, so a stale override is named rather than guessed at.\n */\n plainText: String(email?.plainText ?? ''),\n plainTextVersionId: String(email?.plainTextVersionId ?? ''),\n }}\n /*\n * A send or a schedule lands on the record, and the record's page is\n * where its report will be — so the merchant is left looking at what\n * they just did rather than at the form that did it.\n */\n onSent={() => router.push(detailHref)}\n />\n )}\n </CardDisplay>\n )\n}\nEmailComposeCard.displayName = 'EmailComposeCard'\n\nexport default EmailComposeCard\n"],"names":["pluginDocsHelp","AppLink","CardDisplay","Button","Typography","useFirestore","useFirestoreDoc","doc","useRouter","campaignSendDisplay","CAMPAIGN_SEND_CONTAINER_FIELD","CampaignComposer","composeDocsHelp","anchor","excerpt","EmailComposeCard","props","hostId","emailId","basePath","firestore","router","data","email","status","detailHref","headerActions","component","componentVariant","nativeButton","href","size","color","subject","String","displayName","state","display","editable","refusal","header","subheader","help","contentGutterX","contentGutterY","HeaderProps","action","variant","campaignId","emailCampaignId","undefined","initial","body","fromName","replyTo","senderId","preheader","audience","listId","segmentId","topicId","templateScreenId","plainText","plainTextVersionId","onSent","push"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ export interface EmailDetailProps {
2
+ hostId: string;
3
+ /** The message document under `hosts/{hostId}/campaigns`. */
4
+ emailId: string;
5
+ /** The emails hub URL, for the way back and for sibling links. */
6
+ basePath: string;
7
+ }
8
+ /**
9
+ * ONE MESSAGE: what it looked like, where it went, and what it did.
10
+ *
11
+ * ## The template it was built from, drawn as it stands NOW
12
+ *
13
+ * The preview renders the template's CURRENT version, because the HTML that
14
+ * was actually mailed is not stored — it is rendered per recipient at send
15
+ * time, with that recipient's merge values in it, and keeping a copy per
16
+ * message would be a copy of the whole email per address. So a message sent
17
+ * before its template was last edited previews as the template is today, and
18
+ * the frame says so rather than letting a reader take it for a record of what
19
+ * went out.
20
+ *
21
+ * ## Every rate names its denominator
22
+ *
23
+ * The arithmetic is `campaign-report.ts` — the same pure module the campaign
24
+ * report reads — so open rate over `delivered` and click rate over `delivered`
25
+ * are computed once, carry their own denominator labels, and come back `null`
26
+ * rather than 0% when they cannot honestly be taken. Nothing on this screen
27
+ * divides anything.
28
+ *
29
+ * ## What this page reads
30
+ *
31
+ * Four documents: the message, its link rollup, the template screen and the
32
+ * template's version. None of them grows with the size of the send. The
33
+ * recipient list is the one read that does, and it is its own card with its
34
+ * own request.
35
+ */
36
+ export declare function EmailDetail(props: EmailDetailProps): import("react").JSX.Element;
37
+ export declare namespace EmailDetail {
38
+ var displayName: string;
39
+ }
40
+ export default EmailDetail;