@aglyn/plugins-email 1.0.0-beta.179 → 1.0.0-beta.180

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 (59) hide show
  1. package/README.md +2 -1
  2. package/package.json +17 -17
  3. package/src/lib/components/email-org-mount.d.ts +116 -0
  4. package/src/lib/components/email-org-mount.js +211 -0
  5. package/src/lib/components/email-org-mount.js.map +1 -0
  6. package/src/lib/components/email-topic-detail.d.ts +2 -1
  7. package/src/lib/components/email-topic-detail.js +7 -1
  8. package/src/lib/components/email-topic-detail.js.map +1 -1
  9. package/src/lib/components/email-topics-card.d.ts +2 -1
  10. package/src/lib/components/email-topics-card.js +7 -1
  11. package/src/lib/components/email-topics-card.js.map +1 -1
  12. package/src/lib/components/email-zones.d.ts +28 -1
  13. package/src/lib/components/email-zones.js.map +1 -1
  14. package/src/lib/components/emails-console-page.d.ts +4 -0
  15. package/src/lib/components/emails-console-page.js +107 -5
  16. package/src/lib/components/emails-console-page.js.map +1 -1
  17. package/src/lib/components/list-detail-card.d.ts +19 -3
  18. package/src/lib/components/list-detail-card.js +52 -13
  19. package/src/lib/components/list-detail-card.js.map +1 -1
  20. package/src/lib/components/list-edit-card.d.ts +2 -1
  21. package/src/lib/components/list-edit-card.js +56 -17
  22. package/src/lib/components/list-edit-card.js.map +1 -1
  23. package/src/lib/components/lists-card.d.ts +2 -1
  24. package/src/lib/components/lists-card.js +5 -5
  25. package/src/lib/components/lists-card.js.map +1 -1
  26. package/src/lib/components/org-email-templates-card.d.ts +34 -0
  27. package/src/lib/components/org-email-templates-card.js +470 -0
  28. package/src/lib/components/org-email-templates-card.js.map +1 -0
  29. package/src/lib/components/org-sending-card.d.ts +30 -0
  30. package/src/lib/components/org-sending-card.js +680 -0
  31. package/src/lib/components/org-sending-card.js.map +1 -0
  32. package/src/lib/components/org-sending-domain-detail.d.ts +28 -0
  33. package/src/lib/components/org-sending-domain-detail.js +497 -0
  34. package/src/lib/components/org-sending-domain-detail.js.map +1 -0
  35. package/src/lib/components/org-site-suppressions.d.ts +20 -0
  36. package/src/lib/components/org-site-suppressions.js +99 -0
  37. package/src/lib/components/org-site-suppressions.js.map +1 -0
  38. package/src/lib/components/org-suppressions-card.d.ts +22 -0
  39. package/src/lib/components/org-suppressions-card.js +196 -0
  40. package/src/lib/components/org-suppressions-card.js.map +1 -0
  41. package/src/lib/components/sending-domain-detail.d.ts +12 -0
  42. package/src/lib/components/sending-domain-detail.js +4 -1
  43. package/src/lib/components/sending-domain-detail.js.map +1 -1
  44. package/src/lib/components/suppression-totals.d.ts +63 -0
  45. package/src/lib/components/suppression-totals.js +105 -0
  46. package/src/lib/components/suppression-totals.js.map +1 -0
  47. package/src/lib/components/suppressions-card.js +7 -80
  48. package/src/lib/components/suppressions-card.js.map +1 -1
  49. package/src/lib/components/use-org-email-topics.d.ts +9 -1
  50. package/src/lib/components/use-org-email-topics.js +4 -3
  51. package/src/lib/components/use-org-email-topics.js.map +1 -1
  52. package/src/lib/components/use-org-sending-views.d.ts +39 -0
  53. package/src/lib/components/use-org-sending-views.js +124 -0
  54. package/src/lib/components/use-org-sending-views.js.map +1 -0
  55. package/src/lib/model/sending-domain-status.d.ts +36 -0
  56. package/src/lib/model/sending-domain-status.js +46 -0
  57. package/src/lib/model/sending-domain-status.js.map +1 -1
  58. package/src/lib/plugin.js +29 -1
  59. package/src/lib/plugin.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-detail-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\n/**\n * ONE AUDIENCE: who it is, how it is built, and every person on it.\n *\n * ## Why a route rather than an expanded row\n *\n * The membership used to unfold inside the list table, which cost three\n * things a merchant actually uses. A list was not linkable, so \"check the\n * Wholesale audience\" could not be sent to anybody. The back button walked out\n * of the whole surface rather than out of the list. And the list's own\n * settings had to live under a table they were not about.\n *\n * ## What it reads\n *\n * The list document — one document, whatever the audience's size — plus one\n * server aggregate for the subscriber total, plus the paged membership window\n * the table below draws. The membership is PII and there is one document per\n * subscriber, so it is read HERE, where somebody asked for it, and nowhere\n * that merely lists audiences.\n *\n * ## The rule is stated, not just flagged\n *\n * A dynamic list is a claim about who is on it, and \"Rule\" as a chip is not\n * that claim. The summary spells out every dimension the rule actually\n * carries, so the answer to \"why is this person not in the audience\" is on the\n * screen the question gets asked on.\n */\n\nimport {\n normalizeDynamicListRule,\n PageHeaderRecord,\n pluginDocsHelp,\n type ConsentGroup,\n} from '@aglyn/aglyn'\nimport { mdiPencilOutline, mdiTrayArrowUp } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { Button, Chip, Stack, Typography } from '@mui/material'\nimport { collection, doc, getCountFromServer } from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport { useFirestore, useFirestoreDoc, useOrgDataScope } from '@aglyn/tenant-feature-instance'\nimport { describeDynamicListRule } from './dynamic-list-rule-fields'\nimport ListImportDrawer from './list-import-drawer'\nimport ListMembersPanel from './list-members-panel'\n\nexport interface ListDetailCardProps {\n hostId: string\n /**\n * The controller this audience is being read AS, resolved by the page from\n * the org document it already holds.\n *\n * Passed through rather than resolved here: the group is a fact about the\n * ORG, one lookup serves every section, and a card that resolved its own\n * would be a second place for the answer to come from.\n */\n consentGroup: ConsentGroup\n listId: string\n /** The emails hub URL, for the way back to the audiences list. */\n basePath: string\n}\n\nexport function ListDetailCard(props: ListDetailCardProps) {\n const { hostId, consentGroup, listId, basePath } = props\n const firestore = useFirestore()\n const { scope } = useOrgDataScope({ hostId })\n\n const { data: list, status } = useFirestoreDoc<Record<string, any>>(\n () =>\n scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null,\n [firestore, scope, listId],\n )\n\n /*\n * The subscriber total, as a server aggregate.\n *\n * Not the length of the page below: a count beside a paged table is exactly\n * where a page length gets mistaken for a total. Taken once, and re-taken\n * only when a membership is added or removed — a figure that disagreed with\n * the table directly under it would read as the add having failed.\n */\n const [count, setCount] = useState<number | null>(null)\n const [membershipVersion, setMembershipVersion] = useState(0)\n useEffect(() => {\n if (!scope) return undefined\n let active = true\n void getCountFromServer(\n collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n )\n .then((snapshot) => {\n if (active) setCount(snapshot.data().count)\n })\n .catch(() => {\n if (active) setCount(null)\n })\n return () => {\n active = false\n }\n }, [firestore, scope, listId, membershipVersion])\n\n const onMembershipChanged = useCallback(\n () => setMembershipVersion((version) => version + 1),\n [],\n )\n\n /*\n * Importing is a DRAWER opened from the header, not a control inside the\n * membership panel. It is a multi-step act — choose a file, read what is in\n * it, state that you have permission — and the middle step is the one that\n * must not be cramped, because it carries the screening warnings and the\n * consent readout the attestation is given against.\n */\n const [importing, setImporting] = useState(false)\n\n const audiencesHref = `${basePath}/audiences`\n const headerActions = (\n <Stack direction=\"row\" spacing={1}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={audiencesHref}\n size=\"small\"\n color=\"primary\"\n >\n {'All audiences'}\n </Button>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${audiencesHref}/${listId}/edit`}\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPencilOutline.path} size={0.8} />}\n >\n {'Edit list'}\n </Button>\n <Button\n size=\"small\"\n color=\"primary\"\n variant=\"contained\"\n startIcon={<MdiIcon path={mdiTrayArrowUp.path} size={0.8} />}\n onClick={() => setImporting(true)}\n >\n {'Import'}\n </Button>\n </Stack>\n )\n\n if (!list) {\n /*\n * Loading and MISSING are different answers, and the difference matters\n * more here than almost anywhere. Not \"no members\" — a list that cannot be\n * read is a different situation from an empty one, and rendering an empty\n * membership for the first is how somebody comes to believe an audience\n * they built has lost everybody. Reading `status` rather than the absence\n * of data is also what stops the refusal being flashed on every arrival,\n * before the org scope has even resolved.\n */\n const settled = Boolean(scope) && status !== 'loading'\n return (\n <CardDisplay\n header={'Audience'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? 'This list could not be loaded. It may have been deleted.'\n : 'Loading this audience…'}\n </Typography>\n </CardDisplay>\n )\n }\n\n const dynamic = list['kind'] === 'dynamic'\n /*\n * The filters, whichever kind of list this is.\n *\n * On a live list they are what the sweep evaluates. On a fixed one they are\n * a saved SEARCH — the same filters, used to find people to add rather than\n * to decide membership — so they are read and described either way, and only\n * the sentence about what happens next differs.\n */\n const rule = normalizeDynamicListRule(list['rule'])\n const hasFilters = rule.sources.length > 0\n const summary = hasFilters ? describeDynamicListRule(rule) : []\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Audience'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#list-members' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={2}>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {dynamic ? (\n <Chip\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n label={\n list['lastEvaluatedAt']?.toDate\n ? `Rule · ${list['lastEvaluatedAt']\n .toDate()\n .toLocaleString()}`\n : 'Rule · not yet evaluated'\n }\n />\n ) : (\n <Chip size=\"small\" variant=\"outlined\" label=\"Fixed membership\" />\n )}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {count === null\n ? 'Counting subscribers…'\n : count === 1\n ? '1 subscriber'\n : `${count.toLocaleString()} subscribers`}\n </Typography>\n </Stack>\n\n {/*\n A live list's filters are stated HERE rather than only on the edit\n page: they are the answer to \"why is this person not in the\n audience\", which is a question asked while looking at the membership.\n A fixed list states them inside the find-people control instead,\n beside the button that acts on them.\n */}\n {dynamic && hasFilters ? (\n <Stack spacing={0.5}>\n {summary.map((clause) => (\n <Typography key={clause} variant=\"body2\" color=\"text.secondary\">\n {clause}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {scope ? (\n <ListMembersPanel\n hostId={hostId}\n consentGroup={consentGroup}\n scope={scope as readonly [string, string]}\n listId={listId}\n listName={String(list['name'] ?? '')}\n onMembershipChanged={onMembershipChanged}\n /*\n Offered only on a FIXED list. A live list's membership is the\n sweep's to decide, and hand-adding its own matches would write\n `via: 'manual'` copies of rows the sweep already owns — rows it\n then may not reconcile away when the person stops matching.\n */\n findRule={dynamic || !hasFilters ? null : rule}\n ruleSummary={summary}\n />\n ) : null}\n </Stack>\n {/*\n Mounted only while it is open, so a page nobody is importing on runs\n none of its effects — the drawer looks for an unfinished import when\n it opens, and that read must not be a cost of visiting an audience.\n */}\n {importing ? (\n <ListImportDrawer\n open\n onClose={() => setImporting(false)}\n hostId={hostId}\n listId={listId}\n listName={String(list['name'] ?? '')}\n onMembershipChanged={onMembershipChanged}\n />\n ) : null}\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the audience; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={String(list['name'] || listId)} />\n {card}\n </>\n )\n}\nListDetailCard.displayName = 'ListDetailCard'\n\nexport default ListDetailCard\n"],"names":["normalizeDynamicListRule","PageHeaderRecord","pluginDocsHelp","mdiPencilOutline","mdiTrayArrowUp","AppLink","CardDisplay","MdiIcon","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","useCallback","useEffect","useState","useFirestore","useFirestoreDoc","useOrgDataScope","describeDynamicListRule","ListImportDrawer","ListMembersPanel","ListDetailCard","props","list","hostId","consentGroup","listId","basePath","firestore","scope","data","status","count","setCount","membershipVersion","setMembershipVersion","undefined","active","then","snapshot","catch","onMembershipChanged","version","importing","setImporting","audiencesHref","headerActions","direction","spacing","component","componentVariant","nativeButton","href","size","color","variant","startIcon","path","onClick","settled","Boolean","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","dynamic","rule","hasFilters","sources","length","summary","card","contentBordered","useFlexGap","sx","flexWrap","alignItems","label","toDate","toLocaleString","map","clause","listName","String","findRule","ruleSummary","open","onClose","title","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-detail-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\n/**\n * ONE AUDIENCE: who it is, how it is built, and every person on it.\n *\n * ## Why a route rather than an expanded row\n *\n * The membership used to unfold inside the list table, which cost three\n * things a merchant actually uses. A list was not linkable, so \"check the\n * Wholesale audience\" could not be sent to anybody. The back button walked out\n * of the whole surface rather than out of the list. And the list's own\n * settings had to live under a table they were not about.\n *\n * ## What it reads\n *\n * The list document — one document, whatever the audience's size — plus one\n * server aggregate for the subscriber total, plus the paged membership window\n * the table below draws. The membership is PII and there is one document per\n * subscriber, so it is read HERE, where somebody asked for it, and nowhere\n * that merely lists audiences.\n *\n * ## The rule is stated, not just flagged\n *\n * A dynamic list is a claim about who is on it, and \"Rule\" as a chip is not\n * that claim. The summary spells out every dimension the rule actually\n * carries, so the answer to \"why is this person not in the audience\" is on the\n * screen the question gets asked on.\n *\n * ## On the organization's page, the site it is worked AS\n *\n * The list is the organization's, but adding somebody to it is done as one\n * site — the enrollment and the consent it records are that site's — and the\n * Consent column reads what each person agreed to with one site's group. So\n * with no site in the URL the page asks which, defaulting to the site the\n * list's filters draw from, then the reader's pick this session, then the\n * org's only site. Nothing below the picker is built until there is an\n * answer: a consent column for no site would be a column about nobody.\n */\n\nimport {\n consentGroupForHost,\n normalizeDynamicListRule,\n PageHeaderRecord,\n pluginDocsHelp,\n type ConsentGroup,\n} from '@aglyn/aglyn'\nimport { mdiPencilOutline, mdiTrayArrowUp } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { Button, Chip, Stack, Typography } from '@mui/material'\nimport { collection, doc, getCountFromServer } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { describeDynamicListRule } from './dynamic-list-rule-fields'\nimport {\n OrgSiteSelect,\n useEmailDataScope,\n useEmailOrgMount,\n} from './email-org-mount'\nimport ListImportDrawer from './list-import-drawer'\nimport ListMembersPanel from './list-members-panel'\n\nexport interface ListDetailCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /**\n * The controller this audience is being read AS, resolved by the page from\n * the org document it already holds — under a site.\n *\n * Passed through rather than resolved here: the group is a fact about the\n * ORG, one lookup serves every section, and a card that resolved its own\n * would be a second place for the answer to come from.\n */\n consentGroup?: ConsentGroup\n /**\n * The org document, on the organization's page, where the group can only be\n * resolved once the reader has said which site they are working as.\n */\n org?: Record<string, unknown>\n listId: string\n /** The emails hub URL, for the way back to the audiences list. */\n basePath: string\n}\n\nexport function ListDetailCard(props: ListDetailCardProps) {\n const { hostId, listId, basePath, org } = props\n const firestore = useFirestore()\n const orgMount = useEmailOrgMount()\n const { scope } = useEmailDataScope(hostId)\n\n const { data: list, status } = useFirestoreDoc<Record<string, any>>(\n () =>\n scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null,\n [firestore, scope, listId],\n )\n\n /*\n * The subscriber total, as a server aggregate.\n *\n * Not the length of the page below: a count beside a paged table is exactly\n * where a page length gets mistaken for a total. Taken once, and re-taken\n * only when a membership is added or removed — a figure that disagreed with\n * the table directly under it would read as the add having failed.\n */\n const [count, setCount] = useState<number | null>(null)\n const [membershipVersion, setMembershipVersion] = useState(0)\n useEffect(() => {\n if (!scope) return undefined\n let active = true\n void getCountFromServer(\n collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n )\n .then((snapshot) => {\n if (active) setCount(snapshot.data().count)\n })\n .catch(() => {\n if (active) setCount(null)\n })\n return () => {\n active = false\n }\n }, [firestore, scope, listId, membershipVersion])\n\n const onMembershipChanged = useCallback(\n () => setMembershipVersion((version) => version + 1),\n [],\n )\n\n /*\n * THE SITE THIS AUDIENCE IS WORKED AS. Under a site, that site. On the\n * organization's page, the reader's choice — else the site the filters draw\n * from, else the session's pick, else the only site.\n */\n const [chosenHostId, setChosenHostId] = useState('')\n const listHostId = String(list?.['hostId'] ?? '')\n const enrollHostId: string =\n hostId ??\n (chosenHostId ||\n (orgMount?.hosts.some((site) => site.id === listHostId)\n ? listHostId\n : '') ||\n orgMount?.pickedHostId ||\n '')\n const consentGroup = useMemo<ConsentGroup | null>(\n () =>\n hostId != null\n ? (props.consentGroup ?? null)\n : enrollHostId\n ? consentGroupForHost(org, enrollHostId)\n : null,\n [hostId, props.consentGroup, enrollHostId, org],\n )\n\n /*\n * Importing is a DRAWER opened from the header, not a control inside the\n * membership panel. It is a multi-step act — choose a file, read what is in\n * it, state that you have permission — and the middle step is the one that\n * must not be cramped, because it carries the screening warnings and the\n * consent readout the attestation is given against.\n */\n const [importing, setImporting] = useState(false)\n\n const audiencesHref = `${basePath}/audiences`\n const headerActions = (\n <Stack direction=\"row\" spacing={1}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={audiencesHref}\n size=\"small\"\n color=\"primary\"\n >\n {'All audiences'}\n </Button>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${audiencesHref}/${listId}/edit`}\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPencilOutline.path} size={0.8} />}\n >\n {'Edit list'}\n </Button>\n <Button\n size=\"small\"\n color=\"primary\"\n variant=\"contained\"\n startIcon={<MdiIcon path={mdiTrayArrowUp.path} size={0.8} />}\n // An import enrolls as a site; on the org page one has to be chosen.\n disabled={!enrollHostId}\n onClick={() => setImporting(true)}\n >\n {'Import'}\n </Button>\n </Stack>\n )\n\n if (!list) {\n /*\n * Loading and MISSING are different answers, and the difference matters\n * more here than almost anywhere. Not \"no members\" — a list that cannot be\n * read is a different situation from an empty one, and rendering an empty\n * membership for the first is how somebody comes to believe an audience\n * they built has lost everybody. Reading `status` rather than the absence\n * of data is also what stops the refusal being flashed on every arrival,\n * before the org scope has even resolved.\n */\n const settled = Boolean(scope) && status !== 'loading'\n return (\n <CardDisplay\n header={'Audience'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? 'This list could not be loaded. It may have been deleted.'\n : 'Loading this audience…'}\n </Typography>\n </CardDisplay>\n )\n }\n\n const dynamic = list['kind'] === 'dynamic'\n /*\n * The filters, whichever kind of list this is.\n *\n * On a live list they are what the sweep evaluates. On a fixed one they are\n * a saved SEARCH — the same filters, used to find people to add rather than\n * to decide membership — so they are read and described either way, and only\n * the sentence about what happens next differs.\n */\n const rule = normalizeDynamicListRule(list['rule'])\n const hasFilters = rule.sources.length > 0\n const summary = hasFilters ? describeDynamicListRule(rule) : []\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Audience'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#list-members' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={2}>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {dynamic ? (\n <Chip\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n label={\n list['lastEvaluatedAt']?.toDate\n ? `Rule · ${list['lastEvaluatedAt']\n .toDate()\n .toLocaleString()}`\n : 'Rule · not yet evaluated'\n }\n />\n ) : (\n <Chip size=\"small\" variant=\"outlined\" label=\"Fixed membership\" />\n )}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {count === null\n ? 'Counting subscribers…'\n : count === 1\n ? '1 subscriber'\n : `${count.toLocaleString()} subscribers`}\n </Typography>\n </Stack>\n\n {/*\n A live list's filters are stated HERE rather than only on the edit\n page: they are the answer to \"why is this person not in the\n audience\", which is a question asked while looking at the membership.\n A fixed list states them inside the find-people control instead,\n beside the button that acts on them.\n */}\n {dynamic && hasFilters ? (\n <Stack spacing={0.5}>\n {summary.map((clause) => (\n <Typography key={clause} variant=\"body2\" color=\"text.secondary\">\n {clause}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {orgMount ? (\n <OrgSiteSelect\n mount={orgMount}\n label=\"Enroll as\"\n value={enrollHostId}\n onChange={setChosenHostId}\n helperText={\n 'People you add are enrolled as this site, and the Consent ' +\n 'column reads what they agreed to with it.'\n }\n />\n ) : null}\n\n {orgMount && !enrollHostId ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Choose the site to work this audience as. Whether someone may ' +\n 'be emailed depends on what they agreed to with a site, so the ' +\n 'members and their consent are read for one site at a time.'}\n </Typography>\n ) : scope && consentGroup ? (\n <ListMembersPanel\n key={enrollHostId}\n hostId={enrollHostId}\n consentGroup={consentGroup}\n scope={scope as readonly [string, string]}\n listId={listId}\n listName={String(list['name'] ?? '')}\n onMembershipChanged={onMembershipChanged}\n /*\n Offered only on a FIXED list. A live list's membership is the\n sweep's to decide, and hand-adding its own matches would write\n `via: 'manual'` copies of rows the sweep already owns — rows it\n then may not reconcile away when the person stops matching.\n */\n findRule={dynamic || !hasFilters ? null : rule}\n ruleSummary={summary}\n />\n ) : null}\n </Stack>\n {/*\n Mounted only while it is open, so a page nobody is importing on runs\n none of its effects — the drawer looks for an unfinished import when\n it opens, and that read must not be a cost of visiting an audience.\n */}\n {importing && enrollHostId ? (\n <ListImportDrawer\n open\n onClose={() => setImporting(false)}\n hostId={enrollHostId}\n listId={listId}\n listName={String(list['name'] ?? '')}\n onMembershipChanged={onMembershipChanged}\n />\n ) : null}\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the audience; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={String(list['name'] || listId)} />\n {card}\n </>\n )\n}\nListDetailCard.displayName = 'ListDetailCard'\n\nexport default ListDetailCard\n"],"names":["consentGroupForHost","normalizeDynamicListRule","PageHeaderRecord","pluginDocsHelp","mdiPencilOutline","mdiTrayArrowUp","AppLink","CardDisplay","MdiIcon","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","useCallback","useEffect","useMemo","useState","useFirestore","useFirestoreDoc","describeDynamicListRule","OrgSiteSelect","useEmailDataScope","useEmailOrgMount","ListImportDrawer","ListMembersPanel","ListDetailCard","props","list","hostId","listId","basePath","org","firestore","orgMount","scope","data","status","count","setCount","membershipVersion","setMembershipVersion","undefined","active","then","snapshot","catch","onMembershipChanged","version","chosenHostId","setChosenHostId","listHostId","String","enrollHostId","hosts","some","site","id","pickedHostId","consentGroup","importing","setImporting","audiencesHref","headerActions","direction","spacing","component","componentVariant","nativeButton","href","size","color","variant","startIcon","path","disabled","onClick","settled","Boolean","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","dynamic","rule","hasFilters","sources","length","summary","card","contentBordered","useFlexGap","sx","flexWrap","alignItems","label","toDate","toLocaleString","map","clause","mount","value","onChange","helperText","listName","findRule","ruleSummary","open","onClose","title","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GAED,SACEA,mBAAmB,EACnBC,wBAAwB,EACxBC,gBAAgB,EAChBC,cAAc,QAET,eAAc;AACrB,SAASC,gBAAgB,EAAEC,cAAc,QAAQ,yBAAwB;AACzE,SAASC,OAAO,EAAEC,WAAW,EAAEC,OAAO,QAAQ,uBAAsB;AACpE,SAASC,MAAM,EAAEC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AAC/D,SAASC,UAAU,EAAEC,GAAG,EAAEC,kBAAkB,QAAQ,qBAAoB;AACxE,SAASC,WAAW,EAAEC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACjE,SAASC,YAAY,EAAEC,eAAe,QAAQ,iCAAgC;AAC9E,SAASC,uBAAuB,QAAQ,gCAA4B;AACpE,SACEC,aAAa,EACbC,iBAAiB,EACjBC,gBAAgB,QACX,uBAAmB;AAC1B,OAAOC,sBAAsB,0BAAsB;AACnD,OAAOC,sBAAsB,0BAAsB;AAwBnD,OAAO,SAASC,eAAeC,KAA0B;cAkP5BC,YAwBFA;QAtFXA;IAnLd,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,GAAG,EAAE,GAAGL;IAC1C,MAAMM,YAAYf;IAClB,MAAMgB,WAAWX;IACjB,MAAM,EAAEY,KAAK,EAAE,GAAGb,kBAAkBO;IAEpC,MAAM,EAAEO,MAAMR,IAAI,EAAES,MAAM,EAAE,GAAGlB,gBAC7B,IACEgB,QAAQvB,IAAIqB,WAAWE,KAAK,CAAC,EAAE,EAAEA,KAAK,CAAC,EAAE,EAAE,SAASL,UAAU,MAChE;QAACG;QAAWE;QAAOL;KAAO;IAG5B;;;;;;;GAOC,GACD,MAAM,CAACQ,OAAOC,SAAS,GAAGtB,SAAwB;IAClD,MAAM,CAACuB,mBAAmBC,qBAAqB,GAAGxB,SAAS;IAC3DF,UAAU;QACR,IAAI,CAACoB,OAAO,OAAOO;QACnB,IAAIC,SAAS;QACb,KAAK9B,mBACHF,WAAWsB,WAAWE,KAAK,CAAC,EAAE,EAAEA,KAAK,CAAC,EAAE,EAAE,SAASL,QAAQ,YAE1Dc,IAAI,CAAC,CAACC;YACL,IAAIF,QAAQJ,SAASM,SAAST,IAAI,GAAGE,KAAK;QAC5C,GACCQ,KAAK,CAAC;YACL,IAAIH,QAAQJ,SAAS;QACvB;QACF,OAAO;YACLI,SAAS;QACX;IACF,GAAG;QAACV;QAAWE;QAAOL;QAAQU;KAAkB;IAEhD,MAAMO,sBAAsBjC,YAC1B,IAAM2B,qBAAqB,CAACO,UAAYA,UAAU,IAClD,EAAE;IAGJ;;;;GAIC,GACD,MAAM,CAACC,cAAcC,gBAAgB,GAAGjC,SAAS;IACjD,MAAMkC,aAAaC,eAAOxB,wBAAAA,IAAM,CAAC,SAAS,mBAAI;IAC9C,MAAMyB,eACJxB,iBAAAA,SACCoB,gBACEf,CAAAA,CAAAA,4BAAAA,SAAUoB,KAAK,CAACC,IAAI,CAAC,CAACC,OAASA,KAAKC,EAAE,KAAKN,eACxCA,aACA,EAAC,MACLjB,4BAAAA,SAAUwB,YAAY,KACtB;IACJ,MAAMC,eAAe3C,QACnB;YAEOW;eADLE,UAAU,QACLF,sBAAAA,MAAMgC,YAAY,YAAlBhC,sBAAsB,OACvB0B,eACEvD,oBAAoBkC,KAAKqB,gBACzB;OACR;QAACxB;QAAQF,MAAMgC,YAAY;QAAEN;QAAcrB;KAAI;IAGjD;;;;;;GAMC,GACD,MAAM,CAAC4B,WAAWC,aAAa,GAAG5C,SAAS;IAE3C,MAAM6C,gBAAgB,GAAG/B,SAAS,UAAU,CAAC;IAC7C,MAAMgC,8BACJ,MAACtD;QAAMuD,WAAU;QAAMC,SAAS;;0BAC9B,KAAC1D;gBACC2D,WAAW9D;eACN;gBAAE+D,kBAAkB;gBAASC,cAAc;YAAM;gBACtDC,MAAMP;gBACNQ,MAAK;gBACLC,OAAM;0BAEL;;0BAEH,KAAChE;gBACC2D,WAAW9D;eACN;gBAAE+D,kBAAkB;gBAASC,cAAc;YAAM;gBACtDC,MAAM,GAAGP,cAAc,CAAC,EAAEhC,OAAO,KAAK,CAAC;gBACvCwC,MAAK;gBACLC,OAAM;gBACNC,SAAQ;gBACRC,yBAAW,KAACnE;oBAAQoE,MAAMxE,iBAAiBwE,IAAI;oBAAEJ,MAAM;;0BAEtD;;0BAEH,KAAC/D;gBACC+D,MAAK;gBACLC,OAAM;gBACNC,SAAQ;gBACRC,yBAAW,KAACnE;oBAAQoE,MAAMvE,eAAeuE,IAAI;oBAAEJ,MAAM;;gBACrD,qEAAqE;gBACrEK,UAAU,CAACtB;gBACXuB,SAAS,IAAMf,aAAa;0BAE3B;;;;IAKP,IAAI,CAACjC,MAAM;QACT;;;;;;;;KAQC,GACD,MAAMiD,UAAUC,QAAQ3C,UAAUE,WAAW;QAC7C,qBACE,KAAChC;YACC0E,QAAQ;YACRC,MAAM/E,eAAe,kBAAkB;gBAAEgF,QAAQ;YAAe;YAChEC,cAAc;YACdC,cAAc;YACdC,aAAa;gBAAEC,QAAQtB;YAAc;sBAErC,cAAA,KAACrD;gBAAW8D,SAAQ;gBAAQD,OAAM;0BAC/BM,UACG,6DACA;;;IAIZ;IAEA,MAAMS,UAAU1D,IAAI,CAAC,OAAO,KAAK;IACjC;;;;;;;GAOC,GACD,MAAM2D,OAAOxF,yBAAyB6B,IAAI,CAAC,OAAO;IAClD,MAAM4D,aAAaD,KAAKE,OAAO,CAACC,MAAM,GAAG;IACzC,MAAMC,UAAUH,aAAapE,wBAAwBmE,QAAQ,EAAE;IAE/D;yCACuC,GACvC,MAAMK,qBACJ,MAACvF;QACC0E,QAAQ;QACRC,MAAM/E,eAAe,kBAAkB;YAAEgF,QAAQ;QAAgB;QACjEC,cAAc;QACdC,cAAc;QACdU,iBAAgB;QAChBT,aAAa;YAAEC,QAAQtB;QAAc;;0BAErC,MAACtD;gBAAMwD,SAAS;;kCACd,MAACxD;wBACCuD,WAAU;wBACVC,SAAS;wBACT6B,UAAU;wBACVC,IAAI;4BAAEC,UAAU;4BAAQC,YAAY;wBAAS;;4BAE5CX,wBACC,KAAC9E;gCACC8D,MAAK;gCACLC,OAAM;gCACNC,SAAQ;gCACR0B,OACEtE,EAAAA,wBAAAA,IAAI,CAAC,kBAAkB,qBAAvBA,sBAAyBuE,MAAM,IAC3B,CAAC,OAAO,EAAEvE,IAAI,CAAC,kBAAkB,CAC9BuE,MAAM,GACNC,cAAc,IAAI,GACrB;+CAIR,KAAC5F;gCAAK8D,MAAK;gCAAQE,SAAQ;gCAAW0B,OAAM;;0CAE9C,KAACxF;gCAAW8D,SAAQ;gCAAQD,OAAM;0CAC/BjC,UAAU,OACP,0BACAA,UAAU,IACR,iBACA,GAAGA,MAAM8D,cAAc,GAAG,YAAY,CAAC;;;;oBAWhDd,WAAWE,2BACV,KAAC/E;wBAAMwD,SAAS;kCACb0B,QAAQU,GAAG,CAAC,CAACC,uBACZ,KAAC5F;gCAAwB8D,SAAQ;gCAAQD,OAAM;0CAC5C+B;+BADcA;yBAKnB;oBAEHpE,yBACC,KAACb;wBACCkF,OAAOrE;wBACPgE,OAAM;wBACNM,OAAOnD;wBACPoD,UAAUvD;wBACVwD,YACE,+DACA;yBAGF;oBAEHxE,YAAY,CAACmB,6BACZ,KAAC3C;wBAAW8D,SAAQ;wBAAQD,OAAM;kCAC/B,mEACC,mEACA;yBAEFpC,SAASwB,6BACX,KAAClC;wBAECI,QAAQwB;wBACRM,cAAcA;wBACdxB,OAAOA;wBACPL,QAAQA;wBACR6E,UAAUvD,QAAOxB,aAAAA,IAAI,CAAC,OAAO,YAAZA,aAAgB;wBACjCmB,qBAAqBA;wBACrB;;;;;aAKC,GACD6D,UAAUtB,WAAW,CAACE,aAAa,OAAOD;wBAC1CsB,aAAalB;uBAdRtC,gBAgBL;;;YAOLO,aAAaP,6BACZ,KAAC7B;gBACCsF,IAAI;gBACJC,SAAS,IAAMlD,aAAa;gBAC5BhC,QAAQwB;gBACRvB,QAAQA;gBACR6E,UAAUvD,QAAOxB,cAAAA,IAAI,CAAC,OAAO,YAAZA,cAAgB;gBACjCmB,qBAAqBA;iBAErB;;;IAIR,qBACE;;0BAGE,KAAC/C;gBAAiBgH,OAAO5D,OAAOxB,IAAI,CAAC,OAAO,IAAIE;;YAC/C8D;;;AAGP;AACAlE,eAAeuF,WAAW,GAAG;AAE7B,eAAevF,eAAc"}
@@ -1,5 +1,6 @@
1
1
  export interface ListEditCardProps {
2
- hostId: string;
2
+ /** The site, or `null` on the organization's Emails page. */
3
+ hostId: string | null;
3
4
  listId: string;
4
5
  /** The emails hub URL, which the audiences routes hang beneath. */
5
6
  basePath: string;
@@ -52,6 +52,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
52
52
  * survives becoming live, and everyone on the list survives becoming fixed.
53
53
  * The rule is kept on a fixed list rather than deleted — it is the filter that
54
54
  * found these people, and losing it on a toggle would lose the work.
55
+ *
56
+ * ## Whose people the filters read is a choice, not a side effect
57
+ *
58
+ * A list is the organization's and its rule reads ONE site's leads, members
59
+ * and form submissions — the list's `hostId`. That is written when somebody
60
+ * chooses it and at no other time. A rename saved from another site's page
61
+ * must not move the filters onto that site's people: the next sweep would
62
+ * re-draw the whole audience from somebody else's customers. So the page
63
+ * reads the stored site, offers the change in words, and writes `hostId` only
64
+ * when it differs from what is stored — which is also how a list that never
65
+ * had one gets the site it is first set up on.
55
66
  */ import { normalizeDynamicListRule, PageHeaderRecord, pluginDocsHelp } from "@aglyn/aglyn";
56
67
  import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
57
68
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
@@ -59,17 +70,17 @@ import { Alert, Button, MenuItem, Stack, TextField, Typography } from "@mui/mate
59
70
  import { doc, updateDoc } from "firebase/firestore";
60
71
  import { useRouter } from "next/navigation";
61
72
  import { useEffect, useRef, useState } from "react";
62
- import { useFirestore, useFirestoreDoc, useOrgDataScope } from "@aglyn/tenant-feature-instance";
73
+ import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
63
74
  import DynamicListRuleFields, { draftToRule, useRuleDraft } from "./dynamic-list-rule-fields.js";
75
+ import { OrgSiteSelect, useEmailDataScope, useEmailOrgMount } from "./email-org-mount.js";
64
76
  export function ListEditCard(props) {
65
- var _ref;
77
+ var _ref, _ref1, _ref2;
66
78
  const { hostId, listId, basePath } = props;
67
79
  const firestore = useFirestore();
68
80
  const router = useRouter();
69
81
  const { enqueueSnackbar } = useSnackbar();
70
- const { scope } = useOrgDataScope({
71
- hostId
72
- });
82
+ const orgMount = useEmailOrgMount();
83
+ const { scope } = useEmailDataScope(hostId);
73
84
  const { data: list, status } = useFirestoreDoc(()=>scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null, [
74
85
  firestore,
75
86
  scope,
@@ -102,6 +113,19 @@ export function ListEditCard(props) {
102
113
  ]);
103
114
  const [draft, setDraft] = useRuleDraft(storedRule, listId);
104
115
  const [busy, setBusy] = useState(false);
116
+ /*
117
+ * WHOSE PEOPLE THE FILTERS READ — see the file header.
118
+ *
119
+ * The stored site stands until somebody chooses another. A list with none
120
+ * yet starts on the site whose page this is, or on the organization's page
121
+ * on the reader's pick this session (or the only site). A stored site the
122
+ * org's page does not list — one since deleted — is not offered as the
123
+ * answer there, so the reader chooses a live one.
124
+ */ const storedHostId = String((_ref1 = list == null ? void 0 : list['hostId']) != null ? _ref1 : '');
125
+ const [chosenHostId, setChosenHostId] = useState('');
126
+ const storedIsOffered = orgMount ? orgMount.hosts.some((site)=>site.id === storedHostId) : Boolean(storedHostId);
127
+ const ruleHostId = chosenHostId || (storedIsOffered ? storedHostId : '') || ((_ref2 = hostId != null ? hostId : orgMount == null ? void 0 : orgMount.pickedHostId) != null ? _ref2 : '');
128
+ /** Under a site, the filters were last set up on a different one. */ const drawsFromAnotherSite = hostId != null && Boolean(storedHostId) && storedHostId !== hostId;
105
129
  const audiencesHref = `${basePath}/audiences`;
106
130
  const detailHref = `${audiencesHref}/${listId}`;
107
131
  const handleSave = async ()=>{
@@ -113,7 +137,7 @@ export function ListEditCard(props) {
113
137
  */ if (!next || !scope || busy) return;
114
138
  setBusy(true);
115
139
  try {
116
- await updateDoc(doc(firestore, scope[0], scope[1], 'lists', listId), {
140
+ await updateDoc(doc(firestore, scope[0], scope[1], 'lists', listId), _extends({
117
141
  name: next,
118
142
  kind,
119
143
  /*
@@ -121,15 +145,10 @@ export function ListEditCard(props) {
121
145
  * what the sweep evaluates; on a fixed one it is the filter that finds
122
146
  * people to add, and keeping it is what lets a merchant refine the
123
147
  * search across visits instead of retyping it.
124
- */ rule: draftToRule(draft),
125
- /*
126
- * WHICH SITE'S people the rule draws from.
127
- *
128
- * Lists are org-shared but leads, members and form submissions are
129
- * host-owned, and org contacts are read narrowed to one host. A rule
130
- * with no host has no silos at all, so the sweep skips it.
131
- */ hostId
132
- });
148
+ */ rule: draftToRule(draft)
149
+ }, ruleHostId && ruleHostId !== storedHostId ? {
150
+ hostId: ruleHostId
151
+ } : {}));
133
152
  enqueueSnackbar('List saved', {
134
153
  variant: 'success',
135
154
  persist: false
@@ -234,12 +253,32 @@ export function ListEditCard(props) {
234
253
  severity: dynamic ? 'info' : 'success',
235
254
  children: dynamic ? 'Everyone matching these filters is enrolled automatically, ' + 'about every fifteen minutes, and leaves when they stop ' + 'matching. Anyone added by hand stays. Being matched is not ' + 'consent to be emailed — a campaign still only reaches people ' + 'whose consent is on record.' : 'Membership does not change on its own. Use the filters below ' + 'to FIND people, then add them from the audience page — who ' + 'you add is who stays on the list.'
236
255
  }),
237
- scope ? /*#__PURE__*/ _jsx(DynamicListRuleFields, {
256
+ orgMount ? /*#__PURE__*/ _jsx(OrgSiteSelect, {
257
+ mount: orgMount,
258
+ label: "Site",
259
+ value: ruleHostId,
260
+ onChange: setChosenHostId,
261
+ helperText: 'The site whose leads, members and form submissions these ' + 'filters read. A list draws from one site’s people.'
262
+ }) : drawsFromAnotherSite ? /*#__PURE__*/ _jsx(Alert, {
263
+ severity: "info",
264
+ action: /*#__PURE__*/ _jsx(Button, {
265
+ color: "inherit",
266
+ size: "small",
267
+ onClick: ()=>setChosenHostId(chosenHostId ? '' : String(hostId)),
268
+ children: chosenHostId ? 'Keep the other site' : 'Use this site'
269
+ }),
270
+ children: chosenHostId ? 'Saving moves these filters onto this site’s people. The ' + 'next sweep re-draws the audience from them.' : 'These filters read another site’s people — the site this ' + 'list was set up on. Saving here keeps it that way.'
271
+ }) : null,
272
+ scope && ruleHostId ? /*#__PURE__*/ _jsx(DynamicListRuleFields, {
238
273
  scope: scope,
239
- hostId: hostId,
274
+ hostId: ruleHostId,
240
275
  draft: draft,
241
276
  onChange: setDraft,
242
277
  listId: listId
278
+ }, ruleHostId) : orgMount ? /*#__PURE__*/ _jsx(Typography, {
279
+ variant: "body2",
280
+ color: "text.secondary",
281
+ children: 'Choose the site whose people these filters read to edit them.'
243
282
  }) : null,
244
283
  draft.sources.length || !dynamic ? null : /*#__PURE__*/ _jsx(Alert, {
245
284
  severity: "warning",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-edit-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\n/**\n * A LIST'S SETTINGS: its name, how its membership is decided, and the filters\n * that decide it.\n *\n * ## Why it is its own route\n *\n * Editing the rule that decides an audience is not a thing to do in a row of a\n * table. It is nine fields, several of them consequential enough that the\n * merchant should see what they currently say before changing them, and a\n * rule saved by accident silently re-populates the audience on the next sweep.\n * A route also means the edit is linkable and the back button leaves it.\n *\n * ## The rule is edited as a DRAFT and written once\n *\n * The controls hold text; `draftToRule` turns it into a rule; Save writes that\n * rule. Nothing is written per keystroke, because a dynamic list's rule is\n * read by a sweep every fifteen minutes, which would otherwise re-materialize\n * the audience against a half-typed intermediate state.\n *\n * ## The two outcomes, and the sentence that tells them apart\n *\n * The SAME filters serve both kinds of list, and the difference is what\n * happens afterwards:\n *\n * - **Live** keeps the rule. Membership is re-evaluated on the sweep and the\n * audience keeps growing as people start matching.\n * - **Fixed** does not. The filters are a way to FIND people; who is on the\n * list is whoever was added, and it stays that way.\n *\n * A reader must never be unsure which they have, so the control says it in\n * those words and the page repeats it under whichever is selected. Switching\n * is safe in both directions and neither drops anybody: the sweep reconciles\n * away only rows IT wrote (`via: 'rule'`), so a person somebody added by hand\n * survives becoming live, and everyone on the list survives becoming fixed.\n * The rule is kept on a fixed list rather than deleted — it is the filter that\n * found these people, and losing it on a toggle would lose the work.\n */\n\nimport {\n normalizeDynamicListRule,\n PageHeaderRecord,\n pluginDocsHelp,\n} from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Button,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc, updateDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useRef, useState } from 'react'\nimport {\n useFirestore,\n useFirestoreDoc,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport DynamicListRuleFields, {\n draftToRule,\n useRuleDraft,\n} from './dynamic-list-rule-fields'\n\nexport interface ListEditCardProps {\n hostId: string\n listId: string\n /** The emails hub URL, which the audiences routes hang beneath. */\n basePath: string\n}\n\nexport function ListEditCard(props: ListEditCardProps) {\n const { hostId, listId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { scope } = useOrgDataScope({ hostId })\n\n const { data: list, status } = useFirestoreDoc<Record<string, any>>(\n () => (scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null),\n [firestore, scope, listId],\n )\n\n const storedName = String(list?.['name'] ?? '')\n const storedKind: 'manual' | 'dynamic' =\n list?.['kind'] === 'dynamic' ? 'dynamic' : 'manual'\n /*\n * Normalized before the form ever sees it, by the same function the\n * materializer reads it back through. A rule shown one way and evaluated\n * another is the defect this screen exists to make visible.\n */\n const storedRule = list ? normalizeDynamicListRule(list['rule']) : undefined\n\n const [name, setName] = useState('')\n const [kind, setKind] = useState<'manual' | 'dynamic'>('manual')\n const dynamic = kind === 'dynamic'\n /*\n * Seeded ONCE, when the document first arrives. The listen is live, so\n * re-seeding on every snapshot would take the field away from whoever is\n * typing in it — including on the snapshot their own save produces.\n */\n const seeded = useRef(false)\n useEffect(() => {\n if (!list || seeded.current) return\n seeded.current = true\n setName(storedName)\n setKind(storedKind)\n }, [list, storedName, storedKind])\n\n const [draft, setDraft] = useRuleDraft(storedRule, listId)\n const [busy, setBusy] = useState(false)\n\n const audiencesHref = `${basePath}/audiences`\n const detailHref = `${audiencesHref}/${listId}`\n\n const handleSave = async () => {\n const next = name.trim()\n /*\n * An empty name is not a rename. The list table orders on `name`, and\n * `orderBy` drops a document that does not carry the field — so a list\n * renamed to nothing would vanish from the page that lists it.\n */\n if (!next || !scope || busy) return\n setBusy(true)\n try {\n await updateDoc(doc(firestore, scope[0], scope[1], 'lists', listId), {\n name: next,\n kind,\n /*\n * The rule is written whichever kind was chosen. On a live list it is\n * what the sweep evaluates; on a fixed one it is the filter that finds\n * people to add, and keeping it is what lets a merchant refine the\n * search across visits instead of retyping it.\n */\n rule: draftToRule(draft),\n /*\n * WHICH SITE'S people the rule draws from.\n *\n * Lists are org-shared but leads, members and form submissions are\n * host-owned, and org contacts are read narrowed to one host. A rule\n * with no host has no silos at all, so the sweep skips it.\n */\n hostId,\n })\n enqueueSnackbar('List saved', { variant: 'success', persist: false })\n router.push(detailHref)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The list was not saved', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n }\n\n const headerActions = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Back to the audience'}\n </Button>\n )\n\n if (!list) {\n const settled = Boolean(scope) && status !== 'loading'\n return (\n <CardDisplay\n header={'Edit list'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? 'This list could not be loaded. It may have been deleted.'\n : 'Loading this audience…'}\n </Typography>\n </CardDisplay>\n )\n }\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Edit audience'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: dynamic ? '#lists-built-from-a-rule' : '#manual-lists',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"List name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n sx={{ flexGrow: 1, minWidth: 240, maxWidth: 360 }}\n />\n {/*\n The one decision on this page that changes what the list DOES, so\n the options are named for their outcome rather than for the field\n they set. \"Dynamic\" and \"manual\" describe the implementation; \"keeps\n growing\" and \"does not change\" describe what the merchant gets.\n */}\n <TextField\n select\n size=\"small\"\n label=\"Membership\"\n value={kind}\n onChange={(event) =>\n setKind(event.target.value as 'manual' | 'dynamic')\n }\n sx={{ minWidth: 240 }}\n >\n <MenuItem value=\"dynamic\">{'Live — keeps growing'}</MenuItem>\n <MenuItem value=\"manual\">{'Fixed — does not change'}</MenuItem>\n </TextField>\n </Stack>\n\n <Alert severity={dynamic ? 'info' : 'success'}>\n {dynamic\n ? 'Everyone matching these filters is enrolled automatically, ' +\n 'about every fifteen minutes, and leaves when they stop ' +\n 'matching. Anyone added by hand stays. Being matched is not ' +\n 'consent to be emailed — a campaign still only reaches people ' +\n 'whose consent is on record.'\n : 'Membership does not change on its own. Use the filters below ' +\n 'to FIND people, then add them from the audience page — who ' +\n 'you add is who stays on the list.'}\n </Alert>\n\n {scope ? (\n <DynamicListRuleFields\n scope={scope as readonly [string, string]}\n hostId={hostId}\n draft={draft}\n onChange={setDraft}\n listId={listId}\n />\n ) : null}\n {/*\n A rule with no source matches nobody, and an audience emptied that\n way looks exactly like one whose sweep has not run yet. It is only a\n warning on a LIVE list: on a fixed one an unset filter selects\n nothing to add and takes nobody off.\n */}\n {draft.sources.length || !dynamic ? null : (\n <Alert severity=\"warning\">\n {'These filters draw from no source, so they match nobody. The ' +\n 'next sweep would empty the list of everyone the rule enrolled.'}\n </Alert>\n )}\n\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n disabled={busy || !name.trim() || !scope}\n onClick={() => void handleSave()}\n >\n {busy ? 'Saving…' : 'Save'}\n </Button>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n >\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/*\n The audience being edited. This route is one level BELOW it, and the\n way back is the card header's own button — the trail names where the\n reader is and does not link its last step.\n */}\n <PageHeaderRecord title={storedName || listId} />\n {card}\n </>\n )\n}\nListEditCard.displayName = 'ListEditCard'\n\nexport default ListEditCard\n"],"names":["normalizeDynamicListRule","PageHeaderRecord","pluginDocsHelp","AppLink","CardDisplay","useSnackbar","Alert","Button","MenuItem","Stack","TextField","Typography","doc","updateDoc","useRouter","useEffect","useRef","useState","useFirestore","useFirestoreDoc","useOrgDataScope","DynamicListRuleFields","draftToRule","useRuleDraft","ListEditCard","props","hostId","listId","basePath","firestore","router","enqueueSnackbar","scope","data","list","status","storedName","String","storedKind","storedRule","undefined","name","setName","kind","setKind","dynamic","seeded","current","draft","setDraft","busy","setBusy","audiencesHref","detailHref","handleSave","next","trim","rule","variant","persist","push","error","console","headerActions","component","componentVariant","nativeButton","href","size","color","settled","Boolean","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","card","contentBordered","spacing","direction","useFlexGap","sx","flexWrap","label","value","onChange","event","target","flexGrow","minWidth","maxWidth","select","severity","sources","length","disabled","onClick","title","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-edit-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\n/**\n * A LIST'S SETTINGS: its name, how its membership is decided, and the filters\n * that decide it.\n *\n * ## Why it is its own route\n *\n * Editing the rule that decides an audience is not a thing to do in a row of a\n * table. It is nine fields, several of them consequential enough that the\n * merchant should see what they currently say before changing them, and a\n * rule saved by accident silently re-populates the audience on the next sweep.\n * A route also means the edit is linkable and the back button leaves it.\n *\n * ## The rule is edited as a DRAFT and written once\n *\n * The controls hold text; `draftToRule` turns it into a rule; Save writes that\n * rule. Nothing is written per keystroke, because a dynamic list's rule is\n * read by a sweep every fifteen minutes, which would otherwise re-materialize\n * the audience against a half-typed intermediate state.\n *\n * ## The two outcomes, and the sentence that tells them apart\n *\n * The SAME filters serve both kinds of list, and the difference is what\n * happens afterwards:\n *\n * - **Live** keeps the rule. Membership is re-evaluated on the sweep and the\n * audience keeps growing as people start matching.\n * - **Fixed** does not. The filters are a way to FIND people; who is on the\n * list is whoever was added, and it stays that way.\n *\n * A reader must never be unsure which they have, so the control says it in\n * those words and the page repeats it under whichever is selected. Switching\n * is safe in both directions and neither drops anybody: the sweep reconciles\n * away only rows IT wrote (`via: 'rule'`), so a person somebody added by hand\n * survives becoming live, and everyone on the list survives becoming fixed.\n * The rule is kept on a fixed list rather than deleted — it is the filter that\n * found these people, and losing it on a toggle would lose the work.\n *\n * ## Whose people the filters read is a choice, not a side effect\n *\n * A list is the organization's and its rule reads ONE site's leads, members\n * and form submissions — the list's `hostId`. That is written when somebody\n * chooses it and at no other time. A rename saved from another site's page\n * must not move the filters onto that site's people: the next sweep would\n * re-draw the whole audience from somebody else's customers. So the page\n * reads the stored site, offers the change in words, and writes `hostId` only\n * when it differs from what is stored — which is also how a list that never\n * had one gets the site it is first set up on.\n */\n\nimport {\n normalizeDynamicListRule,\n PageHeaderRecord,\n pluginDocsHelp,\n} from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Button,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc, updateDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useRef, useState } from 'react'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport DynamicListRuleFields, {\n draftToRule,\n useRuleDraft,\n} from './dynamic-list-rule-fields'\nimport {\n OrgSiteSelect,\n useEmailDataScope,\n useEmailOrgMount,\n} from './email-org-mount'\n\nexport interface ListEditCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n listId: string\n /** The emails hub URL, which the audiences routes hang beneath. */\n basePath: string\n}\n\nexport function ListEditCard(props: ListEditCardProps) {\n const { hostId, listId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const orgMount = useEmailOrgMount()\n const { scope } = useEmailDataScope(hostId)\n\n const { data: list, status } = useFirestoreDoc<Record<string, any>>(\n () => (scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null),\n [firestore, scope, listId],\n )\n\n const storedName = String(list?.['name'] ?? '')\n const storedKind: 'manual' | 'dynamic' =\n list?.['kind'] === 'dynamic' ? 'dynamic' : 'manual'\n /*\n * Normalized before the form ever sees it, by the same function the\n * materializer reads it back through. A rule shown one way and evaluated\n * another is the defect this screen exists to make visible.\n */\n const storedRule = list ? normalizeDynamicListRule(list['rule']) : undefined\n\n const [name, setName] = useState('')\n const [kind, setKind] = useState<'manual' | 'dynamic'>('manual')\n const dynamic = kind === 'dynamic'\n /*\n * Seeded ONCE, when the document first arrives. The listen is live, so\n * re-seeding on every snapshot would take the field away from whoever is\n * typing in it — including on the snapshot their own save produces.\n */\n const seeded = useRef(false)\n useEffect(() => {\n if (!list || seeded.current) return\n seeded.current = true\n setName(storedName)\n setKind(storedKind)\n }, [list, storedName, storedKind])\n\n const [draft, setDraft] = useRuleDraft(storedRule, listId)\n const [busy, setBusy] = useState(false)\n\n /*\n * WHOSE PEOPLE THE FILTERS READ — see the file header.\n *\n * The stored site stands until somebody chooses another. A list with none\n * yet starts on the site whose page this is, or on the organization's page\n * on the reader's pick this session (or the only site). A stored site the\n * org's page does not list — one since deleted — is not offered as the\n * answer there, so the reader chooses a live one.\n */\n const storedHostId = String(list?.['hostId'] ?? '')\n const [chosenHostId, setChosenHostId] = useState('')\n const storedIsOffered = orgMount\n ? orgMount.hosts.some((site) => site.id === storedHostId)\n : Boolean(storedHostId)\n const ruleHostId: string =\n chosenHostId ||\n (storedIsOffered ? storedHostId : '') ||\n (hostId ?? orgMount?.pickedHostId ?? '')\n /** Under a site, the filters were last set up on a different one. */\n const drawsFromAnotherSite =\n hostId != null && Boolean(storedHostId) && storedHostId !== hostId\n\n const audiencesHref = `${basePath}/audiences`\n const detailHref = `${audiencesHref}/${listId}`\n\n const handleSave = async () => {\n const next = name.trim()\n /*\n * An empty name is not a rename. The list table orders on `name`, and\n * `orderBy` drops a document that does not carry the field — so a list\n * renamed to nothing would vanish from the page that lists it.\n */\n if (!next || !scope || busy) return\n setBusy(true)\n try {\n await updateDoc(doc(firestore, scope[0], scope[1], 'lists', listId), {\n name: next,\n kind,\n /*\n * The rule is written whichever kind was chosen. On a live list it is\n * what the sweep evaluates; on a fixed one it is the filter that finds\n * people to add, and keeping it is what lets a merchant refine the\n * search across visits instead of retyping it.\n */\n rule: draftToRule(draft),\n /*\n * WHICH SITE'S people the rule draws from.\n *\n * Lists are org-shared but leads, members and form submissions are\n * host-owned, and org contacts are read narrowed to one host. A rule\n * with no host has no silos at all, so the sweep skips it. Written\n * only when it changes — see the file header.\n */\n ...(ruleHostId && ruleHostId !== storedHostId\n ? { hostId: ruleHostId }\n : {}),\n })\n enqueueSnackbar('List saved', { variant: 'success', persist: false })\n router.push(detailHref)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The list was not saved', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n }\n\n const headerActions = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Back to the audience'}\n </Button>\n )\n\n if (!list) {\n const settled = Boolean(scope) && status !== 'loading'\n return (\n <CardDisplay\n header={'Edit list'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? 'This list could not be loaded. It may have been deleted.'\n : 'Loading this audience…'}\n </Typography>\n </CardDisplay>\n )\n }\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Edit audience'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: dynamic ? '#lists-built-from-a-rule' : '#manual-lists',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"List name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n sx={{ flexGrow: 1, minWidth: 240, maxWidth: 360 }}\n />\n {/*\n The one decision on this page that changes what the list DOES, so\n the options are named for their outcome rather than for the field\n they set. \"Dynamic\" and \"manual\" describe the implementation; \"keeps\n growing\" and \"does not change\" describe what the merchant gets.\n */}\n <TextField\n select\n size=\"small\"\n label=\"Membership\"\n value={kind}\n onChange={(event) =>\n setKind(event.target.value as 'manual' | 'dynamic')\n }\n sx={{ minWidth: 240 }}\n >\n <MenuItem value=\"dynamic\">{'Live — keeps growing'}</MenuItem>\n <MenuItem value=\"manual\">{'Fixed — does not change'}</MenuItem>\n </TextField>\n </Stack>\n\n <Alert severity={dynamic ? 'info' : 'success'}>\n {dynamic\n ? 'Everyone matching these filters is enrolled automatically, ' +\n 'about every fifteen minutes, and leaves when they stop ' +\n 'matching. Anyone added by hand stays. Being matched is not ' +\n 'consent to be emailed — a campaign still only reaches people ' +\n 'whose consent is on record.'\n : 'Membership does not change on its own. Use the filters below ' +\n 'to FIND people, then add them from the audience page — who ' +\n 'you add is who stays on the list.'}\n </Alert>\n\n {/*\n WHOSE PEOPLE, said before the filters that read them. On the org\n page it is a picker. Under a site it is said only when the stored\n site is another one, with the one control that moves it here.\n */}\n {orgMount ? (\n <OrgSiteSelect\n mount={orgMount}\n label=\"Site\"\n value={ruleHostId}\n onChange={setChosenHostId}\n helperText={\n 'The site whose leads, members and form submissions these ' +\n 'filters read. A list draws from one site’s people.'\n }\n />\n ) : drawsFromAnotherSite ? (\n <Alert\n severity=\"info\"\n action={\n <Button\n color=\"inherit\"\n size=\"small\"\n onClick={() =>\n setChosenHostId(chosenHostId ? '' : String(hostId))\n }\n >\n {chosenHostId ? 'Keep the other site' : 'Use this site'}\n </Button>\n }\n >\n {chosenHostId\n ? 'Saving moves these filters onto this site’s people. The ' +\n 'next sweep re-draws the audience from them.'\n : 'These filters read another site’s people — the site this ' +\n 'list was set up on. Saving here keeps it that way.'}\n </Alert>\n ) : null}\n\n {scope && ruleHostId ? (\n <DynamicListRuleFields\n key={ruleHostId}\n scope={scope as readonly [string, string]}\n hostId={ruleHostId}\n draft={draft}\n onChange={setDraft}\n listId={listId}\n />\n ) : orgMount ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Choose the site whose people these filters read to edit them.'}\n </Typography>\n ) : null}\n {/*\n A rule with no source matches nobody, and an audience emptied that\n way looks exactly like one whose sweep has not run yet. It is only a\n warning on a LIVE list: on a fixed one an unset filter selects\n nothing to add and takes nobody off.\n */}\n {draft.sources.length || !dynamic ? null : (\n <Alert severity=\"warning\">\n {'These filters draw from no source, so they match nobody. The ' +\n 'next sweep would empty the list of everyone the rule enrolled.'}\n </Alert>\n )}\n\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n disabled={busy || !name.trim() || !scope}\n onClick={() => void handleSave()}\n >\n {busy ? 'Saving…' : 'Save'}\n </Button>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n >\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/*\n The audience being edited. This route is one level BELOW it, and the\n way back is the card header's own button — the trail names where the\n reader is and does not link its last step.\n */}\n <PageHeaderRecord title={storedName || listId} />\n {card}\n </>\n )\n}\nListEditCard.displayName = 'ListEditCard'\n\nexport default ListEditCard\n"],"names":["normalizeDynamicListRule","PageHeaderRecord","pluginDocsHelp","AppLink","CardDisplay","useSnackbar","Alert","Button","MenuItem","Stack","TextField","Typography","doc","updateDoc","useRouter","useEffect","useRef","useState","useFirestore","useFirestoreDoc","DynamicListRuleFields","draftToRule","useRuleDraft","OrgSiteSelect","useEmailDataScope","useEmailOrgMount","ListEditCard","props","hostId","listId","basePath","firestore","router","enqueueSnackbar","orgMount","scope","data","list","status","storedName","String","storedKind","storedRule","undefined","name","setName","kind","setKind","dynamic","seeded","current","draft","setDraft","busy","setBusy","storedHostId","chosenHostId","setChosenHostId","storedIsOffered","hosts","some","site","id","Boolean","ruleHostId","pickedHostId","drawsFromAnotherSite","audiencesHref","detailHref","handleSave","next","trim","rule","variant","persist","push","error","console","headerActions","component","componentVariant","nativeButton","href","size","color","settled","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","card","contentBordered","spacing","direction","useFlexGap","sx","flexWrap","label","value","onChange","event","target","flexGrow","minWidth","maxWidth","select","severity","mount","helperText","onClick","sources","length","disabled","title","displayName"],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  export interface OrgListsCardProps {
2
- hostId: string;
2
+ /** The site, or `null` on the organization's Emails page. */
3
+ hostId: string | null;
3
4
  /** The emails hub URL, which every list route hangs beneath. */
4
5
  basePath: string;
5
6
  }
@@ -28,8 +28,9 @@ import { Alert, Button, Chip, Stack, Typography } from "@mui/material";
28
28
  import { collection, doc, getCountFromServer, limit, orderBy, query, setDoc } from "firebase/firestore";
29
29
  import { useRouter } from "next/navigation";
30
30
  import { useEffect, useState } from "react";
31
- import { useFirestore, useOrgDataScope, usePagedCollection, useUser } from "@aglyn/tenant-feature-instance";
31
+ import { useFirestore, usePagedCollection, useUser } from "@aglyn/tenant-feature-instance";
32
32
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
33
+ import { useEmailDataScope } from "./email-org-mount.js";
33
34
  /**
34
35
  * Email lists (AGL-254): static audiences at `orgs/{orgId}/lists`, shared
35
36
  * across the org's sites. Members arrive via the enrollList automation
@@ -57,10 +58,9 @@ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
57
58
  // The org lookup is async (AGL-1061): `scope` is null until it settles,
58
59
  // and stays null for a host with no owning org. Lists have always been
59
60
  // org-shared (AGL-254) — there is no host path to fall back to any more
60
- // (AGL-1050), so creating one is held rather than misdirected.
61
- const { scope } = useOrgDataScope({
62
- hostId
63
- });
61
+ // (AGL-1050), so creating one is held rather than misdirected. On the
62
+ // organization's page the mount names the org and nothing is looked up.
63
+ const { scope } = useEmailDataScope(hostId);
64
64
  /*
65
65
  * Ordered by the server and paged, rather than capped and re-sorted here
66
66
  * (AGL-2501, AGL-2292).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/lists-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 { createResourceUid, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\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 { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n doc,\n getCountFromServer,\n limit,\n orderBy,\n query,\n setDoc,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useState } from 'react'\nimport {\n useFirestore,\n useOrgDataScope,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\n\nexport interface OrgListsCardProps {\n hostId: string\n /** The emails hub URL, which every list route hangs beneath. */\n basePath: string\n}\n\n/**\n * Email lists (AGL-254): static audiences at `orgs/{orgId}/lists`, shared\n * across the org's sites. Members arrive via the enrollList automation\n * step, popup email capture, or manual adds; campaigns target a list from\n * the audience picker.\n *\n * ## A list is a resource, so it has pages\n *\n * The row opens `…/audiences/{listId}`, and everything a list can be asked or\n * told lives there or on its edit page. The membership used to expand INSIDE\n * this table, which made three things impossible at once: a merchant could not\n * link anybody to one audience, the browser's back button did not walk out of\n * a list, and the rename lived under a table it was not about.\n *\n * Row click and the row's own link are two affordances rather than one. A\n * click handler cannot be middle-clicked, copied, or opened from the browser's\n * context menu, and a link alone loses the whole-row target.\n */\nexport function OrgListsCard(props: OrgListsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const { data: user } = useUser()\n // The org lookup is async (AGL-1061): `scope` is null until it settles,\n // and stays null for a host with no owning org. Lists have always been\n // org-shared (AGL-254) — there is no host path to fall back to any more\n // (AGL-1050), so creating one is held rather than misdirected.\n const { scope } = useOrgDataScope({ hostId })\n\n /*\n * Ordered by the server and paged, rather than capped and re-sorted here\n * (AGL-2501, AGL-2292).\n *\n * `limit(50)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids from `createResourceUid()` — an arbitrary\n * fifty of the org's lists, which the `localeCompare` below then arranged\n * alphabetically. An agency past fifty lists saw a believable A-to-Z page\n * that was missing most of the alphabet, and the campaign composer's\n * audience picker reads the same collection.\n *\n * `name` is safe to order on here, which is a claim about the writers and\n * not a preference: this card is the only thing that CREATES a list\n * (`handleCreate` refuses an empty name), the automation step and the\n * newsletter enrollment both resolve an existing list and deliberately\n * never create one, and `lists` is absent from `IMPORTABLE_FIELDS`, so no\n * restore path can produce a nameless document for `orderBy` to drop.\n */\n const {\n rows: lists,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n scope\n ? query(\n collection(firestore, scope[0], scope[1], 'lists'),\n orderBy('name'),\n limit(pageLimit),\n )\n : null,\n [firestore, scope],\n { idField: '$id' },\n )\n const [counts, setCounts] = useState<Record<string, number>>({})\n useEffect(() => {\n // `lists` can only be non-empty when `scope` resolved, but the\n // effect must say so for itself — it reads `scope` outside the query.\n if (!scope) return undefined\n let active = true\n void Promise.all(\n lists.map(async (list: any) => {\n try {\n const snapshot = await getCountFromServer(\n collection(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n list.$id,\n 'members',\n ),\n )\n return [list.$id, snapshot.data().count] as const\n } catch {\n return [list.$id, 0] as const\n }\n }),\n ).then((entries) => {\n if (active) setCounts(Object.fromEntries(entries))\n })\n return () => {\n active = false\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [firestore, scope, JSON.stringify(lists.map((l: any) => l.$id))])\n\n /*\n * Creating is a DRAWER, and the drawer is the shared one.\n *\n * The card used to carry a name box, a membership select and a Create\n * button stacked above the table. A create form wedged over a list has\n * nowhere to grow, which is how the rule behind a dynamic list came to be\n * authored through four controls out of nine — the form could not hold the\n * rest. Naming happens here; everything the audience IS happens on its edit\n * page, which has the room.\n */\n const [createOpen, setCreateOpen] = useState(false)\n const [createError, setCreateError] = useState<unknown>(null)\n const [creating, setCreating] = useState(false)\n\n const handleCreate = async (values: Record<string, any>) => {\n const name = String(values?.['displayName'] ?? '').trim()\n // Without an org there is nowhere org-shared to put the list, and the\n // host path that used to absorb it is gone (AGL-1050). The button is\n // disabled in this state; this is the same answer for a stale closure.\n if (!name || !scope || creating) return\n setCreating(true)\n setCreateError(null)\n const id = createResourceUid()\n try {\n await setDoc(doc(firestore, scope[0], scope[1], 'lists', id), {\n name,\n /*\n * Manual until somebody says otherwise.\n *\n * A list arrives holding the people you put on it, and becomes\n * rule-driven on its edit page where the rule can actually be\n * written. Offering the choice in the naming drawer would ask for a\n * decision before the thing it decides — a rule — can be seen.\n */\n kind: 'manual',\n createdAt: Timestamp.now(),\n })\n setCreateOpen(false)\n enqueueSnackbar(`List \"${name}\" created`, {\n variant: 'success',\n persist: false,\n })\n router.push(`${basePath}/audiences/${id}`)\n } catch (error) {\n console.error(error)\n setCreateError(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setCreating(false)\n }\n }\n\n const handleDelete = async (list: any) => {\n if (!scope) return\n const accepted = await confirm({\n title: 'Delete list?',\n description: `\"${list.name}\" and its enrollments stop being targetable; automations enrolling into it start reporting errors.`,\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone made this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n // The list owns a `members` subcollection of enrolled contacts, and\n // Firestore doesn't cascade — deleting the doc from here left that PII\n // behind, unreachable but still readable to every org member. Only the\n // Admin SDK can recursiveDelete, so this goes through the erase route\n // (AGL-946).\n try {\n const response = await authorizedFetch(user, '/api/resources/erase', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n scope: scope[0],\n scopeId: scope[1],\n kind: 'lists',\n id: list.$id,\n }),\n })\n const result = await response.json().catch(() => ({}))\n if (!response.ok) throw new Error(result?.error ?? 'List delete failed')\n } catch (error: any) {\n return void enqueueSnackbar(error?.message ?? 'List delete failed', {\n variant: 'error',\n })\n }\n enqueueSnackbar('List deleted', { variant: 'success', persist: false })\n }\n\n const listHref = (list: any) => `${basePath}/audiences/${list.$id}`\n\n const rowActions = (list: any): RowActionsMenuItem[] => [\n {\n key: 'details',\n label: 'Open details',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: listHref(list),\n },\n {\n key: 'edit',\n label: 'Edit list',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: `${listHref(list)}/edit`,\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDelete(list),\n },\n ]\n\n /*\n * One row per list. The name is a link AND the row opens the list: a click\n * handler cannot be middle-clicked, copied or opened from the context menu,\n * and a link alone loses the whole-row target.\n */\n const columns: GridColDef[] = [\n {\n field: 'name',\n headerName: 'List',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }) => (\n <AppLink\n href={listHref(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 {row.name}\n </AppLink>\n ),\n },\n {\n field: 'kind',\n headerName: 'Membership',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) =>\n row.kind === 'dynamic' ? 'Rule' : 'Manual',\n renderCell: ({ row }) =>\n row.kind === 'dynamic' ? (\n <Chip\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n /*\n Freshness, not just kind. A dynamic list that has stopped being\n swept looks exactly like one whose population has not changed,\n so the last evaluation time is the only thing that tells them\n apart — and it is the first thing to look at when a merchant\n reports that somebody is missing from an audience.\n */\n label={\n row.lastEvaluatedAt?.toDate\n ? `Rule · ${row.lastEvaluatedAt.toDate().toLocaleString()}`\n : 'Rule · not yet evaluated'\n }\n />\n ) : (\n <Chip size=\"small\" variant=\"outlined\" label=\"Manual\" />\n ),\n },\n {\n field: 'subscribers',\n headerName: 'Subscribers',\n width: 130,\n valueGetter: (_value, row) => counts[row.$id] ?? '…',\n },\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.name ?? row.$id)}\n items={rowActions(row)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header=\"Email lists\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={!scope || creating}\n onClick={() => setCreateOpen(true)}\n >\n {creating ? 'Creating…' : 'Create audience'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Audiences shared across your organization. A manual list holds ' +\n 'the people you enroll into it. A dynamic list holds everyone ' +\n 'matching a rule, re-checked about every fifteen minutes. Target ' +\n 'either from the campaign composer.'}\n </Typography>\n {/*\n A list document stores a name and nothing else descriptive, so the\n shared Description box is refused: a field the writer discards on\n write is worse than a field that was never offered.\n */}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create new audience\"\n submitLabel=\"Create audience\"\n onSubmit={handleCreate}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {'This audience could not be created.'}\n </Alert>\n ) : null\n }\n includeDescription={false}\n />\n {lists.length === 0 ? null : (\n <>\n <ListTable\n aria-label=\"Email lists\"\n rows={lists}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, row) => router.push(listHref(row))}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={lists.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nOrgListsCard.displayName = 'OrgListsCard'\n\nexport default OrgListsCard\n"],"names":["createResourceUid","pluginDocsHelp","mdiDeleteOutline","mdiEyeOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListPagination","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","CreateArtifactDrawer","useSnackbar","Timestamp","Alert","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","limit","orderBy","query","setDoc","useRouter","useEffect","useState","useFirestore","useOrgDataScope","usePagedCollection","useUser","authorizedFetch","OrgListsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","data","user","scope","rows","lists","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","counts","setCounts","undefined","active","Promise","all","map","list","snapshot","$id","count","then","entries","Object","fromEntries","JSON","stringify","l","createOpen","setCreateOpen","createError","setCreateError","creating","setCreating","handleCreate","values","name","String","trim","id","kind","createdAt","now","variant","persist","push","error","console","handleDelete","accepted","title","description","confirmationText","confirmationButtonProps","color","catch","response","method","headers","body","scopeId","result","json","ok","Error","message","listHref","rowActions","key","label","icon","path","size","href","destructive","onClick","columns","field","headerName","flex","minWidth","renderCell","row","event","stopPropagation","valueGetter","_value","lastEvaluatedAt","toDate","toLocaleString","width","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","disabled","spacing","open","onClose","submitLabel","onSubmit","errorSlot","severity","sx","mt","mb","includeDescription","length","aria-label","rowHeight","onOpen","_id","hideFooter","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/lists-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 { createResourceUid, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\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 { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n doc,\n getCountFromServer,\n limit,\n orderBy,\n query,\n setDoc,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useState } from 'react'\nimport {\n useFirestore,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { useEmailDataScope } from './email-org-mount'\n\nexport interface OrgListsCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The emails hub URL, which every list route hangs beneath. */\n basePath: string\n}\n\n/**\n * Email lists (AGL-254): static audiences at `orgs/{orgId}/lists`, shared\n * across the org's sites. Members arrive via the enrollList automation\n * step, popup email capture, or manual adds; campaigns target a list from\n * the audience picker.\n *\n * ## A list is a resource, so it has pages\n *\n * The row opens `…/audiences/{listId}`, and everything a list can be asked or\n * told lives there or on its edit page. The membership used to expand INSIDE\n * this table, which made three things impossible at once: a merchant could not\n * link anybody to one audience, the browser's back button did not walk out of\n * a list, and the rename lived under a table it was not about.\n *\n * Row click and the row's own link are two affordances rather than one. A\n * click handler cannot be middle-clicked, copied, or opened from the browser's\n * context menu, and a link alone loses the whole-row target.\n */\nexport function OrgListsCard(props: OrgListsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const { data: user } = useUser()\n // The org lookup is async (AGL-1061): `scope` is null until it settles,\n // and stays null for a host with no owning org. Lists have always been\n // org-shared (AGL-254) — there is no host path to fall back to any more\n // (AGL-1050), so creating one is held rather than misdirected. On the\n // organization's page the mount names the org and nothing is looked up.\n const { scope } = useEmailDataScope(hostId)\n\n /*\n * Ordered by the server and paged, rather than capped and re-sorted here\n * (AGL-2501, AGL-2292).\n *\n * `limit(50)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids from `createResourceUid()` — an arbitrary\n * fifty of the org's lists, which the `localeCompare` below then arranged\n * alphabetically. An agency past fifty lists saw a believable A-to-Z page\n * that was missing most of the alphabet, and the campaign composer's\n * audience picker reads the same collection.\n *\n * `name` is safe to order on here, which is a claim about the writers and\n * not a preference: this card is the only thing that CREATES a list\n * (`handleCreate` refuses an empty name), the automation step and the\n * newsletter enrollment both resolve an existing list and deliberately\n * never create one, and `lists` is absent from `IMPORTABLE_FIELDS`, so no\n * restore path can produce a nameless document for `orderBy` to drop.\n */\n const {\n rows: lists,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n scope\n ? query(\n collection(firestore, scope[0], scope[1], 'lists'),\n orderBy('name'),\n limit(pageLimit),\n )\n : null,\n [firestore, scope],\n { idField: '$id' },\n )\n const [counts, setCounts] = useState<Record<string, number>>({})\n useEffect(() => {\n // `lists` can only be non-empty when `scope` resolved, but the\n // effect must say so for itself — it reads `scope` outside the query.\n if (!scope) return undefined\n let active = true\n void Promise.all(\n lists.map(async (list: any) => {\n try {\n const snapshot = await getCountFromServer(\n collection(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n list.$id,\n 'members',\n ),\n )\n return [list.$id, snapshot.data().count] as const\n } catch {\n return [list.$id, 0] as const\n }\n }),\n ).then((entries) => {\n if (active) setCounts(Object.fromEntries(entries))\n })\n return () => {\n active = false\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [firestore, scope, JSON.stringify(lists.map((l: any) => l.$id))])\n\n /*\n * Creating is a DRAWER, and the drawer is the shared one.\n *\n * The card used to carry a name box, a membership select and a Create\n * button stacked above the table. A create form wedged over a list has\n * nowhere to grow, which is how the rule behind a dynamic list came to be\n * authored through four controls out of nine — the form could not hold the\n * rest. Naming happens here; everything the audience IS happens on its edit\n * page, which has the room.\n */\n const [createOpen, setCreateOpen] = useState(false)\n const [createError, setCreateError] = useState<unknown>(null)\n const [creating, setCreating] = useState(false)\n\n const handleCreate = async (values: Record<string, any>) => {\n const name = String(values?.['displayName'] ?? '').trim()\n // Without an org there is nowhere org-shared to put the list, and the\n // host path that used to absorb it is gone (AGL-1050). The button is\n // disabled in this state; this is the same answer for a stale closure.\n if (!name || !scope || creating) return\n setCreating(true)\n setCreateError(null)\n const id = createResourceUid()\n try {\n await setDoc(doc(firestore, scope[0], scope[1], 'lists', id), {\n name,\n /*\n * Manual until somebody says otherwise.\n *\n * A list arrives holding the people you put on it, and becomes\n * rule-driven on its edit page where the rule can actually be\n * written. Offering the choice in the naming drawer would ask for a\n * decision before the thing it decides — a rule — can be seen.\n */\n kind: 'manual',\n createdAt: Timestamp.now(),\n })\n setCreateOpen(false)\n enqueueSnackbar(`List \"${name}\" created`, {\n variant: 'success',\n persist: false,\n })\n router.push(`${basePath}/audiences/${id}`)\n } catch (error) {\n console.error(error)\n setCreateError(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setCreating(false)\n }\n }\n\n const handleDelete = async (list: any) => {\n if (!scope) return\n const accepted = await confirm({\n title: 'Delete list?',\n description: `\"${list.name}\" and its enrollments stop being targetable; automations enrolling into it start reporting errors.`,\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone made this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n // The list owns a `members` subcollection of enrolled contacts, and\n // Firestore doesn't cascade — deleting the doc from here left that PII\n // behind, unreachable but still readable to every org member. Only the\n // Admin SDK can recursiveDelete, so this goes through the erase route\n // (AGL-946).\n try {\n const response = await authorizedFetch(user, '/api/resources/erase', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n scope: scope[0],\n scopeId: scope[1],\n kind: 'lists',\n id: list.$id,\n }),\n })\n const result = await response.json().catch(() => ({}))\n if (!response.ok) throw new Error(result?.error ?? 'List delete failed')\n } catch (error: any) {\n return void enqueueSnackbar(error?.message ?? 'List delete failed', {\n variant: 'error',\n })\n }\n enqueueSnackbar('List deleted', { variant: 'success', persist: false })\n }\n\n const listHref = (list: any) => `${basePath}/audiences/${list.$id}`\n\n const rowActions = (list: any): RowActionsMenuItem[] => [\n {\n key: 'details',\n label: 'Open details',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: listHref(list),\n },\n {\n key: 'edit',\n label: 'Edit list',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: `${listHref(list)}/edit`,\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDelete(list),\n },\n ]\n\n /*\n * One row per list. The name is a link AND the row opens the list: a click\n * handler cannot be middle-clicked, copied or opened from the context menu,\n * and a link alone loses the whole-row target.\n */\n const columns: GridColDef[] = [\n {\n field: 'name',\n headerName: 'List',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }) => (\n <AppLink\n href={listHref(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 {row.name}\n </AppLink>\n ),\n },\n {\n field: 'kind',\n headerName: 'Membership',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) =>\n row.kind === 'dynamic' ? 'Rule' : 'Manual',\n renderCell: ({ row }) =>\n row.kind === 'dynamic' ? (\n <Chip\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n /*\n Freshness, not just kind. A dynamic list that has stopped being\n swept looks exactly like one whose population has not changed,\n so the last evaluation time is the only thing that tells them\n apart — and it is the first thing to look at when a merchant\n reports that somebody is missing from an audience.\n */\n label={\n row.lastEvaluatedAt?.toDate\n ? `Rule · ${row.lastEvaluatedAt.toDate().toLocaleString()}`\n : 'Rule · not yet evaluated'\n }\n />\n ) : (\n <Chip size=\"small\" variant=\"outlined\" label=\"Manual\" />\n ),\n },\n {\n field: 'subscribers',\n headerName: 'Subscribers',\n width: 130,\n valueGetter: (_value, row) => counts[row.$id] ?? '…',\n },\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.name ?? row.$id)}\n items={rowActions(row)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header=\"Email lists\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={!scope || creating}\n onClick={() => setCreateOpen(true)}\n >\n {creating ? 'Creating…' : 'Create audience'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Audiences shared across your organization. A manual list holds ' +\n 'the people you enroll into it. A dynamic list holds everyone ' +\n 'matching a rule, re-checked about every fifteen minutes. Target ' +\n 'either from the campaign composer.'}\n </Typography>\n {/*\n A list document stores a name and nothing else descriptive, so the\n shared Description box is refused: a field the writer discards on\n write is worse than a field that was never offered.\n */}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create new audience\"\n submitLabel=\"Create audience\"\n onSubmit={handleCreate}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {'This audience could not be created.'}\n </Alert>\n ) : null\n }\n includeDescription={false}\n />\n {lists.length === 0 ? null : (\n <>\n <ListTable\n aria-label=\"Email lists\"\n rows={lists}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, row) => router.push(listHref(row))}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={lists.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nOrgListsCard.displayName = 'OrgListsCard'\n\nexport default OrgListsCard\n"],"names":["createResourceUid","pluginDocsHelp","mdiDeleteOutline","mdiEyeOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListPagination","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","CreateArtifactDrawer","useSnackbar","Timestamp","Alert","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","limit","orderBy","query","setDoc","useRouter","useEffect","useState","useFirestore","usePagedCollection","useUser","authorizedFetch","useEmailDataScope","OrgListsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","data","user","scope","rows","lists","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","counts","setCounts","undefined","active","Promise","all","map","list","snapshot","$id","count","then","entries","Object","fromEntries","JSON","stringify","l","createOpen","setCreateOpen","createError","setCreateError","creating","setCreating","handleCreate","values","name","String","trim","id","kind","createdAt","now","variant","persist","push","error","console","handleDelete","accepted","title","description","confirmationText","confirmationButtonProps","color","catch","response","method","headers","body","scopeId","result","json","ok","Error","message","listHref","rowActions","key","label","icon","path","size","href","destructive","onClick","columns","field","headerName","flex","minWidth","renderCell","row","event","stopPropagation","valueGetter","_value","lastEvaluatedAt","toDate","toLocaleString","width","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","disabled","spacing","open","onClose","submitLabel","onSubmit","errorSlot","severity","sx","mt","mb","includeDescription","length","aria-label","rowHeight","onOpen","_id","hideFooter","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How many of one site's templates the organization's list reads.
3
+ *
4
+ * Lower than the site's own list, because this one reads a page of sites at
5
+ * once: a site with more than this is named under the table, with a link to
6
+ * its own Templates page, which reads its whole set.
7
+ */
8
+ export declare const ORG_TEMPLATE_CEILING = 50;
9
+ /**
10
+ * THE ORGANIZATION'S TEMPLATES: every site's reusable email designs, in one
11
+ * table.
12
+ *
13
+ * A template is a screen on ONE site — its besigner, its assets and the
14
+ * messages built from it are that site's — so this table reads each site in
15
+ * turn and names the site on every row. A row opens the template's page on
16
+ * its own site, where its report and its recipients are; nothing here is an
17
+ * organization-level template page, because there is no such thing.
18
+ *
19
+ * The reads are one listener per site, over one page of sites at a time —
20
+ * ordered by name — each capped at {@link ORG_TEMPLATE_CEILING} templates.
21
+ * The shared footer pages the templates in hand, as a site's own list does;
22
+ * an organization with more sites than one page chooses which page of sites
23
+ * is read with the Sites filter above the table, and only that page's sites
24
+ * are read. Both caps say so on screen when they bite, so a missing template
25
+ * is never mistaken for one that does not exist.
26
+ *
27
+ * "New template" asks which site the design is made on — the besigner opens
28
+ * on a site — and skips the question when the org has one site.
29
+ */
30
+ export declare function OrgEmailTemplatesCard(): import("react").JSX.Element;
31
+ export declare namespace OrgEmailTemplatesCard {
32
+ var displayName: string;
33
+ }
34
+ export default OrgEmailTemplatesCard;