@aglyn/plugins-marketing 1.0.0-beta.221 → 1.0.0-beta.223

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 (127) hide show
  1. package/README.md +4 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-conversions-card.js +53 -64
  4. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  5. package/src/lib/components/campaign-detail-card.js +18 -3
  6. package/src/lib/components/campaign-detail-card.js.map +1 -1
  7. package/src/lib/components/campaign-edit-drawer.d.ts +6 -0
  8. package/src/lib/components/campaign-edit-drawer.js +13 -2
  9. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  10. package/src/lib/components/campaign-queries.d.ts +5 -0
  11. package/src/lib/components/campaign-queries.js +8 -1
  12. package/src/lib/components/campaign-queries.js.map +1 -1
  13. package/src/lib/components/campaign-reach-sections.d.ts +28 -0
  14. package/src/lib/components/campaign-reach-sections.js +96 -5
  15. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  16. package/src/lib/components/campaign-report-card.js +5 -4
  17. package/src/lib/components/campaign-report-card.js.map +1 -1
  18. package/src/lib/components/campaigns-card.js +1 -1
  19. package/src/lib/components/campaigns-card.js.map +1 -1
  20. package/src/lib/components/conversion-attribution.component.d.ts +18 -0
  21. package/src/lib/components/conversion-attribution.component.js +177 -39
  22. package/src/lib/components/conversion-attribution.component.js.map +1 -1
  23. package/src/lib/components/email-compose-card.js +1 -1
  24. package/src/lib/components/email-compose-card.js.map +1 -1
  25. package/src/lib/components/email-detail.js +5 -4
  26. package/src/lib/components/email-detail.js.map +1 -1
  27. package/src/lib/components/email-template-report-card.d.ts +53 -0
  28. package/src/lib/components/email-template-report-card.js +469 -0
  29. package/src/lib/components/email-template-report-card.js.map +1 -0
  30. package/src/lib/components/emails-list-card.js +2 -2
  31. package/src/lib/components/emails-list-card.js.map +1 -1
  32. package/src/lib/components/host-experiments-card.component.js +3 -2
  33. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  34. package/src/lib/components/record-attribution-widget.d.ts +5 -0
  35. package/src/lib/components/record-attribution-widget.js +6 -3
  36. package/src/lib/components/record-attribution-widget.js.map +1 -1
  37. package/src/lib/components/site-runtime.d.ts +5 -0
  38. package/src/lib/components/site-runtime.js +104 -3
  39. package/src/lib/components/site-runtime.js.map +1 -1
  40. package/src/lib/declarations.server.d.ts +9 -0
  41. package/src/lib/declarations.server.js +78 -0
  42. package/src/lib/declarations.server.js.map +1 -1
  43. package/src/lib/model/campaign-container.d.ts +531 -0
  44. package/src/lib/model/campaign-container.js +532 -0
  45. package/src/lib/model/campaign-container.js.map +1 -0
  46. package/src/lib/model/campaign-conversions.d.ts +55 -4
  47. package/src/lib/model/campaign-conversions.js +60 -4
  48. package/src/lib/model/campaign-conversions.js.map +1 -1
  49. package/src/lib/model/campaign-report.d.ts +158 -0
  50. package/src/lib/model/campaign-report.js +268 -0
  51. package/src/lib/model/campaign-report.js.map +1 -0
  52. package/src/lib/model/campaign-revenue.d.ts +3 -3
  53. package/src/lib/model/campaign-revenue.js +3 -3
  54. package/src/lib/model/campaign-revenue.js.map +1 -1
  55. package/src/lib/model/campaign-send-time.js +2 -2
  56. package/src/lib/model/campaign-send-time.js.map +1 -1
  57. package/src/lib/model/campaign-visits.d.ts +74 -0
  58. package/src/lib/model/campaign-visits.js +65 -0
  59. package/src/lib/model/campaign-visits.js.map +1 -0
  60. package/src/lib/model/compile-client-automations.d.ts +2 -2
  61. package/src/lib/model/compile-client-automations.js +3 -3
  62. package/src/lib/model/compile-client-automations.js.map +1 -1
  63. package/src/lib/model/email-record.d.ts +175 -0
  64. package/src/lib/model/email-record.js +206 -0
  65. package/src/lib/model/email-record.js.map +1 -0
  66. package/src/lib/model/site-contract.d.ts +2 -2
  67. package/src/lib/model/site-contract.js.map +1 -1
  68. package/src/lib/model/template-report.d.ts +158 -0
  69. package/src/lib/model/template-report.js +249 -0
  70. package/src/lib/model/template-report.js.map +1 -0
  71. package/src/lib/plugin.js +16 -0
  72. package/src/lib/plugin.js.map +1 -1
  73. package/src/lib/preview-props.js.map +1 -1
  74. package/src/lib/server/campaign-attribution-store.d.ts +55 -0
  75. package/src/lib/server/campaign-attribution-store.js +98 -0
  76. package/src/lib/server/campaign-attribution-store.js.map +1 -0
  77. package/src/lib/server/campaign-conversion-attribution.d.ts +314 -0
  78. package/src/lib/server/campaign-conversion-attribution.js +549 -0
  79. package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
  80. package/src/lib/server/campaign-manage.js +1 -1
  81. package/src/lib/server/campaign-manage.js.map +1 -1
  82. package/src/lib/server/campaign-org-refs.js +1 -1
  83. package/src/lib/server/campaign-org-refs.js.map +1 -1
  84. package/src/lib/server/campaign-recipients.js +7 -7
  85. package/src/lib/server/campaign-recipients.js.map +1 -1
  86. package/src/lib/server/campaign-send.js +25 -19
  87. package/src/lib/server/campaign-send.js.map +1 -1
  88. package/src/lib/server/campaign-touch-targets.d.ts +61 -0
  89. package/src/lib/server/campaign-touch-targets.js +162 -0
  90. package/src/lib/server/campaign-touch-targets.js.map +1 -0
  91. package/src/lib/server/campaign-visit-beacon.d.ts +44 -0
  92. package/src/lib/server/campaign-visit-beacon.js +125 -0
  93. package/src/lib/server/campaign-visit-beacon.js.map +1 -0
  94. package/src/lib/server/conversion-creditor.d.ts +18 -0
  95. package/src/lib/server/conversion-creditor.js +238 -0
  96. package/src/lib/server/conversion-creditor.js.map +1 -0
  97. package/src/lib/server/email-campaign-touch.d.ts +81 -0
  98. package/src/lib/server/email-campaign-touch.js +227 -0
  99. package/src/lib/server/email-campaign-touch.js.map +1 -0
  100. package/src/lib/server/email-events.js +32 -31
  101. package/src/lib/server/email-events.js.map +1 -1
  102. package/src/lib/server/email-revenue-attribution.d.ts +186 -0
  103. package/src/lib/server/email-revenue-attribution.js +322 -0
  104. package/src/lib/server/email-revenue-attribution.js.map +1 -0
  105. package/src/lib/server/email-send-record-index.d.ts +34 -0
  106. package/src/lib/server/email-send-record-index.js +90 -0
  107. package/src/lib/server/email-send-record-index.js.map +1 -0
  108. package/src/lib/server/get-client-automations.js.map +1 -1
  109. package/src/lib/server/marketing-figures.js +3 -3
  110. package/src/lib/server/marketing-figures.js.map +1 -1
  111. package/src/lib/server/send-tally.d.ts +32 -0
  112. package/src/lib/server/send-tally.js +54 -0
  113. package/src/lib/server/send-tally.js.map +1 -0
  114. package/src/lib/server/site-page-enricher.js +18 -2
  115. package/src/lib/server/site-page-enricher.js.map +1 -1
  116. package/src/lib/testing/stand-in-person-records.d.ts +35 -0
  117. package/src/lib/testing/stand-in-person-records.js +67 -0
  118. package/src/lib/testing/stand-in-person-records.js.map +1 -0
  119. package/src/lib/testing/stand-in-record-counts.d.ts +26 -0
  120. package/src/lib/testing/stand-in-record-counts.js +70 -0
  121. package/src/lib/testing/stand-in-record-counts.js.map +1 -0
  122. package/src/lib/transfer/campaigns-package.d.ts +72 -0
  123. package/src/lib/transfer/campaigns-package.js +145 -0
  124. package/src/lib/transfer/campaigns-package.js.map +1 -0
  125. package/src/lib/transfer/campaigns-package.server.d.ts +33 -0
  126. package/src/lib/transfer/campaigns-package.server.js +305 -0
  127. package/src/lib/transfer/campaigns-package.server.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/email-record.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\n/**\n * READING ONE MESSAGE RECORD.\n *\n * A message — an email that was or will be sent — is a document under\n * `hosts/{hostId}/campaigns`. Two facts about that document are awkward\n * enough that every surface reading it gets them wrong the same way, so they\n * are decided here once.\n */\n\n/**\n * When a message went out, or is due to.\n *\n * **There is no one SEND date field.** The send path writes `{status:'sent',\n * sentAt}` from one branch and `{status:'scheduled', sendAtMs}` from another.\n * So a list ordered on either field in Firestore does not mis-sort —\n * `orderBy` DROPS every document missing the ordered field — and half the\n * messages simply vanish. Sorting on this instead keeps both kinds in one\n * ordered list.\n *\n * `sentAt` is a Firestore `Timestamp`, which the client SDK gives as an\n * object with `seconds` and `toMillis()`; a document read from cache while a\n * write is pending can carry a sentinel with neither, which reads as 0 and\n * sorts last rather than throwing.\n *\n * A DRAFT correctly answers 0 here: it has no send time, and inventing one\n * from its creation would put an unsent message on the timeline of mail that\n * went out. Ordering a list is {@link emailListTimeMs}'s job.\n *\n * @returns epoch ms, or 0 when the message has no time at all.\n */\nexport function emailSendTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n // A send held for staff review is parked at a time no run reaches\n // (AGL-3356). That is a mechanism, not a send time, and printed it reads as\n // the year 9999 — so a held send answers \"no time\", like a draft, and the\n // lists order it by when it was created.\n if (\n record?.['staffReview']?.['state'] === 'held' &&\n String(record?.['status'] ?? '') === 'scheduled'\n ) {\n return 0\n }\n const sentAt = record?.['sentAt'] as\n | { toMillis?: () => number; seconds?: number }\n | undefined\n if (typeof sentAt?.toMillis === 'function') return sentAt.toMillis()\n if (typeof sentAt?.seconds === 'number') return sentAt.seconds * 1000\n const scheduled = Number(record?.['sendAtMs'] ?? 0)\n return Number.isFinite(scheduled) ? scheduled : 0\n}\n\n/**\n * The field every writer of a message stamps when it MINTS the document.\n *\n * The one date on every message, which is what neither `sentAt` nor\n * `sendAtMs` is — each is written by one branch of the send path and neither\n * is on both kinds. A field carried by every record is the precondition for\n * ordering this collection in Firestore rather than in the browser, because\n * `orderBy` drops documents that lack the ordered field instead of\n * mis-sorting them.\n *\n * Epoch milliseconds rather than a `Timestamp`, matching `sendAtMs` beside\n * it: the two are compared against each other on every list that draws\n * drafts and sends together, and a comparison that has to unwrap one side\n * first is a comparison somebody writes wrong.\n */\nexport const EMAIL_CREATED_AT_FIELD = 'createdAtMs'\n\n/**\n * When a message was created, for the records that carry it.\n *\n * `null` — not 0 — for a record carrying no {@link EMAIL_CREATED_AT_FIELD},\n * because 0 is a real instant at the far end of the sort and \"we do not know\"\n * is not \"1970\". A null orders by the record's send time instead, so the list\n * is correct for a record from any era.\n */\nexport function emailCreatedAtMs(\n record: Record<string, any> | null | undefined,\n): number | null {\n const created = Number(record?.[EMAIL_CREATED_AT_FIELD] ?? Number.NaN)\n return Number.isFinite(created) && created > 0 ? created : null\n}\n\n/**\n * WHERE A MESSAGE SITS IN A LIST OF MESSAGES.\n *\n * Its send time where it has one, and its creation time where it does not.\n *\n * A draft has neither `sentAt` nor `sendAtMs`, so ordering a list on the send\n * time alone gives every draft the key 0 and files it below mail sent years\n * ago — the newest thing a merchant did, at the bottom of the page, behind\n * whatever paging the list has. Falling back to creation puts it where the\n * merchant left it.\n *\n * The fallback is deliberately not the other way round. A SENT message orders\n * by when it went out, never by when it was drafted: the list is a record of\n * what happened, and a message drafted in March and sent in June belongs in\n * June.\n *\n * @returns epoch ms, or 0 for a record with no time of any kind — a draft\n * written before the created stamp existed, which sorts last exactly as it\n * did before.\n */\nexport function emailListTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n return emailSendTimeMs(record) || emailCreatedAtMs(record) || 0\n}\n\n/**\n * Reader-facing text for a message's state.\n *\n * The KEYS are persisted values written by the send path and read by the\n * scheduled-campaign processor — nothing here may change them, including\n * `canceled`, which is already the American spelling and is a stored enum in\n * either case. Only the right-hand side is display text, and an unrecognized\n * state falls through as itself rather than being flattened into one of\n * these: a state this list cannot name is worth seeing.\n *\n * `draft` is an email that has been created and not sent. `sending` is the\n * claim the scheduled processor and the send-now route both take before they\n * mail, and it is named here because a merchant who reloads mid-send would\n * otherwise be shown the raw token.\n *\n * ⚠️ **Not what a console row should draw.** This names the STORED value, and\n * an email delivering an audience larger than one batch is stored as\n * `scheduled` between runs — so a row rendering this says \"Scheduled\" about a\n * send that has already put five hundred messages in five hundred inboxes.\n * `campaignSendDisplay` in `campaign-container.ts` reads the counters beside\n * the status and is what every surface here draws. This remains the honest\n * answer to the narrower question of what one stored token means.\n */\nexport function emailStateLabel(status: unknown): string {\n const value = String(status ?? '')\n if (value === 'sent') return 'Sent'\n if (value === 'scheduled') return 'Scheduled'\n if (value === 'canceled') return 'Canceled'\n if (value === 'draft') return 'Draft'\n if (value === 'sending') return 'Sending'\n return value || 'Unknown'\n}\n\n/**\n * Whether an email has yet been mailed to anybody.\n *\n * The surfaces that report on a send need this because an unsent email has no\n * `stats` at all, and a report that divides into an absent denominator\n * presents \"not sent yet\" as a delivery rate of 0% — the same class of fault\n * as a rate rendered over a denominator it does not name.\n */\nexport function emailIsUnsent(\n record: Record<string, any> | null | undefined,\n): boolean {\n const value = String(record?.['status'] ?? '')\n return value === 'draft' || value === 'scheduled' || value === 'sending'\n}\n\n/**\n * Reader-facing names for the built-in audience kinds.\n *\n * The KEYS are the persisted `audience` values written on every message since\n * the composer shipped; only the values on the right are display text. A\n * `list` audience is deliberately absent — it is not a kind of audience, it\n * is a NAMED one, and naming it is {@link emailAudienceLabel}'s job.\n */\nexport const EMAIL_AUDIENCE_LABELS: Record<string, string> = {\n leads: 'All leads',\n members: 'All site members',\n segment: 'A contact segment',\n manual: 'Addresses typed into the composer',\n}\n\n/**\n * Which audience a message went to, named for a reader.\n *\n * A list send is named by the name the SEND recorded, never by the name the\n * list carries today: resolving it now would let a rename rewrite the history\n * of a message that went out months ago, and a deletion erase it. A send that\n * recorded no name says so instead of printing a document id, which is not\n * something a merchant recognises as a list.\n */\nexport function emailAudienceLabel(\n record: Record<string, any> | null | undefined,\n): string {\n const audience = String(record?.['audience'] ?? '')\n if (audience !== 'list') {\n return EMAIL_AUDIENCE_LABELS[audience] ?? audience ?? 'Not recorded'\n }\n const name = String(record?.['listName'] ?? '')\n if (name) return name\n return record?.['listId']\n ? 'A list this send did not name'\n : 'A list this send did not record'\n}\n\n/**\n * The sender a message actually left with, as this record has it.\n *\n * `recorded` is the whole point of the shape. The address a send goes out on\n * is stamped by the send — see `sentAsStamp` — and every message sent before\n * that stamp existed carries nothing, so a surface has to be able to tell an\n * unrecorded sender apart from one it could compose. Composing it is the\n * thing that must not happen: a site's sending identity is a setting, and\n * resolving it now would answer \"what would this send as today\" on a report\n * whose subject is a message that went out months ago, under whatever address\n * the site held then.\n *\n * The same rule {@link emailAudienceLabel} follows for a list name, and for\n * the same reason: the report says what happened, not what would happen now.\n *\n * `replyTo` is null on a message that set none, which is not missing\n * information — a message with no `Reply-To:` takes replies at its `from`,\n * and that is a fact the record states by omission rather than one it lost.\n * The difference only reads correctly when `recorded` is true, which is why\n * a caller must consult that first.\n */\nexport interface EmailSentAs {\n /** False on a message sent before the send began stamping its sender. */\n recorded: boolean\n from: string | null\n /** The display name recipients saw, or null when the address stood alone. */\n fromName: string | null\n /** Where replies were pointed, or null when they go to `from`. */\n replyTo: string | null\n}\n\nexport function emailSentAs(\n record: Record<string, any> | null | undefined,\n): EmailSentAs {\n const stamp = record?.['sentAs'] as Record<string, any> | null | undefined\n const from = String(stamp?.['from'] ?? '').trim()\n // The ADDRESS is what makes the stamp a record of a send. A `sentAs` map\n // carrying only a display name is a partial write, and reporting it as\n // recorded would put a name on screen beside an address nothing supplied.\n if (!from) {\n return { recorded: false, from: null, fromName: null, replyTo: null }\n }\n return {\n recorded: true,\n from,\n fromName: String(stamp?.['fromName'] ?? '').trim() || null,\n replyTo: String(stamp?.['replyTo'] ?? '').trim() || null,\n }\n}\n"],"names":["emailSendTimeMs","record","String","sentAt","toMillis","seconds","scheduled","Number","isFinite","EMAIL_CREATED_AT_FIELD","emailCreatedAtMs","created","NaN","emailListTimeMs","emailStateLabel","status","value","emailIsUnsent","EMAIL_AUDIENCE_LABELS","leads","members","segment","manual","emailAudienceLabel","audience","name","emailSentAs","stamp","from","trim","recorded","fromName","replyTo"],"mappings":"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"}
@@ -20,7 +20,7 @@
20
20
  * reads back. Pure types — shared by the /server graph and the client
21
21
  * runtime without pulling either's dependencies.
22
22
  */
23
- import type { HostActionStep, SiteEventType } from '@aglyn/aglyn';
23
+ import type { ClientInteractionStep, SiteEventType } from '@aglyn/aglyn';
24
24
  import type { ExperimentStatus } from './experiments';
25
25
  export interface ClientAutomation {
26
26
  id: string;
@@ -35,7 +35,7 @@ export interface ClientAutomation {
35
35
  cooldownMinutes?: number;
36
36
  /** Fire on every occurrence, not once per pageview (AGL-562). */
37
37
  everyTime?: boolean;
38
- steps: HostActionStep[];
38
+ steps: ClientInteractionStep[];
39
39
  hasServerSteps: boolean;
40
40
  }
41
41
  export interface ScreenExperiment {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/site-contract.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\n/**\n * The marketing plugin's site wire contract (AGL-419): shapes its server\n * page-enricher writes into the page props and its client site-runtime\n * reads back. Pure types — shared by the /server graph and the client\n * runtime without pulling either's dependencies.\n */\n\nimport type { HostActionStep, SiteEventType } from '@aglyn/aglyn'\nimport type { ExperimentStatus } from './experiments'\n\nexport interface ClientAutomation {\n id: string\n event: SiteEventType\n selector?: string\n threshold?: number\n /** Fire at most once per visitor (AGL-266). */\n oncePerVisitor?: boolean\n /** Fire at most once per browser session (AGL-274). */\n oncePerSession?: boolean\n /** Minimum minutes between fires for the same visitor (AGL-274). */\n cooldownMinutes?: number\n /** Fire on every occurrence, not once per pageview (AGL-562). */\n everyTime?: boolean\n steps: HostActionStep[]\n hasServerSteps: boolean\n}\n\nexport interface ScreenExperiment {\n id: string\n target: 'screen' | 'section'\n status: ExperimentStatus\n nodeId?: string\n winnerVariantId?: string\n /** Past this the client runner serves the default (AGL-273). */\n endAtMs?: number\n goal?: { event: string; filter?: string }\n variants: Array<{ id: string; weight?: number; versionId?: string }>\n payloads: Record<string, Record<string, any> | null>\n}\n\nexport interface AnnouncementBarData {\n text: string\n href?: string\n backgroundColor?: string\n textColor?: string\n dismissible: boolean\n contentHash: string\n overlayId?: string\n}\n\nexport interface PopupData {\n headline?: string\n body: string\n imageUrl?: string\n /**\n * Alt text for {@link imageUrl} (AGL-1896).\n *\n * The popup image used to render with a hardcoded `alt=\"\"` and there was\n * no field anywhere that could have changed it — an author-chosen banner,\n * on a paying customer's published site, invisible to a screen reader by\n * construction. Defaults from the DAM asset's own alt when the author\n * picks one through \"Browse media\"; an empty value still renders `alt=\"\"`,\n * which is the right markup for a genuinely decorative banner.\n */\n imageAlt?: string\n ctaLabel?: string\n ctaHref?: string\n trigger: string\n triggerValue: number\n frequencyDays: number\n /**\n * Cap the popup to one showing per browser session (AGL-2174). When\n * set, `frequencyDays` is not consulted — the two are alternative\n * answers to the same question.\n */\n oncePerSession?: boolean\n collectEmail?: boolean\n startAtMs?: number\n endAtMs?: number\n contentHash: string\n overlayId?: string\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;CAKC,GA6CD,WA+BC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/site-contract.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\n/**\n * The marketing plugin's site wire contract (AGL-419): shapes its server\n * page-enricher writes into the page props and its client site-runtime\n * reads back. Pure types — shared by the /server graph and the client\n * runtime without pulling either's dependencies.\n */\n\nimport type { ClientInteractionStep, SiteEventType } from '@aglyn/aglyn'\nimport type { ExperimentStatus } from './experiments'\n\nexport interface ClientAutomation {\n id: string\n event: SiteEventType\n selector?: string\n threshold?: number\n /** Fire at most once per visitor (AGL-266). */\n oncePerVisitor?: boolean\n /** Fire at most once per browser session (AGL-274). */\n oncePerSession?: boolean\n /** Minimum minutes between fires for the same visitor (AGL-274). */\n cooldownMinutes?: number\n /** Fire on every occurrence, not once per pageview (AGL-562). */\n everyTime?: boolean\n steps: ClientInteractionStep[]\n hasServerSteps: boolean\n}\n\nexport interface ScreenExperiment {\n id: string\n target: 'screen' | 'section'\n status: ExperimentStatus\n nodeId?: string\n winnerVariantId?: string\n /** Past this the client runner serves the default (AGL-273). */\n endAtMs?: number\n goal?: { event: string; filter?: string }\n variants: Array<{ id: string; weight?: number; versionId?: string }>\n payloads: Record<string, Record<string, any> | null>\n}\n\nexport interface AnnouncementBarData {\n text: string\n href?: string\n backgroundColor?: string\n textColor?: string\n dismissible: boolean\n contentHash: string\n overlayId?: string\n}\n\nexport interface PopupData {\n headline?: string\n body: string\n imageUrl?: string\n /**\n * Alt text for {@link imageUrl} (AGL-1896).\n *\n * The popup image used to render with a hardcoded `alt=\"\"` and there was\n * no field anywhere that could have changed it — an author-chosen banner,\n * on a paying customer's published site, invisible to a screen reader by\n * construction. Defaults from the DAM asset's own alt when the author\n * picks one through \"Browse media\"; an empty value still renders `alt=\"\"`,\n * which is the right markup for a genuinely decorative banner.\n */\n imageAlt?: string\n ctaLabel?: string\n ctaHref?: string\n trigger: string\n triggerValue: number\n frequencyDays: number\n /**\n * Cap the popup to one showing per browser session (AGL-2174). When\n * set, `frequencyDays` is not consulted — the two are alternative\n * answers to the same question.\n */\n oncePerSession?: boolean\n collectEmail?: boolean\n startAtMs?: number\n endAtMs?: number\n contentHash: string\n overlayId?: string\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;CAKC,GA6CD,WA+BC"}
@@ -0,0 +1,158 @@
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
+ /**
18
+ * ONE DESIGN, MEASURED ACROSS EVERY CAMPAIGN THAT USED IT.
19
+ *
20
+ * ## What this adds to `campaign-report.ts`
21
+ *
22
+ * A campaign report divides two numbers that were recorded by the same send.
23
+ * A design report divides two SUMS, and a sum has a membership: which
24
+ * campaigns went into it. That is the whole difficulty, and it is a new way
25
+ * for the repo's rule — every rate names its denominator — to be broken.
26
+ *
27
+ * ## The rule, restated for a sum
28
+ *
29
+ * **A rate's numerator and its denominator are summed over the SAME set of
30
+ * campaigns, and the screen is told how large that set is.**
31
+ *
32
+ * `delivered` is written only by the delivery webhook, so a design used by
33
+ * five campaigns may have three that recorded it and two that never did.
34
+ * Summing `uniqueOpens` over all five and dividing by `delivered` summed over
35
+ * three produces an open rate that can exceed 100% and is, in every case,
36
+ * the flattering substitution `campaign-report.ts` exists to refuse — carried
37
+ * out by addition instead of by picking the wrong field.
38
+ *
39
+ * So each rate here is built from a FILTERED subset, and
40
+ * {@link TemplateRate.denominatorLabel} names the subset as well as the
41
+ * quantity: `delivered across 3 of 5 campaigns`. A reader comparing this
42
+ * design's open rate with another's can see the two were taken over different
43
+ * populations without being told separately.
44
+ *
45
+ * ## Counts are over everything; rates are over what can be divided
46
+ *
47
+ * The COUNTS — opens, clicks, unsubscribes — are summed over every campaign,
48
+ * because a count is true whatever else was recorded. Only the rates narrow.
49
+ * The two are labelled differently on screen for that reason, and the caveats
50
+ * say how many campaigns each rate left out.
51
+ *
52
+ * ## Why this is a pure module
53
+ *
54
+ * Same reason as `campaign-report.ts`: a division written in JSX is a
55
+ * denominator chosen by whoever writes the next card. Here it is worse — the
56
+ * subset is invisible in the output unless something carries it — so the
57
+ * subset is data, computed once, and the screen renders it.
58
+ */
59
+ import { type SendRate, type SendStats } from '@aglyn/shared-ui-email-campaigns/model/send-report';
60
+ /** A rate over a sum, carrying how many campaigns the sum covers. */
61
+ export type TemplateRate = SendRate;
62
+ /**
63
+ * One campaign that used the design, as the report reads it.
64
+ *
65
+ * `sentAtMs` is nullable because a campaign document can exist without ever
66
+ * having been sent — a scheduled campaign holds `sendAtMs` and no `sentAt`.
67
+ * Those carry no engagement and the reader is shown their status rather than
68
+ * a row of zeroes.
69
+ */
70
+ export interface TemplateCampaign {
71
+ campaignId: string;
72
+ subject: string;
73
+ /** Epoch ms, or null for a campaign that has not been sent. */
74
+ sentAtMs: number | null;
75
+ /** `'sent'`, `'scheduled'`, `'canceled'`. A persisted value; not relabeled. */
76
+ status: string;
77
+ /** The audience KIND — `'leads'`, `'members'`, `'segment'`, `'list'`. */
78
+ audience: string;
79
+ /** Set when `audience` is `'list'`. */
80
+ listId?: string;
81
+ /** The list's name AS IT WAS at send time, when the send recorded one. */
82
+ listName?: string;
83
+ stats: SendStats | undefined;
84
+ }
85
+ /**
86
+ * One audience the design was sent to.
87
+ *
88
+ * A LIST is named by the name the send recorded, not by the name the list
89
+ * carries today: a renamed or deleted list would otherwise rewrite the
90
+ * history of a campaign that went out months ago, and a deleted one would
91
+ * erase it. The same reasoning as the campaign report's populations.
92
+ */
93
+ export interface TemplateAudience {
94
+ /** Stable key — the list id, or the audience kind for the built-ins. */
95
+ id: string;
96
+ label: string;
97
+ /** How many campaigns using this design went to this audience. */
98
+ campaigns: number;
99
+ /** Addresses those campaigns ADDRESSED, summed. */
100
+ addressed: number;
101
+ /**
102
+ * The named list has since been deleted or renamed out of reach.
103
+ *
104
+ * Only ever true for a `list` audience, and only when the send recorded no
105
+ * name — an older send, before the name was stored. The screen says "list
106
+ * no longer named" rather than printing a raw document id as if it were
107
+ * something a merchant would recognise.
108
+ */
109
+ unnamed?: boolean;
110
+ }
111
+ /** Why a number the design report would otherwise show is being withheld. */
112
+ export interface TemplateCaveat {
113
+ id: 'no-campaigns' | 'delivery-partial' | 'click-tracking-partial' | 'campaigns-truncated' | 'audience-truncated';
114
+ message: string;
115
+ }
116
+ /** Everything the design report screen renders. */
117
+ export interface TemplateReport {
118
+ /** Campaigns that used this design AND were sent. */
119
+ sentCampaigns: number;
120
+ /** Campaigns that used this design, sent or not. */
121
+ totalCampaigns: number;
122
+ /** Epoch ms of the most recent send, or null. */
123
+ lastSentAtMs: number | null;
124
+ recipients: number;
125
+ sent: number;
126
+ /** Summed over the campaigns that recorded it; null when none did. */
127
+ delivered: number | null;
128
+ opens: number;
129
+ clicks: number;
130
+ uniqueOpens: number | null;
131
+ uniqueClicks: number | null;
132
+ bounced: number;
133
+ complained: number;
134
+ unsubscribes: number;
135
+ /** Rates, each `null` when its denominator is zero or unrecorded. */
136
+ rates: {
137
+ delivery: TemplateRate | null;
138
+ open: TemplateRate | null;
139
+ click: TemplateRate | null;
140
+ clickToOpen: TemplateRate | null;
141
+ bounce: TemplateRate | null;
142
+ complaint: TemplateRate | null;
143
+ unsubscribe: TemplateRate | null;
144
+ };
145
+ audiences: TemplateAudience[];
146
+ caveats: TemplateCaveat[];
147
+ }
148
+ /**
149
+ * Aggregates every campaign that used one design.
150
+ *
151
+ * @param campaigns every campaign document naming this design, sent or not.
152
+ * @param truncated the query that produced `campaigns` stopped at its read
153
+ * ceiling, so every total here is a FLOOR. Reported as a caveat rather
154
+ * than hidden, on the same reasoning as `audienceSizeTruncated`: a
155
+ * figure that is at least this large is useful, and a figure presented
156
+ * as complete when it is not is worse than none.
157
+ */
158
+ export declare function templateReport(campaigns: readonly TemplateCampaign[], truncated?: boolean): TemplateReport;
@@ -0,0 +1,249 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * ONE DESIGN, MEASURED ACROSS EVERY CAMPAIGN THAT USED IT.
19
+ *
20
+ * ## What this adds to `campaign-report.ts`
21
+ *
22
+ * A campaign report divides two numbers that were recorded by the same send.
23
+ * A design report divides two SUMS, and a sum has a membership: which
24
+ * campaigns went into it. That is the whole difficulty, and it is a new way
25
+ * for the repo's rule — every rate names its denominator — to be broken.
26
+ *
27
+ * ## The rule, restated for a sum
28
+ *
29
+ * **A rate's numerator and its denominator are summed over the SAME set of
30
+ * campaigns, and the screen is told how large that set is.**
31
+ *
32
+ * `delivered` is written only by the delivery webhook, so a design used by
33
+ * five campaigns may have three that recorded it and two that never did.
34
+ * Summing `uniqueOpens` over all five and dividing by `delivered` summed over
35
+ * three produces an open rate that can exceed 100% and is, in every case,
36
+ * the flattering substitution `campaign-report.ts` exists to refuse — carried
37
+ * out by addition instead of by picking the wrong field.
38
+ *
39
+ * So each rate here is built from a FILTERED subset, and
40
+ * {@link TemplateRate.denominatorLabel} names the subset as well as the
41
+ * quantity: `delivered across 3 of 5 campaigns`. A reader comparing this
42
+ * design's open rate with another's can see the two were taken over different
43
+ * populations without being told separately.
44
+ *
45
+ * ## Counts are over everything; rates are over what can be divided
46
+ *
47
+ * The COUNTS — opens, clicks, unsubscribes — are summed over every campaign,
48
+ * because a count is true whatever else was recorded. Only the rates narrow.
49
+ * The two are labelled differently on screen for that reason, and the caveats
50
+ * say how many campaigns each rate left out.
51
+ *
52
+ * ## Why this is a pure module
53
+ *
54
+ * Same reason as `campaign-report.ts`: a division written in JSX is a
55
+ * denominator chosen by whoever writes the next card. Here it is worse — the
56
+ * subset is invisible in the output unless something carries it — so the
57
+ * subset is data, computed once, and the screen renders it.
58
+ */ import { sendRate } from "@aglyn/shared-ui-email-campaigns/model/send-report";
59
+ import { emailAudienceLabel } from "./email-record.js";
60
+ /** A campaign that actually went out. The only kind engagement can describe. */ function wasSent(campaign) {
61
+ return campaign.sentAtMs !== null;
62
+ }
63
+ /** Sums one stats field over a set of campaigns, defaulting absent to zero. */ function total(campaigns, field) {
64
+ return campaigns.reduce((running, campaign)=>{
65
+ var _ref;
66
+ var _campaign_stats;
67
+ return running + Number((_ref = (_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats[field]) != null ? _ref : 0);
68
+ }, 0);
69
+ }
70
+ /**
71
+ * `${quantity} across N of M campaigns` — the denominator label for a sum.
72
+ *
73
+ * The `of M` half is what makes a narrowed rate readable. `delivered across 3
74
+ * campaigns` is true and still invites the reader to assume the design has
75
+ * three; `across 3 of 5` says on its face that two are missing from this
76
+ * number and not from the counts above it.
77
+ */ function acrossLabel(quantity, covered, all) {
78
+ return covered === all ? `${quantity} across ${covered} ${covered === 1 ? 'campaign' : 'campaigns'}` : `${quantity} across ${covered} of ${all} campaigns`;
79
+ }
80
+ /**
81
+ * Aggregates every campaign that used one design.
82
+ *
83
+ * @param campaigns every campaign document naming this design, sent or not.
84
+ * @param truncated the query that produced `campaigns` stopped at its read
85
+ * ceiling, so every total here is a FLOOR. Reported as a caveat rather
86
+ * than hidden, on the same reasoning as `audienceSizeTruncated`: a
87
+ * figure that is at least this large is useful, and a figure presented
88
+ * as complete when it is not is worse than none.
89
+ */ export function templateReport(campaigns, truncated = false) {
90
+ const sentCampaigns = campaigns.filter(wasSent);
91
+ const all = sentCampaigns.length;
92
+ /*
93
+ * THE SUBSETS. Each rate divides sums taken over one of these and nothing
94
+ * else, which is what makes the numerator and denominator describe the same
95
+ * population — see this module's header.
96
+ */ const withDelivery = sentCampaigns.filter((campaign)=>{
97
+ var _campaign_stats;
98
+ return ((_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.delivered) !== undefined;
99
+ });
100
+ const withUniqueOpens = withDelivery.filter((campaign)=>{
101
+ var _campaign_stats;
102
+ return ((_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.uniqueOpens) !== undefined;
103
+ });
104
+ /*
105
+ * Click rates need BOTH a delivery denominator and the record that the send
106
+ * carried an HTML part. A send with no HTML part reports zero clicks
107
+ * whatever recipients did, so including it in a click rate's denominator
108
+ * drags the rate down by however many untrackable sends this design has —
109
+ * a measurement of our own sending code presented as a measurement of the
110
+ * audience. `campaign-report.ts` refuses the same substitution for one
111
+ * campaign; a sum must refuse it per campaign, not for the design as a
112
+ * whole, or one old send would withhold the design's whole click rate.
113
+ */ const withClickTracking = withDelivery.filter((campaign)=>{
114
+ var _campaign_stats, _campaign_stats1;
115
+ return ((_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.clickTracked) === true && ((_campaign_stats1 = campaign.stats) == null ? void 0 : _campaign_stats1.uniqueClicks) !== undefined;
116
+ });
117
+ const withOpenersAndClicks = withClickTracking.filter((campaign)=>{
118
+ var _campaign_stats;
119
+ return ((_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.uniqueOpens) !== undefined;
120
+ });
121
+ const sent = total(sentCampaigns, 'sent');
122
+ const delivered = withDelivery.length ? total(withDelivery, 'delivered') : null;
123
+ const uniqueOpens = withUniqueOpens.length ? total(withUniqueOpens, 'uniqueOpens') : null;
124
+ const uniqueClicks = withClickTracking.length ? total(withClickTracking, 'uniqueClicks') : null;
125
+ const rates = {
126
+ /*
127
+ * Over the campaigns that recorded a delivery, and `sent` summed over
128
+ * those SAME campaigns rather than over all of them. The alternative —
129
+ * delivered over the design's whole `sent` — reads as a delivery failure
130
+ * proportional to how many of this design's sends predate the webhook.
131
+ */ delivery: delivered === null ? null : sendRate(delivered, total(withDelivery, 'sent'), acrossLabel('sent', withDelivery.length, all)),
132
+ open: sendRate(uniqueOpens != null ? uniqueOpens : undefined, withUniqueOpens.length ? total(withUniqueOpens, 'delivered') : undefined, acrossLabel('delivered', withUniqueOpens.length, all)),
133
+ click: sendRate(uniqueClicks != null ? uniqueClicks : undefined, withClickTracking.length ? total(withClickTracking, 'delivered') : undefined, acrossLabel('delivered', withClickTracking.length, all)),
134
+ clickToOpen: sendRate(withOpenersAndClicks.length ? total(withOpenersAndClicks, 'uniqueClicks') : undefined, withOpenersAndClicks.length ? total(withOpenersAndClicks, 'uniqueOpens') : undefined, acrossLabel('unique openers', withOpenersAndClicks.length, all)),
135
+ /*
136
+ * Bounces and their rate are over every sent campaign, because both
137
+ * halves are recorded for all of them: `sent` by the send itself and
138
+ * `bounced` by a webhook whose absence is a genuine zero — no bounce
139
+ * event means nothing bounced, unlike `delivered`, whose absence means
140
+ * nothing was measured.
141
+ */ bounce: sendRate(total(sentCampaigns, 'bounced'), sent, acrossLabel('sent', all, all)),
142
+ complaint: sendRate(withDelivery.length ? total(withDelivery, 'complained') : undefined, delivered != null ? delivered : undefined, acrossLabel('delivered', withDelivery.length, all)),
143
+ unsubscribe: sendRate(withDelivery.length ? total(withDelivery, 'unsubscribes') : undefined, delivered != null ? delivered : undefined, acrossLabel('delivered', withDelivery.length, all))
144
+ };
145
+ const caveats = [];
146
+ if (!all) {
147
+ caveats.push({
148
+ id: 'no-campaigns',
149
+ message: campaigns.length ? 'No campaign using this design has been sent yet, so there is ' + 'nothing to measure. The campaigns below are scheduled or were ' + 'canceled before they went out.' : 'This design has never been sent, so there is nothing to measure.'
150
+ });
151
+ } else if (withDelivery.length < all) {
152
+ caveats.push({
153
+ id: 'delivery-partial',
154
+ message: `${all - withDelivery.length} of ${all} campaigns using this design ` + 'recorded no delivery events, so they are left out of every rate ' + 'taken over delivered — open, click, complaint and unsubscribe. ' + 'Their counts are still included above.'
155
+ });
156
+ }
157
+ if (all && withClickTracking.length < withDelivery.length) {
158
+ caveats.push({
159
+ id: 'click-tracking-partial',
160
+ message: `${withDelivery.length - withClickTracking.length} of these campaigns ` + 'did not record carrying an HTML part. Click tracking rewrites links ' + 'in the HTML, so those sends report zero clicks whatever recipients ' + 'did, and they are left out of the click rates rather than counted ' + 'as sends nobody clicked.'
161
+ });
162
+ }
163
+ if (truncated) {
164
+ caveats.push({
165
+ id: 'campaigns-truncated',
166
+ message: 'This design has been used by more campaigns than one read returns, ' + 'so every total here is a floor — the real figures are at least ' + 'this large.'
167
+ });
168
+ }
169
+ if (sentCampaigns.some((campaign)=>{
170
+ var _campaign_stats;
171
+ return (_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.audienceSizeTruncated;
172
+ })) {
173
+ caveats.push({
174
+ id: 'audience-truncated',
175
+ message: 'At least one of these sends stopped audience resolution at its read ' + 'ceiling, so the audience figures it contributed are floors.'
176
+ });
177
+ }
178
+ return {
179
+ sentCampaigns: all,
180
+ totalCampaigns: campaigns.length,
181
+ lastSentAtMs: sentCampaigns.reduce((latest, campaign)=>{
182
+ var _campaign_sentAtMs;
183
+ return latest === null || ((_campaign_sentAtMs = campaign.sentAtMs) != null ? _campaign_sentAtMs : 0) > latest ? campaign.sentAtMs : latest;
184
+ }, null),
185
+ recipients: total(sentCampaigns, 'recipients'),
186
+ sent,
187
+ delivered,
188
+ opens: total(sentCampaigns, 'opens'),
189
+ clicks: total(sentCampaigns, 'clicks'),
190
+ uniqueOpens,
191
+ uniqueClicks,
192
+ bounced: total(sentCampaigns, 'bounced'),
193
+ complained: total(sentCampaigns, 'complained'),
194
+ unsubscribes: total(sentCampaigns, 'unsubscribes'),
195
+ rates,
196
+ audiences: templateAudiences(sentCampaigns),
197
+ caveats
198
+ };
199
+ }
200
+ /**
201
+ * The audiences the design actually went to, largest first.
202
+ *
203
+ * Keyed on the LIST ID for a list send and on the audience kind otherwise, so
204
+ * two campaigns to the same list are one row and two campaigns to "all leads"
205
+ * are one row — but a list send and a segment send are never merged, because
206
+ * they are different questions about who received this design.
207
+ *
208
+ * A list send whose campaign recorded no list id at all is dropped rather
209
+ * than filed under a generic "List" heading: a row that cannot name which
210
+ * list is a row that answers the question wrongly.
211
+ */ function templateAudiences(campaigns) {
212
+ const rows = new Map();
213
+ for (const campaign of campaigns){
214
+ var _ref;
215
+ var _campaign_stats;
216
+ const isList = campaign.audience === 'list';
217
+ if (isList && !campaign.listId) continue;
218
+ const id = isList ? `list:${campaign.listId}` : campaign.audience;
219
+ const existing = rows.get(id);
220
+ const addressed = Number((_ref = (_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.recipients) != null ? _ref : 0);
221
+ if (existing) {
222
+ existing.campaigns += 1;
223
+ existing.addressed += addressed;
224
+ // A later send that DID record the name fills one that did not, so a
225
+ // list mailed twice is named whenever any of those sends named it.
226
+ if (existing.unnamed && campaign.listName) {
227
+ existing.label = campaign.listName;
228
+ delete existing.unnamed;
229
+ }
230
+ continue;
231
+ }
232
+ rows.set(id, _extends({
233
+ id,
234
+ // Named once, in `email-record.ts`, so a list row on this table and
235
+ // the same list on a message's own page cannot disagree about what to
236
+ // call it.
237
+ label: emailAudienceLabel(campaign),
238
+ campaigns: 1,
239
+ addressed
240
+ }, isList && !campaign.listName ? {
241
+ unnamed: true
242
+ } : {}));
243
+ }
244
+ return [
245
+ ...rows.values()
246
+ ].sort((a, b)=>b.addressed - a.addressed || a.label.localeCompare(b.label));
247
+ }
248
+
249
+ //# sourceMappingURL=template-report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/template-report.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\n/**\n * ONE DESIGN, MEASURED ACROSS EVERY CAMPAIGN THAT USED IT.\n *\n * ## What this adds to `campaign-report.ts`\n *\n * A campaign report divides two numbers that were recorded by the same send.\n * A design report divides two SUMS, and a sum has a membership: which\n * campaigns went into it. That is the whole difficulty, and it is a new way\n * for the repo's rule — every rate names its denominator — to be broken.\n *\n * ## The rule, restated for a sum\n *\n * **A rate's numerator and its denominator are summed over the SAME set of\n * campaigns, and the screen is told how large that set is.**\n *\n * `delivered` is written only by the delivery webhook, so a design used by\n * five campaigns may have three that recorded it and two that never did.\n * Summing `uniqueOpens` over all five and dividing by `delivered` summed over\n * three produces an open rate that can exceed 100% and is, in every case,\n * the flattering substitution `campaign-report.ts` exists to refuse — carried\n * out by addition instead of by picking the wrong field.\n *\n * So each rate here is built from a FILTERED subset, and\n * {@link TemplateRate.denominatorLabel} names the subset as well as the\n * quantity: `delivered across 3 of 5 campaigns`. A reader comparing this\n * design's open rate with another's can see the two were taken over different\n * populations without being told separately.\n *\n * ## Counts are over everything; rates are over what can be divided\n *\n * The COUNTS — opens, clicks, unsubscribes — are summed over every campaign,\n * because a count is true whatever else was recorded. Only the rates narrow.\n * The two are labelled differently on screen for that reason, and the caveats\n * say how many campaigns each rate left out.\n *\n * ## Why this is a pure module\n *\n * Same reason as `campaign-report.ts`: a division written in JSX is a\n * denominator chosen by whoever writes the next card. Here it is worse — the\n * subset is invisible in the output unless something carries it — so the\n * subset is data, computed once, and the screen renders it.\n */\n\nimport {\n sendRate,\n type SendRate,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { emailAudienceLabel } from './email-record'\n\n/** A rate over a sum, carrying how many campaigns the sum covers. */\nexport type TemplateRate = SendRate\n\n/**\n * One campaign that used the design, as the report reads it.\n *\n * `sentAtMs` is nullable because a campaign document can exist without ever\n * having been sent — a scheduled campaign holds `sendAtMs` and no `sentAt`.\n * Those carry no engagement and the reader is shown their status rather than\n * a row of zeroes.\n */\nexport interface TemplateCampaign {\n campaignId: string\n subject: string\n /** Epoch ms, or null for a campaign that has not been sent. */\n sentAtMs: number | null\n /** `'sent'`, `'scheduled'`, `'canceled'`. A persisted value; not relabeled. */\n status: string\n /** The audience KIND — `'leads'`, `'members'`, `'segment'`, `'list'`. */\n audience: string\n /** Set when `audience` is `'list'`. */\n listId?: string\n /** The list's name AS IT WAS at send time, when the send recorded one. */\n listName?: string\n stats: SendStats | undefined\n}\n\n/**\n * One audience the design was sent to.\n *\n * A LIST is named by the name the send recorded, not by the name the list\n * carries today: a renamed or deleted list would otherwise rewrite the\n * history of a campaign that went out months ago, and a deleted one would\n * erase it. The same reasoning as the campaign report's populations.\n */\nexport interface TemplateAudience {\n /** Stable key — the list id, or the audience kind for the built-ins. */\n id: string\n label: string\n /** How many campaigns using this design went to this audience. */\n campaigns: number\n /** Addresses those campaigns ADDRESSED, summed. */\n addressed: number\n /**\n * The named list has since been deleted or renamed out of reach.\n *\n * Only ever true for a `list` audience, and only when the send recorded no\n * name — an older send, before the name was stored. The screen says \"list\n * no longer named\" rather than printing a raw document id as if it were\n * something a merchant would recognise.\n */\n unnamed?: boolean\n}\n\n/** Why a number the design report would otherwise show is being withheld. */\nexport interface TemplateCaveat {\n id:\n | 'no-campaigns'\n | 'delivery-partial'\n | 'click-tracking-partial'\n | 'campaigns-truncated'\n | 'audience-truncated'\n message: string\n}\n\n/** Everything the design report screen renders. */\nexport interface TemplateReport {\n /** Campaigns that used this design AND were sent. */\n sentCampaigns: number\n /** Campaigns that used this design, sent or not. */\n totalCampaigns: number\n /** Epoch ms of the most recent send, or null. */\n lastSentAtMs: number | null\n\n /*========================================\n * COUNTS — over every sent campaign.\n *=======================================*/\n recipients: number\n sent: number\n /** Summed over the campaigns that recorded it; null when none did. */\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n delivery: TemplateRate | null\n open: TemplateRate | null\n click: TemplateRate | null\n clickToOpen: TemplateRate | null\n bounce: TemplateRate | null\n complaint: TemplateRate | null\n unsubscribe: TemplateRate | null\n }\n\n audiences: TemplateAudience[]\n caveats: TemplateCaveat[]\n}\n\n/** A campaign that actually went out. The only kind engagement can describe. */\nfunction wasSent(campaign: TemplateCampaign): boolean {\n return campaign.sentAtMs !== null\n}\n\n/** Sums one stats field over a set of campaigns, defaulting absent to zero. */\nfunction total(\n campaigns: readonly TemplateCampaign[],\n field: keyof SendStats,\n): number {\n return campaigns.reduce(\n (running, campaign) => running + Number(campaign.stats?.[field] ?? 0),\n 0,\n )\n}\n\n/**\n * `${quantity} across N of M campaigns` — the denominator label for a sum.\n *\n * The `of M` half is what makes a narrowed rate readable. `delivered across 3\n * campaigns` is true and still invites the reader to assume the design has\n * three; `across 3 of 5` says on its face that two are missing from this\n * number and not from the counts above it.\n */\nfunction acrossLabel(quantity: string, covered: number, all: number): string {\n return covered === all\n ? `${quantity} across ${covered} ${covered === 1 ? 'campaign' : 'campaigns'}`\n : `${quantity} across ${covered} of ${all} campaigns`\n}\n\n/**\n * Aggregates every campaign that used one design.\n *\n * @param campaigns every campaign document naming this design, sent or not.\n * @param truncated the query that produced `campaigns` stopped at its read\n * ceiling, so every total here is a FLOOR. Reported as a caveat rather\n * than hidden, on the same reasoning as `audienceSizeTruncated`: a\n * figure that is at least this large is useful, and a figure presented\n * as complete when it is not is worse than none.\n */\nexport function templateReport(\n campaigns: readonly TemplateCampaign[],\n truncated = false,\n): TemplateReport {\n const sentCampaigns = campaigns.filter(wasSent)\n const all = sentCampaigns.length\n\n /*\n * THE SUBSETS. Each rate divides sums taken over one of these and nothing\n * else, which is what makes the numerator and denominator describe the same\n * population — see this module's header.\n */\n const withDelivery = sentCampaigns.filter(\n (campaign) => campaign.stats?.delivered !== undefined,\n )\n const withUniqueOpens = withDelivery.filter(\n (campaign) => campaign.stats?.uniqueOpens !== undefined,\n )\n /*\n * Click rates need BOTH a delivery denominator and the record that the send\n * carried an HTML part. A send with no HTML part reports zero clicks\n * whatever recipients did, so including it in a click rate's denominator\n * drags the rate down by however many untrackable sends this design has —\n * a measurement of our own sending code presented as a measurement of the\n * audience. `campaign-report.ts` refuses the same substitution for one\n * campaign; a sum must refuse it per campaign, not for the design as a\n * whole, or one old send would withhold the design's whole click rate.\n */\n const withClickTracking = withDelivery.filter(\n (campaign) =>\n campaign.stats?.clickTracked === true &&\n campaign.stats?.uniqueClicks !== undefined,\n )\n const withOpenersAndClicks = withClickTracking.filter(\n (campaign) => campaign.stats?.uniqueOpens !== undefined,\n )\n\n const sent = total(sentCampaigns, 'sent')\n const delivered = withDelivery.length\n ? total(withDelivery, 'delivered')\n : null\n const uniqueOpens = withUniqueOpens.length\n ? total(withUniqueOpens, 'uniqueOpens')\n : null\n const uniqueClicks = withClickTracking.length\n ? total(withClickTracking, 'uniqueClicks')\n : null\n\n const rates: TemplateReport['rates'] = {\n /*\n * Over the campaigns that recorded a delivery, and `sent` summed over\n * those SAME campaigns rather than over all of them. The alternative —\n * delivered over the design's whole `sent` — reads as a delivery failure\n * proportional to how many of this design's sends predate the webhook.\n */\n delivery: delivered === null\n ? null\n : sendRate(\n delivered,\n total(withDelivery, 'sent'),\n acrossLabel('sent', withDelivery.length, all),\n ),\n open: sendRate(\n uniqueOpens ?? undefined,\n withUniqueOpens.length ? total(withUniqueOpens, 'delivered') : undefined,\n acrossLabel('delivered', withUniqueOpens.length, all),\n ),\n click: sendRate(\n uniqueClicks ?? undefined,\n withClickTracking.length\n ? total(withClickTracking, 'delivered')\n : undefined,\n acrossLabel('delivered', withClickTracking.length, all),\n ),\n clickToOpen: sendRate(\n withOpenersAndClicks.length\n ? total(withOpenersAndClicks, 'uniqueClicks')\n : undefined,\n withOpenersAndClicks.length\n ? total(withOpenersAndClicks, 'uniqueOpens')\n : undefined,\n acrossLabel('unique openers', withOpenersAndClicks.length, all),\n ),\n /*\n * Bounces and their rate are over every sent campaign, because both\n * halves are recorded for all of them: `sent` by the send itself and\n * `bounced` by a webhook whose absence is a genuine zero — no bounce\n * event means nothing bounced, unlike `delivered`, whose absence means\n * nothing was measured.\n */\n bounce: sendRate(\n total(sentCampaigns, 'bounced'),\n sent,\n acrossLabel('sent', all, all),\n ),\n complaint: sendRate(\n withDelivery.length ? total(withDelivery, 'complained') : undefined,\n delivered ?? undefined,\n acrossLabel('delivered', withDelivery.length, all),\n ),\n unsubscribe: sendRate(\n withDelivery.length ? total(withDelivery, 'unsubscribes') : undefined,\n delivered ?? undefined,\n acrossLabel('delivered', withDelivery.length, all),\n ),\n }\n\n const caveats: TemplateCaveat[] = []\n if (!all) {\n caveats.push({\n id: 'no-campaigns',\n message: campaigns.length\n ? 'No campaign using this design has been sent yet, so there is ' +\n 'nothing to measure. The campaigns below are scheduled or were ' +\n 'canceled before they went out.'\n : 'This design has never been sent, so there is nothing to measure.',\n })\n } else if (withDelivery.length < all) {\n caveats.push({\n id: 'delivery-partial',\n message:\n `${all - withDelivery.length} of ${all} campaigns using this design ` +\n 'recorded no delivery events, so they are left out of every rate ' +\n 'taken over delivered — open, click, complaint and unsubscribe. ' +\n 'Their counts are still included above.',\n })\n }\n if (all && withClickTracking.length < withDelivery.length) {\n caveats.push({\n id: 'click-tracking-partial',\n message:\n `${withDelivery.length - withClickTracking.length} of these campaigns ` +\n 'did not record carrying an HTML part. Click tracking rewrites links ' +\n 'in the HTML, so those sends report zero clicks whatever recipients ' +\n 'did, and they are left out of the click rates rather than counted ' +\n 'as sends nobody clicked.',\n })\n }\n if (truncated) {\n caveats.push({\n id: 'campaigns-truncated',\n message:\n 'This design has been used by more campaigns than one read returns, ' +\n 'so every total here is a floor — the real figures are at least ' +\n 'this large.',\n })\n }\n if (sentCampaigns.some((campaign) => campaign.stats?.audienceSizeTruncated)) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'At least one of these sends stopped audience resolution at its read ' +\n 'ceiling, so the audience figures it contributed are floors.',\n })\n }\n\n return {\n sentCampaigns: all,\n totalCampaigns: campaigns.length,\n lastSentAtMs: sentCampaigns.reduce<number | null>(\n (latest, campaign) =>\n latest === null || (campaign.sentAtMs ?? 0) > latest\n ? campaign.sentAtMs\n : latest,\n null,\n ),\n recipients: total(sentCampaigns, 'recipients'),\n sent,\n delivered,\n opens: total(sentCampaigns, 'opens'),\n clicks: total(sentCampaigns, 'clicks'),\n uniqueOpens,\n uniqueClicks,\n bounced: total(sentCampaigns, 'bounced'),\n complained: total(sentCampaigns, 'complained'),\n unsubscribes: total(sentCampaigns, 'unsubscribes'),\n rates,\n audiences: templateAudiences(sentCampaigns),\n caveats,\n }\n}\n\n/**\n * The audiences the design actually went to, largest first.\n *\n * Keyed on the LIST ID for a list send and on the audience kind otherwise, so\n * two campaigns to the same list are one row and two campaigns to \"all leads\"\n * are one row — but a list send and a segment send are never merged, because\n * they are different questions about who received this design.\n *\n * A list send whose campaign recorded no list id at all is dropped rather\n * than filed under a generic \"List\" heading: a row that cannot name which\n * list is a row that answers the question wrongly.\n */\nfunction templateAudiences(\n campaigns: readonly TemplateCampaign[],\n): TemplateAudience[] {\n const rows = new Map<string, TemplateAudience>()\n for (const campaign of campaigns) {\n const isList = campaign.audience === 'list'\n if (isList && !campaign.listId) continue\n const id = isList ? `list:${campaign.listId}` : campaign.audience\n const existing = rows.get(id)\n const addressed = Number(campaign.stats?.recipients ?? 0)\n if (existing) {\n existing.campaigns += 1\n existing.addressed += addressed\n // A later send that DID record the name fills one that did not, so a\n // list mailed twice is named whenever any of those sends named it.\n if (existing.unnamed && campaign.listName) {\n existing.label = campaign.listName\n delete existing.unnamed\n }\n continue\n }\n rows.set(id, {\n id,\n // Named once, in `email-record.ts`, so a list row on this table and\n // the same list on a message's own page cannot disagree about what to\n // call it.\n label: emailAudienceLabel(campaign),\n campaigns: 1,\n addressed,\n ...(isList && !campaign.listName ? { unnamed: true as const } : {}),\n })\n }\n return [...rows.values()].sort(\n (a, b) => b.addressed - a.addressed || a.label.localeCompare(b.label),\n )\n}\n"],"names":["sendRate","emailAudienceLabel","wasSent","campaign","sentAtMs","total","campaigns","field","reduce","running","Number","stats","acrossLabel","quantity","covered","all","templateReport","truncated","sentCampaigns","filter","length","withDelivery","delivered","undefined","withUniqueOpens","uniqueOpens","withClickTracking","clickTracked","uniqueClicks","withOpenersAndClicks","sent","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","caveats","push","id","message","some","audienceSizeTruncated","totalCampaigns","lastSentAtMs","latest","recipients","opens","clicks","bounced","complained","unsubscribes","audiences","templateAudiences","rows","Map","isList","audience","listId","existing","get","addressed","unnamed","listName","label","set","values","sort","a","b","localeCompare"],"mappings":"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"}
package/src/lib/plugin.js CHANGED
@@ -20,6 +20,7 @@ import { lazy } from "react";
20
20
  import { CAMPAIGN_DESIGN_CREATE_ZONE, CAMPAIGN_DESIGN_PREVIEW_ZONE, CAMPAIGN_SENDER_EDITOR_ZONE, CAMPAIGN_TOPIC_OPTIONS_ZONE, CAMPAIGN_TOPIC_SELECT_ZONE } from "./components/campaign-email-zones.js";
21
21
  import { EXPERIMENT_RESULT_ZONE, EXPERIMENT_VARIANTS_ZONE } from "./components/experiment-zones.js";
22
22
  import { MARKETING_CONSOLE_SECTIONS, MARKETING_ORG_CONSOLE_SECTIONS } from "./components/marketing-console-sections.js";
23
+ import { registerPluginTransferResourceUi } from "@aglyn/aglyn/plugin-manager/plugin-transfer-resources";
23
24
  import { BUNDLE_ID } from "./constants/bundle-common.js";
24
25
  import { StaffOrgEmailCard } from "./components/staff-org-email-card.component.js";
25
26
  import { registerMarketingRecordLists } from "./model/overlay-record-list.js";
@@ -31,6 +32,7 @@ import { registerMarketingRecordRoutes } from "./model/marketing-record-routes.j
31
32
  * site's on the organization's Inbox.
32
33
  */ const InboxCampaignsWidget = lazy(()=>import("./components/inbox-campaigns-widget.js"));
33
34
  /** The Emails page's Messages section: the list, one message, its composer. */ const EmailMessagesWidget = lazy(()=>import("./components/email-messages-widget.js"));
35
+ /** What the sends built from one template did, on the template's page. */ const EmailTemplateReportCard = lazy(()=>import("./components/email-template-report-card.js"));
34
36
  /** Who received the sends built from one template, on the template's page. */ const EmailRecipientsCard = lazy(()=>import("./components/email-recipients-card.js"));
35
37
  /** Loaded where a record page draws one of the attribution zones. */ const RecordAttributionWidget = lazy(()=>import("./components/record-attribution-widget.js"));
36
38
  /** The site's section experiments, reported to the besigner's Interactions section. */ const BesignerSectionExperiments = lazy(()=>import("./components/besigner-section-experiments.js"));
@@ -43,6 +45,12 @@ import { registerMarketingRecordRoutes } from "./model/marketing-record-routes.j
43
45
  * their own per-plan checks off the passed `org`). The popup image picker
44
46
  * uses the shell's media browser via `useMediaPicker`.
45
47
  */ export function registerMarketingConsole() {
48
+ // How the Import & export hub names campaigns in a workspace package (AGL-3535).
49
+ registerPluginTransferResourceUi('marketing.campaigns', {
50
+ label: 'Campaigns'
51
+ }, {
52
+ pluginId: BUNDLE_ID
53
+ });
46
54
  /*
47
55
  * The two positions the A/B testing card hosts (AGL-2914), declared before
48
56
  * the extension that draws the card. An id another plugin has already taken
@@ -128,6 +136,14 @@ import { registerMarketingRecordRoutes } from "./model/marketing-record-routes.j
128
136
  title: 'Messages',
129
137
  Component: EmailMessagesWidget
130
138
  },
139
+ // A template's page: what the sends built from it did, then who
140
+ // received them. The template is the Email plugin's; the sends are ours.
141
+ {
142
+ slot: 'emailTemplateReport',
143
+ widgetId: 'marketing-email-template-report',
144
+ title: 'Sent from this template',
145
+ Component: EmailTemplateReportCard
146
+ },
131
147
  {
132
148
  slot: 'emailTemplateRecipients',
133
149
  widgetId: 'marketing-email-template-recipients',