@aglyn/plugins-marketing 1.0.0-beta.199 → 1.0.0-beta.201

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 (52) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-composer.js +14 -6
  3. package/src/lib/components/campaign-composer.js.map +1 -1
  4. package/src/lib/components/campaign-conversions-card.js +6 -0
  5. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  6. package/src/lib/components/campaign-detail-card.js +107 -37
  7. package/src/lib/components/campaign-detail-card.js.map +1 -1
  8. package/src/lib/components/campaign-members-section.d.ts +16 -4
  9. package/src/lib/components/campaign-members-section.js +171 -105
  10. package/src/lib/components/campaign-members-section.js.map +1 -1
  11. package/src/lib/components/campaign-queries.d.ts +11 -0
  12. package/src/lib/components/campaign-queries.js +13 -0
  13. package/src/lib/components/campaign-queries.js.map +1 -1
  14. package/src/lib/components/campaigns-card.d.ts +8 -6
  15. package/src/lib/components/campaigns-card.js +296 -128
  16. package/src/lib/components/campaigns-card.js.map +1 -1
  17. package/src/lib/components/email-compose-card.js +4 -3
  18. package/src/lib/components/email-compose-card.js.map +1 -1
  19. package/src/lib/components/email-detail.js +16 -5
  20. package/src/lib/components/email-detail.js.map +1 -1
  21. package/src/lib/components/email-recipients-card.js +3 -1
  22. package/src/lib/components/email-recipients-card.js.map +1 -1
  23. package/src/lib/components/emails-list-card.d.ts +11 -15
  24. package/src/lib/components/emails-list-card.js +142 -143
  25. package/src/lib/components/emails-list-card.js.map +1 -1
  26. package/src/lib/components/host-experiments-card.component.js +110 -78
  27. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  28. package/src/lib/components/org-experiments-card.js +7 -3
  29. package/src/lib/components/org-experiments-card.js.map +1 -1
  30. package/src/lib/model/campaign-list-query.d.ts +99 -0
  31. package/src/lib/model/campaign-list-query.js +373 -0
  32. package/src/lib/model/campaign-list-query.js.map +1 -0
  33. package/src/lib/model/campaign-members-query.d.ts +37 -0
  34. package/src/lib/model/campaign-members-query.js +120 -0
  35. package/src/lib/model/campaign-members-query.js.map +1 -0
  36. package/src/lib/model/experiment-list-query.d.ts +22 -0
  37. package/src/lib/model/experiment-list-query.js +85 -0
  38. package/src/lib/model/experiment-list-query.js.map +1 -0
  39. package/src/lib/server/campaign-form-flags.d.ts +35 -0
  40. package/src/lib/server/campaign-form-flags.js +52 -0
  41. package/src/lib/server/campaign-form-flags.js.map +1 -0
  42. package/src/lib/server/campaign-manage.d.ts +1 -1
  43. package/src/lib/server/campaign-manage.js +42 -20
  44. package/src/lib/server/campaign-manage.js.map +1 -1
  45. package/src/lib/server/campaign-send.d.ts +42 -1
  46. package/src/lib/server/campaign-send.js +247 -16
  47. package/src/lib/server/campaign-send.js.map +1 -1
  48. package/src/lib/server/email-events.js +13 -1
  49. package/src/lib/server/email-events.js.map +1 -1
  50. package/src/lib/components/email-send-filter.d.ts +0 -32
  51. package/src/lib/components/email-send-filter.js +0 -55
  52. package/src/lib/components/email-send-filter.js.map +0 -1
@@ -17,32 +17,27 @@
17
17
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
18
  import { AppLink } from "@aglyn/shared-ui-jsx";
19
19
  import { Figure, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
20
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
21
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
20
22
  import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
21
24
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
25
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
22
26
  import { buildRoute, CAMPAIGN_MEMBERSHIP_FIELD, readCampaignIds, Route } from "@aglyn/aglyn";
23
27
  import { Alert, Stack, Typography } from "@mui/material";
24
28
  import { collection, documentId, limit, orderBy, query, where } from "firebase/firestore";
25
- import { useMemo } from "react";
29
+ import { useCallback, useMemo } from "react";
26
30
  import { useConsoleHostRoute, useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
27
31
  import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowedRange } from "../model/campaign-membership-figures.js";
32
+ import { CAMPAIGN_MEMBERS_QUERY, CAMPAIGN_MEMBERS_SEARCH_NOTICE, campaignMembersBase, campaignMembersSearchClause } from "../model/campaign-members-query.js";
28
33
  /**
29
- * How many members of one kind the section enumerates.
34
+ * How many forms the holdings figures add up.
30
35
  *
31
- * One document past it is asked for, so "this campaign holds more than are
32
- * listed" is a fact rather than a guess — the same probe the emails table on
33
- * this page makes.
34
- */ const MEMBER_CEILING = 25;
35
- /**
36
- * The rows a reader may act on: the window, minus what has been deleted.
37
- *
38
- * The soft delete is filtered HERE and not in the query. `deletedAt` is
39
- * written only when a record is removed, so `where('deletedAt', '==', null)`
40
- * would match nothing at all — Firestore's equality matches documents that
41
- * HAVE the field — and every live record would vanish from the campaign. The
42
- * campaigns table filters its own soft-deleted containers the same way.
43
- */ function live(docs) {
44
- return (docs != null ? docs : []).slice(0, MEMBER_CEILING).filter((row)=>!row['deletedAt']);
45
- }
36
+ * The tables page through every member; the figures are a sum, read from a
37
+ * window of their own. One document past it is asked for, so "the figures
38
+ * cover some of the forms" is a fact rather than a guess — the same probe
39
+ * the emails figures on this page make.
40
+ */ const FIGURES_CEILING = 25;
46
41
  /**
47
42
  * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,
48
43
  * and what those forms have collected.
@@ -77,7 +72,7 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
77
72
  * the same submissions count toward every campaign the form is filed
78
73
  * under. Rows say so and the total refuses to present itself as
79
74
  * exclusive.
80
- * 3. **A counter nobody wrote is not a zero.** `stats.leads` is incremented
75
+ * 3. **A counter nobody wrote is not a zero.** `stats.leads` is counted
81
76
  * only for a form whose `routing.lead` is set, and `stats.views` only by
82
77
  * the analytics beacon. Both reach the shared `Figure`, which draws an em
83
78
  * dash and the words "not recorded" rather than a confident 0.
@@ -98,13 +93,25 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
98
93
  * A campaign holds no member list. Each record names the campaigns it is in,
99
94
  * which is what makes deleting one record leave nothing behind and what lets
100
95
  * a record's own page draw its campaigns without a query. The query here is
101
- * the other direction of the same field, served by Firestore's automatic
102
- * single-field index.
96
+ * the other direction of the same field.
97
+ *
98
+ * ## Each kind is a table paged by its own query (AGL-3321)
99
+ *
100
+ * The screens and the forms are each a list a reader pages and searches, so
101
+ * the membership, the soft delete and the search word are all on the
102
+ * table's Firestore query (`campaign-members-query.ts`), and every member is
103
+ * reachable a page at a time. Nothing is matched over rows already read: a
104
+ * deleted screen is left out by `deletedAt == null`, which every screen
105
+ * stores, and the search is a prefix range on the stored `nameLower`.
103
106
  *
104
- * `orderBy(documentId())`, like every other bounded list in this console: a
107
+ * Unsearched, a table walks the document name, which every record has — a
105
108
  * form carries `updatedAt` only if some writer stamped one, and ordering on a
106
109
  * field a writer may omit does not mis-sort the list, it drops rows from it.
107
110
  *
111
+ * The forms' HOLDINGS are a sum, not a list, so they read a window of their
112
+ * own — the first {@link FIGURES_CEILING} forms by document name — and say
113
+ * when the campaign holds more, as the mail figures on this page do.
114
+ *
108
115
  * ## Contacts are named here and NOT listed, and the reason is a query limit
109
116
  *
110
117
  * A contact is org-scoped and shared between the sites that captured it, so
@@ -121,15 +128,12 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
121
128
  const { hostId, siteName, campaignId, startAtMs, endAtMs } = props;
122
129
  const firestore = useFirestore();
123
130
  const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId);
124
- const memberQuery = (collectionName)=>()=>query(collection(firestore, 'hosts', hostId, collectionName), where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId), orderBy(documentId()), limit(MEMBER_CEILING + 1));
125
- const { data: screenDocs, status: screensStatus } = useFirestoreCollection(memberQuery('screens'), [
126
- firestore,
127
- hostId,
128
- campaignId
129
- ], {
130
- idField: '$id'
131
- });
132
- const { data: formDocs, status: formsStatus } = useFirestoreCollection(memberQuery('forms'), [
131
+ /*
132
+ * The window the holdings figures add up: the forms filed here, by
133
+ * document name, one past the ceiling as the probe. A form has no soft
134
+ * delete — it is deleted outright — so every document this returns is a
135
+ * form in use or a retired one, and both hold what they collected.
136
+ */ const { data: figureDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'forms'), where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId), orderBy(documentId()), limit(FIGURES_CEILING + 1)), [
133
137
  firestore,
134
138
  hostId,
135
139
  campaignId
@@ -148,73 +152,68 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
148
152
  endAtMs
149
153
  ]);
150
154
  const windowed = isWindowedRange(range);
151
- const screens = useMemo(()=>{
152
- return live(screenDocs).map((row)=>{
153
- var _row_versionId, _row_displayName;
154
- const id = String(row['$id']);
155
- const versionId = String((_row_versionId = row['versionId']) != null ? _row_versionId : '');
156
- return {
157
- id,
158
- name: String((_row_displayName = row['displayName']) != null ? _row_displayName : id),
159
- /*
155
+ const screenRow = useCallback((row)=>{
156
+ var _row_versionId, _row_displayName;
157
+ const id = String(row['$id']);
158
+ const versionId = String((_row_versionId = row['versionId']) != null ? _row_versionId : '');
159
+ return {
160
+ id,
161
+ name: String((_row_displayName = row['displayName']) != null ? _row_displayName : id),
162
+ /*
160
163
  * A screen with no saved version has no detail address to build —
161
164
  * the route carries a version — so it stays plain text rather than
162
165
  * linking somewhere that 404s. The screens list applies the same
163
166
  * guard to its own name column.
164
167
  */ href: versionId && orgSlug && host ? buildRoute(Route.SCREEN_DETAILS, {
165
- orgSlug,
166
- host,
167
- screenId: id,
168
- versionId
169
- }) : null,
170
- hrefReason: versionId ? 'This site’s console URL has not resolved yet' : 'This screen has no saved version yet',
171
- campaigns: readCampaignIds(row).length
172
- };
173
- });
168
+ orgSlug,
169
+ host,
170
+ screenId: id,
171
+ versionId
172
+ }) : null,
173
+ hrefReason: versionId ? 'This site’s console URL has not resolved yet' : 'This screen has no saved version yet',
174
+ campaigns: readCampaignIds(row).length
175
+ };
174
176
  }, [
175
- screenDocs,
176
177
  orgSlug,
177
178
  host
178
179
  ]);
179
180
  /*
180
- * The counters ride on the documents the membership query already returned,
181
- * so every figure below is paid for by a read this section was making
182
- * anyway. Nothing here opens a second listener.
183
- */ const forms = useMemo(()=>{
184
- return live(formDocs).map((row)=>{
185
- var _row_displayName;
186
- const id = String(row['$id']);
187
- return {
188
- id,
189
- name: String((_row_displayName = row['displayName']) != null ? _row_displayName : id),
190
- href: orgSlug && host ? buildRoute(Route.FORM_DETAILS, {
191
- orgSlug,
192
- host,
193
- formId: id
194
- }) : null,
195
- hrefReason: 'This site’s console URL has not resolved yet',
196
- totals: campaignFormTotals(row['stats'], range),
197
- campaigns: readCampaignIds(row).length
198
- };
199
- });
181
+ * The counters ride on the form documents, so each row's figures are paid
182
+ * for by the read that lists it.
183
+ */ const formRow = useCallback((row)=>{
184
+ var _row_displayName;
185
+ const id = String(row['$id']);
186
+ return {
187
+ id,
188
+ name: String((_row_displayName = row['displayName']) != null ? _row_displayName : id),
189
+ href: orgSlug && host ? buildRoute(Route.FORM_DETAILS, {
190
+ orgSlug,
191
+ host,
192
+ formId: id
193
+ }) : null,
194
+ hrefReason: 'This site’s console URL has not resolved yet',
195
+ totals: campaignFormTotals(row['stats'], range),
196
+ campaigns: readCampaignIds(row).length
197
+ };
200
198
  }, [
201
- formDocs,
202
199
  orgSlug,
203
200
  host,
204
201
  range
205
202
  ]);
206
- const rollup = useMemo(()=>campaignFormsRollup(forms.map((row)=>{
203
+ const figureForms = useMemo(()=>(figureDocs != null ? figureDocs : []).slice(0, FIGURES_CEILING).map(formRow), [
204
+ figureDocs,
205
+ formRow
206
+ ]);
207
+ const figuresTruncated = (figureDocs != null ? figureDocs : []).length > FIGURES_CEILING;
208
+ const rollup = useMemo(()=>campaignFormsRollup(figureForms.map((row)=>{
207
209
  var _row_campaigns;
208
210
  return {
209
211
  totals: row.totals,
210
212
  campaigns: (_row_campaigns = row.campaigns) != null ? _row_campaigns : 1
211
213
  };
212
214
  })), [
213
- forms
215
+ figureForms
214
216
  ]);
215
- const screensTruncated = (screenDocs != null ? screenDocs : []).length > MEMBER_CEILING;
216
- const formsTruncated = (formDocs != null ? formDocs : []).length > MEMBER_CEILING;
217
- const settled = screensStatus !== 'loading' && formsStatus !== 'loading';
218
217
  const analyticsHref = orgSlug && host ? buildRoute(Route.HOST_ANALYTICS, {
219
218
  orgSlug,
220
219
  host
@@ -236,9 +235,10 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
236
235
  /*#__PURE__*/ _jsx(MemberTable, {
237
236
  heading: "Screens",
238
237
  noun: "screen",
239
- rows: screens,
240
- truncated: screensTruncated,
241
- settled: settled
238
+ collectionName: "screens",
239
+ hostId: hostId,
240
+ campaignId: campaignId,
241
+ toRow: screenRow
242
242
  }),
243
243
  /*#__PURE__*/ _jsxs(Typography, {
244
244
  variant: "caption",
@@ -254,16 +254,18 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
254
254
  /*#__PURE__*/ _jsx(MemberTable, {
255
255
  heading: "Forms",
256
256
  noun: "form",
257
- rows: forms,
258
- truncated: formsTruncated,
259
- settled: settled,
257
+ collectionName: "forms",
258
+ hostId: hostId,
259
+ campaignId: campaignId,
260
+ toRow: formRow,
260
261
  figures: true
261
262
  }),
262
- forms.length ? /*#__PURE__*/ _jsx(FormsHoldings, {
263
+ figureForms.length ? /*#__PURE__*/ _jsx(FormsHoldings, {
263
264
  rollup: rollup,
264
265
  windowed: windowed,
265
266
  from: (_range_from = range.from) != null ? _range_from : null,
266
- to: (_range_to = range.to) != null ? _range_to : null
267
+ to: (_range_to = range.to) != null ? _range_to : null,
268
+ truncated: figuresTruncated
267
269
  }) : null,
268
270
  /*#__PURE__*/ _jsxs(Stack, {
269
271
  spacing: 0.5,
@@ -307,7 +309,7 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
307
309
  * is no honest way to divide a submission between two campaigns a form is
308
310
  * filed under, so the total is left whole and the overlap is disclosed.
309
311
  */ function FormsHoldings(props) {
310
- const { rollup, windowed, from, to } = props;
312
+ const { rollup, windowed, from, to, truncated } = props;
311
313
  const note = (recorded)=>recorded < rollup.members ? `across ${recorded} of ${rollup.members} forms` : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`;
312
314
  const span = windowed ? `the campaign’s months (${from != null ? from : 'the first month recorded'} to ${to != null ? to : 'now'})` : 'each form’s whole history';
313
315
  return /*#__PURE__*/ _jsxs(Stack, {
@@ -361,6 +363,10 @@ import { campaignFormsRollup, campaignFormTotals, campaignPeriodRange, isWindowe
361
363
  color: "text.secondary",
362
364
  children: 'This campaign has no dates, so nothing narrows the figures to ' + 'it. They are lifetime totals and include everything from ' + 'before the forms were put in this campaign. Give the campaign ' + 'a start and an end to count only its months.'
363
365
  }),
366
+ truncated ? /*#__PURE__*/ _jsx(Alert, {
367
+ severity: "info",
368
+ children: `These figures add up the first ${FIGURES_CEILING} forms in this ` + 'campaign, in document order. It holds more; the table above ' + 'lists every one of them.'
369
+ }) : null,
364
370
  rollup.shared ? /*#__PURE__*/ _jsx(Typography, {
365
371
  variant: "caption",
366
372
  color: "text.secondary",
@@ -391,20 +397,53 @@ FormsHoldings.displayName = 'FormsHoldings';
391
397
  'Leads'
392
398
  ]
393
399
  ];
400
+ /** How the search reads on a notice: the box, not a column. */ const MEMBER_SEARCH_HEADERS = {
401
+ name: 'Search'
402
+ };
394
403
  /**
395
- * One kind's members, as a record list in the shared grid.
404
+ * One kind's members, as a record list in the shared grid, paged by its own
405
+ * query (AGL-3321).
396
406
  *
397
- * The rows are a window this component already holds — the same arrangement
398
- * the campaign's emails list beside it uses, and for the same reason: the
399
- * ceiling bounds the read, and the grid's footer lets a reader walk what came
400
- * back without the card deciding how many rows fit.
407
+ * The membership, the soft delete and the search word are predicates on the
408
+ * query (`campaign-members-query.ts`); the grid neither filters nor sorts
409
+ * the page it is handed. The search box is the grid's quick filter, turned
410
+ * into "name starts with" because the membership holds the query's one
411
+ * array clause; the Filters panel is not offered, because every column
412
+ * beside it would need an index of its own for a list this size.
401
413
  *
402
414
  * `figures` adds the counter columns for a kind that carries them on its own
403
415
  * document. It is a prop rather than two lists because the row grammar — the
404
416
  * name cell, the reason a member has no link, the footer — is the part that
405
417
  * has to stay identical between screens and forms.
406
418
  */ function MemberTable(props) {
407
- const { heading, noun, rows, truncated, settled, figures } = props;
419
+ const { heading, noun, collectionName, hostId, campaignId, toRow, figures } = props;
420
+ const firestore = useFirestore();
421
+ const gridFilter = useListGridFilter({});
422
+ const search = campaignMembersSearchClause(gridFilter.searchWords);
423
+ const page = useListQuery({
424
+ collection: collection(firestore, 'hosts', hostId, collectionName),
425
+ declaration: CAMPAIGN_MEMBERS_QUERY,
426
+ request: {
427
+ clauses: search ? [
428
+ search
429
+ ] : [],
430
+ base: campaignMembersBase(collectionName, campaignId)
431
+ },
432
+ deps: [
433
+ firestore,
434
+ hostId,
435
+ collectionName,
436
+ campaignId
437
+ ],
438
+ idField: '$id'
439
+ });
440
+ const rows = useMemo(()=>page.rows.map(toRow), [
441
+ page.rows,
442
+ toRow
443
+ ]);
444
+ // Nothing typed and nothing on the first page: the campaign holds none,
445
+ // which is said in words rather than as an empty grid.
446
+ const none = !search && page.page === 0 && rows.length === 0;
408
447
  const columns = useMemo(()=>[
409
448
  {
410
449
  field: 'name',
@@ -472,23 +511,50 @@ FormsHoldings.displayName = 'FormsHoldings';
472
511
  color: "text.secondary",
473
512
  children: heading
474
513
  }),
475
- truncated ? /*#__PURE__*/ _jsx(Alert, {
476
- severity: "info",
477
- children: `More than ${MEMBER_CEILING} ${noun}s are in this campaign. The ` + `first ${MEMBER_CEILING} are listed, in document order.`
478
- }) : null,
479
- rows.length ? /*#__PURE__*/ _jsx(ListTable, {
480
- "aria-label": heading,
481
- rows: rows,
482
- columns: columns,
483
- getRowId: (row)=>row.id,
484
- rowHeight: TABLE_ROW_HEIGHT,
485
- // A member's name carries why it has no link, and the other
486
- // campaigns it is in, beneath it.
487
- getRowHeight: ()=>'auto'
488
- }) : /*#__PURE__*/ _jsx(Typography, {
514
+ none ? /*#__PURE__*/ _jsx(Typography, {
489
515
  variant: "body2",
490
516
  color: "text.secondary",
491
- children: settled ? `No ${noun} is in this campaign. Open a ${noun} and pick this ` + 'campaign on its own page to put it in one.' : `Reading this campaign’s ${noun}s…`
517
+ children: page.status === 'loading' ? `Reading this campaign’s ${noun}s…` : `No ${noun} is in this campaign. Open a ${noun} and pick this ` + 'campaign on its own page to put it in one.'
518
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
519
+ children: [
520
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
521
+ refused: listQueryRefusals(page.plan.refused, {
522
+ fields: CAMPAIGN_MEMBERS_QUERY.fields,
523
+ headers: MEMBER_SEARCH_HEADERS
524
+ }),
525
+ notices: search ? [
526
+ CAMPAIGN_MEMBERS_SEARCH_NOTICE,
527
+ ...page.plan.notices
528
+ ] : page.plan.notices
529
+ }),
530
+ /*#__PURE__*/ _jsx(ListTable, {
531
+ "aria-label": heading,
532
+ rows: rows,
533
+ columns: columns,
534
+ getRowId: (row)=>row.id,
535
+ rowHeight: TABLE_ROW_HEIGHT,
536
+ // A member's name carries why it has no link, and the other
537
+ // campaigns it is in, beneath it.
538
+ getRowHeight: ()=>'auto',
539
+ loading: page.status === 'loading',
540
+ filterMode: "server",
541
+ filterModel: gridFilter.filterModel,
542
+ onFilterModelChange: gridFilter.onFilterModelChange,
543
+ quickFilter: true,
544
+ disableColumnFilter: true,
545
+ disableColumnSorting: true,
546
+ hideFooter: true,
547
+ noRowsLabel: `No ${noun} in this campaign starts with that`
548
+ }),
549
+ /*#__PURE__*/ _jsx(ListPagination, {
550
+ page: page.page,
551
+ pageSize: page.pageSize,
552
+ rowCount: rows.length,
553
+ hasMore: page.hasMore,
554
+ onPageChange: page.setPage,
555
+ onPageSizeChange: page.setPageSize
556
+ })
557
+ ]
492
558
  })
493
559
  ]
494
560
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-members-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n buildRoute,\n CAMPAIGN_MEMBERSHIP_FIELD,\n readCampaignIds,\n Route,\n type FormStats,\n type FormStatsTotals,\n} from '@aglyn/aglyn'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignFormsRollup,\n campaignFormTotals,\n campaignPeriodRange,\n isWindowedRange,\n type CampaignFormsRollup,\n} from '../model/campaign-membership-figures'\n\n/**\n * How many members of one kind the section enumerates.\n *\n * One document past it is asked for, so \"this campaign holds more than are\n * listed\" is a fact rather than a guess — the same probe the emails table on\n * this page makes.\n */\nconst MEMBER_CEILING = 25\n\nexport interface CampaignMembersSectionProps {\n hostId: string\n /**\n * The site's name, when the section is one of several — the org hub draws\n * one per site the campaign is placed on, and each heading says whose\n * forms and screens it lists.\n */\n siteName?: string\n campaignId: string\n /** The campaign's first day, when it has one. */\n startAtMs?: number | null\n /** The campaign's last day, when it has one. */\n endAtMs?: number | null\n}\n\n/**\n * The rows a reader may act on: the window, minus what has been deleted.\n *\n * The soft delete is filtered HERE and not in the query. `deletedAt` is\n * written only when a record is removed, so `where('deletedAt', '==', null)`\n * would match nothing at all — Firestore's equality matches documents that\n * HAVE the field — and every live record would vanish from the campaign. The\n * campaigns table filters its own soft-deleted containers the same way.\n */\nfunction live(\n docs: Array<Record<string, unknown>> | undefined,\n): Array<Record<string, unknown>> {\n return (docs ?? [])\n .slice(0, MEMBER_CEILING)\n .filter((row) => !row['deletedAt'])\n}\n\n/** One row, whatever collection it came out of. */\ninterface MemberRow {\n id: string\n name: string\n href: string | null\n /** Why there is no link, when there is none. */\n hrefReason?: string\n /** The member's own counters, for a kind that carries them. */\n totals?: FormStatsTotals\n /** Campaigns the member is filed under, this one included. */\n campaigns?: number\n}\n\n/**\n * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,\n * and what those forms have collected.\n *\n * ## Declared, and therefore different from every section above it\n *\n * The conversions, revenue and destinations sections are EVIDENCE: they join\n * on the campaign's send ids and report what visitors did BECAUSE of this\n * campaign's mail. This one is a statement the merchant made — a screen or a\n * form carries this campaign's id in {@link CAMPAIGN_MEMBERSHIP_FIELD} — plus\n * the counters those records carry on their own documents.\n *\n * The distinction survives into the wording, because the arithmetic will not\n * enforce it. A form's submissions are the form's; filing the form under a\n * campaign does not make them the campaign's, and a heading that said\n * \"this campaign produced 40 leads\" over a number meaning \"the forms filed\n * here have 40 leads\" would be a worse page than one showing nothing. So the\n * figures are labeled as what the forms HOLD, they sit in their own block\n * under their own disclaimer, and no total on this page adds them to an\n * attributed one.\n *\n * ## Three ways the forms' counters could mislead, each answered\n *\n * 1. **They are lifetime.** `stats.submissions` has counted since the form\n * existed, including every submission from before anybody filed it here.\n * Where the campaign carries dates, the figures are summed from the\n * monthly `stats.periods` series over those months instead, and the\n * caption says which of the two a reader is looking at. Months are whole,\n * so a campaign starting mid-month includes that month entire — stated,\n * not rounded away.\n * 2. **A form can be in twenty campaigns.** `campaignIds` is an array, so\n * the same submissions count toward every campaign the form is filed\n * under. Rows say so and the total refuses to present itself as\n * exclusive.\n * 3. **A counter nobody wrote is not a zero.** `stats.leads` is incremented\n * only for a form whose `routing.lead` is set, and `stats.views` only by\n * the analytics beacon. Both reach the shared `Figure`, which draws an em\n * dash and the words \"not recorded\" rather than a confident 0.\n *\n * ## Screens carry no counter, and this section will not invent one\n *\n * A form's counters ride on the form document, so the query this section\n * already makes pays for them. A screen's traffic does not: it is one\n * document per screen per day in `hosts/{hostId}/screenAnalytics`, with no\n * lifetime total anywhere, so a views column here would cost a range read\n * across screens times days on every open of this page — and the display of\n * those figures is a paid entitlement this section does not resolve. The\n * screens block names the absence and links to the surface that does measure\n * it, which is a smaller lie than a number nobody can stand behind.\n *\n * ## The join is `array-contains`, on the member's own document\n *\n * A campaign holds no member list. Each record names the campaigns it is in,\n * which is what makes deleting one record leave nothing behind and what lets\n * a record's own page draw its campaigns without a query. The query here is\n * the other direction of the same field, served by Firestore's automatic\n * single-field index.\n *\n * `orderBy(documentId())`, like every other bounded list in this console: a\n * form carries `updatedAt` only if some writer stamped one, and ordering on a\n * field a writer may omit does not mis-sort the list, it drops rows from it.\n *\n * ## Contacts are named here and NOT listed, and the reason is a query limit\n *\n * A contact is org-scoped and shared between the sites that captured it, so\n * every client read of the collection has to carry\n * `visibleTo array-contains-any` — that is what makes the read provable\n * per-document under the rules. Firestore permits ONE array clause per query,\n * and that is it: there is no client query that filters contacts by campaign\n * as well, so there is no pre-filtered address to link to either. The link\n * offered goes to the Contacts page and says it arrives unfiltered; the\n * assignment is real, is set from the contact's own drawer, and a count of it\n * would cost a scan of the collection the customer is billed on.\n */\nexport function CampaignMembersSection(props: CampaignMembersSectionProps) {\n const { hostId, siteName, campaignId, startAtMs, endAtMs } = props\n const firestore = useFirestore()\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n\n const memberQuery = (collectionName: string) => () =>\n query(\n collection(firestore, 'hosts', hostId, collectionName),\n where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId),\n orderBy(documentId()),\n limit(MEMBER_CEILING + 1),\n )\n\n const { data: screenDocs, status: screensStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('screens'), [firestore, hostId, campaignId], {\n idField: '$id',\n })\n const { data: formDocs, status: formsStatus } = useFirestoreCollection<\n Record<string, unknown>\n >(memberQuery('forms'), [firestore, hostId, campaignId], { idField: '$id' })\n\n /*\n * The months the campaign's dates cover, or nothing where it has neither.\n * Derived from props the detail card already holds, so asking what a\n * figure covers costs no read.\n */\n const range = useMemo(\n () => campaignPeriodRange({ startAtMs, endAtMs }),\n [startAtMs, endAtMs],\n )\n const windowed = isWindowedRange(range)\n\n const screens = useMemo<MemberRow[]>(() => {\n return live(screenDocs).map((row) => {\n const id = String(row['$id'])\n const versionId = String(row['versionId'] ?? '')\n return {\n id,\n name: String(row['displayName'] ?? id),\n /*\n * A screen with no saved version has no detail address to build —\n * the route carries a version — so it stays plain text rather than\n * linking somewhere that 404s. The screens list applies the same\n * guard to its own name column.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : null,\n hrefReason: versionId\n ? 'This site’s console URL has not resolved yet'\n : 'This screen has no saved version yet',\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [screenDocs, orgSlug, host])\n\n /*\n * The counters ride on the documents the membership query already returned,\n * so every figure below is paid for by a read this section was making\n * anyway. Nothing here opens a second listener.\n */\n const forms = useMemo<MemberRow[]>(() => {\n return live(formDocs).map((row) => {\n const id = String(row['$id'])\n return {\n id,\n name: String(row['displayName'] ?? id),\n href:\n orgSlug && host\n ? buildRoute(Route.FORM_DETAILS, { orgSlug, host, formId: id })\n : null,\n hrefReason: 'This site’s console URL has not resolved yet',\n totals: campaignFormTotals(row['stats'] as FormStats | undefined, range),\n campaigns: readCampaignIds(row).length,\n }\n })\n }, [formDocs, orgSlug, host, range])\n\n const rollup = useMemo(\n () =>\n campaignFormsRollup(\n forms.map((row) => ({\n totals: row.totals as FormStatsTotals,\n campaigns: row.campaigns ?? 1,\n })),\n ),\n [forms],\n )\n\n const screensTruncated = (screenDocs ?? []).length > MEMBER_CEILING\n const formsTruncated = (formDocs ?? []).length > MEMBER_CEILING\n const settled = screensStatus !== 'loading' && formsStatus !== 'loading'\n\n const analyticsHref =\n orgSlug && host\n ? buildRoute(Route.HOST_ANALYTICS, { orgSlug, host })\n : null\n const contactsHref =\n orgSlug && host ? buildRoute(Route.HOST_CONTACTS, { orgSlug, host }) : null\n\n return (\n <Section\n title={\n siteName\n ? `Assigned to this campaign on ${siteName}`\n : 'Assigned to this campaign'\n }\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages and forms somebody put in this campaign. Assignment is ' +\n 'a grouping — what the campaign was credited with is measured ' +\n 'above, from the links its emails carried.'}\n </Typography>\n <MemberTable\n heading=\"Screens\"\n noun=\"screen\"\n rows={screens}\n truncated={screensTruncated}\n settled={settled}\n />\n {/*\n The absence, named where a reader would look for the number.\n\n A screen's traffic is not on the screen document — it is a day doc\n per screen in `screenAnalytics` — so a views column here would be a\n read across screens times days on every open, for figures whose\n display is a paid entitlement this section does not resolve. The\n page that does both is linked instead.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Page views are not shown here. A screen keeps no running total ' +\n 'on its own record — traffic is stored a day at a time — so ' +\n 'counting it would be a fresh read of the site’s history every ' +\n 'time this page opens. '}\n {analyticsHref ? (\n <AppLink href={analyticsHref}>\n {'The site’s analytics measures it by screen.'}\n </AppLink>\n ) : (\n 'The site’s analytics page measures it by screen.'\n )}\n </Typography>\n <MemberTable\n heading=\"Forms\"\n noun=\"form\"\n rows={forms}\n truncated={formsTruncated}\n settled={settled}\n figures\n />\n {/*\n THE MEMBERSHIP TOTAL, fenced off from the attributed ones above.\n\n Same `Figure` primitive as the mail rollup, deliberately different\n words: it says what the forms HOLD, and the caption beneath refuses\n the reading a merchant will reach for first.\n */}\n {forms.length ? (\n <FormsHoldings\n rollup={rollup}\n windowed={windowed}\n from={range.from ?? null}\n to={range.to ?? null}\n />\n ) : null}\n {/*\n The people, named rather than omitted.\n\n A contact CAN be filed under a campaign and this page cannot list\n them — see the module comment for the query rule that decides it.\n Leaving the heading out entirely would read as \"contacts cannot be\n assigned\", which is the one thing it does not mean.\n */}\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Contacts'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Contacts are filed under a campaign from the contact’s own ' +\n 'panel on the Contacts page. They are not listed or counted ' +\n 'here: a contact belongs to the organization rather than to ' +\n 'this site, so the query that reads them is already spending ' +\n 'its one array filter on which sites may see the person. '}\n {contactsHref ? (\n <AppLink href={contactsHref}>{'Open Contacts'}</AppLink>\n ) : null}\n {contactsHref\n ? ' — it opens unfiltered, because that same rule leaves no ' +\n 'campaign filter for the address to carry.'\n : ''}\n </Typography>\n </Stack>\n </Stack>\n </Section>\n )\n}\n\n/**\n * What the campaign's forms have collected — and what that is NOT.\n *\n * Two sentences do the whole job of keeping this apart from the attributed\n * figures, and both are load-bearing:\n *\n * - The first refuses causation outright. A merchant reading a number under\n * a campaign's heading will assume the campaign caused it unless told\n * otherwise in the same breath.\n * - The second says which months the number covers. A lifetime total under a\n * campaign that ran for six weeks is the same lie one step quieter.\n *\n * `shared` is stated as a count rather than folded into the arithmetic. There\n * is no honest way to divide a submission between two campaigns a form is\n * filed under, so the total is left whole and the overlap is disclosed.\n */\nfunction FormsHoldings(props: {\n rollup: CampaignFormsRollup\n windowed: boolean\n from: string | null\n to: string | null\n}) {\n const { rollup, windowed, from, to } = props\n const note = (recorded: number) =>\n recorded < rollup.members\n ? `across ${recorded} of ${rollup.members} forms`\n : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`\n const span = windowed\n ? `the campaign’s months (${from ?? 'the first month recorded'} to ${\n to ?? 'now'\n })`\n : 'each form’s whole history'\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'What these forms hold'}\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Views\"\n value={rollup.views.value}\n note={note(rollup.views.recorded)}\n />\n <Figure\n label=\"Started\"\n value={rollup.starts.value}\n note={note(rollup.starts.recorded)}\n />\n <Figure\n label=\"Submissions\"\n value={rollup.submissions.value}\n note={note(rollup.submissions.recorded)}\n />\n <Figure\n label=\"Leads\"\n value={rollup.leads.value}\n note={note(rollup.leads.recorded)}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These are the forms’ own counters, not this campaign’s results. ' +\n 'A submission is counted here because somebody filed the form ' +\n 'under this campaign, whether or not this campaign’s mail sent ' +\n `the visitor. Counted over ${span}.`}\n </Typography>\n {windowed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whole calendar months: a campaign that started or ended mid-' +\n 'month takes that month entire, and a form counting nothing in ' +\n 'those months adds nothing here even where its lifetime total ' +\n 'is large.'}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign has no dates, so nothing narrows the figures to ' +\n 'it. They are lifetime totals and include everything from ' +\n 'before the forms were put in this campaign. Give the campaign ' +\n 'a start and an end to count only its months.'}\n </Typography>\n )}\n {rollup.shared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${rollup.shared} of these ${rollup.members} forms ${\n rollup.shared === 1 ? 'is' : 'are'\n } filed under another campaign too, so the same submissions are ` +\n 'counted there as well. This total is not exclusive to this ' +\n 'campaign.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nFormsHoldings.displayName = 'FormsHoldings'\n\n/** A counter as a cell: an em dash where nothing was recorded, never a zero. */\nfunction statCell(value: number | null | undefined) {\n return value === null || value === undefined ? '—' : value.toLocaleString()\n}\n\n/** The counters a form carries on its own document, in the order they are read. */\nconst MEMBER_FIGURES = [\n ['views', 'Views'],\n ['starts', 'Started'],\n ['submissions', 'Submissions'],\n ['leads', 'Leads'],\n] as const\n\n/**\n * One kind's members, as a record list in the shared grid.\n *\n * The rows are a window this component already holds — the same arrangement\n * the campaign's emails list beside it uses, and for the same reason: the\n * ceiling bounds the read, and the grid's footer lets a reader walk what came\n * back without the card deciding how many rows fit.\n *\n * `figures` adds the counter columns for a kind that carries them on its own\n * document. It is a prop rather than two lists because the row grammar — the\n * name cell, the reason a member has no link, the footer — is the part that\n * has to stay identical between screens and forms.\n */\nfunction MemberTable(props: {\n heading: string\n noun: string\n rows: MemberRow[]\n truncated: boolean\n settled: boolean\n figures?: boolean\n}) {\n const { heading, noun, rows, truncated, settled, figures } = props\n const columns = useMemo<GridColDef<MemberRow>[]>(\n () => [\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack sx={{ py: 1, minWidth: 0 }}>\n {row.href ? (\n <AppLink href={row.href}>{row.name}</AppLink>\n ) : (\n <>\n <Typography variant=\"body2\">{row.name}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.hrefReason}\n </Typography>\n </>\n )}\n {/*\n The overlap, on the row that causes it. A reader comparing two\n campaigns has to be able to see which rows they have in common\n without opening either.\n */}\n {(row.campaigns ?? 1) > 1 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Also in ${(row.campaigns ?? 1) - 1} other campaign${\n (row.campaigns ?? 1) - 1 === 1 ? '' : 's'\n }`}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n ...(figures\n ? MEMBER_FIGURES.map(\n ([figure, headerName]): GridColDef<MemberRow> => ({\n field: figure,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 120,\n valueGetter: (_value, row) => row.totals?.[figure] ?? null,\n renderCell: ({ row }) => statCell(row.totals?.[figure]),\n }),\n )\n : []),\n ],\n [figures],\n )\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {heading}\n </Typography>\n {truncated ? (\n <Alert severity=\"info\">\n {`More than ${MEMBER_CEILING} ${noun}s are in this campaign. The ` +\n `first ${MEMBER_CEILING} are listed, in document order.`}\n </Alert>\n ) : null}\n {rows.length ? (\n <ListTable\n aria-label={heading}\n rows={rows}\n columns={columns}\n getRowId={(row: MemberRow) => row.id}\n rowHeight={TABLE_ROW_HEIGHT}\n // A member's name carries why it has no link, and the other\n // campaigns it is in, beneath it.\n getRowHeight={() => 'auto'}\n />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? `No ${noun} is in this campaign. Open a ${noun} and pick this ` +\n 'campaign on its own page to put it in one.'\n : `Reading this campaign’s ${noun}s…`}\n </Typography>\n )}\n </Stack>\n )\n}\n\nCampaignMembersSection.displayName = 'CampaignMembersSection'\n\nexport default CampaignMembersSection\n"],"names":["AppLink","Figure","Section","ListTable","TABLE_ROW_HEIGHT","buildRoute","CAMPAIGN_MEMBERSHIP_FIELD","readCampaignIds","Route","Alert","Stack","Typography","collection","documentId","limit","orderBy","query","where","useMemo","useConsoleHostRoute","useFirestore","useFirestoreCollection","campaignFormsRollup","campaignFormTotals","campaignPeriodRange","isWindowedRange","MEMBER_CEILING","live","docs","slice","filter","row","CampaignMembersSection","props","range","hostId","siteName","campaignId","startAtMs","endAtMs","firestore","orgSlug","subdomain","host","memberQuery","collectionName","data","screenDocs","status","screensStatus","idField","formDocs","formsStatus","windowed","screens","map","id","String","versionId","name","href","SCREEN_DETAILS","screenId","hrefReason","campaigns","length","forms","FORM_DETAILS","formId","totals","rollup","screensTruncated","formsTruncated","settled","analyticsHref","HOST_ANALYTICS","contactsHref","HOST_CONTACTS","title","spacing","variant","color","MemberTable","heading","noun","rows","truncated","figures","FormsHoldings","from","to","note","recorded","members","span","direction","sx","flexWrap","label","value","views","starts","submissions","leads","shared","displayName","statCell","undefined","toLocaleString","MEMBER_FIGURES","columns","field","headerName","flex","minWidth","renderCell","py","figure","type","align","headerAlign","width","valueGetter","_value","severity","aria-label","getRowId","rowHeight","getRowHeight"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-members-section.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { AppLink } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport { ListTable } from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n buildRoute,\n CAMPAIGN_MEMBERSHIP_FIELD,\n readCampaignIds,\n Route,\n type FormStats,\n type FormStatsTotals,\n} from '@aglyn/aglyn'\nimport { Alert, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n campaignFormsRollup,\n campaignFormTotals,\n campaignPeriodRange,\n isWindowedRange,\n type CampaignFormsRollup,\n} from '../model/campaign-membership-figures'\nimport {\n CAMPAIGN_MEMBERS_QUERY,\n CAMPAIGN_MEMBERS_SEARCH_NOTICE,\n campaignMembersBase,\n campaignMembersSearchClause,\n type CampaignMemberCollection,\n} from '../model/campaign-members-query'\n\n/**\n * How many forms the holdings figures add up.\n *\n * The tables page through every member; the figures are a sum, read from a\n * window of their own. One document past it is asked for, so \"the figures\n * cover some of the forms\" is a fact rather than a guess — the same probe\n * the emails figures on this page make.\n */\nconst FIGURES_CEILING = 25\n\nexport interface CampaignMembersSectionProps {\n hostId: string\n /**\n * The site's name, when the section is one of several — the org hub draws\n * one per site the campaign is placed on, and each heading says whose\n * forms and screens it lists.\n */\n siteName?: string\n campaignId: string\n /** The campaign's first day, when it has one. */\n startAtMs?: number | null\n /** The campaign's last day, when it has one. */\n endAtMs?: number | null\n}\n\n/** One row, whatever collection it came out of. */\ninterface MemberRow {\n id: string\n name: string\n href: string | null\n /** Why there is no link, when there is none. */\n hrefReason?: string\n /** The member's own counters, for a kind that carries them. */\n totals?: FormStatsTotals\n /** Campaigns the member is filed under, this one included. */\n campaigns?: number\n}\n\n/**\n * WHAT ELSE IS IN THIS CAMPAIGN — the landing pages and forms assigned to it,\n * and what those forms have collected.\n *\n * ## Declared, and therefore different from every section above it\n *\n * The conversions, revenue and destinations sections are EVIDENCE: they join\n * on the campaign's send ids and report what visitors did BECAUSE of this\n * campaign's mail. This one is a statement the merchant made — a screen or a\n * form carries this campaign's id in {@link CAMPAIGN_MEMBERSHIP_FIELD} — plus\n * the counters those records carry on their own documents.\n *\n * The distinction survives into the wording, because the arithmetic will not\n * enforce it. A form's submissions are the form's; filing the form under a\n * campaign does not make them the campaign's, and a heading that said\n * \"this campaign produced 40 leads\" over a number meaning \"the forms filed\n * here have 40 leads\" would be a worse page than one showing nothing. So the\n * figures are labeled as what the forms HOLD, they sit in their own block\n * under their own disclaimer, and no total on this page adds them to an\n * attributed one.\n *\n * ## Three ways the forms' counters could mislead, each answered\n *\n * 1. **They are lifetime.** `stats.submissions` has counted since the form\n * existed, including every submission from before anybody filed it here.\n * Where the campaign carries dates, the figures are summed from the\n * monthly `stats.periods` series over those months instead, and the\n * caption says which of the two a reader is looking at. Months are whole,\n * so a campaign starting mid-month includes that month entire — stated,\n * not rounded away.\n * 2. **A form can be in twenty campaigns.** `campaignIds` is an array, so\n * the same submissions count toward every campaign the form is filed\n * under. Rows say so and the total refuses to present itself as\n * exclusive.\n * 3. **A counter nobody wrote is not a zero.** `stats.leads` is counted\n * only for a form whose `routing.lead` is set, and `stats.views` only by\n * the analytics beacon. Both reach the shared `Figure`, which draws an em\n * dash and the words \"not recorded\" rather than a confident 0.\n *\n * ## Screens carry no counter, and this section will not invent one\n *\n * A form's counters ride on the form document, so the query this section\n * already makes pays for them. A screen's traffic does not: it is one\n * document per screen per day in `hosts/{hostId}/screenAnalytics`, with no\n * lifetime total anywhere, so a views column here would cost a range read\n * across screens times days on every open of this page — and the display of\n * those figures is a paid entitlement this section does not resolve. The\n * screens block names the absence and links to the surface that does measure\n * it, which is a smaller lie than a number nobody can stand behind.\n *\n * ## The join is `array-contains`, on the member's own document\n *\n * A campaign holds no member list. Each record names the campaigns it is in,\n * which is what makes deleting one record leave nothing behind and what lets\n * a record's own page draw its campaigns without a query. The query here is\n * the other direction of the same field.\n *\n * ## Each kind is a table paged by its own query (AGL-3321)\n *\n * The screens and the forms are each a list a reader pages and searches, so\n * the membership, the soft delete and the search word are all on the\n * table's Firestore query (`campaign-members-query.ts`), and every member is\n * reachable a page at a time. Nothing is matched over rows already read: a\n * deleted screen is left out by `deletedAt == null`, which every screen\n * stores, and the search is a prefix range on the stored `nameLower`.\n *\n * Unsearched, a table walks the document name, which every record has — a\n * form carries `updatedAt` only if some writer stamped one, and ordering on a\n * field a writer may omit does not mis-sort the list, it drops rows from it.\n *\n * The forms' HOLDINGS are a sum, not a list, so they read a window of their\n * own — the first {@link FIGURES_CEILING} forms by document name — and say\n * when the campaign holds more, as the mail figures on this page do.\n *\n * ## Contacts are named here and NOT listed, and the reason is a query limit\n *\n * A contact is org-scoped and shared between the sites that captured it, so\n * every client read of the collection has to carry\n * `visibleTo array-contains-any` — that is what makes the read provable\n * per-document under the rules. Firestore permits ONE array clause per query,\n * and that is it: there is no client query that filters contacts by campaign\n * as well, so there is no pre-filtered address to link to either. The link\n * offered goes to the Contacts page and says it arrives unfiltered; the\n * assignment is real, is set from the contact's own drawer, and a count of it\n * would cost a scan of the collection the customer is billed on.\n */\nexport function CampaignMembersSection(props: CampaignMembersSectionProps) {\n const { hostId, siteName, campaignId, startAtMs, endAtMs } = props\n const firestore = useFirestore()\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n\n /*\n * The window the holdings figures add up: the forms filed here, by\n * document name, one past the ceiling as the probe. A form has no soft\n * delete — it is deleted outright — so every document this returns is a\n * form in use or a retired one, and both hold what they collected.\n */\n const { data: figureDocs } = useFirestoreCollection<Record<string, unknown>>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'forms'),\n where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId),\n orderBy(documentId()),\n limit(FIGURES_CEILING + 1),\n ),\n [firestore, hostId, campaignId],\n { idField: '$id' },\n )\n\n /*\n * The months the campaign's dates cover, or nothing where it has neither.\n * Derived from props the detail card already holds, so asking what a\n * figure covers costs no read.\n */\n const range = useMemo(\n () => campaignPeriodRange({ startAtMs, endAtMs }),\n [startAtMs, endAtMs],\n )\n const windowed = isWindowedRange(range)\n\n const screenRow = useCallback(\n (row: Record<string, unknown>): MemberRow => {\n const id = String(row['$id'])\n const versionId = String(row['versionId'] ?? '')\n return {\n id,\n name: String(row['displayName'] ?? id),\n /*\n * A screen with no saved version has no detail address to build —\n * the route carries a version — so it stays plain text rather than\n * linking somewhere that 404s. The screens list applies the same\n * guard to its own name column.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.SCREEN_DETAILS, {\n orgSlug,\n host,\n screenId: id,\n versionId,\n })\n : null,\n hrefReason: versionId\n ? 'This site’s console URL has not resolved yet'\n : 'This screen has no saved version yet',\n campaigns: readCampaignIds(row).length,\n }\n },\n [orgSlug, host],\n )\n\n /*\n * The counters ride on the form documents, so each row's figures are paid\n * for by the read that lists it.\n */\n const formRow = useCallback(\n (row: Record<string, unknown>): MemberRow => {\n const id = String(row['$id'])\n return {\n id,\n name: String(row['displayName'] ?? id),\n href:\n orgSlug && host\n ? buildRoute(Route.FORM_DETAILS, { orgSlug, host, formId: id })\n : null,\n hrefReason: 'This site’s console URL has not resolved yet',\n totals: campaignFormTotals(row['stats'] as FormStats | undefined, range),\n campaigns: readCampaignIds(row).length,\n }\n },\n [orgSlug, host, range],\n )\n\n const figureForms = useMemo(\n () => (figureDocs ?? []).slice(0, FIGURES_CEILING).map(formRow),\n [figureDocs, formRow],\n )\n const figuresTruncated = (figureDocs ?? []).length > FIGURES_CEILING\n const rollup = useMemo(\n () =>\n campaignFormsRollup(\n figureForms.map((row) => ({\n totals: row.totals as FormStatsTotals,\n campaigns: row.campaigns ?? 1,\n })),\n ),\n [figureForms],\n )\n\n const analyticsHref =\n orgSlug && host\n ? buildRoute(Route.HOST_ANALYTICS, { orgSlug, host })\n : null\n const contactsHref =\n orgSlug && host ? buildRoute(Route.HOST_CONTACTS, { orgSlug, host }) : null\n\n return (\n <Section\n title={\n siteName\n ? `Assigned to this campaign on ${siteName}`\n : 'Assigned to this campaign'\n }\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The pages and forms somebody put in this campaign. Assignment is ' +\n 'a grouping — what the campaign was credited with is measured ' +\n 'above, from the links its emails carried.'}\n </Typography>\n <MemberTable\n heading=\"Screens\"\n noun=\"screen\"\n collectionName=\"screens\"\n hostId={hostId}\n campaignId={campaignId}\n toRow={screenRow}\n />\n {/*\n The absence, named where a reader would look for the number.\n\n A screen's traffic is not on the screen document — it is a day doc\n per screen in `screenAnalytics` — so a views column here would be a\n read across screens times days on every open, for figures whose\n display is a paid entitlement this section does not resolve. The\n page that does both is linked instead.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Page views are not shown here. A screen keeps no running total ' +\n 'on its own record — traffic is stored a day at a time — so ' +\n 'counting it would be a fresh read of the site’s history every ' +\n 'time this page opens. '}\n {analyticsHref ? (\n <AppLink href={analyticsHref}>\n {'The site’s analytics measures it by screen.'}\n </AppLink>\n ) : (\n 'The site’s analytics page measures it by screen.'\n )}\n </Typography>\n <MemberTable\n heading=\"Forms\"\n noun=\"form\"\n collectionName=\"forms\"\n hostId={hostId}\n campaignId={campaignId}\n toRow={formRow}\n figures\n />\n {/*\n THE MEMBERSHIP TOTAL, fenced off from the attributed ones above.\n\n Same `Figure` primitive as the mail rollup, deliberately different\n words: it says what the forms HOLD, and the caption beneath refuses\n the reading a merchant will reach for first.\n */}\n {figureForms.length ? (\n <FormsHoldings\n rollup={rollup}\n windowed={windowed}\n from={range.from ?? null}\n to={range.to ?? null}\n truncated={figuresTruncated}\n />\n ) : null}\n {/*\n The people, named rather than omitted.\n\n A contact CAN be filed under a campaign and this page cannot list\n them — see the module comment for the query rule that decides it.\n Leaving the heading out entirely would read as \"contacts cannot be\n assigned\", which is the one thing it does not mean.\n */}\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Contacts'}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Contacts are filed under a campaign from the contact’s own ' +\n 'panel on the Contacts page. They are not listed or counted ' +\n 'here: a contact belongs to the organization rather than to ' +\n 'this site, so the query that reads them is already spending ' +\n 'its one array filter on which sites may see the person. '}\n {contactsHref ? (\n <AppLink href={contactsHref}>{'Open Contacts'}</AppLink>\n ) : null}\n {contactsHref\n ? ' — it opens unfiltered, because that same rule leaves no ' +\n 'campaign filter for the address to carry.'\n : ''}\n </Typography>\n </Stack>\n </Stack>\n </Section>\n )\n}\n\n/**\n * What the campaign's forms have collected — and what that is NOT.\n *\n * Two sentences do the whole job of keeping this apart from the attributed\n * figures, and both are load-bearing:\n *\n * - The first refuses causation outright. A merchant reading a number under\n * a campaign's heading will assume the campaign caused it unless told\n * otherwise in the same breath.\n * - The second says which months the number covers. A lifetime total under a\n * campaign that ran for six weeks is the same lie one step quieter.\n *\n * `shared` is stated as a count rather than folded into the arithmetic. There\n * is no honest way to divide a submission between two campaigns a form is\n * filed under, so the total is left whole and the overlap is disclosed.\n */\nfunction FormsHoldings(props: {\n rollup: CampaignFormsRollup\n windowed: boolean\n from: string | null\n to: string | null\n /** The campaign holds more forms than the figures add up. */\n truncated: boolean\n}) {\n const { rollup, windowed, from, to, truncated } = props\n const note = (recorded: number) =>\n recorded < rollup.members\n ? `across ${recorded} of ${rollup.members} forms`\n : `across ${rollup.members} form${rollup.members === 1 ? '' : 's'}`\n const span = windowed\n ? `the campaign’s months (${from ?? 'the first month recorded'} to ${\n to ?? 'now'\n })`\n : 'each form’s whole history'\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'What these forms hold'}\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <Figure\n label=\"Views\"\n value={rollup.views.value}\n note={note(rollup.views.recorded)}\n />\n <Figure\n label=\"Started\"\n value={rollup.starts.value}\n note={note(rollup.starts.recorded)}\n />\n <Figure\n label=\"Submissions\"\n value={rollup.submissions.value}\n note={note(rollup.submissions.recorded)}\n />\n <Figure\n label=\"Leads\"\n value={rollup.leads.value}\n note={note(rollup.leads.recorded)}\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'These are the forms’ own counters, not this campaign’s results. ' +\n 'A submission is counted here because somebody filed the form ' +\n 'under this campaign, whether or not this campaign’s mail sent ' +\n `the visitor. Counted over ${span}.`}\n </Typography>\n {windowed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whole calendar months: a campaign that started or ended mid-' +\n 'month takes that month entire, and a form counting nothing in ' +\n 'those months adds nothing here even where its lifetime total ' +\n 'is large.'}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This campaign has no dates, so nothing narrows the figures to ' +\n 'it. They are lifetime totals and include everything from ' +\n 'before the forms were put in this campaign. Give the campaign ' +\n 'a start and an end to count only its months.'}\n </Typography>\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`These figures add up the first ${FIGURES_CEILING} forms in this ` +\n 'campaign, in document order. It holds more; the table above ' +\n 'lists every one of them.'}\n </Alert>\n ) : null}\n {rollup.shared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${rollup.shared} of these ${rollup.members} forms ${\n rollup.shared === 1 ? 'is' : 'are'\n } filed under another campaign too, so the same submissions are ` +\n 'counted there as well. This total is not exclusive to this ' +\n 'campaign.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\nFormsHoldings.displayName = 'FormsHoldings'\n\n/** A counter as a cell: an em dash where nothing was recorded, never a zero. */\nfunction statCell(value: number | null | undefined) {\n return value === null || value === undefined ? '—' : value.toLocaleString()\n}\n\n/** The counters a form carries on its own document, in the order they are read. */\nconst MEMBER_FIGURES = [\n ['views', 'Views'],\n ['starts', 'Started'],\n ['submissions', 'Submissions'],\n ['leads', 'Leads'],\n] as const\n\n/** How the search reads on a notice: the box, not a column. */\nconst MEMBER_SEARCH_HEADERS: Readonly<Record<string, string>> = {\n name: 'Search',\n}\n\n/**\n * One kind's members, as a record list in the shared grid, paged by its own\n * query (AGL-3321).\n *\n * The membership, the soft delete and the search word are predicates on the\n * query (`campaign-members-query.ts`); the grid neither filters nor sorts\n * the page it is handed. The search box is the grid's quick filter, turned\n * into \"name starts with\" because the membership holds the query's one\n * array clause; the Filters panel is not offered, because every column\n * beside it would need an index of its own for a list this size.\n *\n * `figures` adds the counter columns for a kind that carries them on its own\n * document. It is a prop rather than two lists because the row grammar — the\n * name cell, the reason a member has no link, the footer — is the part that\n * has to stay identical between screens and forms.\n */\nfunction MemberTable(props: {\n heading: string\n noun: string\n collectionName: CampaignMemberCollection\n hostId: string\n campaignId: string\n toRow: (row: Record<string, unknown>) => MemberRow\n figures?: boolean\n}) {\n const { heading, noun, collectionName, hostId, campaignId, toRow, figures } =\n props\n const firestore = useFirestore()\n const gridFilter = useListGridFilter({})\n const search = campaignMembersSearchClause(gridFilter.searchWords)\n const page = useListQuery<Record<string, unknown>>({\n collection: collection(firestore, 'hosts', hostId, collectionName),\n declaration: CAMPAIGN_MEMBERS_QUERY,\n request: {\n clauses: search ? [search] : [],\n base: campaignMembersBase(collectionName, campaignId),\n },\n deps: [firestore, hostId, collectionName, campaignId],\n idField: '$id',\n })\n const rows = useMemo(() => page.rows.map(toRow), [page.rows, toRow])\n // Nothing typed and nothing on the first page: the campaign holds none,\n // which is said in words rather than as an empty grid.\n const none = !search && page.page === 0 && rows.length === 0\n const columns = useMemo<GridColDef<MemberRow>[]>(\n () => [\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }) => (\n <Stack sx={{ py: 1, minWidth: 0 }}>\n {row.href ? (\n <AppLink href={row.href}>{row.name}</AppLink>\n ) : (\n <>\n <Typography variant=\"body2\">{row.name}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.hrefReason}\n </Typography>\n </>\n )}\n {/*\n The overlap, on the row that causes it. A reader comparing two\n campaigns has to be able to see which rows they have in common\n without opening either.\n */}\n {(row.campaigns ?? 1) > 1 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Also in ${(row.campaigns ?? 1) - 1} other campaign${\n (row.campaigns ?? 1) - 1 === 1 ? '' : 's'\n }`}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n ...(figures\n ? MEMBER_FIGURES.map(\n ([figure, headerName]): GridColDef<MemberRow> => ({\n field: figure,\n headerName,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n width: 120,\n valueGetter: (_value, row) => row.totals?.[figure] ?? null,\n renderCell: ({ row }) => statCell(row.totals?.[figure]),\n }),\n )\n : []),\n ],\n [figures],\n )\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {heading}\n </Typography>\n {none ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {page.status === 'loading'\n ? `Reading this campaign’s ${noun}s…`\n : `No ${noun} is in this campaign. Open a ${noun} and pick this ` +\n 'campaign on its own page to put it in one.'}\n </Typography>\n ) : (\n <>\n <ListQueryNotices\n refused={listQueryRefusals(page.plan.refused, {\n fields: CAMPAIGN_MEMBERS_QUERY.fields,\n headers: MEMBER_SEARCH_HEADERS,\n })}\n notices={\n search\n ? [CAMPAIGN_MEMBERS_SEARCH_NOTICE, ...page.plan.notices]\n : page.plan.notices\n }\n />\n <ListTable\n aria-label={heading}\n rows={rows}\n columns={columns}\n getRowId={(row: MemberRow) => row.id}\n rowHeight={TABLE_ROW_HEIGHT}\n // A member's name carries why it has no link, and the other\n // campaigns it is in, beneath it.\n getRowHeight={() => 'auto'}\n loading={page.status === 'loading'}\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnFilter\n disableColumnSorting\n hideFooter\n noRowsLabel={`No ${noun} in this campaign starts with that`}\n />\n <ListPagination\n page={page.page}\n pageSize={page.pageSize}\n rowCount={rows.length}\n hasMore={page.hasMore}\n onPageChange={page.setPage}\n onPageSizeChange={page.setPageSize}\n />\n </>\n )}\n </Stack>\n )\n}\n\nCampaignMembersSection.displayName = 'CampaignMembersSection'\n\nexport default CampaignMembersSection\n"],"names":["AppLink","Figure","Section","ListPagination","ListQueryNotices","listQueryRefusals","ListTable","useListGridFilter","TABLE_ROW_HEIGHT","useListQuery","buildRoute","CAMPAIGN_MEMBERSHIP_FIELD","readCampaignIds","Route","Alert","Stack","Typography","collection","documentId","limit","orderBy","query","where","useCallback","useMemo","useConsoleHostRoute","useFirestore","useFirestoreCollection","campaignFormsRollup","campaignFormTotals","campaignPeriodRange","isWindowedRange","CAMPAIGN_MEMBERS_QUERY","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersBase","campaignMembersSearchClause","FIGURES_CEILING","CampaignMembersSection","props","range","hostId","siteName","campaignId","startAtMs","endAtMs","firestore","orgSlug","subdomain","host","data","figureDocs","idField","windowed","screenRow","row","id","String","versionId","name","href","SCREEN_DETAILS","screenId","hrefReason","campaigns","length","formRow","FORM_DETAILS","formId","totals","figureForms","slice","map","figuresTruncated","rollup","analyticsHref","HOST_ANALYTICS","contactsHref","HOST_CONTACTS","title","spacing","variant","color","MemberTable","heading","noun","collectionName","toRow","figures","FormsHoldings","from","to","truncated","note","recorded","members","span","direction","sx","flexWrap","label","value","views","starts","submissions","leads","severity","shared","displayName","statCell","undefined","toLocaleString","MEMBER_FIGURES","MEMBER_SEARCH_HEADERS","gridFilter","search","searchWords","page","declaration","request","clauses","base","deps","rows","none","columns","field","headerName","flex","minWidth","renderCell","py","figure","type","align","headerAlign","width","valueGetter","_value","status","refused","plan","fields","headers","notices","aria-label","getRowId","rowHeight","getRowHeight","loading","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnFilter","disableColumnSorting","hideFooter","noRowsLabel","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize"],"mappings":"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"}
@@ -48,6 +48,17 @@ export declare function campaignContainersQuery(firestore: Firestore, orgId: str
48
48
  * site when `hostId` names a site; `null` until the org is known.
49
49
  */
50
50
  export declare function campaignSendsQuery(firestore: Firestore, orgId: string | null, hostId: string | null): Query | null;
51
+ /**
52
+ * The two collections UNSCOPED, for a paged list that puts its scope on its
53
+ * query plan (AGL-3321): `campaignSendsScope` / `campaignContainersScope` in
54
+ * `model/campaign-list-query.ts` carry the site's clause there, beside the
55
+ * list's own. A site hub's sends are asked `hostId == {site}` rather than by
56
+ * `visibleTo`, so search keeps the query's one array clause; the rules
57
+ * admit that read for the site's collaborators too.
58
+ */
59
+ export declare function campaignSendsCollection(firestore: Firestore, orgId: string | null): Query | null;
60
+ /** `orgs/{orgId}/emailCampaigns`, unscoped — see {@link campaignSendsCollection}. */
61
+ export declare function campaignContainersCollection(firestore: Firestore, orgId: string | null): Query | null;
51
62
  /** One container, `orgs/{orgId}/emailCampaigns/{id}`. */
52
63
  export declare function campaignContainerDoc(firestore: Firestore, orgId: string, campaignId: string): DocumentReference;
53
64
  /** One send, `orgs/{orgId}/campaigns/{sendId}`. */
@@ -56,6 +56,19 @@ import { collection, doc, query, where } from "firebase/firestore";
56
56
  hostScopeToken(hostId)
57
57
  ])) : sends;
58
58
  }
59
+ /**
60
+ * The two collections UNSCOPED, for a paged list that puts its scope on its
61
+ * query plan (AGL-3321): `campaignSendsScope` / `campaignContainersScope` in
62
+ * `model/campaign-list-query.ts` carry the site's clause there, beside the
63
+ * list's own. A site hub's sends are asked `hostId == {site}` rather than by
64
+ * `visibleTo`, so search keeps the query's one array clause; the rules
65
+ * admit that read for the site's collaborators too.
66
+ */ export function campaignSendsCollection(firestore, orgId) {
67
+ return orgId ? collection(firestore, ...orgCampaignSendsPath(orgId)) : null;
68
+ }
69
+ /** `orgs/{orgId}/emailCampaigns`, unscoped — see {@link campaignSendsCollection}. */ export function campaignContainersCollection(firestore, orgId) {
70
+ return orgId ? collection(firestore, ...orgEmailCampaignsPath(orgId)) : null;
71
+ }
59
72
  /** One container, `orgs/{orgId}/emailCampaigns/{id}`. */ export function campaignContainerDoc(firestore, orgId, campaignId) {
60
73
  return doc(firestore, ...orgEmailCampaignsPath(orgId), campaignId);
61
74
  }