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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +2 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-detail-card.d.ts +12 -1
  4. package/src/lib/components/campaign-detail-card.js +146 -35
  5. package/src/lib/components/campaign-detail-card.js.map +1 -1
  6. package/src/lib/components/campaign-edit-drawer.d.ts +13 -0
  7. package/src/lib/components/campaign-edit-drawer.js +56 -4
  8. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  9. package/src/lib/components/campaign-glance-card.component.js +10 -2
  10. package/src/lib/components/campaign-glance-card.component.js.map +1 -1
  11. package/src/lib/components/campaign-members-section.d.ts +6 -0
  12. package/src/lib/components/campaign-members-section.js +2 -2
  13. package/src/lib/components/campaign-members-section.js.map +1 -1
  14. package/src/lib/components/campaign-queries.d.ts +58 -0
  15. package/src/lib/components/campaign-queries.js +72 -0
  16. package/src/lib/components/campaign-queries.js.map +1 -0
  17. package/src/lib/components/campaign-reach-sections.d.ts +21 -7
  18. package/src/lib/components/campaign-reach-sections.js +20 -17
  19. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  20. package/src/lib/components/campaign-report-card.d.ts +8 -1
  21. package/src/lib/components/campaign-report-card.js +33 -16
  22. package/src/lib/components/campaign-report-card.js.map +1 -1
  23. package/src/lib/components/campaigns-card.d.ts +10 -1
  24. package/src/lib/components/campaigns-card.js +102 -19
  25. package/src/lib/components/campaigns-card.js.map +1 -1
  26. package/src/lib/components/email-compose-card.d.ts +3 -2
  27. package/src/lib/components/email-compose-card.js +18 -7
  28. package/src/lib/components/email-compose-card.js.map +1 -1
  29. package/src/lib/components/email-detail.d.ts +11 -2
  30. package/src/lib/components/email-detail.js +50 -23
  31. package/src/lib/components/email-detail.js.map +1 -1
  32. package/src/lib/components/emails-list-card.d.ts +10 -1
  33. package/src/lib/components/emails-list-card.js +129 -23
  34. package/src/lib/components/emails-list-card.js.map +1 -1
  35. package/src/lib/components/host-marketing-summary-card.component.js +9 -1
  36. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -1
  37. package/src/lib/components/marketing-console-page.js +81 -4
  38. package/src/lib/components/marketing-console-page.js.map +1 -1
  39. package/src/lib/components/marketing-console-sections.d.ts +16 -1
  40. package/src/lib/components/marketing-console-sections.js +25 -2
  41. package/src/lib/components/marketing-console-sections.js.map +1 -1
  42. package/src/lib/components/marketing-org-mount.d.ts +71 -0
  43. package/src/lib/components/marketing-org-mount.js +84 -0
  44. package/src/lib/components/marketing-org-mount.js.map +1 -0
  45. package/src/lib/components/use-campaign-send-api.d.ts +2 -2
  46. package/src/lib/components/use-campaign-send-api.js +22 -6
  47. package/src/lib/components/use-campaign-send-api.js.map +1 -1
  48. package/src/lib/components/use-emails-hub-path.d.ts +13 -3
  49. package/src/lib/components/use-emails-hub-path.js +25 -4
  50. package/src/lib/components/use-emails-hub-path.js.map +1 -1
  51. package/src/lib/components/use-marketing-hub-path.d.ts +19 -16
  52. package/src/lib/components/use-marketing-hub-path.js +6 -32
  53. package/src/lib/components/use-marketing-hub-path.js.map +1 -1
  54. package/src/lib/model/marketing-record-routes.js +11 -4
  55. package/src/lib/model/marketing-record-routes.js.map +1 -1
  56. package/src/lib/plugin.js +31 -1
  57. package/src/lib/plugin.js.map +1 -1
  58. package/src/lib/server/campaign-manage.d.ts +31 -2
  59. package/src/lib/server/campaign-manage.js +365 -137
  60. package/src/lib/server/campaign-manage.js.map +1 -1
  61. package/src/lib/server/campaign-org-refs.d.ts +45 -0
  62. package/src/lib/server/campaign-org-refs.js +83 -0
  63. package/src/lib/server/campaign-org-refs.js.map +1 -0
  64. package/src/lib/server/campaign-process-scheduled.d.ts +32 -2
  65. package/src/lib/server/campaign-process-scheduled.js +79 -9
  66. package/src/lib/server/campaign-process-scheduled.js.map +1 -1
  67. package/src/lib/server/campaign-recipients.js +18 -4
  68. package/src/lib/server/campaign-recipients.js.map +1 -1
  69. package/src/lib/server/campaign-send.js +139 -55
  70. package/src/lib/server/campaign-send.js.map +1 -1
  71. package/src/lib/server/email-campaign-reach.d.ts +6 -5
  72. package/src/lib/server/email-campaign-reach.js +14 -14
  73. package/src/lib/server/email-campaign-reach.js.map +1 -1
  74. package/src/lib/server/email-events.js +48 -17
  75. package/src/lib/server/email-events.js.map +1 -1
  76. package/src/lib/server/marketing-figures.js +12 -1
  77. package/src/lib/server/marketing-figures.js.map +1 -1
  78. package/src/lib/server.js +7 -3
  79. package/src/lib/server.js.map +1 -1
@@ -26,13 +26,14 @@ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
26
26
  import { ceilingedWindow, collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
27
27
  import { DUPLICATE_MENU_LABEL, useDuplicateResource, useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
28
28
  import { Alert, Button, Chip, Stack, Typography } from "@mui/material";
29
- import { collection } from "firebase/firestore";
30
29
  import { useRouter } from "next/navigation";
31
30
  import { useCallback, useMemo, useState } from "react";
32
- import { campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
31
+ import { campaignPlacedOnHost, campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
33
32
  import { emailListTimeMs, emailSendTimeMs } from "@aglyn/shared-ui-email-campaigns/model/email-record";
34
33
  import { useCampaignManageApi, useCampaignSendApi } from "./use-campaign-send-api.js";
35
34
  import { useMarketingHubPath } from "./use-marketing-hub-path.js";
35
+ import { campaignContainersQuery, campaignSendsQuery } from "./campaign-queries.js";
36
+ import { orgSiteHubPath, orgSiteName, orgSiteOptions, useMarketingOrgId, useMarketingOrgMount } from "./marketing-org-mount.js";
36
37
  /** How many messages one read of this list covers. */ const EMAIL_CEILING = 30;
37
38
  /** How many campaigns the create drawer offers to file a new email under. */ const CONTAINER_CEILING = 50;
38
39
  /**
@@ -93,14 +94,30 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
93
94
  * The page is therefore a SLICE of a window this card already holds, not a
94
95
  * query: paging an id-ordered walk and re-sorting each page by date would run
95
96
  * in one order within a page and another across them.
97
+ *
98
+ * ## Under a site, and over the organization
99
+ *
100
+ * Sends are the org's. Under a site this lists the ones sent as that site;
101
+ * on the org hub it lists every site's, with a Site column, and every action
102
+ * on a row names the site that row is sent as — the only site that can
103
+ * duplicate it, discard it or open its template. Creating one there first
104
+ * asks which site it is sent as.
96
105
  */ export function EmailsListCard(props) {
97
106
  const { hostId, basePath } = props;
98
107
  const firestore = useFirestore();
99
- // The sibling hub: a campaign's page belongs to the Marketing console.
100
- const marketingHub = useMarketingHubPath();
108
+ const orgMount = useMarketingOrgMount();
109
+ const { orgId } = useMarketingOrgId(hostId);
110
+ // The sibling hub: a campaign's page belongs to the Marketing console —
111
+ // which, on the org hub, is the page this list is already on.
112
+ const siteMarketingHub = useMarketingHubPath();
113
+ const marketingHub = orgMount ? orgMount.basePath : siteMarketingHub;
101
114
  const router = useRouter();
102
- const { data: emailDocs } = useFirestoreCollection(()=>collectionCeiling(collection(firestore, 'hosts', hostId, 'campaigns'), EMAIL_CEILING), [
115
+ const { data: emailDocs } = useFirestoreCollection(()=>{
116
+ const sends = campaignSendsQuery(firestore, orgId, hostId);
117
+ return sends ? collectionCeiling(sends, EMAIL_CEILING) : null;
118
+ }, [
103
119
  firestore,
120
+ orgId,
104
121
  hostId
105
122
  ], {
106
123
  idField: '$id'
@@ -121,7 +138,22 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
121
138
  ].sort((a, b)=>emailListTimeMs(b) - emailListTimeMs(a)), [
122
139
  readEmails
123
140
  ]);
124
- const emailHref = (email)=>`${basePath}/messages/${email.$id}`;
141
+ /*
142
+ * A message's own pages: the Emails console's under a site, the org hub's
143
+ * Emails section over the org. A template is a site's design, so on the
144
+ * org hub it opens on the site the row is sent as.
145
+ */ const messagesPath = orgMount ? `${orgMount.basePath}/emails` : `${basePath}/messages`;
146
+ const emailHref = (email)=>`${messagesPath}/${email.$id}`;
147
+ const templatesHub = (email)=>orgMount ? orgSiteHubPath(orgMount, email == null ? void 0 : email.hostId, 'emails') : basePath;
148
+ /** The site a row is sent as, which every action on it must name. */ const rowHostId = useCallback((email)=>hostId != null ? hostId : (email == null ? void 0 : email.hostId) ? String(email.hostId) : null, [
149
+ hostId
150
+ ]);
151
+ const hostOfEmail = useMemo(()=>new Map(readEmails.map((email)=>[
152
+ String(email.$id),
153
+ (email == null ? void 0 : email.hostId) ? String(email.hostId) : null
154
+ ])), [
155
+ readEmails
156
+ ]);
125
157
  const { confirm } = useConfirmationContext();
126
158
  const { enqueueSnackbar } = useSnackbar();
127
159
  // Duplicate (AGL-2936): a new draft with the message and its design, and
@@ -129,15 +161,17 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
129
161
  // which is the plugin's own.
130
162
  const manageForCopy = useCampaignManageApi(hostId);
131
163
  const duplicate = useDuplicateResource({
132
- hostId,
164
+ hostId: hostId != null ? hostId : '',
133
165
  perform: async ({ sourceId, name, attemptKey })=>{
134
- var _ref, _payload_name;
135
- const { response, payload } = await manageForCopy({
166
+ var _hostOfEmail_get, _ref, _payload_name;
167
+ const { response, payload } = await manageForCopy(_extends({
136
168
  action: 'duplicate',
137
169
  campaignId: sourceId,
138
170
  name,
139
171
  attemptKey
140
- });
172
+ }, hostId ? {} : {
173
+ hostId: (_hostOfEmail_get = hostOfEmail.get(sourceId)) != null ? _hostOfEmail_get : undefined
174
+ }));
141
175
  if (!response.ok) throw new Error((_ref = payload == null ? void 0 : payload.error) != null ? _ref : 'Duplicate failed');
142
176
  return {
143
177
  id: String(payload.emailId),
@@ -180,10 +214,13 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
180
214
  if (!agreed) return;
181
215
  setDiscardingId(id);
182
216
  try {
183
- const { response, payload } = await manageApi({
217
+ var _rowHostId;
218
+ const { response, payload } = await manageApi(_extends({
184
219
  action: 'discardEmail',
185
220
  campaignId: id
186
- });
221
+ }, hostId ? {} : {
222
+ hostId: (_rowHostId = rowHostId(email)) != null ? _rowHostId : undefined
223
+ }));
187
224
  if (!response.ok) {
188
225
  var _ref1;
189
226
  return void enqueueSnackbar((_ref1 = payload == null ? void 0 : payload.error) != null ? _ref1 : 'This draft could not be discarded', {
@@ -208,7 +245,9 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
208
245
  confirm,
209
246
  discardingId,
210
247
  enqueueSnackbar,
211
- manageApi
248
+ hostId,
249
+ manageApi,
250
+ rowHostId
212
251
  ]);
213
252
  /**
214
253
  * What a message can be opened INTO, from the list.
@@ -232,6 +271,9 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
232
271
  const containerId = String((_ref = email == null ? void 0 : email[CAMPAIGN_SEND_CONTAINER_FIELD]) != null ? _ref : '');
233
272
  const templateScreenId = String((_ref1 = email == null ? void 0 : email.templateScreenId) != null ? _ref1 : '');
234
273
  const state = String((_ref2 = email == null ? void 0 : email.status) != null ? _ref2 : '');
274
+ const templates = templatesHub(email);
275
+ /** On the org hub, a send that names no site has nothing to act as. */ const siteless = !rowHostId(email);
276
+ const sitelessReason = 'This email does not record which site it was sent as. Manage it from ' + 'that site’s own Emails page.';
235
277
  return [
236
278
  {
237
279
  key: 'details',
@@ -262,9 +304,9 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
262
304
  path: mdiPaletteOutline.path,
263
305
  size: 0.8
264
306
  }),
265
- href: templateScreenId ? `${basePath}/templates/${templateScreenId}` : undefined,
266
- disabled: !templateScreenId,
267
- disabledReason: 'This message was not built from a template'
307
+ href: templateScreenId && templates ? `${templates}/templates/${templateScreenId}` : undefined,
308
+ disabled: !templateScreenId || !templates,
309
+ disabledReason: templateScreenId ? 'This site’s console URL has not resolved yet' : 'This message was not built from a template'
268
310
  },
269
311
  {
270
312
  key: 'duplicate',
@@ -273,6 +315,8 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
273
315
  path: mdiContentCopy.path,
274
316
  size: 0.8
275
317
  }),
318
+ disabled: siteless,
319
+ disabledReason: sitelessReason,
276
320
  onClick: ()=>{
277
321
  var _ref, _ref1;
278
322
  return duplicate.request('campaign', {
@@ -289,8 +333,8 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
289
333
  size: 0.8
290
334
  }),
291
335
  destructive: true,
292
- disabled: state !== 'draft' || Boolean(discardingId),
293
- disabledReason: (_DISCARD_REFUSAL_state = DISCARD_REFUSAL[state]) != null ? _DISCARD_REFUSAL_state : 'Only a draft can be discarded',
336
+ disabled: siteless || state !== 'draft' || Boolean(discardingId),
337
+ disabledReason: siteless ? sitelessReason : (_DISCARD_REFUSAL_state = DISCARD_REFUSAL[state]) != null ? _DISCARD_REFUSAL_state : 'Only a draft can be discarded',
294
338
  onClick: ()=>void handleDiscard(email)
295
339
  }
296
340
  ];
@@ -299,6 +343,13 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
299
343
  const [creating, setCreating] = useState(false);
300
344
  const campaignSendApi = useCampaignSendApi(hostId);
301
345
  /*
346
+ * The sites a new email can be sent as, on the org hub. One site answers
347
+ * the question itself, so the field is asked only when there is a choice.
348
+ */ const orgSites = useMemo(()=>orgMount ? orgSiteOptions(orgMount) : [], [
349
+ orgMount
350
+ ]);
351
+ const askSite = !hostId && orgSites.length > 1;
352
+ /*
302
353
  * The campaigns a new email may be filed under, read only while the drawer
303
354
  * is OPEN.
304
355
  *
@@ -306,8 +357,12 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
306
357
  * somebody asks to write something. Mounting this unconditionally would put
307
358
  * a second collection read on every reader who came to look at the table,
308
359
  * which is the cost the whole surface is routed to avoid.
309
- */ const { data: campaignDocs } = useFirestoreCollection(()=>createOpen ? collectionCeiling(collection(firestore, 'hosts', hostId, 'emailCampaigns'), CONTAINER_CEILING) : null, [
360
+ */ const { data: campaignDocs } = useFirestoreCollection(()=>{
361
+ const containers = createOpen ? campaignContainersQuery(firestore, orgId, hostId) : null;
362
+ return containers ? collectionCeiling(containers, CONTAINER_CEILING) : null;
363
+ }, [
310
364
  firestore,
365
+ orgId,
311
366
  hostId,
312
367
  createOpen
313
368
  ], {
@@ -335,12 +390,38 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
335
390
  * exist — the route reserves no allowance and moves no meter for a draft,
336
391
  * and the scheduled processor only ever picks up `scheduled`.
337
392
  *=========================================*/ const handleCreate = useCallback(async (values)=>{
393
+ var _values_hostId;
394
+ var _orgSites_;
338
395
  if (creating) return;
396
+ const sendAs = hostId != null ? hostId : askSite ? String((_values_hostId = values.hostId) != null ? _values_hostId : '') : (_orgSites_ = orgSites[0]) == null ? void 0 : _orgSites_.value;
397
+ if (!sendAs) {
398
+ return void enqueueSnackbar('Choose the site this email is sent as', {
399
+ variant: 'warning',
400
+ allowDuplicate: true
401
+ });
402
+ }
403
+ /*
404
+ * The organization hub offers every campaign, because the site is
405
+ * chosen in the same form. An email can only join a campaign placed on
406
+ * the site it is sent as — the send route refuses anything else — so
407
+ * the mismatch is named here, where it can be fixed, rather than
408
+ * arriving as the route's bare refusal. A site hub lists only the
409
+ * campaigns placed on it, so there is nothing to check there.
410
+ */ const chosenCampaign = !hostId && values.emailCampaignId ? (campaignDocs != null ? campaignDocs : []).find((campaign)=>String(campaign.$id) === String(values.emailCampaignId)) : null;
411
+ if (chosenCampaign && !campaignPlacedOnHost(chosenCampaign, sendAs)) {
412
+ return void enqueueSnackbar('That campaign is not offered on the site this email is sent as. ' + 'Choose another, or add the site to the campaign first.', {
413
+ variant: 'warning',
414
+ allowDuplicate: true
415
+ });
416
+ }
339
417
  setCreating(true);
340
418
  try {
341
419
  var _values_displayName;
342
420
  const { response, payload } = await campaignSendApi(_extends({
343
- action: 'draft',
421
+ action: 'draft'
422
+ }, hostId ? {} : {
423
+ hostId: sendAs
424
+ }, {
344
425
  displayName: String((_values_displayName = values.displayName) != null ? _values_displayName : '').trim()
345
426
  }, values.emailCampaignId ? {
346
427
  emailCampaignId: String(values.emailCampaignId)
@@ -358,7 +439,7 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
358
439
  * the campaign; the record now exists and holds nothing else, so the
359
440
  * next thing to do with it is compose it — and its own page is a
360
441
  * report of a send that has not happened.
361
- */ router.push(`${basePath}/messages/${payload.campaignId}/edit`);
442
+ */ router.push(`${messagesPath}/${payload.campaignId}/edit`);
362
443
  } catch (error) {
363
444
  console.error(error);
364
445
  enqueueSnackbar('This email could not be created', {
@@ -369,10 +450,14 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
369
450
  setCreating(false);
370
451
  }
371
452
  }, [
372
- basePath,
453
+ askSite,
454
+ campaignDocs,
373
455
  campaignSendApi,
374
456
  creating,
375
457
  enqueueSnackbar,
458
+ hostId,
459
+ messagesPath,
460
+ orgSites,
376
461
  router
377
462
  ]);
378
463
  /*
@@ -394,6 +479,15 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
394
479
  children: value
395
480
  })
396
481
  },
482
+ // Which site each row is sent as, on the org hub only.
483
+ ...orgMount ? [
484
+ {
485
+ field: 'site',
486
+ headerName: 'Site',
487
+ width: 150,
488
+ valueGetter: (_value, row)=>(row == null ? void 0 : row.hostId) ? orgSiteName(orgMount, row.hostId) : '—'
489
+ }
490
+ ] : [],
397
491
  {
398
492
  /*
399
493
  * WHAT THIS EMAIL IS DOING, not what field it stores.
@@ -506,7 +600,7 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
506
600
  }),
507
601
  truncated ? /*#__PURE__*/ _jsx(Alert, {
508
602
  severity: "info",
509
- children: `Showing ${EMAIL_CEILING} messages. This site has sent or ` + 'scheduled more than that, and the rest are not in this list.'
603
+ children: `Showing ${EMAIL_CEILING} messages. ${hostId ? 'This site' : 'This organization'} has sent or ` + 'scheduled more than that, and the rest are not in this list.'
510
604
  }) : null
511
605
  ]
512
606
  }),
@@ -518,6 +612,18 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
518
612
  includeDescription: false,
519
613
  onSubmit: handleCreate,
520
614
  extraFields: [
615
+ ...askSite ? [
616
+ {
617
+ component: 'select',
618
+ name: 'hostId',
619
+ label: 'Send as',
620
+ isRequired: true,
621
+ initialValue: '',
622
+ helperText: 'The site this email is sent from — its sender, designs ' + 'and unsubscribe page',
623
+ disableDefaultOption: true,
624
+ options: orgSites
625
+ }
626
+ ] : [],
521
627
  {
522
628
  component: 'select',
523
629
  name: 'emailCampaignId',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/emails-list-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n mdiBullhornOutline,\n mdiContentCopy,\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPaletteOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n ceilingedWindow,\n collectionCeiling,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n DUPLICATE_MENU_LABEL,\n useDuplicateResource,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n campaignSendDisplay,\n CAMPAIGN_SEND_CONTAINER_FIELD,\n type CampaignSendDisplayState,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport {\n emailListTimeMs,\n emailSendTimeMs,\n} from '@aglyn/shared-ui-email-campaigns/model/email-record'\nimport {\n useCampaignManageApi,\n useCampaignSendApi,\n} from './use-campaign-send-api'\nimport { useMarketingHubPath } from './use-marketing-hub-path'\n\n/** How many messages one read of this list covers. */\nconst EMAIL_CEILING = 30\n\n/** How many campaigns the create drawer offers to file a new email under. */\nconst CONTAINER_CEILING = 50\n\n/**\n * Why discard is refused, keyed by the state that refuses it.\n *\n * The KEYS are persisted status values written by the send path. Each line\n * says what to do instead, because \"you cannot\" on its own leaves a merchant\n * with an email they wanted rid of and no next step — and for a scheduled one\n * the next step is a different act with a different consequence, so naming it\n * is the difference between withdrawing mail and losing the record of it.\n *\n * A state absent from here falls through to the generic refusal rather than\n * being flattened into one of these: a state this list cannot name is worth\n * seeing.\n */\nconst DISCARD_REFUSAL: Record<string, string> = {\n sent: 'This email has been sent. Its report and its unsubscribe links have to go on resolving.',\n scheduled: 'Cancel the send first — cancelling takes it off the clock and keeps the email.',\n sending: 'This email is being sent right now.',\n canceled: 'A canceled email is kept as the record that it was withdrawn.',\n}\n\n/**\n * The chip color for one display state.\n *\n * `sending` is the one worth a color: an email part way through an audience\n * larger than one batch is doing something right now, and a merchant scanning\n * the list for what needs attention should find it without reading. The rest\n * are neutral, because a finished send and a draft are not events.\n */\nconst STATE_COLOR: Partial<\n Record<CampaignSendDisplayState, 'info' | 'warning'>\n> = {\n sending: 'info',\n stopped: 'warning',\n}\n\nconst emailsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#opens--clicks',\n excerpt:\n 'Every message this site has sent or has scheduled, each with its own ' +\n 'report: what was delivered, who opened it, and which links they followed.',\n})\n\nexport interface EmailsListCardProps {\n hostId: string\n /** The emails hub URL, so a row can link to the message's own page. */\n basePath: string\n}\n\n/**\n * EVERY MESSAGE, AS AGAINST EVERY CAMPAIGN.\n *\n * A campaign groups messages; this is the messages. The two are different\n * questions — \"how did the spring promotion do\" and \"what went out on the\n * 14th, and to whom\" — and they were previously the same list because a\n * campaign document WAS a single send.\n *\n * ## Ordered in the browser, deliberately\n *\n * No SEND date is on every message: a sent one carries `sentAt` and a\n * scheduled one carries `sendAtMs`, written by two different branches of the\n * send path, and a draft carries neither — so `orderBy` on either would not\n * mis-sort this list, it would DROP half of it. `collectionCeiling` reads a\n * bounded window in document-id order and probes one past the ceiling, so the\n * rows are sorted here and the reader is told when there are more.\n *\n * Every writer stamps a `createdAtMs`, which is the field this list could be\n * ordered on in Firestore. Moving it there is a query change with the same\n * hazard as the two above — `orderBy` drops a document missing the field —\n * so it needs the corpus proven to carry one, not just the writers.\n *\n * The page is therefore a SLICE of a window this card already holds, not a\n * query: paging an id-ordered walk and re-sorting each page by date would run\n * in one order within a page and another across them.\n */\nexport function EmailsListCard(props: EmailsListCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n // The sibling hub: a campaign's page belongs to the Marketing console.\n const marketingHub = useMarketingHubPath()\n const router = useRouter()\n\n const { data: emailDocs } = useFirestoreCollection<any>(\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'campaigns'),\n EMAIL_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { rows: readEmails, truncated } = ceilingedWindow<any>(\n emailDocs,\n EMAIL_CEILING,\n )\n /*\n * Newest first on the time each message SITS at — its send time where it\n * has one, its creation where it does not.\n *\n * A draft has neither `sentAt` nor `sendAtMs`, so sorting on the send time\n * alone gave every draft the key 0 and filed the email a merchant is in the\n * middle of writing at the very bottom of the list, behind whatever paging\n * it has. `emailListTimeMs` is the same ordering with that one gap closed;\n * a SENT message still orders by when it went out, never by when it was\n * drafted.\n */\n const emails = useMemo(\n () =>\n [...readEmails].sort(\n (a: any, b: any) => emailListTimeMs(b) - emailListTimeMs(a),\n ),\n [readEmails],\n )\n\n const emailHref = (email: any) => `${basePath}/messages/${email.$id}`\n\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n // Duplicate (AGL-2936): a new draft with the message and its design, and\n // nobody to send to until the person chooses — through the manage door,\n // which is the plugin's own.\n const manageForCopy = useCampaignManageApi(hostId)\n const duplicate = useDuplicateResource({\n hostId,\n perform: async ({ sourceId, name, attemptKey }) => {\n const { response, payload } = await manageForCopy({\n action: 'duplicate',\n campaignId: sourceId,\n name,\n attemptKey,\n })\n if (!response.ok) throw new Error(payload?.error ?? 'Duplicate failed')\n return {\n id: String(payload.emailId),\n versionId: null,\n name: String(payload.name ?? name),\n }\n },\n onDuplicated: (_kind, copy) =>\n enqueueSnackbar(`Duplicated as “${copy.name}” — a draft with no audience yet`, {\n variant: 'success',\n }),\n })\n const manageApi = useCampaignManageApi(hostId)\n /** The email a discard is in flight for, so its row menu can say so. */\n const [discardingId, setDiscardingId] = useState('')\n\n /*==========================================\n * THROWING AWAY A DRAFT.\n *\n * The one removal this surface has, and it is deliberately the narrowest\n * one: an email that was never sent, whose record nobody outside the\n * console has ever seen. Everything else on this list is evidence — a sent\n * message's report is what a merchant answers a complaint with, and its id\n * is inside the HMAC of every unsubscribe footer it delivered — and a\n * scheduled one is withdrawn with Cancel, which keeps the record and takes\n * it off the clock.\n *\n * The menu already refuses anything but a draft, and so does the route.\n * That is not belt and braces: the state on screen is a snapshot that can\n * be seconds old, and the record can be claimed by `sendNow` in between.\n * The route's refusal is the one that decides, inside the transaction that\n * does the delete.\n *=========================================*/\n const handleDiscard = useCallback(\n async (email: any) => {\n if (discardingId) return\n const id = String(email?.$id ?? '')\n const name = String(email?.subject || email?.displayName || 'this draft')\n const agreed = await confirm({\n title: 'Discard this draft?',\n description:\n `${name} has not been sent to anybody, and discarding it removes ` +\n 'it for good — the subject, the message and everything else ' +\n 'written on it. There is no undo.',\n confirmationText: 'Discard',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDiscardingId(id)\n try {\n const { response, payload } = await manageApi({\n action: 'discardEmail',\n campaignId: id,\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'This draft could not be discarded',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Draft discarded', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('This draft could not be discarded', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDiscardingId('')\n }\n },\n [confirm, discardingId, enqueueSnackbar, manageApi],\n )\n\n /**\n * What a message can be opened INTO, from the list.\n *\n * All three are places the message's own report already links to, moved one\n * screen earlier: the report itself, the campaign it belongs to, and the\n * template it was rendered from. A message need have neither of the last\n * two — one sent before campaigns grouped anything names no container, and\n * one composed inline was built from no template — so those entries are\n * shown DISABLED with the reason rather than hidden. A control that\n * disappears and a control that does not apply look identical, and only one\n * of them tells the reader which case they are in.\n *\n * DISCARD is the fourth entry and is offered on the same terms: a draft can\n * be thrown away, and nothing else can. It stays visible on a sent or\n * scheduled message carrying the reason it is refused, because that is the\n * honest answer to “how do I get rid of this” — and the route refuses it\n * too, so the menu is describing a rule rather than being one.\n */\n const rowActions = (email: any): RowActionsMenuItem[] => {\n const containerId = String(email?.[CAMPAIGN_SEND_CONTAINER_FIELD] ?? '')\n const templateScreenId = String(email?.templateScreenId ?? '')\n const state = String(email?.status ?? '')\n return [\n {\n key: 'details',\n label: 'Open report',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: emailHref(email),\n },\n {\n key: 'campaign',\n label: 'Open its campaign',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n // The campaign's page belongs to the Marketing console, so this one\n // href is built from the sibling hub rather than this surface's own.\n href:\n containerId && marketingHub\n ? `${marketingHub}/campaigns/${containerId}`\n : undefined,\n disabled: !containerId || !marketingHub,\n disabledReason: containerId\n ? 'This site’s console URL has not resolved yet'\n : 'Sent before campaigns grouped their emails, so it belongs to none',\n },\n {\n key: 'template',\n label: 'Open its template',\n icon: <MdiIcon path={mdiPaletteOutline.path} size={0.8} />,\n href: templateScreenId\n ? `${basePath}/templates/${templateScreenId}`\n : undefined,\n disabled: !templateScreenId,\n disabledReason: 'This message was not built from a template',\n },\n {\n key: 'duplicate',\n label: DUPLICATE_MENU_LABEL,\n icon: <MdiIcon path={mdiContentCopy.path} size={0.8} />,\n onClick: () =>\n duplicate.request('campaign', {\n id: String(email.$id),\n name: String(email?.displayName ?? email?.subject ?? ''),\n }),\n },\n {\n key: 'discard',\n label: 'Discard draft',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: state !== 'draft' || Boolean(discardingId),\n disabledReason: DISCARD_REFUSAL[state] ?? 'Only a draft can be discarded',\n onClick: () => void handleDiscard(email),\n },\n ]\n }\n\n const [createOpen, setCreateOpen] = useState(false)\n const [creating, setCreating] = useState(false)\n const campaignSendApi = useCampaignSendApi(hostId)\n\n /*\n * The campaigns a new email may be filed under, read only while the drawer\n * is OPEN.\n *\n * A null query opens no listener, so the list costs what it always did until\n * somebody asks to write something. Mounting this unconditionally would put\n * a second collection read on every reader who came to look at the table,\n * which is the cost the whole surface is routed to avoid.\n */\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () =>\n createOpen\n ? collectionCeiling(\n collection(firestore, 'hosts', hostId, 'emailCampaigns'),\n CONTAINER_CEILING,\n )\n : null,\n [firestore, hostId, createOpen],\n { idField: '$id' },\n )\n const campaignOptions = useMemo(\n () =>\n [...(campaignDocs ?? [])]\n .map((campaign: any) => ({\n value: String(campaign.$id),\n label: String(campaign.name || 'Untitled campaign'),\n }))\n .sort((a, b) => a.label.localeCompare(b.label)),\n [campaignDocs],\n )\n\n /*==========================================\n * CREATE, THEN GO TO THE EMAIL'S OWN PAGE.\n *\n * The drawer collects only what it takes to MINT the record — the friendly\n * name, and the campaign it belongs to. Everything else about the email is\n * written on the email's own page, which is where a record is edited\n * throughout this console: a list page carries no form.\n *\n * The record is real from this moment: `/marketing/campaigns/{id}` resolves,\n * the row appears in the table below as a Draft, and the id it is created\n * under is the id it keeps when it is eventually sent. It costs nothing to\n * exist — the route reserves no allowance and moves no meter for a draft,\n * and the scheduled processor only ever picks up `scheduled`.\n *=========================================*/\n const handleCreate = useCallback(\n async (values: Record<string, any>) => {\n if (creating) return\n setCreating(true)\n try {\n const { response, payload } = await campaignSendApi({\n action: 'draft',\n displayName: String(values.displayName ?? '').trim(),\n ...(values.emailCampaignId\n ? { emailCampaignId: String(values.emailCampaignId) }\n : {}),\n })\n if (!response.ok || !payload?.campaignId) {\n return void enqueueSnackbar(\n payload?.error ?? 'This email could not be created',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setCreateOpen(false)\n /*\n * Straight to where it is WRITTEN. The drawer collected the name and\n * the campaign; the record now exists and holds nothing else, so the\n * next thing to do with it is compose it — and its own page is a\n * report of a send that has not happened.\n */\n router.push(`${basePath}/messages/${payload.campaignId}/edit`)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('This email could not be created', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setCreating(false)\n }\n },\n [basePath, campaignSendApi, creating, enqueueSnackbar, router],\n )\n\n /*\n * One row per message, in the order above; the whole window is in hand, so\n * the grid pages it. The subject is a link AND the row opens the report: a\n * click handler cannot be middle-clicked or opened in a new tab.\n */\n const columns: GridColDef[] = [\n {\n field: 'subject',\n headerName: 'Subject',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => row.subject || 'Untitled email',\n renderCell: ({ row, value }) => (\n <AppLink\n href={emailHref(row)}\n // The row's own handler would fire too and push the same route\n // twice — one history entry per back press.\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {value}\n </AppLink>\n ),\n },\n {\n /*\n * WHAT THIS EMAIL IS DOING, not what field it stores.\n *\n * An email delivering an audience larger than one batch is written back\n * as `scheduled` between runs, so a chip rendering the status said\n * \"Scheduled\" about a send that had already reached five hundred people.\n * The derivation reads the counters beside the status and says which of\n * the two it is.\n */\n field: 'state',\n headerName: 'State',\n width: 150,\n valueGetter: (_value, row) => campaignSendDisplay(row).label,\n renderCell: ({ row }) => {\n const display = campaignSendDisplay(row)\n return (\n <Chip\n size=\"small\"\n color={STATE_COLOR[display.state]}\n label={display.label}\n />\n )\n },\n },\n {\n field: 'when',\n headerName: 'When',\n width: 190,\n // Sorted on the time the list is ordered by, so a draft sorts by its\n // creation rather than as the oldest thing on the page.\n valueGetter: (_value, row) => emailListTimeMs(row),\n renderCell: ({ row }) => {\n const at = emailSendTimeMs(row)\n return at ? new Date(at).toLocaleString() : '—'\n },\n },\n ...(\n [\n ['recipients', 'Addressed'],\n ['opens', 'Opens'],\n ['clicks', 'Clicks'],\n ] as const\n ).map(\n ([stat, headerName]): GridColDef => ({\n field: stat,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 110,\n valueGetter: (_value, row) => Number(row.stats?.[stat] ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n }),\n ),\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.subject || 'Untitled email')}\n items={rowActions(row)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header={'Messages'}\n help={emailsDocsHelp}\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setCreateOpen(true)}\n >\n {'New email'}\n </Button>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n {duplicate.dialog}\n <Stack spacing={2}>\n {emails.length === 0 ? (\n <Stack spacing={2} sx={{ alignItems: 'flex-start' }}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing has been sent or scheduled yet. Write one here, or ' +\n 'from a campaign, and it appears in this list with its own ' +\n 'report.'}\n </Typography>\n <Button variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'New email'}\n </Button>\n </Stack>\n ) : (\n <ListTable\n aria-label=\"Messages\"\n rows={emails}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, row) => router.push(emailHref(row))}\n />\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`Showing ${EMAIL_CEILING} messages. This site has sent or ` +\n 'scheduled more than that, and the rest are not in this list.'}\n </Alert>\n ) : null}\n </Stack>\n {/*\n AN EMAIL WRITTEN HERE BELONGS TO A CAMPAIGN, OR TO NO CAMPAIGN.\n\n The second is a real answer and not a gap in the model. A send with no\n container is what the product has always had — every message that\n predates campaigns is one — and `campaignListRows` adopts each of them\n as a campaign of one at read time, which is the \"Single send\" chip on\n the campaigns table. So composing from this list mints nothing, files\n nothing, and requires nobody to invent a campaign first: it leaves the\n container empty, and the send that results is presented the way every\n containerless send already is.\n\n Offering the campaigns anyway is what stops the opposite mistake — a\n merchant who DOES have a spring campaign writing its third email into\n a single send that never joins the rollup.\n */}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"New email\"\n submitLabel={creating ? 'Creating…' : 'Start writing'}\n includeDescription={false}\n onSubmit={handleCreate}\n extraFields={[\n {\n component: 'select',\n name: 'emailCampaignId',\n label: 'Campaign',\n initialValue: '',\n helperText:\n 'Leave this as a single send unless the email belongs with ' +\n 'others',\n disableDefaultOption: true,\n options: [\n { value: '', label: 'Single send — not part of a campaign' },\n ...campaignOptions,\n ],\n },\n ]}\n />\n </CardDisplay>\n )\n}\nEmailsListCard.displayName = 'EmailsListCard'\n\nexport default EmailsListCard\n"],"names":["pluginDocsHelp","mdiBullhornOutline","mdiContentCopy","mdiDeleteOutline","mdiEyeOutline","mdiPaletteOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","CreateArtifactDrawer","useSnackbar","ceilingedWindow","collectionCeiling","DUPLICATE_MENU_LABEL","useDuplicateResource","useFirestore","useFirestoreCollection","Alert","Button","Chip","Stack","Typography","collection","useRouter","useCallback","useMemo","useState","campaignSendDisplay","CAMPAIGN_SEND_CONTAINER_FIELD","emailListTimeMs","emailSendTimeMs","useCampaignManageApi","useCampaignSendApi","useMarketingHubPath","EMAIL_CEILING","CONTAINER_CEILING","DISCARD_REFUSAL","sent","scheduled","sending","canceled","STATE_COLOR","stopped","emailsDocsHelp","anchor","excerpt","EmailsListCard","props","hostId","basePath","firestore","marketingHub","router","data","emailDocs","idField","rows","readEmails","truncated","emails","sort","a","b","emailHref","email","$id","confirm","enqueueSnackbar","manageForCopy","duplicate","perform","sourceId","name","attemptKey","payload","response","action","campaignId","ok","Error","error","id","String","emailId","versionId","onDuplicated","_kind","copy","variant","manageApi","discardingId","setDiscardingId","handleDiscard","subject","displayName","agreed","title","description","confirmationText","then","catch","allowDuplicate","persist","console","rowActions","containerId","templateScreenId","state","status","key","label","icon","path","size","href","undefined","disabled","disabledReason","onClick","request","destructive","Boolean","createOpen","setCreateOpen","creating","setCreating","campaignSendApi","campaignDocs","campaignOptions","map","campaign","value","localeCompare","handleCreate","values","trim","emailCampaignId","push","columns","field","headerName","flex","minWidth","valueGetter","_value","row","renderCell","event","stopPropagation","width","display","color","at","Date","toLocaleString","stat","type","align","headerAlign","Number","stats","valueFormatter","items","header","help","HeaderProps","contentGutterX","contentGutterY","dialog","spacing","length","sx","alignItems","aria-label","rowHeight","onOpen","_id","severity","open","onClose","submitLabel","includeDescription","onSubmit","extraFields","component","initialValue","helperText","disableDefaultOption","options"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SAASA,cAAc,QAAQ,eAAc;AAC7C,SACEC,kBAAkB,EAClBC,cAAc,EACdC,gBAAgB,EAChBC,aAAa,EACbC,iBAAiB,QACZ,yBAAwB;AAC/B,SACEC,OAAO,EACPC,WAAW,EACXC,OAAO,EACPC,sBAAsB,QACjB,uBAAsB;AAC7B,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAE7D,SAASC,gBAAgB,QAAQ,8CAA6C;AAC9E,SAASC,oBAAoB,QAAQ,6BAA4B;AACjE,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SACEC,eAAe,EACfC,iBAAiB,QACZ,+DAA8D;AACrE,SACEC,oBAAoB,EACpBC,oBAAoB,EACpBC,YAAY,EACZC,sBAAsB,QACjB,iCAAgC;AACvC,SAASC,KAAK,EAAEC,MAAM,EAAEC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AAEtE,SAASC,UAAU,QAAQ,qBAAoB;AAC/C,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SACEC,mBAAmB,EACnBC,6BAA6B,QAExB,4DAA2D;AAClE,SACEC,eAAe,EACfC,eAAe,QACV,sDAAqD;AAC5D,SACEC,oBAAoB,EACpBC,kBAAkB,QACb,6BAAyB;AAChC,SAASC,mBAAmB,QAAQ,8BAA0B;AAE9D,oDAAoD,GACpD,MAAMC,gBAAgB;AAEtB,2EAA2E,GAC3E,MAAMC,oBAAoB;AAE1B;;;;;;;;;;;;CAYC,GACD,MAAMC,kBAA0C;IAC9CC,MAAM;IACNC,WAAW;IACXC,SAAS;IACTC,UAAU;AACZ;AAEA;;;;;;;CAOC,GACD,MAAMC,cAEF;IACFF,SAAS;IACTG,SAAS;AACX;AAEA,MAAMC,iBAAiBhD,eAAe,kBAAkB;IACtDiD,QAAQ;IACRC,SACE,0EACA;AACJ;AAQA;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,eAAeC,KAA0B;IACvD,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAE,GAAGF;IAC7B,MAAMG,YAAYnC;IAClB,uEAAuE;IACvE,MAAMoC,eAAelB;IACrB,MAAMmB,SAAS7B;IAEf,MAAM,EAAE8B,MAAMC,SAAS,EAAE,GAAGtC,uBAC1B,IACEJ,kBACEU,WAAW4B,WAAW,SAASF,QAAQ,cACvCd,gBAEJ;QAACgB;QAAWF;KAAO,EACnB;QAAEO,SAAS;IAAM;IAEnB,MAAM,EAAEC,MAAMC,UAAU,EAAEC,SAAS,EAAE,GAAG/C,gBACtC2C,WACApB;IAEF;;;;;;;;;;GAUC,GACD,MAAMyB,SAASlC,QACb,IACE;eAAIgC;SAAW,CAACG,IAAI,CAClB,CAACC,GAAQC,IAAWjC,gBAAgBiC,KAAKjC,gBAAgBgC,KAE7D;QAACJ;KAAW;IAGd,MAAMM,YAAY,CAACC,QAAe,GAAGf,SAAS,UAAU,EAAEe,MAAMC,GAAG,EAAE;IAErE,MAAM,EAAEC,OAAO,EAAE,GAAG9D;IACpB,MAAM,EAAE+D,eAAe,EAAE,GAAGzD;IAC5B,yEAAyE;IACzE,wEAAwE;IACxE,6BAA6B;IAC7B,MAAM0D,gBAAgBrC,qBAAqBiB;IAC3C,MAAMqB,YAAYvD,qBAAqB;QACrCkC;QACAsB,SAAS,OAAO,EAAEC,QAAQ,EAAEC,IAAI,EAAEC,UAAU,EAAE;sBAW7BC;YAVf,MAAM,EAAEC,QAAQ,EAAED,OAAO,EAAE,GAAG,MAAMN,cAAc;gBAChDQ,QAAQ;gBACRC,YAAYN;gBACZC;gBACAC;YACF;YACA,IAAI,CAACE,SAASG,EAAE,EAAE,MAAM,IAAIC,cAAML,2BAAAA,QAASM,KAAK,mBAAI;YACpD,OAAO;gBACLC,IAAIC,OAAOR,QAAQS,OAAO;gBAC1BC,WAAW;gBACXZ,MAAMU,QAAOR,gBAAAA,QAAQF,IAAI,YAAZE,gBAAgBF;YAC/B;QACF;QACAa,cAAc,CAACC,OAAOC,OACpBpB,gBAAgB,CAAC,eAAe,EAAEoB,KAAKf,IAAI,CAAC,gCAAgC,CAAC,EAAE;gBAC7EgB,SAAS;YACX;IACJ;IACA,MAAMC,YAAY1D,qBAAqBiB;IACvC,sEAAsE,GACtE,MAAM,CAAC0C,cAAcC,gBAAgB,GAAGjE,SAAS;IAEjD;;;;;;;;;;;;;;;;6CAgB2C,GAC3C,MAAMkE,gBAAgBpE,YACpB,OAAOwC;;QACL,IAAI0B,cAAc;QAClB,MAAMT,KAAKC,eAAOlB,yBAAAA,MAAOC,GAAG,mBAAI;QAChC,MAAMO,OAAOU,OAAOlB,CAAAA,yBAAAA,MAAO6B,OAAO,MAAI7B,yBAAAA,MAAO8B,WAAW,KAAI;QAC5D,MAAMC,SAAS,MAAM7B,QAAQ;YAC3B8B,OAAO;YACPC,aACE,GAAGzB,KAAK,yDAAyD,CAAC,GAClE,gEACA;YACF0B,kBAAkB;QACpB,GACGC,IAAI,CAAC,IAAM,MACXC,KAAK,CAAC,IAAM;QACf,IAAI,CAACL,QAAQ;QACbJ,gBAAgBV;QAChB,IAAI;YACF,MAAM,EAAEN,QAAQ,EAAED,OAAO,EAAE,GAAG,MAAMe,UAAU;gBAC5Cb,QAAQ;gBACRC,YAAYI;YACd;YACA,IAAI,CAACN,SAASG,EAAE,EAAE;;gBAChB,OAAO,KAAKX,yBACVO,2BAAAA,QAASM,KAAK,oBAAI,qCAClB;oBAAEQ,SAAS;oBAAWa,gBAAgB;gBAAK;YAE/C;YACAlC,gBAAgB,mBAAmB;gBACjCqB,SAAS;gBACTc,SAAS;YACX;QACF,EAAE,OAAOtB,OAAO;YACduB,QAAQvB,KAAK,CAACA;YACdb,gBAAgB,qCAAqC;gBACnDqB,SAAS;gBACTa,gBAAgB;YAClB;QACF,SAAU;YACRV,gBAAgB;QAClB;IACF,GACA;QAACzB;QAASwB;QAAcvB;QAAiBsB;KAAU;IAGrD;;;;;;;;;;;;;;;;;GAiBC,GACD,MAAMe,aAAa,CAACxC;gCAoDE5B;QAnDpB,MAAMqE,cAAcvB,eAAOlB,yBAAAA,KAAO,CAACpC,8BAA8B,mBAAI;QACrE,MAAM8E,mBAAmBxB,gBAAOlB,yBAAAA,MAAO0C,gBAAgB,oBAAI;QAC3D,MAAMC,QAAQzB,gBAAOlB,yBAAAA,MAAO4C,MAAM,oBAAI;QACtC,OAAO;YACL;gBACEC,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAAC5G;oBAAQ6G,MAAMjH,cAAciH,IAAI;oBAAEC,MAAM;;gBAC/CC,MAAMnD,UAAUC;YAClB;YACA;gBACE6C,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAAC5G;oBAAQ6G,MAAMpH,mBAAmBoH,IAAI;oBAAEC,MAAM;;gBACpD,oEAAoE;gBACpE,qEAAqE;gBACrEC,MACET,eAAetD,eACX,GAAGA,aAAa,WAAW,EAAEsD,aAAa,GAC1CU;gBACNC,UAAU,CAACX,eAAe,CAACtD;gBAC3BkE,gBAAgBZ,cACZ,iDACA;YACN;YACA;gBACEI,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAAC5G;oBAAQ6G,MAAMhH,kBAAkBgH,IAAI;oBAAEC,MAAM;;gBACnDC,MAAMR,mBACF,GAAGzD,SAAS,WAAW,EAAEyD,kBAAkB,GAC3CS;gBACJC,UAAU,CAACV;gBACXW,gBAAgB;YAClB;YACA;gBACER,KAAK;gBACLC,OAAOjG;gBACPkG,oBAAM,KAAC5G;oBAAQ6G,MAAMnH,eAAemH,IAAI;oBAAEC,MAAM;;gBAChDK,SAAS;wBAGQtD;2BAFfK,UAAUkD,OAAO,CAAC,YAAY;wBAC5BtC,IAAIC,OAAOlB,MAAMC,GAAG;wBACpBO,MAAMU,QAAOlB,gBAAAA,yBAAAA,MAAO8B,WAAW,oBAAI9B,yBAAAA,MAAO6B,OAAO,YAApC7B,OAAwC;oBACvD;;YACJ;YACA;gBACE6C,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAAC5G;oBAAQ6G,MAAMlH,iBAAiBkH,IAAI;oBAAEC,MAAM;;gBAClDO,aAAa;gBACbJ,UAAUT,UAAU,WAAWc,QAAQ/B;gBACvC2B,cAAc,GAAEjF,yBAAAA,eAAe,CAACuE,MAAM,YAAtBvE,yBAA0B;gBAC1CkF,SAAS,IAAM,KAAK1B,cAAc5B;YACpC;SACD;IACH;IAEA,MAAM,CAAC0D,YAAYC,cAAc,GAAGjG,SAAS;IAC7C,MAAM,CAACkG,UAAUC,YAAY,GAAGnG,SAAS;IACzC,MAAMoG,kBAAkB9F,mBAAmBgB;IAE3C;;;;;;;;GAQC,GACD,MAAM,EAAEK,MAAM0E,YAAY,EAAE,GAAG/G,uBAC7B,IACE0G,aACI9G,kBACEU,WAAW4B,WAAW,SAASF,QAAQ,mBACvCb,qBAEF,MACN;QAACe;QAAWF;QAAQ0E;KAAW,EAC/B;QAAEnE,SAAS;IAAM;IAEnB,MAAMyE,kBAAkBvG,QACtB,IACE;eAAKsG,uBAAAA,eAAgB,EAAE;SAAE,CACtBE,GAAG,CAAC,CAACC,WAAmB,CAAA;gBACvBC,OAAOjD,OAAOgD,SAASjE,GAAG;gBAC1B6C,OAAO5B,OAAOgD,SAAS1D,IAAI,IAAI;YACjC,CAAA,GACCZ,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEiD,KAAK,CAACsB,aAAa,CAACtE,EAAEgD,KAAK,IACjD;QAACiB;KAAa;IAGhB;;;;;;;;;;;;;6CAa2C,GAC3C,MAAMM,eAAe7G,YACnB,OAAO8G;QACL,IAAIV,UAAU;QACdC,YAAY;QACZ,IAAI;gBAGoBS;YAFtB,MAAM,EAAE3D,QAAQ,EAAED,OAAO,EAAE,GAAG,MAAMoD,gBAAgB;gBAClDlD,QAAQ;gBACRkB,aAAaZ,QAAOoD,sBAAAA,OAAOxC,WAAW,YAAlBwC,sBAAsB,IAAIC,IAAI;eAC9CD,OAAOE,eAAe,GACtB;gBAAEA,iBAAiBtD,OAAOoD,OAAOE,eAAe;YAAE,IAClD,CAAC;YAEP,IAAI,CAAC7D,SAASG,EAAE,IAAI,EAACJ,2BAAAA,QAASG,UAAU,GAAE;;gBACxC,OAAO,KAAKV,wBACVO,2BAAAA,QAASM,KAAK,mBAAI,mCAClB;oBAAEQ,SAAS;oBAAWa,gBAAgB;gBAAK;YAE/C;YACAsB,cAAc;YACd;;;;;SAKC,GACDvE,OAAOqF,IAAI,CAAC,GAAGxF,SAAS,UAAU,EAAEyB,QAAQG,UAAU,CAAC,KAAK,CAAC;QAC/D,EAAE,OAAOG,OAAO;YACduB,QAAQvB,KAAK,CAACA;YACdb,gBAAgB,mCAAmC;gBACjDqB,SAAS;gBACTa,gBAAgB;YAClB;QACF,SAAU;YACRwB,YAAY;QACd;IACF,GACA;QAAC5E;QAAU6E;QAAiBF;QAAUzD;QAAiBf;KAAO;IAGhE;;;;GAIC,GACD,MAAMsF,UAAwB;QAC5B;YACEC,OAAO;YACPC,YAAY;YACZC,MAAM;YACNC,UAAU;YACVC,aAAa,CAACC,QAAQC,MAAQA,IAAIpD,OAAO,IAAI;YAC7CqD,YAAY,CAAC,EAAED,GAAG,EAAEd,KAAK,EAAE,iBACzB,KAAClI;oBACCiH,MAAMnD,UAAUkF;oBAChB,+DAA+D;oBAC/D,4CAA4C;oBAC5C3B,SAAS,CAAC6B,QACRA,MAAMC,eAAe;8BAGtBjB;;QAGP;QACA;YACE;;;;;;;;OAQC,GACDQ,OAAO;YACPC,YAAY;YACZS,OAAO;YACPN,aAAa,CAACC,QAAQC,MAAQtH,oBAAoBsH,KAAKnC,KAAK;YAC5DoC,YAAY,CAAC,EAAED,GAAG,EAAE;gBAClB,MAAMK,UAAU3H,oBAAoBsH;gBACpC,qBACE,KAAC9H;oBACC8F,MAAK;oBACLsC,OAAO9G,WAAW,CAAC6G,QAAQ3C,KAAK,CAAC;oBACjCG,OAAOwC,QAAQxC,KAAK;;YAG1B;QACF;QACA;YACE6B,OAAO;YACPC,YAAY;YACZS,OAAO;YACP,qEAAqE;YACrE,wDAAwD;YACxDN,aAAa,CAACC,QAAQC,MAAQpH,gBAAgBoH;YAC9CC,YAAY,CAAC,EAAED,GAAG,EAAE;gBAClB,MAAMO,KAAK1H,gBAAgBmH;gBAC3B,OAAOO,KAAK,IAAIC,KAAKD,IAAIE,cAAc,KAAK;YAC9C;QACF;WACG,AACD;YACE;gBAAC;gBAAc;aAAY;YAC3B;gBAAC;gBAAS;aAAQ;YAClB;gBAAC;gBAAU;aAAS;SACrB,CACDzB,GAAG,CACH,CAAC,CAAC0B,MAAMf,WAAW,GAAkB,CAAA;gBACnCD,OAAOgB;gBACPf;gBACAgB,MAAM;gBACNC,OAAO;gBACPC,aAAa;gBACbT,OAAO;gBACPN,aAAa,CAACC,QAAQC;;wBAAeA;2BAAPc,gBAAOd,aAAAA,IAAIe,KAAK,qBAATf,UAAW,CAACU,KAAK,mBAAI;;gBAC1DM,gBAAgB,CAAC9B,QAAkBA,MAAMuB,cAAc;YACzD,CAAA;QAEFnJ,kBACE,CAAC0I,oBACC,KAAC5I;gBACCyG,OAAO5B,OAAO+D,IAAIpD,OAAO,IAAI;gBAC7BqE,OAAO1D,WAAWyC;gBAGtB;YAAEI,OAAO;QAAG;KAEf;IAED,qBACE,MAACnJ;QACCiK,QAAQ;QACRC,MAAMzH;QACN0H,aAAa;YACXzF,sBACE,KAAC1D;gBACC+F,MAAK;gBACLzB,SAAQ;gBACR8B,SAAS,IAAMK,cAAc;0BAE5B;;QAGP;QACA2C,cAAc;QACdC,cAAc;;YAEblG,UAAUmG,MAAM;0BACjB,MAACpJ;gBAAMqJ,SAAS;;oBACb9G,OAAO+G,MAAM,KAAK,kBACjB,MAACtJ;wBAAMqJ,SAAS;wBAAGE,IAAI;4BAAEC,YAAY;wBAAa;;0CAChD,KAACvJ;gCAAWmE,SAAQ;gCAAQ+D,OAAM;0CAC/B,gEACC,+DACA;;0CAEJ,KAACrI;gCAAOsE,SAAQ;gCAAY8B,SAAS,IAAMK,cAAc;0CACtD;;;uCAIL,KAACrH;wBACCuK,cAAW;wBACXrH,MAAMG;wBACN+E,SAASA;wBACToC,WAAWtK;wBACXuK,QAAQ,CAACC,KAAK/B,MAAQ7F,OAAOqF,IAAI,CAAC1E,UAAUkF;;oBAG/CvF,0BACC,KAACzC;wBAAMgK,UAAS;kCACb,CAAC,QAAQ,EAAE/I,cAAc,iCAAiC,CAAC,GAC1D;yBAEF;;;0BAkBN,KAACzB;gBACCyK,MAAMxD;gBACNyD,SAAS,IAAMxD,cAAc;gBAC7B3B,OAAM;gBACNoF,aAAaxD,WAAW,cAAc;gBACtCyD,oBAAoB;gBACpBC,UAAUjD;gBACVkD,aAAa;oBACX;wBACEC,WAAW;wBACXhH,MAAM;wBACNsC,OAAO;wBACP2E,cAAc;wBACdC,YACE,+DACA;wBACFC,sBAAsB;wBACtBC,SAAS;4BACP;gCAAEzD,OAAO;gCAAIrB,OAAO;4BAAuC;+BACxDkB;yBACJ;oBACH;iBACD;;;;AAIT;AACAlF,eAAegD,WAAW,GAAG;AAE7B,eAAehD,eAAc"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/emails-list-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n mdiBullhornOutline,\n mdiContentCopy,\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPaletteOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n ceilingedWindow,\n collectionCeiling,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n DUPLICATE_MENU_LABEL,\n useDuplicateResource,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n campaignPlacedOnHost,\n campaignSendDisplay,\n CAMPAIGN_SEND_CONTAINER_FIELD,\n type CampaignSendDisplayState,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport {\n emailListTimeMs,\n emailSendTimeMs,\n} from '@aglyn/shared-ui-email-campaigns/model/email-record'\nimport {\n useCampaignManageApi,\n useCampaignSendApi,\n} from './use-campaign-send-api'\nimport { useMarketingHubPath } from './use-marketing-hub-path'\nimport { campaignContainersQuery, campaignSendsQuery } from './campaign-queries'\nimport {\n orgSiteHubPath,\n orgSiteName,\n orgSiteOptions,\n useMarketingOrgId,\n useMarketingOrgMount,\n} from './marketing-org-mount'\n\n/** How many messages one read of this list covers. */\nconst EMAIL_CEILING = 30\n\n/** How many campaigns the create drawer offers to file a new email under. */\nconst CONTAINER_CEILING = 50\n\n/**\n * Why discard is refused, keyed by the state that refuses it.\n *\n * The KEYS are persisted status values written by the send path. Each line\n * says what to do instead, because \"you cannot\" on its own leaves a merchant\n * with an email they wanted rid of and no next step — and for a scheduled one\n * the next step is a different act with a different consequence, so naming it\n * is the difference between withdrawing mail and losing the record of it.\n *\n * A state absent from here falls through to the generic refusal rather than\n * being flattened into one of these: a state this list cannot name is worth\n * seeing.\n */\nconst DISCARD_REFUSAL: Record<string, string> = {\n sent: 'This email has been sent. Its report and its unsubscribe links have to go on resolving.',\n scheduled: 'Cancel the send first — cancelling takes it off the clock and keeps the email.',\n sending: 'This email is being sent right now.',\n canceled: 'A canceled email is kept as the record that it was withdrawn.',\n}\n\n/**\n * The chip color for one display state.\n *\n * `sending` is the one worth a color: an email part way through an audience\n * larger than one batch is doing something right now, and a merchant scanning\n * the list for what needs attention should find it without reading. The rest\n * are neutral, because a finished send and a draft are not events.\n */\nconst STATE_COLOR: Partial<\n Record<CampaignSendDisplayState, 'info' | 'warning'>\n> = {\n sending: 'info',\n stopped: 'warning',\n}\n\nconst emailsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#opens--clicks',\n excerpt:\n 'Every message this site has sent or has scheduled, each with its own ' +\n 'report: what was delivered, who opened it, and which links they followed.',\n})\n\nexport interface EmailsListCardProps {\n /** The site, or `null` on the org Marketing hub. */\n hostId: string | null\n /** The emails hub URL, so a row can link to the message's own page. */\n basePath: string\n}\n\n/**\n * EVERY MESSAGE, AS AGAINST EVERY CAMPAIGN.\n *\n * A campaign groups messages; this is the messages. The two are different\n * questions — \"how did the spring promotion do\" and \"what went out on the\n * 14th, and to whom\" — and they were previously the same list because a\n * campaign document WAS a single send.\n *\n * ## Ordered in the browser, deliberately\n *\n * No SEND date is on every message: a sent one carries `sentAt` and a\n * scheduled one carries `sendAtMs`, written by two different branches of the\n * send path, and a draft carries neither — so `orderBy` on either would not\n * mis-sort this list, it would DROP half of it. `collectionCeiling` reads a\n * bounded window in document-id order and probes one past the ceiling, so the\n * rows are sorted here and the reader is told when there are more.\n *\n * Every writer stamps a `createdAtMs`, which is the field this list could be\n * ordered on in Firestore. Moving it there is a query change with the same\n * hazard as the two above — `orderBy` drops a document missing the field —\n * so it needs the corpus proven to carry one, not just the writers.\n *\n * The page is therefore a SLICE of a window this card already holds, not a\n * query: paging an id-ordered walk and re-sorting each page by date would run\n * in one order within a page and another across them.\n *\n * ## Under a site, and over the organization\n *\n * Sends are the org's. Under a site this lists the ones sent as that site;\n * on the org hub it lists every site's, with a Site column, and every action\n * on a row names the site that row is sent as — the only site that can\n * duplicate it, discard it or open its template. Creating one there first\n * asks which site it is sent as.\n */\nexport function EmailsListCard(props: EmailsListCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const orgMount = useMarketingOrgMount()\n const { orgId } = useMarketingOrgId(hostId)\n // The sibling hub: a campaign's page belongs to the Marketing console —\n // which, on the org hub, is the page this list is already on.\n const siteMarketingHub = useMarketingHubPath()\n const marketingHub = orgMount ? orgMount.basePath : siteMarketingHub\n const router = useRouter()\n\n const { data: emailDocs } = useFirestoreCollection<any>(\n () => {\n const sends = campaignSendsQuery(firestore, orgId, hostId)\n return sends ? collectionCeiling(sends, EMAIL_CEILING) : null\n },\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const { rows: readEmails, truncated } = ceilingedWindow<any>(\n emailDocs,\n EMAIL_CEILING,\n )\n /*\n * Newest first on the time each message SITS at — its send time where it\n * has one, its creation where it does not.\n *\n * A draft has neither `sentAt` nor `sendAtMs`, so sorting on the send time\n * alone gave every draft the key 0 and filed the email a merchant is in the\n * middle of writing at the very bottom of the list, behind whatever paging\n * it has. `emailListTimeMs` is the same ordering with that one gap closed;\n * a SENT message still orders by when it went out, never by when it was\n * drafted.\n */\n const emails = useMemo(\n () =>\n [...readEmails].sort(\n (a: any, b: any) => emailListTimeMs(b) - emailListTimeMs(a),\n ),\n [readEmails],\n )\n\n /*\n * A message's own pages: the Emails console's under a site, the org hub's\n * Emails section over the org. A template is a site's design, so on the\n * org hub it opens on the site the row is sent as.\n */\n const messagesPath = orgMount\n ? `${orgMount.basePath}/emails`\n : `${basePath}/messages`\n const emailHref = (email: any) => `${messagesPath}/${email.$id}`\n const templatesHub = (email: any): string | null =>\n orgMount ? orgSiteHubPath(orgMount, email?.hostId, 'emails') : basePath\n /** The site a row is sent as, which every action on it must name. */\n const rowHostId = useCallback(\n (email: any): string | null =>\n hostId ?? (email?.hostId ? String(email.hostId) : null),\n [hostId],\n )\n const hostOfEmail = useMemo(\n () =>\n new Map<string, string | null>(\n readEmails.map((email: any) => [\n String(email.$id),\n email?.hostId ? String(email.hostId) : null,\n ]),\n ),\n [readEmails],\n )\n\n const { confirm } = useConfirmationContext()\n const { enqueueSnackbar } = useSnackbar()\n // Duplicate (AGL-2936): a new draft with the message and its design, and\n // nobody to send to until the person chooses — through the manage door,\n // which is the plugin's own.\n const manageForCopy = useCampaignManageApi(hostId)\n const duplicate = useDuplicateResource({\n hostId: hostId ?? '',\n perform: async ({ sourceId, name, attemptKey }) => {\n const { response, payload } = await manageForCopy({\n action: 'duplicate',\n campaignId: sourceId,\n name,\n attemptKey,\n // The copy is made as the site the source is sent as.\n ...(hostId ? {} : { hostId: hostOfEmail.get(sourceId) ?? undefined }),\n })\n if (!response.ok) throw new Error(payload?.error ?? 'Duplicate failed')\n return {\n id: String(payload.emailId),\n versionId: null,\n name: String(payload.name ?? name),\n }\n },\n onDuplicated: (_kind, copy) =>\n enqueueSnackbar(`Duplicated as “${copy.name}” — a draft with no audience yet`, {\n variant: 'success',\n }),\n })\n const manageApi = useCampaignManageApi(hostId)\n /** The email a discard is in flight for, so its row menu can say so. */\n const [discardingId, setDiscardingId] = useState('')\n\n /*==========================================\n * THROWING AWAY A DRAFT.\n *\n * The one removal this surface has, and it is deliberately the narrowest\n * one: an email that was never sent, whose record nobody outside the\n * console has ever seen. Everything else on this list is evidence — a sent\n * message's report is what a merchant answers a complaint with, and its id\n * is inside the HMAC of every unsubscribe footer it delivered — and a\n * scheduled one is withdrawn with Cancel, which keeps the record and takes\n * it off the clock.\n *\n * The menu already refuses anything but a draft, and so does the route.\n * That is not belt and braces: the state on screen is a snapshot that can\n * be seconds old, and the record can be claimed by `sendNow` in between.\n * The route's refusal is the one that decides, inside the transaction that\n * does the delete.\n *=========================================*/\n const handleDiscard = useCallback(\n async (email: any) => {\n if (discardingId) return\n const id = String(email?.$id ?? '')\n const name = String(email?.subject || email?.displayName || 'this draft')\n const agreed = await confirm({\n title: 'Discard this draft?',\n description:\n `${name} has not been sent to anybody, and discarding it removes ` +\n 'it for good — the subject, the message and everything else ' +\n 'written on it. There is no undo.',\n confirmationText: 'Discard',\n })\n .then(() => true)\n .catch(() => false)\n if (!agreed) return\n setDiscardingId(id)\n try {\n const { response, payload } = await manageApi({\n action: 'discardEmail',\n campaignId: id,\n ...(hostId ? {} : { hostId: rowHostId(email) ?? undefined }),\n })\n if (!response.ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'This draft could not be discarded',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n enqueueSnackbar('Draft discarded', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('This draft could not be discarded', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setDiscardingId('')\n }\n },\n [confirm, discardingId, enqueueSnackbar, hostId, manageApi, rowHostId],\n )\n\n /**\n * What a message can be opened INTO, from the list.\n *\n * All three are places the message's own report already links to, moved one\n * screen earlier: the report itself, the campaign it belongs to, and the\n * template it was rendered from. A message need have neither of the last\n * two — one sent before campaigns grouped anything names no container, and\n * one composed inline was built from no template — so those entries are\n * shown DISABLED with the reason rather than hidden. A control that\n * disappears and a control that does not apply look identical, and only one\n * of them tells the reader which case they are in.\n *\n * DISCARD is the fourth entry and is offered on the same terms: a draft can\n * be thrown away, and nothing else can. It stays visible on a sent or\n * scheduled message carrying the reason it is refused, because that is the\n * honest answer to “how do I get rid of this” — and the route refuses it\n * too, so the menu is describing a rule rather than being one.\n */\n const rowActions = (email: any): RowActionsMenuItem[] => {\n const containerId = String(email?.[CAMPAIGN_SEND_CONTAINER_FIELD] ?? '')\n const templateScreenId = String(email?.templateScreenId ?? '')\n const state = String(email?.status ?? '')\n const templates = templatesHub(email)\n /** On the org hub, a send that names no site has nothing to act as. */\n const siteless = !rowHostId(email)\n const sitelessReason =\n 'This email does not record which site it was sent as. Manage it from ' +\n 'that site’s own Emails page.'\n return [\n {\n key: 'details',\n label: 'Open report',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: emailHref(email),\n },\n {\n key: 'campaign',\n label: 'Open its campaign',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n // The campaign's page belongs to the Marketing console, so this one\n // href is built from the sibling hub rather than this surface's own.\n href:\n containerId && marketingHub\n ? `${marketingHub}/campaigns/${containerId}`\n : undefined,\n disabled: !containerId || !marketingHub,\n disabledReason: containerId\n ? 'This site’s console URL has not resolved yet'\n : 'Sent before campaigns grouped their emails, so it belongs to none',\n },\n {\n key: 'template',\n label: 'Open its template',\n icon: <MdiIcon path={mdiPaletteOutline.path} size={0.8} />,\n href:\n templateScreenId && templates\n ? `${templates}/templates/${templateScreenId}`\n : undefined,\n disabled: !templateScreenId || !templates,\n disabledReason: templateScreenId\n ? 'This site’s console URL has not resolved yet'\n : 'This message was not built from a template',\n },\n {\n key: 'duplicate',\n label: DUPLICATE_MENU_LABEL,\n icon: <MdiIcon path={mdiContentCopy.path} size={0.8} />,\n disabled: siteless,\n disabledReason: sitelessReason,\n onClick: () =>\n duplicate.request('campaign', {\n id: String(email.$id),\n name: String(email?.displayName ?? email?.subject ?? ''),\n }),\n },\n {\n key: 'discard',\n label: 'Discard draft',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: siteless || state !== 'draft' || Boolean(discardingId),\n disabledReason: siteless\n ? sitelessReason\n : (DISCARD_REFUSAL[state] ?? 'Only a draft can be discarded'),\n onClick: () => void handleDiscard(email),\n },\n ]\n }\n\n const [createOpen, setCreateOpen] = useState(false)\n const [creating, setCreating] = useState(false)\n const campaignSendApi = useCampaignSendApi(hostId)\n /*\n * The sites a new email can be sent as, on the org hub. One site answers\n * the question itself, so the field is asked only when there is a choice.\n */\n const orgSites = useMemo(\n () => (orgMount ? orgSiteOptions(orgMount) : []),\n [orgMount],\n )\n const askSite = !hostId && orgSites.length > 1\n\n /*\n * The campaigns a new email may be filed under, read only while the drawer\n * is OPEN.\n *\n * A null query opens no listener, so the list costs what it always did until\n * somebody asks to write something. Mounting this unconditionally would put\n * a second collection read on every reader who came to look at the table,\n * which is the cost the whole surface is routed to avoid.\n */\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () => {\n const containers = createOpen\n ? campaignContainersQuery(firestore, orgId, hostId)\n : null\n return containers ? collectionCeiling(containers, CONTAINER_CEILING) : null\n },\n [firestore, orgId, hostId, createOpen],\n { idField: '$id' },\n )\n const campaignOptions = useMemo(\n () =>\n [...(campaignDocs ?? [])]\n .map((campaign: any) => ({\n value: String(campaign.$id),\n label: String(campaign.name || 'Untitled campaign'),\n }))\n .sort((a, b) => a.label.localeCompare(b.label)),\n [campaignDocs],\n )\n\n /*==========================================\n * CREATE, THEN GO TO THE EMAIL'S OWN PAGE.\n *\n * The drawer collects only what it takes to MINT the record — the friendly\n * name, and the campaign it belongs to. Everything else about the email is\n * written on the email's own page, which is where a record is edited\n * throughout this console: a list page carries no form.\n *\n * The record is real from this moment: `/marketing/campaigns/{id}` resolves,\n * the row appears in the table below as a Draft, and the id it is created\n * under is the id it keeps when it is eventually sent. It costs nothing to\n * exist — the route reserves no allowance and moves no meter for a draft,\n * and the scheduled processor only ever picks up `scheduled`.\n *=========================================*/\n const handleCreate = useCallback(\n async (values: Record<string, any>) => {\n if (creating) return\n const sendAs =\n hostId ?? (askSite ? String(values.hostId ?? '') : orgSites[0]?.value)\n if (!sendAs) {\n return void enqueueSnackbar('Choose the site this email is sent as', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n /*\n * The organization hub offers every campaign, because the site is\n * chosen in the same form. An email can only join a campaign placed on\n * the site it is sent as — the send route refuses anything else — so\n * the mismatch is named here, where it can be fixed, rather than\n * arriving as the route's bare refusal. A site hub lists only the\n * campaigns placed on it, so there is nothing to check there.\n */\n const chosenCampaign =\n !hostId && values.emailCampaignId\n ? (campaignDocs ?? []).find(\n (campaign: any) =>\n String(campaign.$id) === String(values.emailCampaignId),\n )\n : null\n if (chosenCampaign && !campaignPlacedOnHost(chosenCampaign, sendAs)) {\n return void enqueueSnackbar(\n 'That campaign is not offered on the site this email is sent as. ' +\n 'Choose another, or add the site to the campaign first.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setCreating(true)\n try {\n const { response, payload } = await campaignSendApi({\n action: 'draft',\n ...(hostId ? {} : { hostId: sendAs }),\n displayName: String(values.displayName ?? '').trim(),\n ...(values.emailCampaignId\n ? { emailCampaignId: String(values.emailCampaignId) }\n : {}),\n })\n if (!response.ok || !payload?.campaignId) {\n return void enqueueSnackbar(\n payload?.error ?? 'This email could not be created',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setCreateOpen(false)\n /*\n * Straight to where it is WRITTEN. The drawer collected the name and\n * the campaign; the record now exists and holds nothing else, so the\n * next thing to do with it is compose it — and its own page is a\n * report of a send that has not happened.\n */\n router.push(`${messagesPath}/${payload.campaignId}/edit`)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('This email could not be created', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setCreating(false)\n }\n },\n [\n askSite,\n campaignDocs,\n campaignSendApi,\n creating,\n enqueueSnackbar,\n hostId,\n messagesPath,\n orgSites,\n router,\n ],\n )\n\n /*\n * One row per message, in the order above; the whole window is in hand, so\n * the grid pages it. The subject is a link AND the row opens the report: a\n * click handler cannot be middle-clicked or opened in a new tab.\n */\n const columns: GridColDef[] = [\n {\n field: 'subject',\n headerName: 'Subject',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => row.subject || 'Untitled email',\n renderCell: ({ row, value }) => (\n <AppLink\n href={emailHref(row)}\n // The row's own handler would fire too and push the same route\n // twice — one history entry per back press.\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {value}\n </AppLink>\n ),\n },\n // Which site each row is sent as, on the org hub only.\n ...(orgMount\n ? [\n {\n field: 'site',\n headerName: 'Site',\n width: 150,\n valueGetter: (_value: unknown, row: any) =>\n row?.hostId ? orgSiteName(orgMount, row.hostId) : '—',\n } satisfies GridColDef,\n ]\n : []),\n {\n /*\n * WHAT THIS EMAIL IS DOING, not what field it stores.\n *\n * An email delivering an audience larger than one batch is written back\n * as `scheduled` between runs, so a chip rendering the status said\n * \"Scheduled\" about a send that had already reached five hundred people.\n * The derivation reads the counters beside the status and says which of\n * the two it is.\n */\n field: 'state',\n headerName: 'State',\n width: 150,\n valueGetter: (_value, row) => campaignSendDisplay(row).label,\n renderCell: ({ row }) => {\n const display = campaignSendDisplay(row)\n return (\n <Chip\n size=\"small\"\n color={STATE_COLOR[display.state]}\n label={display.label}\n />\n )\n },\n },\n {\n field: 'when',\n headerName: 'When',\n width: 190,\n // Sorted on the time the list is ordered by, so a draft sorts by its\n // creation rather than as the oldest thing on the page.\n valueGetter: (_value, row) => emailListTimeMs(row),\n renderCell: ({ row }) => {\n const at = emailSendTimeMs(row)\n return at ? new Date(at).toLocaleString() : '—'\n },\n },\n ...(\n [\n ['recipients', 'Addressed'],\n ['opens', 'Opens'],\n ['clicks', 'Clicks'],\n ] as const\n ).map(\n ([stat, headerName]): GridColDef => ({\n field: stat,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 110,\n valueGetter: (_value, row) => Number(row.stats?.[stat] ?? 0),\n valueFormatter: (value: number) => value.toLocaleString(),\n }),\n ),\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.subject || 'Untitled email')}\n items={rowActions(row)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header={'Messages'}\n help={emailsDocsHelp}\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => setCreateOpen(true)}\n >\n {'New email'}\n </Button>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n {duplicate.dialog}\n <Stack spacing={2}>\n {emails.length === 0 ? (\n <Stack spacing={2} sx={{ alignItems: 'flex-start' }}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing has been sent or scheduled yet. Write one here, or ' +\n 'from a campaign, and it appears in this list with its own ' +\n 'report.'}\n </Typography>\n <Button variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'New email'}\n </Button>\n </Stack>\n ) : (\n <ListTable\n aria-label=\"Messages\"\n rows={emails}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, row) => router.push(emailHref(row))}\n />\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`Showing ${EMAIL_CEILING} messages. ${\n hostId ? 'This site' : 'This organization'\n } has sent or ` +\n 'scheduled more than that, and the rest are not in this list.'}\n </Alert>\n ) : null}\n </Stack>\n {/*\n AN EMAIL WRITTEN HERE BELONGS TO A CAMPAIGN, OR TO NO CAMPAIGN.\n\n The second is a real answer and not a gap in the model. A send with no\n container is what the product has always had — every message that\n predates campaigns is one — and `campaignListRows` adopts each of them\n as a campaign of one at read time, which is the \"Single send\" chip on\n the campaigns table. So composing from this list mints nothing, files\n nothing, and requires nobody to invent a campaign first: it leaves the\n container empty, and the send that results is presented the way every\n containerless send already is.\n\n Offering the campaigns anyway is what stops the opposite mistake — a\n merchant who DOES have a spring campaign writing its third email into\n a single send that never joins the rollup.\n */}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"New email\"\n submitLabel={creating ? 'Creating…' : 'Start writing'}\n includeDescription={false}\n onSubmit={handleCreate}\n extraFields={[\n ...(askSite\n ? [\n {\n component: 'select',\n name: 'hostId',\n label: 'Send as',\n isRequired: true,\n initialValue: '',\n helperText:\n 'The site this email is sent from — its sender, designs ' +\n 'and unsubscribe page',\n disableDefaultOption: true,\n options: orgSites,\n },\n ]\n : []),\n {\n component: 'select',\n name: 'emailCampaignId',\n label: 'Campaign',\n initialValue: '',\n helperText:\n 'Leave this as a single send unless the email belongs with ' +\n 'others',\n disableDefaultOption: true,\n options: [\n { value: '', label: 'Single send — not part of a campaign' },\n ...campaignOptions,\n ],\n },\n ]}\n />\n </CardDisplay>\n )\n}\nEmailsListCard.displayName = 'EmailsListCard'\n\nexport default EmailsListCard\n"],"names":["pluginDocsHelp","mdiBullhornOutline","mdiContentCopy","mdiDeleteOutline","mdiEyeOutline","mdiPaletteOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","CreateArtifactDrawer","useSnackbar","ceilingedWindow","collectionCeiling","DUPLICATE_MENU_LABEL","useDuplicateResource","useFirestore","useFirestoreCollection","Alert","Button","Chip","Stack","Typography","useRouter","useCallback","useMemo","useState","campaignPlacedOnHost","campaignSendDisplay","CAMPAIGN_SEND_CONTAINER_FIELD","emailListTimeMs","emailSendTimeMs","useCampaignManageApi","useCampaignSendApi","useMarketingHubPath","campaignContainersQuery","campaignSendsQuery","orgSiteHubPath","orgSiteName","orgSiteOptions","useMarketingOrgId","useMarketingOrgMount","EMAIL_CEILING","CONTAINER_CEILING","DISCARD_REFUSAL","sent","scheduled","sending","canceled","STATE_COLOR","stopped","emailsDocsHelp","anchor","excerpt","EmailsListCard","props","hostId","basePath","firestore","orgMount","orgId","siteMarketingHub","marketingHub","router","data","emailDocs","sends","idField","rows","readEmails","truncated","emails","sort","a","b","messagesPath","emailHref","email","$id","templatesHub","rowHostId","String","hostOfEmail","Map","map","confirm","enqueueSnackbar","manageForCopy","duplicate","perform","sourceId","name","attemptKey","payload","response","action","campaignId","get","undefined","ok","Error","error","id","emailId","versionId","onDuplicated","_kind","copy","variant","manageApi","discardingId","setDiscardingId","handleDiscard","subject","displayName","agreed","title","description","confirmationText","then","catch","allowDuplicate","persist","console","rowActions","containerId","templateScreenId","state","status","templates","siteless","sitelessReason","key","label","icon","path","size","href","disabled","disabledReason","onClick","request","destructive","Boolean","createOpen","setCreateOpen","creating","setCreating","campaignSendApi","orgSites","askSite","length","campaignDocs","containers","campaignOptions","campaign","value","localeCompare","handleCreate","values","sendAs","chosenCampaign","emailCampaignId","find","trim","push","columns","field","headerName","flex","minWidth","valueGetter","_value","row","renderCell","event","stopPropagation","width","display","color","at","Date","toLocaleString","stat","type","align","headerAlign","Number","stats","valueFormatter","items","header","help","HeaderProps","contentGutterX","contentGutterY","dialog","spacing","sx","alignItems","aria-label","rowHeight","onOpen","_id","severity","open","onClose","submitLabel","includeDescription","onSubmit","extraFields","component","isRequired","initialValue","helperText","disableDefaultOption","options"],"mappings":"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"}
@@ -22,6 +22,8 @@ import { collection, limit, query } from "firebase/firestore";
22
22
  import { useMemo } from "react";
23
23
  import { useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
24
24
  import { pluginDocsHelp } from "@aglyn/aglyn";
25
+ import { campaignSendsQuery } from "./campaign-queries.js";
26
+ import { useMarketingOrgId } from "./marketing-org-mount.js";
25
27
  /**
26
28
  * Marketing at a glance (wave v8): one row of numbers across the
27
29
  * channels this page manages — live overlays and their lifetime
@@ -31,14 +33,20 @@ import { pluginDocsHelp } from "@aglyn/aglyn";
31
33
  */ export function HostMarketingSummaryCard(props) {
32
34
  const { hostId } = props;
33
35
  const firestore = useFirestore();
36
+ const { orgId } = useMarketingOrgId(hostId);
34
37
  const { data: overlayDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'overlays'), limit(50)), [
35
38
  firestore,
36
39
  hostId
37
40
  ], {
38
41
  idField: '$id'
39
42
  });
40
- const { data: campaignDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'campaigns'), limit(50)), [
43
+ // The org's sends, narrowed to the ones this site sent.
44
+ const { data: campaignDocs } = useFirestoreCollection(()=>{
45
+ const sends = campaignSendsQuery(firestore, orgId, hostId);
46
+ return sends ? query(sends, limit(50)) : null;
47
+ }, [
41
48
  firestore,
49
+ orgId,
42
50
  hostId
43
51
  ], {
44
52
  idField: '$id'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/host-marketing-summary-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { overlayActiveAt } from '../model'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Stack, Typography } from '@mui/material'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\n\nexport interface HostMarketingSummaryCardProps {\n hostId: string\n}\n\n/**\n * Marketing at a glance (wave v8): one row of numbers across the\n * channels this page manages — live overlays and their lifetime\n * engagement, campaign sends/opens/clicks, and experiment states — so\n * the hub answers \"is anything running and is it working\" without\n * opening each card.\n */\nexport function HostMarketingSummaryCard(props: HostMarketingSummaryCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: overlayDocs } = useFirestoreCollection<any>(\n () => query(collection(firestore, 'hosts', hostId, 'overlays'), limit(50)),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () =>\n query(collection(firestore, 'hosts', hostId, 'campaigns'), limit(50)),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { data: experimentDocs } = useFirestoreCollection<any>(\n () =>\n query(collection(firestore, 'hosts', hostId, 'experiments'), limit(50)),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n const summary = useMemo(() => {\n const overlays = (overlayDocs ?? []).filter((doc: any) => !doc.deletedAt)\n const liveOverlays = overlays.filter(\n (overlay: any) =>\n overlay.enabled !== false && overlayActiveAt(overlay, Date.now()),\n ).length\n let impressions = 0\n let overlayClicks = 0\n for (const overlay of overlays) {\n impressions += Number(overlay.stats?.impressions ?? 0)\n overlayClicks += Number(overlay.stats?.clicks ?? 0)\n }\n const campaigns = (campaignDocs ?? []).filter(\n (doc: any) => !doc.deletedAt,\n )\n let sent = 0\n let opens = 0\n let emailClicks = 0\n let scheduled = 0\n for (const campaign of campaigns) {\n sent += Number(campaign.stats?.sent ?? 0)\n opens += Number(campaign.stats?.opens ?? 0)\n emailClicks += Number(campaign.stats?.clicks ?? 0)\n if (campaign.status === 'scheduled') scheduled += 1\n }\n const experiments = (experimentDocs ?? []).filter(\n (doc: any) => !doc.deletedAt,\n )\n const running = experiments.filter(\n (experiment: any) => experiment.status === 'running',\n ).length\n const decided = experiments.filter(\n (experiment: any) => experiment.winnerVariantId,\n ).length\n return {\n liveOverlays,\n impressions,\n overlayClicks,\n sent,\n opens,\n emailClicks,\n scheduled,\n running,\n decided,\n }\n }, [overlayDocs, campaignDocs, experimentDocs])\n\n const metrics: Array<{ label: string; value: string }> = [\n { label: 'Live overlays', value: String(summary.liveOverlays) },\n {\n label: 'Overlay views',\n value: summary.impressions.toLocaleString(),\n },\n {\n label: 'Overlay clicks',\n value: summary.overlayClicks.toLocaleString(),\n },\n { label: 'Emails sent', value: summary.sent.toLocaleString() },\n {\n label: 'Opens / clicks',\n value: `${summary.opens.toLocaleString()} / ${summary.emailClicks.toLocaleString()}`,\n },\n ...(summary.scheduled\n ? [{ label: 'Scheduled sends', value: String(summary.scheduled) }]\n : []),\n {\n label: 'Experiments',\n value: `${summary.running} running · ${summary.decided} decided`,\n },\n ]\n\n return (\n <CardDisplay\n header={'At a glance'}\n help={pluginDocsHelp('marketingOverlays', {\n anchor: '#engagement-stats',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack\n direction=\"row\"\n spacing={3}\n sx={{ flexWrap: 'wrap', rowGap: 1.5 }}\n >\n {metrics.map((metric) => (\n <Stack key={metric.label} spacing={0}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {metric.label}\n </Typography>\n <Typography variant=\"h6\">{metric.value}</Typography>\n </Stack>\n ))}\n </Stack>\n </CardDisplay>\n )\n}\nHostMarketingSummaryCard.displayName = 'HostMarketingSummaryCard'\n\nexport default HostMarketingSummaryCard\n"],"names":["overlayActiveAt","CardDisplay","Stack","Typography","collection","limit","query","useMemo","useFirestore","useFirestoreCollection","pluginDocsHelp","HostMarketingSummaryCard","props","hostId","firestore","data","overlayDocs","idField","campaignDocs","experimentDocs","summary","overlays","filter","doc","deletedAt","liveOverlays","overlay","enabled","Date","now","length","impressions","overlayClicks","Number","stats","clicks","campaigns","sent","opens","emailClicks","scheduled","campaign","status","experiments","running","experiment","decided","winnerVariantId","metrics","label","value","String","toLocaleString","header","help","anchor","contentGutterX","contentGutterY","direction","spacing","sx","flexWrap","rowGap","map","metric","variant","color","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/host-marketing-summary-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { overlayActiveAt } from '../model'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Stack, Typography } from '@mui/material'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { campaignSendsQuery } from './campaign-queries'\nimport { useMarketingOrgId } from './marketing-org-mount'\n\nexport interface HostMarketingSummaryCardProps {\n hostId: string\n}\n\n/**\n * Marketing at a glance (wave v8): one row of numbers across the\n * channels this page manages — live overlays and their lifetime\n * engagement, campaign sends/opens/clicks, and experiment states — so\n * the hub answers \"is anything running and is it working\" without\n * opening each card.\n */\nexport function HostMarketingSummaryCard(props: HostMarketingSummaryCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { orgId } = useMarketingOrgId(hostId)\n const { data: overlayDocs } = useFirestoreCollection<any>(\n () => query(collection(firestore, 'hosts', hostId, 'overlays'), limit(50)),\n [firestore, hostId],\n { idField: '$id' },\n )\n // The org's sends, narrowed to the ones this site sent.\n const { data: campaignDocs } = useFirestoreCollection<any>(\n () => {\n const sends = campaignSendsQuery(firestore, orgId, hostId)\n return sends ? query(sends, limit(50)) : null\n },\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const { data: experimentDocs } = useFirestoreCollection<any>(\n () =>\n query(collection(firestore, 'hosts', hostId, 'experiments'), limit(50)),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n const summary = useMemo(() => {\n const overlays = (overlayDocs ?? []).filter((doc: any) => !doc.deletedAt)\n const liveOverlays = overlays.filter(\n (overlay: any) =>\n overlay.enabled !== false && overlayActiveAt(overlay, Date.now()),\n ).length\n let impressions = 0\n let overlayClicks = 0\n for (const overlay of overlays) {\n impressions += Number(overlay.stats?.impressions ?? 0)\n overlayClicks += Number(overlay.stats?.clicks ?? 0)\n }\n const campaigns = (campaignDocs ?? []).filter(\n (doc: any) => !doc.deletedAt,\n )\n let sent = 0\n let opens = 0\n let emailClicks = 0\n let scheduled = 0\n for (const campaign of campaigns) {\n sent += Number(campaign.stats?.sent ?? 0)\n opens += Number(campaign.stats?.opens ?? 0)\n emailClicks += Number(campaign.stats?.clicks ?? 0)\n if (campaign.status === 'scheduled') scheduled += 1\n }\n const experiments = (experimentDocs ?? []).filter(\n (doc: any) => !doc.deletedAt,\n )\n const running = experiments.filter(\n (experiment: any) => experiment.status === 'running',\n ).length\n const decided = experiments.filter(\n (experiment: any) => experiment.winnerVariantId,\n ).length\n return {\n liveOverlays,\n impressions,\n overlayClicks,\n sent,\n opens,\n emailClicks,\n scheduled,\n running,\n decided,\n }\n }, [overlayDocs, campaignDocs, experimentDocs])\n\n const metrics: Array<{ label: string; value: string }> = [\n { label: 'Live overlays', value: String(summary.liveOverlays) },\n {\n label: 'Overlay views',\n value: summary.impressions.toLocaleString(),\n },\n {\n label: 'Overlay clicks',\n value: summary.overlayClicks.toLocaleString(),\n },\n { label: 'Emails sent', value: summary.sent.toLocaleString() },\n {\n label: 'Opens / clicks',\n value: `${summary.opens.toLocaleString()} / ${summary.emailClicks.toLocaleString()}`,\n },\n ...(summary.scheduled\n ? [{ label: 'Scheduled sends', value: String(summary.scheduled) }]\n : []),\n {\n label: 'Experiments',\n value: `${summary.running} running · ${summary.decided} decided`,\n },\n ]\n\n return (\n <CardDisplay\n header={'At a glance'}\n help={pluginDocsHelp('marketingOverlays', {\n anchor: '#engagement-stats',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack\n direction=\"row\"\n spacing={3}\n sx={{ flexWrap: 'wrap', rowGap: 1.5 }}\n >\n {metrics.map((metric) => (\n <Stack key={metric.label} spacing={0}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {metric.label}\n </Typography>\n <Typography variant=\"h6\">{metric.value}</Typography>\n </Stack>\n ))}\n </Stack>\n </CardDisplay>\n )\n}\nHostMarketingSummaryCard.displayName = 'HostMarketingSummaryCard'\n\nexport default HostMarketingSummaryCard\n"],"names":["overlayActiveAt","CardDisplay","Stack","Typography","collection","limit","query","useMemo","useFirestore","useFirestoreCollection","pluginDocsHelp","campaignSendsQuery","useMarketingOrgId","HostMarketingSummaryCard","props","hostId","firestore","orgId","data","overlayDocs","idField","campaignDocs","sends","experimentDocs","summary","overlays","filter","doc","deletedAt","liveOverlays","overlay","enabled","Date","now","length","impressions","overlayClicks","Number","stats","clicks","campaigns","sent","opens","emailClicks","scheduled","campaign","status","experiments","running","experiment","decided","winnerVariantId","metrics","label","value","String","toLocaleString","header","help","anchor","contentGutterX","contentGutterY","direction","spacing","sx","flexWrap","rowGap","map","metric","variant","color","displayName"],"mappings":"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"}