@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
@@ -14,17 +14,82 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
18
  import { AppLink } from "@aglyn/shared-ui-jsx";
19
19
  import { Chip, Stack, Typography } from "@mui/material";
20
20
  import { doc } from "firebase/firestore";
21
21
  import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
22
22
  import { useMarketingHubPath } from "./use-marketing-hub-path.js";
23
- import { campaignConversionId, campaignTouchLabel } from "../model/campaign-conversions.js";
23
+ import { useMarketingOrgId } from "./marketing-org-mount.js";
24
+ import { campaignContainerDoc, campaignSendDoc } from "./campaign-queries.js";
25
+ import { campaignConversionId, campaignCreditRule, campaignTouchLabel } from "../model/campaign-conversions.js";
26
+ /**
27
+ * A campaign's name, read from its own document: the container's `name`, or
28
+ * a send's `subject` — the id until the read answers or when the document is
29
+ * gone, because an id is still the true value and a blank is not.
30
+ */ function CampaignName(props) {
31
+ var _ref;
32
+ const { orgId, id, of } = props;
33
+ const firestore = useFirestore();
34
+ const { data } = useFirestoreDoc(()=>orgId && id ? of === 'container' ? campaignContainerDoc(firestore, orgId, id) : campaignSendDoc(firestore, orgId, id) : null, [
35
+ firestore,
36
+ orgId,
37
+ id,
38
+ of
39
+ ]);
40
+ const label = String((_ref = of === 'container' ? data == null ? void 0 : data.name : data == null ? void 0 : data.subject) != null ? _ref : '').trim();
41
+ return /*#__PURE__*/ _jsx(_Fragment, {
42
+ children: label || id
43
+ });
44
+ }
45
+ /** The chip a channel is drawn with, and whether its credit names a document. */ function channelChip(record) {
46
+ switch(record.channel){
47
+ case 'email':
48
+ return {
49
+ label: 'Campaign email',
50
+ linked: true
51
+ };
52
+ case 'sequence':
53
+ return {
54
+ label: 'Sequence',
55
+ linked: true
56
+ };
57
+ case 'page':
58
+ return {
59
+ label: 'Campaign page',
60
+ linked: true
61
+ };
62
+ default:
63
+ return record.campaignId ? {
64
+ label: 'Campaign link',
65
+ linked: true
66
+ } : {
67
+ label: 'Web link',
68
+ linked: false
69
+ };
70
+ }
71
+ }
72
+ /** When and how the visitor was touched, in the sentence under the credit. */ function touchSentence(record) {
73
+ var _record_touchedAtMs;
74
+ const touched = Number((_record_touchedAtMs = record.touchedAtMs) != null ? _record_touchedAtMs : 0);
75
+ const at = touched ? ` on ${new Date(touched).toLocaleString()}` : '';
76
+ switch(record.channel){
77
+ case 'sequence':
78
+ return touched ? `Reached by a sequence email${at}` : 'Reached by a sequence email';
79
+ case 'page':
80
+ return `Viewed ${record.path ? record.path : 'a page filed under it'}${at}`;
81
+ default:
82
+ return touched ? `Followed the link${at}` : 'Followed a campaign link';
83
+ }
84
+ }
24
85
  export function ConversionAttribution(props) {
25
- var _props_marketingBasePath, _record_convertedAtMs, _record_touchedAtMs, _record_model, _record_windowDays;
86
+ var _props_filedUnder, _props_marketingBasePath, _record_convertedAtMs;
26
87
  const { hostId, kind, refId, quiet } = props;
88
+ const filedUnder = (_props_filedUnder = props.filedUnder) != null ? _props_filedUnder : [];
27
89
  const firestore = useFirestore();
90
+ // The org whose campaigns the names are read from; nothing is read until it
91
+ // is known, and the ids stand in until then.
92
+ const { orgId } = useMarketingOrgId(hostId);
28
93
  /*
29
94
  * Free — the org slug and the subdomain are already in the URL this console
30
95
  * is on, so no host document is resolved to render a link. `null` until the
@@ -55,69 +120,142 @@ export function ConversionAttribution(props) {
55
120
  * the reader's eye takes to reach it, and this sits inside a dialog that
56
121
  * opens with the read.
57
122
  */ if (status === 'loading') return null;
123
+ const campaignHref = (id)=>marketingBasePath && id ? `${marketingBasePath}/campaigns/${id}` : undefined;
124
+ /*
125
+ * FILED UNDER — the merchant's declaration, drawn first and under its own
126
+ * heading so it can never be read as the credit below it.
127
+ */ const filed = filedUnder.length ? /*#__PURE__*/ _jsxs(Stack, {
128
+ spacing: 0.5,
129
+ children: [
130
+ /*#__PURE__*/ _jsx(Typography, {
131
+ variant: "caption",
132
+ color: "text.secondary",
133
+ children: 'Filed under'
134
+ }),
135
+ /*#__PURE__*/ _jsx(Stack, {
136
+ direction: "row",
137
+ spacing: 1,
138
+ useFlexGap: true,
139
+ sx: {
140
+ flexWrap: 'wrap'
141
+ },
142
+ children: filedUnder.map((id)=>{
143
+ const href = campaignHref(id);
144
+ const name = /*#__PURE__*/ _jsx(CampaignName, {
145
+ orgId: orgId,
146
+ id: id,
147
+ of: "container"
148
+ });
149
+ return href ? /*#__PURE__*/ _jsx(AppLink, {
150
+ href: href,
151
+ children: name
152
+ }, id) : /*#__PURE__*/ _jsx(Typography, {
153
+ variant: "body2",
154
+ children: name
155
+ }, id);
156
+ })
157
+ })
158
+ ]
159
+ }) : null;
58
160
  if (!record) {
59
- if (quiet) return null;
161
+ if (quiet && !filed) return null;
60
162
  return /*#__PURE__*/ _jsxs(Stack, {
61
- spacing: 0.5,
163
+ spacing: 1,
62
164
  children: [
63
165
  /*#__PURE__*/ _jsx(Typography, {
64
166
  variant: "overline",
65
167
  color: "text.secondary",
66
168
  children: 'Campaign'
67
169
  }),
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.'
170
+ filed,
171
+ quiet ? null : /*#__PURE__*/ _jsxs(Stack, {
172
+ spacing: 0.5,
173
+ children: [
174
+ filed ? /*#__PURE__*/ _jsx(Typography, {
175
+ variant: "caption",
176
+ color: "text.secondary",
177
+ children: 'Credited to'
178
+ }) : null,
179
+ /*#__PURE__*/ _jsx(Typography, {
180
+ variant: "body2",
181
+ color: "text.secondary",
182
+ children: 'Not credited to a campaign — this arrived directly, or from a ' + 'link that carried no campaign, and no page filed under a ' + 'campaign was viewed before it. Nothing is inferred from a ' + 'referrer and no campaign is credited for being the most ' + 'recent one to run.'
183
+ })
184
+ ]
72
185
  })
73
186
  ]
74
187
  });
75
188
  }
76
189
  const label = campaignTouchLabel(record);
77
190
  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 isSequence = record.channel === 'sequence';
81
- const campaignHref = (isEmail || isSequence) && marketingBasePath && record.campaignId ? `${marketingBasePath}/campaigns/${record.campaignId}` : undefined;
191
+ const chip = channelChip(record);
192
+ const creditedHref = chip.linked ? campaignHref(record.campaignId) : undefined;
193
+ const creditedName = chip.linked && record.campaignId ? /*#__PURE__*/ _jsx(CampaignName, {
194
+ orgId: orgId,
195
+ id: record.campaignId,
196
+ of: record.channel === 'email' ? 'send' : 'container'
197
+ }) : null;
82
198
  return /*#__PURE__*/ _jsxs(Stack, {
83
- spacing: 0.5,
199
+ spacing: 1,
84
200
  children: [
85
201
  /*#__PURE__*/ _jsx(Typography, {
86
202
  variant: "overline",
87
203
  color: "text.secondary",
88
204
  children: 'Campaign'
89
205
  }),
206
+ filed,
90
207
  /*#__PURE__*/ _jsxs(Stack, {
91
- direction: "row",
92
- spacing: 1,
93
- useFlexGap: true,
94
- sx: {
95
- alignItems: 'center',
96
- flexWrap: 'wrap'
97
- },
208
+ spacing: 0.5,
98
209
  children: [
99
- /*#__PURE__*/ _jsx(Chip, {
100
- size: "small",
101
- color: isEmail || isSequence ? 'primary' : 'default',
102
- variant: isEmail || isSequence ? 'filled' : 'outlined',
103
- label: isEmail ? 'Campaign email' : isSequence ? 'Sequence' : 'Web link'
104
- }),
105
- campaignHref ? /*#__PURE__*/ _jsx(AppLink, {
106
- href: campaignHref,
107
- children: label || record.campaignId
108
- }) : /*#__PURE__*/ _jsx(Typography, {
109
- variant: "body2",
210
+ filed ? /*#__PURE__*/ _jsx(Typography, {
211
+ variant: "caption",
212
+ color: "text.secondary",
213
+ children: 'Credited to'
214
+ }) : null,
215
+ /*#__PURE__*/ _jsxs(Stack, {
216
+ direction: "row",
217
+ spacing: 1,
218
+ useFlexGap: true,
110
219
  sx: {
111
- fontWeight: 'bold'
220
+ alignItems: 'center',
221
+ flexWrap: 'wrap'
112
222
  },
113
- children: label || 'unnamed'
223
+ children: [
224
+ /*#__PURE__*/ _jsx(Chip, {
225
+ size: "small",
226
+ color: chip.linked ? 'primary' : 'default',
227
+ variant: chip.linked ? 'filled' : 'outlined',
228
+ label: chip.label
229
+ }),
230
+ creditedHref ? /*#__PURE__*/ _jsx(AppLink, {
231
+ href: creditedHref,
232
+ children: creditedName
233
+ }) : creditedName ? /*#__PURE__*/ _jsx(Typography, {
234
+ variant: "body2",
235
+ sx: {
236
+ fontWeight: 'bold'
237
+ },
238
+ children: creditedName
239
+ }) : /*#__PURE__*/ _jsx(Typography, {
240
+ variant: "body2",
241
+ sx: {
242
+ fontWeight: 'bold'
243
+ },
244
+ children: label || 'unnamed'
245
+ }),
246
+ record.channel === 'web' && record.campaignId && label ? /*#__PURE__*/ _jsx(Typography, {
247
+ variant: "caption",
248
+ color: "text.secondary",
249
+ children: label
250
+ }) : null
251
+ ]
252
+ }),
253
+ /*#__PURE__*/ _jsx(Typography, {
254
+ variant: "caption",
255
+ color: "text.secondary",
256
+ children: touchSentence(record) + (converted ? `, converted ${new Date(converted).toLocaleString()}` : '') + '. ' + campaignCreditRule(record.model, record.windowDays)
114
257
  })
115
258
  ]
116
- }),
117
- /*#__PURE__*/ _jsx(Typography, {
118
- variant: "caption",
119
- color: "text.secondary",
120
- children: (isSequence ? touched ? `Reached by a sequence email on ${new Date(touched).toLocaleString()}` : 'Reached by a sequence email' : 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.`
121
259
  })
122
260
  ]
123
261
  });
@@ -1 +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-marketing-hub-path'\nimport {\n campaignConversionId,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '../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 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. A SEQUENCE touch\n * (AGL-3254) names the campaign a rep's sequence is in, a container with a\n * page, so it links like an email touch and says which door it came through.\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 isSequence = record.channel === 'sequence'\n const campaignHref =\n (isEmail || isSequence) && 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 || isSequence ? 'primary' : 'default'}\n variant={isEmail || isSequence ? 'filled' : 'outlined'}\n label={isEmail ? 'Campaign email' : isSequence ? 'Sequence' : '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 {(isSequence\n ? touched\n ? `Reached by a sequence email on ${new Date(touched).toLocaleString()}`\n : 'Reached by a sequence email'\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","isSequence","campaignHref","campaignId","undefined","direction","useFlexGap","sx","alignItems","flexWrap","size","href","fontWeight","Date","toLocaleString","model","String","windowDays","displayName"],"mappings":"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"}
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-marketing-hub-path'\nimport { useMarketingOrgId } from './marketing-org-mount'\nimport { campaignContainerDoc, campaignSendDoc } from './campaign-queries'\nimport {\n campaignConversionId,\n campaignCreditRule,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '../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 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. A SEQUENCE touch\n * (AGL-3254) names the campaign a rep's sequence is in, a container with a\n * page, so it links like an email touch and says which door it came through.\n * A PAGE touch (AGL-3461) names the campaign the viewed page is filed under,\n * and a web touch whose label a campaign declares names that campaign: both\n * link to it.\n *\n * ## Filed under is not credited to\n *\n * A surface that knows what its record is filed under — the Inbox knows the\n * form's campaigns and the page's — hands them in as `filedUnder`, and they\n * are drawn ABOVE the credit under their own heading. The two answer\n * different questions: which campaigns the merchant put this form and page\n * in, and which campaign's touch the visitor arrived through. A submission can\n * be filed under one campaign and credited to another, or to none, and a\n * screen that ran the two together would hide exactly that.\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 * The campaigns the record is FILED under, by container id (AGL-3461) —\n * drawn as \"Filed under\" above the credit. Absent: the heading is not drawn.\n */\n filedUnder?: readonly string[]\n}\n\n/** Which collection a campaign id on a record names. */\ntype CampaignIdKind = 'container' | 'send'\n\n/**\n * A campaign's name, read from its own document: the container's `name`, or\n * a send's `subject` — the id until the read answers or when the document is\n * gone, because an id is still the true value and a blank is not.\n */\nfunction CampaignName(props: {\n orgId: string | null\n id: string\n of: CampaignIdKind\n}) {\n const { orgId, id, of } = props\n const firestore = useFirestore()\n const { data } = useFirestoreDoc<{ name?: string; subject?: string }>(\n () =>\n orgId && id\n ? of === 'container'\n ? campaignContainerDoc(firestore, orgId, id)\n : campaignSendDoc(firestore, orgId, id)\n : null,\n [firestore, orgId, id, of],\n )\n const label = String((of === 'container' ? data?.name : data?.subject) ?? '').trim()\n return <>{label || id}</>\n}\n\n/** The chip a channel is drawn with, and whether its credit names a document. */\nfunction channelChip(record: CampaignConversionRecord): {\n label: string\n linked: boolean\n} {\n switch (record.channel) {\n case 'email':\n return { label: 'Campaign email', linked: true }\n case 'sequence':\n return { label: 'Sequence', linked: true }\n case 'page':\n return { label: 'Campaign page', linked: true }\n default:\n return record.campaignId\n ? { label: 'Campaign link', linked: true }\n : { label: 'Web link', linked: false }\n }\n}\n\n/** When and how the visitor was touched, in the sentence under the credit. */\nfunction touchSentence(record: CampaignConversionRecord): string {\n const touched = Number(record.touchedAtMs ?? 0)\n const at = touched ? ` on ${new Date(touched).toLocaleString()}` : ''\n switch (record.channel) {\n case 'sequence':\n return touched ? `Reached by a sequence email${at}` : 'Reached by a sequence email'\n case 'page':\n return `Viewed ${record.path ? record.path : 'a page filed under it'}${at}`\n default:\n return touched ? `Followed the link${at}` : 'Followed a campaign link'\n }\n}\n\nexport function ConversionAttribution(props: ConversionAttributionProps) {\n const { hostId, kind, refId, quiet } = props\n const filedUnder = props.filedUnder ?? []\n const firestore = useFirestore()\n // The org whose campaigns the names are read from; nothing is read until it\n // is known, and the ids stand in until then.\n const { orgId } = useMarketingOrgId(hostId)\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 const campaignHref = (id: string | undefined) =>\n marketingBasePath && id ? `${marketingBasePath}/campaigns/${id}` : undefined\n\n /*\n * FILED UNDER — the merchant's declaration, drawn first and under its own\n * heading so it can never be read as the credit below it.\n */\n const filed = filedUnder.length ? (\n <Stack spacing={0.5}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Filed under'}\n </Typography>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n {filedUnder.map((id) => {\n const href = campaignHref(id)\n const name = <CampaignName orgId={orgId} id={id} of=\"container\" />\n return href ? (\n <AppLink key={id} href={href}>\n {name}\n </AppLink>\n ) : (\n <Typography key={id} variant=\"body2\">\n {name}\n </Typography>\n )\n })}\n </Stack>\n </Stack>\n ) : null\n\n if (!record) {\n if (quiet && !filed) return null\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n {filed}\n {quiet ? null : (\n <Stack spacing={0.5}>\n {filed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to'}\n </Typography>\n ) : null}\n {/*\n NOT a zero, and not an empty campaign chip. The sentence says\n which 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, and no page filed under a ' +\n 'campaign was viewed before it. Nothing is inferred from a ' +\n 'referrer and no campaign is credited for being the most ' +\n 'recent one to run.'}\n </Typography>\n </Stack>\n )}\n </Stack>\n )\n }\n\n const label = campaignTouchLabel(record)\n const converted = Number(record.convertedAtMs ?? 0)\n const chip = channelChip(record)\n const creditedHref = chip.linked ? campaignHref(record.campaignId) : undefined\n const creditedName =\n chip.linked && record.campaignId ? (\n <CampaignName\n orgId={orgId}\n id={record.campaignId}\n of={record.channel === 'email' ? 'send' : 'container'}\n />\n ) : null\n\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n {filed}\n <Stack spacing={0.5}>\n {filed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to'}\n </Typography>\n ) : null}\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\n read differently: one names a campaign whose report can be opened,\n the other names text somebody typed into a URL.\n */}\n <Chip\n size=\"small\"\n color={chip.linked ? 'primary' : 'default'}\n variant={chip.linked ? 'filled' : 'outlined'}\n label={chip.label}\n />\n {creditedHref ? (\n <AppLink href={creditedHref}>{creditedName}</AppLink>\n ) : creditedName ? (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {creditedName}\n </Typography>\n ) : (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {label || 'unnamed'}\n </Typography>\n )}\n {/* A declared label keeps its text beside the campaign it names. */}\n {record.channel === 'web' && record.campaignId && label ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {label}\n </Typography>\n ) : null}\n </Stack>\n {/*\n THE RULE, under the claim. A credit whose rule the reader cannot\n state is a credit they will use anyway, and the two things they\n need are which touch wins and how long one stays creditable.\n `model` and `windowDays` come off the RECORD, so a conversion\n credited under an older rule prints the rule it was credited under.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {touchSentence(record) +\n (converted ? `, converted ${new Date(converted).toLocaleString()}` : '') +\n '. ' +\n campaignCreditRule(record.model, record.windowDays)}\n </Typography>\n </Stack>\n </Stack>\n )\n}\nConversionAttribution.displayName = 'ConversionAttribution'\n\nexport default ConversionAttribution\n"],"names":["AppLink","Chip","Stack","Typography","doc","useFirestore","useFirestoreDoc","useMarketingHubPath","useMarketingOrgId","campaignContainerDoc","campaignSendDoc","campaignConversionId","campaignCreditRule","campaignTouchLabel","CampaignName","props","of","orgId","id","firestore","data","label","String","name","subject","trim","channelChip","record","channel","linked","campaignId","touchSentence","touched","Number","touchedAtMs","at","Date","toLocaleString","path","ConversionAttribution","hostId","kind","refId","quiet","filedUnder","resolvedHub","marketingBasePath","attributionId","status","campaignHref","undefined","filed","length","spacing","variant","color","direction","useFlexGap","sx","flexWrap","map","href","converted","convertedAtMs","chip","creditedHref","creditedName","alignItems","size","fontWeight","model","windowDays","displayName"],"mappings":"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"}
@@ -63,7 +63,7 @@ import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
63
63
  import { Button, Typography } from "@mui/material";
64
64
  import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
65
65
  import { useRouter } from "next/navigation";
66
- import { campaignHeldForReviewNotice, campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
66
+ import { campaignHeldForReviewNotice, campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "../model/campaign-container.js";
67
67
  import CampaignComposer from "./campaign-composer.js";
68
68
  import { campaignSendDoc } from "./campaign-queries.js";
69
69
  import { useMarketingOrgId } from "./marketing-org-mount.js";
@@ -1 +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 * ## The site it is written as\n *\n * The record is the org's; the composer is a site's — its senders, its\n * designs, its experiments. Under a site that is the site. On the org hub it\n * is the send's own `hostId`, and a send that names none is not opened for\n * writing there.\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 { useRouter } from 'next/navigation'\nimport {\n campaignHeldForReviewNotice,\n campaignSendDisplay,\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport CampaignComposer from './campaign-composer'\nimport { campaignSendDoc } from './campaign-queries'\nimport { useMarketingOrgId } from './marketing-org-mount'\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 /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The message document under `orgs/{orgId}/campaigns`. */\n emailId: string\n /**\n * The Emails page's own URL, under the site or the organization, for the\n * way back to the message's own page.\n */\n basePath: string\n}\n\nexport function EmailComposeCard(props: EmailComposeCardProps) {\n const { emailId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { orgId } = useMarketingOrgId(props.hostId)\n\n const { data: email, status } = useFirestoreDoc<Record<string, any>>(\n () => (orgId ? campaignSendDoc(firestore, orgId, emailId) : null),\n [firestore, orgId, emailId],\n )\n /** The site this email is written as; see the file header. */\n const hostId: string | null =\n props.hostId ?? (email?.hostId ? String(email.hostId) : null)\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') &&\n display?.state !== 'sending' &&\n // A held send is written by nobody until staff decide (AGL-3356).\n display?.state !== 'held'\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 : !hostId\n ? 'This email does not record which site it is sent as, so it can ' +\n 'only be written from that site’s own Emails page.'\n : editable\n ? ''\n : display?.state === 'held'\n ? campaignHeldForReviewNotice(email as never)\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","useRouter","campaignHeldForReviewNotice","campaignSendDisplay","CAMPAIGN_SEND_CONTAINER_FIELD","CampaignComposer","campaignSendDoc","useMarketingOrgId","composeDocsHelp","anchor","excerpt","EmailComposeCard","props","emailId","basePath","firestore","router","orgId","hostId","data","email","status","String","detailHref","headerActions","component","componentVariant","nativeButton","href","size","color","subject","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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"}
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 * ## The site it is written as\n *\n * The record is the org's; the composer is a site's — its senders, its\n * designs, its experiments. Under a site that is the site. On the org hub it\n * is the send's own `hostId`, and a send that names none is not opened for\n * writing there.\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 { useRouter } from 'next/navigation'\nimport {\n campaignHeldForReviewNotice,\n campaignSendDisplay,\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '../model/campaign-container'\nimport CampaignComposer from './campaign-composer'\nimport { campaignSendDoc } from './campaign-queries'\nimport { useMarketingOrgId } from './marketing-org-mount'\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 /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The message document under `orgs/{orgId}/campaigns`. */\n emailId: string\n /**\n * The Emails page's own URL, under the site or the organization, for the\n * way back to the message's own page.\n */\n basePath: string\n}\n\nexport function EmailComposeCard(props: EmailComposeCardProps) {\n const { emailId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { orgId } = useMarketingOrgId(props.hostId)\n\n const { data: email, status } = useFirestoreDoc<Record<string, any>>(\n () => (orgId ? campaignSendDoc(firestore, orgId, emailId) : null),\n [firestore, orgId, emailId],\n )\n /** The site this email is written as; see the file header. */\n const hostId: string | null =\n props.hostId ?? (email?.hostId ? String(email.hostId) : null)\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') &&\n display?.state !== 'sending' &&\n // A held send is written by nobody until staff decide (AGL-3356).\n display?.state !== 'held'\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 : !hostId\n ? 'This email does not record which site it is sent as, so it can ' +\n 'only be written from that site’s own Emails page.'\n : editable\n ? ''\n : display?.state === 'held'\n ? campaignHeldForReviewNotice(email as never)\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","useRouter","campaignHeldForReviewNotice","campaignSendDisplay","CAMPAIGN_SEND_CONTAINER_FIELD","CampaignComposer","campaignSendDoc","useMarketingOrgId","composeDocsHelp","anchor","excerpt","EmailComposeCard","props","emailId","basePath","firestore","router","orgId","hostId","data","email","status","String","detailHref","headerActions","component","componentVariant","nativeButton","href","size","color","subject","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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"}
@@ -28,9 +28,10 @@ import { Alert, Button, Chip, Divider, Stack, TableBody, TableCell, TableHead, T
28
28
  import { doc } from "firebase/firestore";
29
29
  import { useRouter } from "next/navigation";
30
30
  import { useCallback, useMemo, useState } from "react";
31
- import { campaignLinkReport, campaignReport } from "@aglyn/shared-ui-email-campaigns/model/campaign-report";
32
- import { campaignHeldForReviewNotice, campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
33
- import { emailAudienceLabel, emailIsUnsent, emailSendTimeMs, emailSentAs } from "@aglyn/shared-ui-email-campaigns/model/email-record";
31
+ import { sendLinkReport } from "@aglyn/shared-ui-email-campaigns/model/send-report";
32
+ import { campaignReport } from "../model/campaign-report.js";
33
+ import { campaignHeldForReviewNotice, campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "../model/campaign-container.js";
34
+ import { emailAudienceLabel, emailIsUnsent, emailSendTimeMs, emailSentAs } from "../model/email-record.js";
34
35
  import { emailPlainTextState } from "@aglyn/aglyn/app-utils/recipient-email-render";
35
36
  import { useMarketingHubPath } from "./use-marketing-hub-path.js";
36
37
  import { siteRecordRouteContext, useRecordRouteContext } from "./record-route-context.js";
@@ -155,7 +156,7 @@ const emailDocsHelp = pluginDocsHelp('emailCampaigns', {
155
156
  const report = useMemo(()=>campaignReport(email == null ? void 0 : email.stats), [
156
157
  email
157
158
  ]);
158
- const linkReport = useMemo(()=>campaignLinkReport(links), [
159
+ const linkReport = useMemo(()=>sendLinkReport(links), [
159
160
  links
160
161
  ]);
161
162
  const subject = String((email == null ? void 0 : email.subject) || 'Untitled email');