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

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 (65) 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/declarations.console-server.d.ts +31 -0
  56. package/src/lib/declarations.console-server.js +43 -0
  57. package/src/lib/declarations.console-server.js.map +1 -0
  58. package/src/lib/model/sending-domain-status.d.ts +36 -0
  59. package/src/lib/model/sending-domain-status.js +46 -0
  60. package/src/lib/model/sending-domain-status.js.map +1 -1
  61. package/src/lib/plugin.js +29 -1
  62. package/src/lib/plugin.js.map +1 -1
  63. package/src/lib/server.d.ts +27 -0
  64. package/src/lib/server.js +130 -5
  65. package/src/lib/server.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topics-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 {\n createResourceUid,\n isEmailTopicId,\n pluginDocsHelp,\n DEFAULT_EMAIL_TOPICS,\n EMAIL_TOPICS_COLLECTION,\n} from '@aglyn/aglyn'\nimport {\n mdiArchiveArrowUpOutline,\n mdiArchiveOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { doc, setDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicsCardProps {\n hostId: string\n /** The emails hub URL, which every topic route hangs beneath. */\n basePath: string\n}\n\n/**\n * Email topics: the streams a recipient can leave one at a time.\n *\n * ## What this closes\n *\n * `docs/specs/email-competitive-gaps.md` §1f is a table of what six\n * competitors have, and the preference-center row was the one where every one\n * of them had something and we had nothing: unsubscribe was all-or-nothing per\n * site. This card is the merchant's half of the fix — the recipient's half is\n * the hosted preference page the campaign footer now links to.\n *\n * ## Why the four built-ins are not documents\n *\n * Every org that exists today has an empty `emailTopics` collection, and a\n * seeding migration would leave any org created while it ran with none. So\n * `DEFAULT_EMAIL_TOPICS` is the FLOOR of the catalog rather than its initial\n * contents: the four are present for every org with no write anywhere, and\n * saving one on its detail page writes a document at the same id that renames\n * or retires it. A built-in with no stored document is not a missing row, it\n * is a row nobody has needed to change.\n *\n * ## Create is a drawer; edit is the topic's own page\n *\n * The console's standing shape for a list surface: creating opens the shared\n * `CreateArtifactDrawer`, the same one Screens, Components, Layouts and\n * Templates use, and changing an existing record happens on that record's\n * page rather than in a form stacked above the table. So this card lists and\n * routes, and `email-topic-detail.tsx` edits.\n *\n * ## The row grammar the rest of the surface uses\n *\n * The same one the audiences table sets: the row opens the topic, the topic's\n * name is ALSO a real link so it can be middle-clicked and copied, and the\n * secondary actions sit behind the shared `RowActionsMenu` rather than as text\n * buttons in the row. Retire is the reason the menu has more than one entry —\n * it is the only thing that can be done to a topic without opening it, and it\n * is destructive enough that it should not sit one mis-click from the row's\n * own open handler.\n */\nexport function EmailTopicsCard(props: EmailTopicsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const { topics, scope } = useOrgEmailTopics(hostId)\n\n const [drawerOpen, setDrawerOpen] = useState(false)\n const [error, setError] = useState<unknown>(null)\n const [busyTopicId, setBusyTopicId] = useState('')\n\n /** The topic's own page, which is also its edit page. */\n const topicHref = useCallback(\n (topicId: string) => `${basePath}/topics/${topicId}`,\n [basePath],\n )\n\n const create = useCallback(\n async (values: Record<string, any>) => {\n if (!scope) return\n const name = String(values?.displayName ?? '').trim()\n if (!name) return\n // `createResourceUid` is nanoid's `A-Za-z0-9_-`, so the id it mints\n // always satisfies `isEmailTopicId`. Checked anyway, because this id is\n // signed into every unsubscribe link the topic ever mints and the send\n // path refuses one that is not usable — a topic nothing can be sent\n // under is a row the merchant would have no way to diagnose.\n const id = createResourceUid()\n if (!isEmailTopicId(id)) {\n return void enqueueSnackbar('Could not create the topic', {\n variant: 'error',\n })\n }\n try {\n await setDoc(\n doc(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION, id),\n {\n name,\n description: String(values?.description ?? '').trim(),\n archived: false,\n },\n )\n setDrawerOpen(false)\n setError(null)\n enqueueSnackbar('Topic added', { variant: 'success', persist: false })\n // Straight to the record, which is where every further change is made.\n router.push(topicHref(id))\n } catch (caught) {\n console.error(caught)\n setError(caught)\n }\n },\n [scope, firestore, enqueueSnackbar, topicHref, router],\n )\n\n /**\n * Retire a topic, or bring one back.\n *\n * Retire and not delete: campaigns already sent under a topic minted\n * unsubscribe links carrying its id, those links are sitting in inboxes, and\n * an id that stops resolving is a preference page that cannot name the\n * message the recipient is holding. The Firestore rules refuse a delete for\n * the same reason.\n *\n * Restoring is not asked about — it puts a choice back rather than taking\n * one away, and it is undone by retiring again.\n */\n const toggleArchived = useCallback(\n async (topic: { id: string; name: string; description: string; archived?: boolean }) => {\n if (!scope || busyTopicId) return\n if (!topic.archived) {\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new ' +\n 'can be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their ' +\n 'unsubscribe links still name this topic. You can bring it back ' +\n 'at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating\n // on the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n setBusyTopicId(topic.id)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: topic.name,\n description: topic.description,\n archived: !topic.archived,\n })\n enqueueSnackbar(topic.archived ? 'Topic restored' : 'Topic retired', {\n variant: 'success',\n persist: false,\n })\n } catch (caught) {\n console.error(caught)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusyTopicId('')\n }\n },\n [scope, busyTopicId, confirm, firestore, enqueueSnackbar],\n )\n\n const rowActions = (topic: {\n id: string\n name: string\n description: string\n archived?: boolean\n }): RowActionsMenuItem[] => [\n {\n key: 'edit',\n label: 'Edit topic',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: topicHref(topic.id),\n },\n {\n key: 'archive',\n label: topic.archived ? 'Restore' : 'Retire',\n icon: (\n <MdiIcon\n path={\n topic.archived\n ? mdiArchiveArrowUpOutline.path\n : mdiArchiveOutline.path\n }\n size={0.8}\n />\n ),\n destructive: !topic.archived,\n disabled: Boolean(busyTopicId),\n disabledReason: busyTopicId ? 'Another topic is being saved' : undefined,\n onClick: () => void toggleArchived(topic),\n },\n ]\n\n return (\n <CardDisplay\n header=\"Topics\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: scope ? (\n <Button variant=\"contained\" onClick={() => setDrawerOpen(true)}>\n {'New topic'}\n </Button>\n ) : null,\n }}\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The streams your recipients can leave one at a time. Every ' +\n 'campaign belongs to one, and the preference page linked from ' +\n 'each email lets someone stop that stream without stopping ' +\n 'everything. Topics are shared across every site in this ' +\n 'organization, the same way your lists are.'}\n </Typography>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Topic'}</TableCell>\n <TableCell>{'What recipients are told they get'}</TableCell>\n <TableCell align=\"right\" />\n </TableRow>\n </TableHead>\n <TableBody>\n {topics.map((topic) => {\n const href = topicHref(topic.id)\n return (\n <TableRow\n key={topic.id}\n hover\n onClick={() => router.push(href)}\n sx={{ cursor: 'pointer' }}\n >\n <TableCell>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {/*\n The row's own handler would fire too and push the\n same route twice — one history entry per back press.\n */}\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {topic.name}\n </AppLink>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DEFAULT_EMAIL_TOPICS.some(\n (it) => it.id === topic.id,\n ) ? (\n <Typography\n variant=\"overline\"\n color=\"text.secondary\"\n >\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n </TableCell>\n <TableCell>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {topic.description || '—'}\n </Typography>\n </TableCell>\n <TableCell\n align=\"right\"\n sx={{ width: 56 }}\n onClick={(event) => event.stopPropagation()}\n >\n <RowActionsMenu\n label={topic.name}\n items={rowActions(topic)}\n />\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </ScrollTable>\n )}\n </Stack>\n <CreateArtifactDrawer\n open={drawerOpen}\n onClose={() => setDrawerOpen(false)}\n title=\"New email topic\"\n submitLabel=\"Create topic\"\n onSubmit={create}\n errorSlot={\n error ? (\n <Typography variant=\"body2\" color=\"error\" sx={{ mt: 2 }}>\n {'Could not create the topic. Try again.'}\n </Typography>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n\nexport default EmailTopicsCard\n"],"names":["createResourceUid","isEmailTopicId","pluginDocsHelp","DEFAULT_EMAIL_TOPICS","EMAIL_TOPICS_COLLECTION","mdiArchiveArrowUpOutline","mdiArchiveOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","CreateArtifactDrawer","ScrollTable","useSnackbar","Button","Chip","Stack","TableBody","TableCell","TableHead","TableRow","Typography","doc","setDoc","useRouter","useCallback","useState","useFirestore","useOrgEmailTopics","writeEmailTopic","EmailTopicsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","topics","scope","drawerOpen","setDrawerOpen","error","setError","busyTopicId","setBusyTopicId","topicHref","topicId","create","values","name","String","displayName","trim","id","variant","description","archived","persist","push","caught","console","toggleArchived","topic","accepted","title","confirmationText","then","catch","rowActions","key","label","icon","path","size","href","destructive","disabled","Boolean","disabledReason","undefined","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","color","align","map","hover","sx","cursor","direction","flexWrap","alignItems","event","stopPropagation","some","it","width","items","open","onClose","submitLabel","onSubmit","errorSlot","mt"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topics-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 {\n createResourceUid,\n isEmailTopicId,\n pluginDocsHelp,\n DEFAULT_EMAIL_TOPICS,\n EMAIL_TOPICS_COLLECTION,\n} from '@aglyn/aglyn'\nimport {\n mdiArchiveArrowUpOutline,\n mdiArchiveOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { doc, setDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useEmailOrgMount } from './email-org-mount'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicsCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The emails hub URL, which every topic route hangs beneath. */\n basePath: string\n}\n\n/**\n * Email topics: the streams a recipient can leave one at a time.\n *\n * ## What this closes\n *\n * `docs/specs/email-competitive-gaps.md` §1f is a table of what six\n * competitors have, and the preference-center row was the one where every one\n * of them had something and we had nothing: unsubscribe was all-or-nothing per\n * site. This card is the merchant's half of the fix — the recipient's half is\n * the hosted preference page the campaign footer now links to.\n *\n * ## Why the four built-ins are not documents\n *\n * Every org that exists today has an empty `emailTopics` collection, and a\n * seeding migration would leave any org created while it ran with none. So\n * `DEFAULT_EMAIL_TOPICS` is the FLOOR of the catalog rather than its initial\n * contents: the four are present for every org with no write anywhere, and\n * saving one on its detail page writes a document at the same id that renames\n * or retires it. A built-in with no stored document is not a missing row, it\n * is a row nobody has needed to change.\n *\n * ## Create is a drawer; edit is the topic's own page\n *\n * The console's standing shape for a list surface: creating opens the shared\n * `CreateArtifactDrawer`, the same one Screens, Components, Layouts and\n * Templates use, and changing an existing record happens on that record's\n * page rather than in a form stacked above the table. So this card lists and\n * routes, and `email-topic-detail.tsx` edits.\n *\n * ## The row grammar the rest of the surface uses\n *\n * The same one the audiences table sets: the row opens the topic, the topic's\n * name is ALSO a real link so it can be middle-clicked and copied, and the\n * secondary actions sit behind the shared `RowActionsMenu` rather than as text\n * buttons in the row. Retire is the reason the menu has more than one entry —\n * it is the only thing that can be done to a topic without opening it, and it\n * is destructive enough that it should not sit one mis-click from the row's\n * own open handler.\n */\nexport function EmailTopicsCard(props: EmailTopicsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The catalog is the org's: read through the site under one, and straight\n // from the organization on its own page.\n const orgMount = useEmailOrgMount()\n const { topics, scope } = useOrgEmailTopics(hostId, {\n orgId: orgMount?.orgId,\n })\n\n const [drawerOpen, setDrawerOpen] = useState(false)\n const [error, setError] = useState<unknown>(null)\n const [busyTopicId, setBusyTopicId] = useState('')\n\n /** The topic's own page, which is also its edit page. */\n const topicHref = useCallback(\n (topicId: string) => `${basePath}/topics/${topicId}`,\n [basePath],\n )\n\n const create = useCallback(\n async (values: Record<string, any>) => {\n if (!scope) return\n const name = String(values?.displayName ?? '').trim()\n if (!name) return\n // `createResourceUid` is nanoid's `A-Za-z0-9_-`, so the id it mints\n // always satisfies `isEmailTopicId`. Checked anyway, because this id is\n // signed into every unsubscribe link the topic ever mints and the send\n // path refuses one that is not usable — a topic nothing can be sent\n // under is a row the merchant would have no way to diagnose.\n const id = createResourceUid()\n if (!isEmailTopicId(id)) {\n return void enqueueSnackbar('Could not create the topic', {\n variant: 'error',\n })\n }\n try {\n await setDoc(\n doc(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION, id),\n {\n name,\n description: String(values?.description ?? '').trim(),\n archived: false,\n },\n )\n setDrawerOpen(false)\n setError(null)\n enqueueSnackbar('Topic added', { variant: 'success', persist: false })\n // Straight to the record, which is where every further change is made.\n router.push(topicHref(id))\n } catch (caught) {\n console.error(caught)\n setError(caught)\n }\n },\n [scope, firestore, enqueueSnackbar, topicHref, router],\n )\n\n /**\n * Retire a topic, or bring one back.\n *\n * Retire and not delete: campaigns already sent under a topic minted\n * unsubscribe links carrying its id, those links are sitting in inboxes, and\n * an id that stops resolving is a preference page that cannot name the\n * message the recipient is holding. The Firestore rules refuse a delete for\n * the same reason.\n *\n * Restoring is not asked about — it puts a choice back rather than taking\n * one away, and it is undone by retiring again.\n */\n const toggleArchived = useCallback(\n async (topic: { id: string; name: string; description: string; archived?: boolean }) => {\n if (!scope || busyTopicId) return\n if (!topic.archived) {\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new ' +\n 'can be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their ' +\n 'unsubscribe links still name this topic. You can bring it back ' +\n 'at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating\n // on the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n setBusyTopicId(topic.id)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: topic.name,\n description: topic.description,\n archived: !topic.archived,\n })\n enqueueSnackbar(topic.archived ? 'Topic restored' : 'Topic retired', {\n variant: 'success',\n persist: false,\n })\n } catch (caught) {\n console.error(caught)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusyTopicId('')\n }\n },\n [scope, busyTopicId, confirm, firestore, enqueueSnackbar],\n )\n\n const rowActions = (topic: {\n id: string\n name: string\n description: string\n archived?: boolean\n }): RowActionsMenuItem[] => [\n {\n key: 'edit',\n label: 'Edit topic',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: topicHref(topic.id),\n },\n {\n key: 'archive',\n label: topic.archived ? 'Restore' : 'Retire',\n icon: (\n <MdiIcon\n path={\n topic.archived\n ? mdiArchiveArrowUpOutline.path\n : mdiArchiveOutline.path\n }\n size={0.8}\n />\n ),\n destructive: !topic.archived,\n disabled: Boolean(busyTopicId),\n disabledReason: busyTopicId ? 'Another topic is being saved' : undefined,\n onClick: () => void toggleArchived(topic),\n },\n ]\n\n return (\n <CardDisplay\n header=\"Topics\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: scope ? (\n <Button variant=\"contained\" onClick={() => setDrawerOpen(true)}>\n {'New topic'}\n </Button>\n ) : null,\n }}\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The streams your recipients can leave one at a time. Every ' +\n 'campaign belongs to one, and the preference page linked from ' +\n 'each email lets someone stop that stream without stopping ' +\n 'everything. Topics are shared across every site in this ' +\n 'organization, the same way your lists are.'}\n </Typography>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Topic'}</TableCell>\n <TableCell>{'What recipients are told they get'}</TableCell>\n <TableCell align=\"right\" />\n </TableRow>\n </TableHead>\n <TableBody>\n {topics.map((topic) => {\n const href = topicHref(topic.id)\n return (\n <TableRow\n key={topic.id}\n hover\n onClick={() => router.push(href)}\n sx={{ cursor: 'pointer' }}\n >\n <TableCell>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {/*\n The row's own handler would fire too and push the\n same route twice — one history entry per back press.\n */}\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {topic.name}\n </AppLink>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DEFAULT_EMAIL_TOPICS.some(\n (it) => it.id === topic.id,\n ) ? (\n <Typography\n variant=\"overline\"\n color=\"text.secondary\"\n >\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n </TableCell>\n <TableCell>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {topic.description || '—'}\n </Typography>\n </TableCell>\n <TableCell\n align=\"right\"\n sx={{ width: 56 }}\n onClick={(event) => event.stopPropagation()}\n >\n <RowActionsMenu\n label={topic.name}\n items={rowActions(topic)}\n />\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </ScrollTable>\n )}\n </Stack>\n <CreateArtifactDrawer\n open={drawerOpen}\n onClose={() => setDrawerOpen(false)}\n title=\"New email topic\"\n submitLabel=\"Create topic\"\n onSubmit={create}\n errorSlot={\n error ? (\n <Typography variant=\"body2\" color=\"error\" sx={{ mt: 2 }}>\n {'Could not create the topic. Try again.'}\n </Typography>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n\nexport default EmailTopicsCard\n"],"names":["createResourceUid","isEmailTopicId","pluginDocsHelp","DEFAULT_EMAIL_TOPICS","EMAIL_TOPICS_COLLECTION","mdiArchiveArrowUpOutline","mdiArchiveOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","CreateArtifactDrawer","ScrollTable","useSnackbar","Button","Chip","Stack","TableBody","TableCell","TableHead","TableRow","Typography","doc","setDoc","useRouter","useCallback","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","drawerOpen","setDrawerOpen","error","setError","busyTopicId","setBusyTopicId","topicHref","topicId","create","values","name","String","displayName","trim","id","variant","description","archived","persist","push","caught","console","toggleArchived","topic","accepted","title","confirmationText","then","catch","rowActions","key","label","icon","path","size","href","destructive","disabled","Boolean","disabledReason","undefined","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","color","align","map","hover","sx","cursor","direction","flexWrap","alignItems","event","stopPropagation","some","it","width","items","open","onClose","submitLabel","onSubmit","errorSlot","mt"],"mappings":"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"}
@@ -1,3 +1,20 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { ConsolePluginOrgMount } from '@aglyn/aglyn';
1
18
  /**
2
19
  * The Messages section of the Emails page.
3
20
  *
@@ -8,11 +25,21 @@
8
25
  * is what the shell handed this page: the site, this page's own base path,
9
26
  * and the segments under `messages` (`[]` the list, `[id]` one message,
10
27
  * `[id, 'edit']` its composer).
28
+ *
29
+ * The same zone serves the ORGANIZATION's Emails page, `/[orgSlug]/emails`,
30
+ * where there is no site: `hostId` is `null` and `orgMount` names the org and
31
+ * its sites, so the widget can list every site's messages and ask which site
32
+ * a new one is sent as. Every message link a widget draws hangs beneath
33
+ * `basePath` at both levels — `${basePath}/messages/{id}`.
11
34
  */
12
35
  export interface EmailMessagesZoneProps {
13
- hostId: string;
36
+ /** The site, or `null` on the organization's Emails page. */
37
+ hostId: string | null;
38
+ /** The Emails page's own path, under the site or the organization. */
14
39
  basePath: string;
15
40
  detail: readonly string[];
41
+ /** The organization and its sites — present only when `hostId` is `null`. */
42
+ orgMount?: ConsolePluginOrgMount;
16
43
  }
17
44
  export declare const EMAIL_MESSAGES_ZONE: import("@aglyn/aglyn/plugin-manager/plugin-zones").PluginZone<EmailMessagesZoneProps>;
18
45
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-zones.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { definePluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\n\n/**\n * The Messages section of the Emails page.\n *\n * A message is one email that was or will be sent. Writing one, scheduling\n * it, sending it and reporting what came of it are a campaign's work — the\n * routes that do all of it are served by whichever plugin owns campaigns — so\n * this plugin hosts the section and that plugin draws it. What is handed over\n * is what the shell handed this page: the site, this page's own base path,\n * and the segments under `messages` (`[]` the list, `[id]` one message,\n * `[id, 'edit']` its composer).\n */\nexport interface EmailMessagesZoneProps {\n hostId: string\n basePath: string\n detail: readonly string[]\n}\n\nexport const EMAIL_MESSAGES_ZONE =\n definePluginZone<EmailMessagesZoneProps>('emailMessages')\n\n/**\n * Who received the messages built from one template, under its report.\n *\n * The template is this plugin's; the recipients of a send are read through\n * the campaign owner's route, so the table is that plugin's to draw.\n */\nexport interface EmailTemplateRecipientsZoneProps {\n hostId: string\n screenId: string\n}\n\nexport const EMAIL_TEMPLATE_RECIPIENTS_ZONE =\n definePluginZone<EmailTemplateRecipientsZoneProps>('emailTemplateRecipients')\n"],"names":["definePluginZone","EMAIL_MESSAGES_ZONE","EMAIL_TEMPLATE_RECIPIENTS_ZONE"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,SAASA,gBAAgB,QAAQ,2CAA0C;AAmB3E,OAAO,MAAMC,sBACXD,iBAAyC,iBAAgB;AAa3D,OAAO,MAAME,iCACXF,iBAAmD,2BAA0B"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-zones.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport type { ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport { definePluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\n\n/**\n * The Messages section of the Emails page.\n *\n * A message is one email that was or will be sent. Writing one, scheduling\n * it, sending it and reporting what came of it are a campaign's work — the\n * routes that do all of it are served by whichever plugin owns campaigns — so\n * this plugin hosts the section and that plugin draws it. What is handed over\n * is what the shell handed this page: the site, this page's own base path,\n * and the segments under `messages` (`[]` the list, `[id]` one message,\n * `[id, 'edit']` its composer).\n *\n * The same zone serves the ORGANIZATION's Emails page, `/[orgSlug]/emails`,\n * where there is no site: `hostId` is `null` and `orgMount` names the org and\n * its sites, so the widget can list every site's messages and ask which site\n * a new one is sent as. Every message link a widget draws hangs beneath\n * `basePath` at both levels — `${basePath}/messages/{id}`.\n */\nexport interface EmailMessagesZoneProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The Emails page's own path, under the site or the organization. */\n basePath: string\n detail: readonly string[]\n /** The organization and its sites — present only when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n}\n\nexport const EMAIL_MESSAGES_ZONE =\n definePluginZone<EmailMessagesZoneProps>('emailMessages')\n\n/**\n * Who received the messages built from one template, under its report.\n *\n * The template is this plugin's; the recipients of a send are read through\n * the campaign owner's route, so the table is that plugin's to draw.\n */\nexport interface EmailTemplateRecipientsZoneProps {\n hostId: string\n screenId: string\n}\n\nexport const EMAIL_TEMPLATE_RECIPIENTS_ZONE =\n definePluginZone<EmailTemplateRecipientsZoneProps>('emailTemplateRecipients')\n"],"names":["definePluginZone","EMAIL_MESSAGES_ZONE","EMAIL_TEMPLATE_RECIPIENTS_ZONE"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,2CAA0C;AA6B3E,OAAO,MAAMC,sBACXD,iBAAyC,iBAAgB;AAa3D,OAAO,MAAME,iCACXF,iBAAmD,2BAA0B"}
@@ -24,6 +24,10 @@ import { type ConsolePluginPageProps } from '@aglyn/aglyn';
24
24
  * linkable, the back button walks sections, the breadcrumb says where you are,
25
25
  * and "mount only what is open" is structural rather than a `lazy` flag
26
26
  * somebody has to remember on the next surface.
27
+ *
28
+ * The same page is the ORGANIZATION's Emails page at `/[orgSlug]/emails`.
29
+ * Handed no site, it publishes the org mount to its cards and renders
30
+ * {@link orgSectionBody} instead: the same six sections, over every site.
27
31
  */
28
32
  export declare function EmailsConsolePage(props: ConsolePluginPageProps): import("react").JSX.Element;
29
33
  export declare namespace EmailsConsolePage {
@@ -27,9 +27,15 @@ import EmailTopicsCard from "./email-topics-card.js";
27
27
  import ListDetailCard from "./list-detail-card.js";
28
28
  import ListEditCard from "./list-edit-card.js";
29
29
  import ListsCard from "./lists-card.js";
30
+ import OrgEmailTemplatesCard from "./org-email-templates-card.js";
31
+ import OrgSendingCard from "./org-sending-card.js";
32
+ import OrgSendingDomainDetail from "./org-sending-domain-detail.js";
33
+ import OrgSiteSuppressions from "./org-site-suppressions.js";
34
+ import OrgSuppressionsCard from "./org-suppressions-card.js";
30
35
  import SendingDomainDetail from "./sending-domain-detail.js";
31
36
  import SendingDomainsCard from "./sending-domains-card.js";
32
37
  import SuppressionsCard from "./suppressions-card.js";
38
+ import { EmailOrgMountProvider } from "./email-org-mount.js";
33
39
  import { EMAIL_MESSAGES_ZONE } from "./email-zones.js";
34
40
  /**
35
41
  * The Messages section: a zone, drawn by whichever plugin owns campaigns.
@@ -168,6 +174,81 @@ import { EMAIL_MESSAGES_ZONE } from "./email-zones.js";
168
174
  return null;
169
175
  }
170
176
  }
177
+ /**
178
+ * The body of one ORGANIZATION-level section, at `/[orgSlug]/emails`.
179
+ *
180
+ * The same six sections as a site's, each answering over the organization.
181
+ * Two of them are already the org's — an audience and a topic are shared by
182
+ * every site — so their cards render unchanged with no site. The other four
183
+ * are site facts: a message is sent as one site, a template is one site's
184
+ * screen, a sending identity and a suppression list are one site's. Those
185
+ * sections read each site in turn, a page of sites at a time, and link a row
186
+ * into the site that holds it.
187
+ *
188
+ * Built only when that section is the one being read, for the reason
189
+ * {@link sectionBody} is: every card opens its reads on mount.
190
+ */ function orgSectionBody(section, detail, basePath, orgMount, /** The org document, from which an audience's consent group is resolved. */ org) {
191
+ switch(section){
192
+ case 'messages':
193
+ // Every site's messages, drawn by the plugin that sends; it is handed
194
+ // the org mount so it can say which site each was sent as.
195
+ return /*#__PURE__*/ _jsx(EmailMessagesSection, {
196
+ hostId: null,
197
+ orgMount: orgMount,
198
+ basePath: basePath,
199
+ detail: detail
200
+ });
201
+ case 'templates':
202
+ /*
203
+ * One table over every site's templates. A template is a screen on one
204
+ * site, so its own page stays that site's: each row links there, and
205
+ * nothing beneath this section is an org route.
206
+ */ return /*#__PURE__*/ _jsx(OrgEmailTemplatesCard, {});
207
+ case 'audiences':
208
+ return detail[0] ? detail[1] === 'edit' ? /*#__PURE__*/ _jsx(ListEditCard, {
209
+ hostId: null,
210
+ listId: detail[0],
211
+ basePath: basePath
212
+ }) : /*#__PURE__*/ _jsx(ListDetailCard, {
213
+ hostId: null,
214
+ org: org,
215
+ listId: detail[0],
216
+ basePath: basePath
217
+ }) : /*#__PURE__*/ _jsx(ListsCard, {
218
+ hostId: null,
219
+ basePath: basePath
220
+ });
221
+ case 'topics':
222
+ return detail[0] ? /*#__PURE__*/ _jsx(EmailTopicDetail, {
223
+ hostId: null,
224
+ topicId: detail[0],
225
+ basePath: basePath
226
+ }) : /*#__PURE__*/ _jsx(EmailTopicsCard, {
227
+ hostId: null,
228
+ basePath: basePath
229
+ });
230
+ case 'sending':
231
+ /*
232
+ * A domain is proved by the organization, so its page is an org route
233
+ * here as it is under a site. What each SITE sends as is the other half,
234
+ * and the section's own page lists it per site.
235
+ */ return detail[0] ? /*#__PURE__*/ _jsx(OrgSendingDomainDetail, {
236
+ domain: decodeURIComponent(detail[0]),
237
+ basePath: basePath
238
+ }) : /*#__PURE__*/ _jsx(OrgSendingCard, {
239
+ basePath: basePath
240
+ });
241
+ case 'suppressions':
242
+ /*
243
+ * `…/suppressions` totals every site's list; `…/suppressions/{hostId}`
244
+ * is one site's list itself, the same card a site's own page draws.
245
+ */ return detail[0] ? /*#__PURE__*/ _jsx(OrgSiteSuppressions, {
246
+ hostId: detail[0]
247
+ }) : /*#__PURE__*/ _jsx(OrgSuppressionsCard, {});
248
+ default:
249
+ return null;
250
+ }
251
+ }
171
252
  /**
172
253
  * Emails page (AGL-395): the console surface owned by the email plugin,
173
254
  * rendered by the shell's generic plugin route.
@@ -193,8 +274,12 @@ import { EMAIL_MESSAGES_ZONE } from "./email-zones.js";
193
274
  * linkable, the back button walks sections, the breadcrumb says where you are,
194
275
  * and "mount only what is open" is structural rather than a `lazy` flag
195
276
  * somebody has to remember on the next surface.
277
+ *
278
+ * The same page is the ORGANIZATION's Emails page at `/[orgSlug]/emails`.
279
+ * Handed no site, it publishes the org mount to its cards and renders
280
+ * {@link orgSectionBody} instead: the same six sections, over every site.
196
281
  */ export function EmailsConsolePage(props) {
197
- const { hostId, org, section, sections, basePath, segments } = props;
282
+ const { hostId, orgMount, org, section, sections, basePath, segments } = props;
198
283
  /*==========================================
199
284
  * THE CONTROLLER THIS SURFACE IS BEING VIEWED AS.
200
285
  *
@@ -207,7 +292,11 @@ import { EMAIL_MESSAGES_ZONE } from "./email-zones.js";
207
292
  *
208
293
  * Pure, from the org document the shell already passed, so it costs no read.
209
294
  * An absent org resolves to the group of one, which is the narrow answer.
210
- *=========================================*/ const consentGroup = useMemo(()=>consentGroupForHost(org, hostId), [
295
+ *
296
+ * Only under a site. The organization's page has no site to resolve a group
297
+ * FOR, and `consentGroupForHost` refuses to invent one — the audience page
298
+ * there resolves it for the site the reader enrolls as, once one is chosen.
299
+ *=========================================*/ const consentGroup = useMemo(()=>hostId == null ? null : consentGroupForHost(org, hostId), [
211
300
  org,
212
301
  hostId
213
302
  ]);
@@ -216,11 +305,24 @@ import { EMAIL_MESSAGES_ZONE } from "./email-zones.js";
216
305
  * default section here would issue its listens on a URL about to be
217
306
  * replaced — on every arrival at `/emails`, which is every nav-tab click.
218
307
  */ if (!section || !(sections == null ? void 0 : sections.length) || !basePath) return null;
308
+ // `segments[0]` IS the section — the shell resolved it into `section`
309
+ // already — so what a section owns is everything after it.
310
+ const detail = (segments != null ? segments : []).slice(1);
311
+ if (hostId == null) {
312
+ // No site and no org to stand in for it: nothing this page can scope.
313
+ if (!orgMount) return null;
314
+ return /*#__PURE__*/ _jsx(EmailOrgMountProvider, {
315
+ mount: orgMount,
316
+ basePath: basePath,
317
+ children: /*#__PURE__*/ _jsx(HubSections, {
318
+ sections: sections,
319
+ children: orgSectionBody(section, detail, basePath, orgMount, org)
320
+ })
321
+ });
322
+ }
219
323
  return /*#__PURE__*/ _jsx(HubSections, {
220
324
  sections: sections,
221
- children: sectionBody(section, hostId, (// `segments[0]` IS the section — the shell resolved it into `section`
222
- // already — so what a section owns is everything after it.
223
- segments != null ? segments : []).slice(1), basePath, consentGroup)
325
+ children: sectionBody(section, hostId, detail, basePath, consentGroup)
224
326
  });
225
327
  }
226
328
  EmailsConsolePage.displayName = 'EmailsConsolePage';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/emails-console-page.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 {\n consentGroupForHost,\n type ConsentGroup,\n type ConsolePluginPageProps,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport { useMemo, type ReactNode } from 'react'\nimport EmailScreensCard from './email-screens-card'\nimport EmailTemplateDetail from './email-template-detail'\nimport EmailTopicDetail from './email-topic-detail'\nimport EmailTopicsCard from './email-topics-card'\nimport ListDetailCard from './list-detail-card'\nimport ListEditCard from './list-edit-card'\nimport ListsCard from './lists-card'\nimport SendingDomainDetail from './sending-domain-detail'\nimport SendingDomainsCard from './sending-domains-card'\nimport SuppressionsCard from './suppressions-card'\nimport type { EmailsConsoleSectionId } from './emails-console-sections'\nimport { EMAIL_MESSAGES_ZONE, type EmailMessagesZoneProps } from './email-zones'\n\n/**\n * The Messages section: a zone, drawn by whichever plugin owns campaigns.\n *\n * Its own component because a zone's renderer comes from a hook and\n * `sectionBody` is a plain function — and so that, like every other branch\n * there, it is constructed only while Messages is the section being read.\n */\nfunction EmailMessagesSection(props: EmailMessagesZoneProps) {\n const Zone = useConsoleWidgetSlot()\n return Zone ? <Zone slot={EMAIL_MESSAGES_ZONE.id} {...props} /> : null\n}\n\n/**\n * The body of one emails section, built only when that section is the one\n * being read (AGL-2501).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT every section on every render, and each card opens its\n * Firestore listens on mount — which is the entire cost this page exists to\n * stop paying. Only the returned branch is ever built.\n */\nfunction sectionBody(\n section: EmailsConsoleSectionId,\n hostId: string,\n /**\n * The section's OWN segments — `segments[1]` onward, already sliced by the\n * caller. A section that owns deeper routes reads them here; one that does\n * not simply ignores them.\n */\n detail: readonly string[],\n basePath: string,\n /**\n * The controller the surface is being viewed as, resolved once by the page.\n *\n * Only the audience detail needs it today — it is what its membership table\n * reads consent FOR — but it is resolved at the page because the answer is a\n * property of the org and this is the one place holding the org document.\n */\n consentGroup: ConsentGroup,\n): ReactNode {\n switch (section) {\n case 'messages':\n /*\n * `/emails/messages/{emailId}` is ONE MESSAGE — the thing that was or\n * will be sent, as against the campaign that groups messages and the\n * template they are built from. A ROUTE rather than an expanded row: it\n * is linkable, which is what a merchant wants to paste into a message\n * about last week's send, and its preview, link table and recipient\n * list are reads the list above it must not pay for.\n *\n * The page links OUT to the campaign this message belongs to, which is\n * a section of the Marketing console.\n */\n /*\n * `…/{emailId}/edit` WRITES the email and `…/{emailId}` reports on\n * it — two jobs with two shapes, and creating stays a drawer on the\n * list. Which of the three is drawn is decided from `detail` by the\n * widget, which builds only the one it is asked for: the composer's\n * listens are not paid for by somebody reading a report.\n */\n return (\n <EmailMessagesSection\n hostId={hostId}\n basePath={basePath}\n detail={detail}\n />\n )\n case 'templates':\n /*\n * `/emails/templates/{screenId}` is one TEMPLATE's page.\n *\n * A route for the same reasons a message's page is one: it is\n * linkable, and the listing above it is a cheaper surface a reader who\n * came for one template should not have to mount. The preview, the\n * aggregate figures and the recipients table all hang off this branch,\n * so none of them is constructed while the list is what is being read.\n */\n return detail[0] ? (\n <EmailTemplateDetail\n hostId={hostId}\n screenId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailScreensCard hostId={hostId} basePath={basePath} />\n )\n case 'audiences':\n /*\n * A list is a resource with its own pages, on the same terms a message\n * is: `/emails/audiences/{listId}` is one audience, and `…/edit` is its\n * settings.\n *\n * The membership used to unfold inside the audiences table. That made a\n * list unlinkable, put the back button one press from leaving the whole\n * surface, and asked the reader of a list of lists to hold the table\n * that lists them AND the table of one list's subscribers on the same\n * screen. The subscribers are also the expensive read here — one PII\n * document per person — so putting them behind a route is what stops\n * them being paid for by somebody who came to see which audiences exist.\n *\n * Ternaries rather than a lookup: only the branch taken is CONSTRUCTED,\n * which is the cost this whole function is shaped around.\n */\n return detail[0] ? (\n detail[1] === 'edit' ? (\n <ListEditCard\n hostId={hostId}\n listId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <ListDetailCard\n hostId={hostId}\n consentGroup={consentGroup}\n listId={detail[0]}\n basePath={basePath}\n />\n )\n ) : (\n <ListsCard hostId={hostId} basePath={basePath} />\n )\n case 'topics':\n // Create is a drawer on the list; EDIT is the topic's own route, which\n // is the section owning its own subtree exactly as `messages` does.\n return detail[0] ? (\n <EmailTopicDetail\n hostId={hostId}\n topicId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailTopicsCard hostId={hostId} basePath={basePath} />\n )\n case 'sending':\n /*\n * `/emails/sending/{domain}` is one domain's page, and it is a route\n * for the reason every other detail here is: the DNS records, the DMARC\n * read and the verification button are what somebody came for, and a\n * link to them is what they paste to whoever actually edits the zone.\n *\n * The domain is the document id, so it is also the segment. It is\n * decoded because a URL carries it encoded and the record is keyed on\n * the bare name.\n */\n return detail[0] ? (\n <SendingDomainDetail\n hostId={hostId}\n domain={decodeURIComponent(detail[0])}\n basePath={basePath}\n />\n ) : (\n <SendingDomainsCard hostId={hostId} basePath={basePath} />\n )\n case 'suppressions':\n return <SuppressionsCard hostId={hostId} />\n default:\n return null\n }\n}\n\n/**\n * Emails page (AGL-395): the console surface owned by the email plugin,\n * rendered by the shell's generic plugin route.\n *\n * Two of its sections are two different things a merchant calls \"an email\":\n * an EMAIL is one message that was or will be sent, and a TEMPLATE is the\n * reusable besigner document a message is built from. Keeping them apart is\n * what lets each carry its own report — a message's own numbers, and a\n * template's summed across every message sent from it. Audience lists, the\n * topic catalog, the sending identities and the suppression list complete the\n * surface.\n *\n * The CAMPAIGN that groups messages is not here. It is a container with a\n * window of dates, a set of lists, a topic and revenue attribution — a\n * marketing object that reaches people by email — so it is a section of the\n * Marketing console, and a message's page links out to the campaign it\n * belongs to.\n *\n * Sections are ROUTES (AGL-2501). `HubTabs lazy` already mounted one panel, so\n * this is not a read saving — `emails-console-read-cost.spec.tsx` was written\n * BEFORE the conversion precisely to hold that line, and reports the same\n * counts after. What routing adds is that the URL names the section: it is\n * linkable, the back button walks sections, the breadcrumb says where you are,\n * and \"mount only what is open\" is structural rather than a `lazy` flag\n * somebody has to remember on the next surface.\n */\nexport function EmailsConsolePage(props: ConsolePluginPageProps) {\n const { hostId, org, section, sections, basePath, segments } = props\n\n /*==========================================\n * THE CONTROLLER THIS SURFACE IS BEING VIEWED AS.\n *\n * The declared group of sites that are one sender, or this site alone. Every\n * consent question asked below is asked FOR a controller and not for a site:\n * a grant is looked up under the asking site, a refusal is honored across\n * the whole group, and a grant held by a site outside it belongs to somebody\n * else. It is the same group `performCampaignSend` resolves, so the audience\n * table and the send agree about the same document by construction.\n *\n * Pure, from the org document the shell already passed, so it costs no read.\n * An absent org resolves to the group of one, which is the narrow answer.\n *=========================================*/\n const consentGroup = useMemo(\n () => consentGroupForHost(org as Record<string, unknown>, hostId),\n [org, hostId],\n )\n\n /*\n * Nothing, deliberately, while the redirect is in flight. Rendering the\n * default section here would issue its listens on a URL about to be\n * replaced — on every arrival at `/emails`, which is every nav-tab click.\n */\n if (!section || !sections?.length || !basePath) return null\n\n return (\n <HubSections sections={sections}>\n {sectionBody(\n section as EmailsConsoleSectionId,\n hostId,\n // `segments[0]` IS the section — the shell resolved it into `section`\n // already — so what a section owns is everything after it.\n (segments ?? []).slice(1),\n basePath,\n consentGroup,\n )}\n </HubSections>\n )\n}\nEmailsConsolePage.displayName = 'EmailsConsolePage'\n\nexport default EmailsConsolePage\n"],"names":["consentGroupForHost","useConsoleWidgetSlot","HubSections","useMemo","EmailScreensCard","EmailTemplateDetail","EmailTopicDetail","EmailTopicsCard","ListDetailCard","ListEditCard","ListsCard","SendingDomainDetail","SendingDomainsCard","SuppressionsCard","EMAIL_MESSAGES_ZONE","EmailMessagesSection","props","Zone","slot","id","sectionBody","section","hostId","detail","basePath","consentGroup","screenId","listId","topicId","domain","decodeURIComponent","EmailsConsolePage","org","sections","segments","length","slice","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/emails-console-page.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 {\n consentGroupForHost,\n type ConsentGroup,\n type ConsolePluginOrgMount,\n type ConsolePluginPageProps,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport { useMemo, type ReactNode } from 'react'\nimport EmailScreensCard from './email-screens-card'\nimport EmailTemplateDetail from './email-template-detail'\nimport EmailTopicDetail from './email-topic-detail'\nimport EmailTopicsCard from './email-topics-card'\nimport ListDetailCard from './list-detail-card'\nimport ListEditCard from './list-edit-card'\nimport ListsCard from './lists-card'\nimport OrgEmailTemplatesCard from './org-email-templates-card'\nimport OrgSendingCard from './org-sending-card'\nimport OrgSendingDomainDetail from './org-sending-domain-detail'\nimport OrgSiteSuppressions from './org-site-suppressions'\nimport OrgSuppressionsCard from './org-suppressions-card'\nimport SendingDomainDetail from './sending-domain-detail'\nimport SendingDomainsCard from './sending-domains-card'\nimport SuppressionsCard from './suppressions-card'\nimport type { EmailsConsoleSectionId } from './emails-console-sections'\nimport { EmailOrgMountProvider } from './email-org-mount'\nimport { EMAIL_MESSAGES_ZONE, type EmailMessagesZoneProps } from './email-zones'\n\n/**\n * The Messages section: a zone, drawn by whichever plugin owns campaigns.\n *\n * Its own component because a zone's renderer comes from a hook and\n * `sectionBody` is a plain function — and so that, like every other branch\n * there, it is constructed only while Messages is the section being read.\n */\nfunction EmailMessagesSection(props: EmailMessagesZoneProps) {\n const Zone = useConsoleWidgetSlot()\n return Zone ? <Zone slot={EMAIL_MESSAGES_ZONE.id} {...props} /> : null\n}\n\n/**\n * The body of one emails section, built only when that section is the one\n * being read (AGL-2501).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT every section on every render, and each card opens its\n * Firestore listens on mount — which is the entire cost this page exists to\n * stop paying. Only the returned branch is ever built.\n */\nfunction sectionBody(\n section: EmailsConsoleSectionId,\n hostId: string,\n /**\n * The section's OWN segments — `segments[1]` onward, already sliced by the\n * caller. A section that owns deeper routes reads them here; one that does\n * not simply ignores them.\n */\n detail: readonly string[],\n basePath: string,\n /**\n * The controller the surface is being viewed as, resolved once by the page.\n *\n * Only the audience detail needs it today — it is what its membership table\n * reads consent FOR — but it is resolved at the page because the answer is a\n * property of the org and this is the one place holding the org document.\n */\n consentGroup: ConsentGroup,\n): ReactNode {\n switch (section) {\n case 'messages':\n /*\n * `/emails/messages/{emailId}` is ONE MESSAGE — the thing that was or\n * will be sent, as against the campaign that groups messages and the\n * template they are built from. A ROUTE rather than an expanded row: it\n * is linkable, which is what a merchant wants to paste into a message\n * about last week's send, and its preview, link table and recipient\n * list are reads the list above it must not pay for.\n *\n * The page links OUT to the campaign this message belongs to, which is\n * a section of the Marketing console.\n */\n /*\n * `…/{emailId}/edit` WRITES the email and `…/{emailId}` reports on\n * it — two jobs with two shapes, and creating stays a drawer on the\n * list. Which of the three is drawn is decided from `detail` by the\n * widget, which builds only the one it is asked for: the composer's\n * listens are not paid for by somebody reading a report.\n */\n return (\n <EmailMessagesSection\n hostId={hostId}\n basePath={basePath}\n detail={detail}\n />\n )\n case 'templates':\n /*\n * `/emails/templates/{screenId}` is one TEMPLATE's page.\n *\n * A route for the same reasons a message's page is one: it is\n * linkable, and the listing above it is a cheaper surface a reader who\n * came for one template should not have to mount. The preview, the\n * aggregate figures and the recipients table all hang off this branch,\n * so none of them is constructed while the list is what is being read.\n */\n return detail[0] ? (\n <EmailTemplateDetail\n hostId={hostId}\n screenId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailScreensCard hostId={hostId} basePath={basePath} />\n )\n case 'audiences':\n /*\n * A list is a resource with its own pages, on the same terms a message\n * is: `/emails/audiences/{listId}` is one audience, and `…/edit` is its\n * settings.\n *\n * The membership used to unfold inside the audiences table. That made a\n * list unlinkable, put the back button one press from leaving the whole\n * surface, and asked the reader of a list of lists to hold the table\n * that lists them AND the table of one list's subscribers on the same\n * screen. The subscribers are also the expensive read here — one PII\n * document per person — so putting them behind a route is what stops\n * them being paid for by somebody who came to see which audiences exist.\n *\n * Ternaries rather than a lookup: only the branch taken is CONSTRUCTED,\n * which is the cost this whole function is shaped around.\n */\n return detail[0] ? (\n detail[1] === 'edit' ? (\n <ListEditCard\n hostId={hostId}\n listId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <ListDetailCard\n hostId={hostId}\n consentGroup={consentGroup}\n listId={detail[0]}\n basePath={basePath}\n />\n )\n ) : (\n <ListsCard hostId={hostId} basePath={basePath} />\n )\n case 'topics':\n // Create is a drawer on the list; EDIT is the topic's own route, which\n // is the section owning its own subtree exactly as `messages` does.\n return detail[0] ? (\n <EmailTopicDetail\n hostId={hostId}\n topicId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailTopicsCard hostId={hostId} basePath={basePath} />\n )\n case 'sending':\n /*\n * `/emails/sending/{domain}` is one domain's page, and it is a route\n * for the reason every other detail here is: the DNS records, the DMARC\n * read and the verification button are what somebody came for, and a\n * link to them is what they paste to whoever actually edits the zone.\n *\n * The domain is the document id, so it is also the segment. It is\n * decoded because a URL carries it encoded and the record is keyed on\n * the bare name.\n */\n return detail[0] ? (\n <SendingDomainDetail\n hostId={hostId}\n domain={decodeURIComponent(detail[0])}\n basePath={basePath}\n />\n ) : (\n <SendingDomainsCard hostId={hostId} basePath={basePath} />\n )\n case 'suppressions':\n return <SuppressionsCard hostId={hostId} />\n default:\n return null\n }\n}\n\n/**\n * The body of one ORGANIZATION-level section, at `/[orgSlug]/emails`.\n *\n * The same six sections as a site's, each answering over the organization.\n * Two of them are already the org's — an audience and a topic are shared by\n * every site — so their cards render unchanged with no site. The other four\n * are site facts: a message is sent as one site, a template is one site's\n * screen, a sending identity and a suppression list are one site's. Those\n * sections read each site in turn, a page of sites at a time, and link a row\n * into the site that holds it.\n *\n * Built only when that section is the one being read, for the reason\n * {@link sectionBody} is: every card opens its reads on mount.\n */\nfunction orgSectionBody(\n section: EmailsConsoleSectionId,\n detail: readonly string[],\n basePath: string,\n orgMount: ConsolePluginOrgMount,\n /** The org document, from which an audience's consent group is resolved. */\n org: Record<string, unknown> | undefined,\n): ReactNode {\n switch (section) {\n case 'messages':\n // Every site's messages, drawn by the plugin that sends; it is handed\n // the org mount so it can say which site each was sent as.\n return (\n <EmailMessagesSection\n hostId={null}\n orgMount={orgMount}\n basePath={basePath}\n detail={detail}\n />\n )\n case 'templates':\n /*\n * One table over every site's templates. A template is a screen on one\n * site, so its own page stays that site's: each row links there, and\n * nothing beneath this section is an org route.\n */\n return <OrgEmailTemplatesCard />\n case 'audiences':\n return detail[0] ? (\n detail[1] === 'edit' ? (\n <ListEditCard hostId={null} listId={detail[0]} basePath={basePath} />\n ) : (\n <ListDetailCard\n hostId={null}\n org={org}\n listId={detail[0]}\n basePath={basePath}\n />\n )\n ) : (\n <ListsCard hostId={null} basePath={basePath} />\n )\n case 'topics':\n return detail[0] ? (\n <EmailTopicDetail\n hostId={null}\n topicId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailTopicsCard hostId={null} basePath={basePath} />\n )\n case 'sending':\n /*\n * A domain is proved by the organization, so its page is an org route\n * here as it is under a site. What each SITE sends as is the other half,\n * and the section's own page lists it per site.\n */\n return detail[0] ? (\n <OrgSendingDomainDetail\n domain={decodeURIComponent(detail[0])}\n basePath={basePath}\n />\n ) : (\n <OrgSendingCard basePath={basePath} />\n )\n case 'suppressions':\n /*\n * `…/suppressions` totals every site's list; `…/suppressions/{hostId}`\n * is one site's list itself, the same card a site's own page draws.\n */\n return detail[0] ? (\n <OrgSiteSuppressions hostId={detail[0]} />\n ) : (\n <OrgSuppressionsCard />\n )\n default:\n return null\n }\n}\n\n/**\n * Emails page (AGL-395): the console surface owned by the email plugin,\n * rendered by the shell's generic plugin route.\n *\n * Two of its sections are two different things a merchant calls \"an email\":\n * an EMAIL is one message that was or will be sent, and a TEMPLATE is the\n * reusable besigner document a message is built from. Keeping them apart is\n * what lets each carry its own report — a message's own numbers, and a\n * template's summed across every message sent from it. Audience lists, the\n * topic catalog, the sending identities and the suppression list complete the\n * surface.\n *\n * The CAMPAIGN that groups messages is not here. It is a container with a\n * window of dates, a set of lists, a topic and revenue attribution — a\n * marketing object that reaches people by email — so it is a section of the\n * Marketing console, and a message's page links out to the campaign it\n * belongs to.\n *\n * Sections are ROUTES (AGL-2501). `HubTabs lazy` already mounted one panel, so\n * this is not a read saving — `emails-console-read-cost.spec.tsx` was written\n * BEFORE the conversion precisely to hold that line, and reports the same\n * counts after. What routing adds is that the URL names the section: it is\n * linkable, the back button walks sections, the breadcrumb says where you are,\n * and \"mount only what is open\" is structural rather than a `lazy` flag\n * somebody has to remember on the next surface.\n *\n * The same page is the ORGANIZATION's Emails page at `/[orgSlug]/emails`.\n * Handed no site, it publishes the org mount to its cards and renders\n * {@link orgSectionBody} instead: the same six sections, over every site.\n */\nexport function EmailsConsolePage(props: ConsolePluginPageProps) {\n const { hostId, orgMount, org, section, sections, basePath, segments } =\n props\n\n /*==========================================\n * THE CONTROLLER THIS SURFACE IS BEING VIEWED AS.\n *\n * The declared group of sites that are one sender, or this site alone. Every\n * consent question asked below is asked FOR a controller and not for a site:\n * a grant is looked up under the asking site, a refusal is honored across\n * the whole group, and a grant held by a site outside it belongs to somebody\n * else. It is the same group `performCampaignSend` resolves, so the audience\n * table and the send agree about the same document by construction.\n *\n * Pure, from the org document the shell already passed, so it costs no read.\n * An absent org resolves to the group of one, which is the narrow answer.\n *\n * Only under a site. The organization's page has no site to resolve a group\n * FOR, and `consentGroupForHost` refuses to invent one — the audience page\n * there resolves it for the site the reader enrolls as, once one is chosen.\n *=========================================*/\n const consentGroup = useMemo(\n () =>\n hostId == null\n ? null\n : consentGroupForHost(org as Record<string, unknown>, hostId),\n [org, hostId],\n )\n\n /*\n * Nothing, deliberately, while the redirect is in flight. Rendering the\n * default section here would issue its listens on a URL about to be\n * replaced — on every arrival at `/emails`, which is every nav-tab click.\n */\n if (!section || !sections?.length || !basePath) return null\n // `segments[0]` IS the section — the shell resolved it into `section`\n // already — so what a section owns is everything after it.\n const detail = (segments ?? []).slice(1)\n\n if (hostId == null) {\n // No site and no org to stand in for it: nothing this page can scope.\n if (!orgMount) return null\n return (\n <EmailOrgMountProvider mount={orgMount} basePath={basePath}>\n <HubSections sections={sections}>\n {orgSectionBody(\n section as EmailsConsoleSectionId,\n detail,\n basePath,\n orgMount,\n org as Record<string, unknown> | undefined,\n )}\n </HubSections>\n </EmailOrgMountProvider>\n )\n }\n\n return (\n <HubSections sections={sections}>\n {sectionBody(\n section as EmailsConsoleSectionId,\n hostId,\n detail,\n basePath,\n consentGroup as ConsentGroup,\n )}\n </HubSections>\n )\n}\nEmailsConsolePage.displayName = 'EmailsConsolePage'\n\nexport default EmailsConsolePage\n"],"names":["consentGroupForHost","useConsoleWidgetSlot","HubSections","useMemo","EmailScreensCard","EmailTemplateDetail","EmailTopicDetail","EmailTopicsCard","ListDetailCard","ListEditCard","ListsCard","OrgEmailTemplatesCard","OrgSendingCard","OrgSendingDomainDetail","OrgSiteSuppressions","OrgSuppressionsCard","SendingDomainDetail","SendingDomainsCard","SuppressionsCard","EmailOrgMountProvider","EMAIL_MESSAGES_ZONE","EmailMessagesSection","props","Zone","slot","id","sectionBody","section","hostId","detail","basePath","consentGroup","screenId","listId","topicId","domain","decodeURIComponent","orgSectionBody","orgMount","org","EmailsConsolePage","sections","segments","length","slice","mount","displayName"],"mappings":"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"}
@@ -23,19 +23,35 @@
23
23
  * that claim. The summary spells out every dimension the rule actually
24
24
  * carries, so the answer to "why is this person not in the audience" is on the
25
25
  * screen the question gets asked on.
26
+ *
27
+ * ## On the organization's page, the site it is worked AS
28
+ *
29
+ * The list is the organization's, but adding somebody to it is done as one
30
+ * site — the enrollment and the consent it records are that site's — and the
31
+ * Consent column reads what each person agreed to with one site's group. So
32
+ * with no site in the URL the page asks which, defaulting to the site the
33
+ * list's filters draw from, then the reader's pick this session, then the
34
+ * org's only site. Nothing below the picker is built until there is an
35
+ * answer: a consent column for no site would be a column about nobody.
26
36
  */
27
37
  import { type ConsentGroup } from '@aglyn/aglyn';
28
38
  export interface ListDetailCardProps {
29
- hostId: string;
39
+ /** The site, or `null` on the organization's Emails page. */
40
+ hostId: string | null;
30
41
  /**
31
42
  * The controller this audience is being read AS, resolved by the page from
32
- * the org document it already holds.
43
+ * the org document it already holds — under a site.
33
44
  *
34
45
  * Passed through rather than resolved here: the group is a fact about the
35
46
  * ORG, one lookup serves every section, and a card that resolved its own
36
47
  * would be a second place for the answer to come from.
37
48
  */
38
- consentGroup: ConsentGroup;
49
+ consentGroup?: ConsentGroup;
50
+ /**
51
+ * The org document, on the organization's page, where the group can only be
52
+ * resolved once the reader has said which site they are working as.
53
+ */
54
+ org?: Record<string, unknown>;
39
55
  listId: string;
40
56
  /** The emails hub URL, for the way back to the audiences list. */
41
57
  basePath: string;
@@ -41,24 +41,34 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
41
41
  * that claim. The summary spells out every dimension the rule actually
42
42
  * carries, so the answer to "why is this person not in the audience" is on the
43
43
  * screen the question gets asked on.
44
- */ import { normalizeDynamicListRule, PageHeaderRecord, pluginDocsHelp } from "@aglyn/aglyn";
44
+ *
45
+ * ## On the organization's page, the site it is worked AS
46
+ *
47
+ * The list is the organization's, but adding somebody to it is done as one
48
+ * site — the enrollment and the consent it records are that site's — and the
49
+ * Consent column reads what each person agreed to with one site's group. So
50
+ * with no site in the URL the page asks which, defaulting to the site the
51
+ * list's filters draw from, then the reader's pick this session, then the
52
+ * org's only site. Nothing below the picker is built until there is an
53
+ * answer: a consent column for no site would be a column about nobody.
54
+ */ import { consentGroupForHost, normalizeDynamicListRule, PageHeaderRecord, pluginDocsHelp } from "@aglyn/aglyn";
45
55
  import { mdiPencilOutline, mdiTrayArrowUp } from "@aglyn/shared-data-mdi";
46
56
  import { AppLink, CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
47
57
  import { Button, Chip, Stack, Typography } from "@mui/material";
48
58
  import { collection, doc, getCountFromServer } from "firebase/firestore";
49
- import { useCallback, useEffect, useState } from "react";
50
- import { useFirestore, useFirestoreDoc, useOrgDataScope } from "@aglyn/tenant-feature-instance";
59
+ import { useCallback, useEffect, useMemo, useState } from "react";
60
+ import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
51
61
  import { describeDynamicListRule } from "./dynamic-list-rule-fields.js";
62
+ import { OrgSiteSelect, useEmailDataScope, useEmailOrgMount } from "./email-org-mount.js";
52
63
  import ListImportDrawer from "./list-import-drawer.js";
53
64
  import ListMembersPanel from "./list-members-panel.js";
54
65
  export function ListDetailCard(props) {
55
- var _list_name, _list_name1;
66
+ var _ref, _list_name, _list_name1;
56
67
  var _list_lastEvaluatedAt;
57
- const { hostId, consentGroup, listId, basePath } = props;
68
+ const { hostId, listId, basePath, org } = props;
58
69
  const firestore = useFirestore();
59
- const { scope } = useOrgDataScope({
60
- hostId
61
- });
70
+ const orgMount = useEmailOrgMount();
71
+ const { scope } = useEmailDataScope(hostId);
62
72
  const { data: list, status } = useFirestoreDoc(()=>scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null, [
63
73
  firestore,
64
74
  scope,
@@ -92,6 +102,22 @@ export function ListDetailCard(props) {
92
102
  ]);
93
103
  const onMembershipChanged = useCallback(()=>setMembershipVersion((version)=>version + 1), []);
94
104
  /*
105
+ * THE SITE THIS AUDIENCE IS WORKED AS. Under a site, that site. On the
106
+ * organization's page, the reader's choice — else the site the filters draw
107
+ * from, else the session's pick, else the only site.
108
+ */ const [chosenHostId, setChosenHostId] = useState('');
109
+ const listHostId = String((_ref = list == null ? void 0 : list['hostId']) != null ? _ref : '');
110
+ const enrollHostId = hostId != null ? hostId : chosenHostId || ((orgMount == null ? void 0 : orgMount.hosts.some((site)=>site.id === listHostId)) ? listHostId : '') || (orgMount == null ? void 0 : orgMount.pickedHostId) || '';
111
+ const consentGroup = useMemo(()=>{
112
+ var _props_consentGroup;
113
+ return hostId != null ? (_props_consentGroup = props.consentGroup) != null ? _props_consentGroup : null : enrollHostId ? consentGroupForHost(org, enrollHostId) : null;
114
+ }, [
115
+ hostId,
116
+ props.consentGroup,
117
+ enrollHostId,
118
+ org
119
+ ]);
120
+ /*
95
121
  * Importing is a DRAWER opened from the header, not a control inside the
96
122
  * membership panel. It is a multi-step act — choose a file, read what is in
97
123
  * it, state that you have permission — and the middle step is the one that
@@ -138,6 +164,8 @@ export function ListDetailCard(props) {
138
164
  path: mdiTrayArrowUp.path,
139
165
  size: 0.8
140
166
  }),
167
+ // An import enrolls as a site; on the org page one has to be chosen.
168
+ disabled: !enrollHostId,
141
169
  onClick: ()=>setImporting(true),
142
170
  children: 'Import'
143
171
  })
@@ -231,8 +259,19 @@ export function ListDetailCard(props) {
231
259
  children: clause
232
260
  }, clause))
233
261
  }) : null,
234
- scope ? /*#__PURE__*/ _jsx(ListMembersPanel, {
235
- hostId: hostId,
262
+ orgMount ? /*#__PURE__*/ _jsx(OrgSiteSelect, {
263
+ mount: orgMount,
264
+ label: "Enroll as",
265
+ value: enrollHostId,
266
+ onChange: setChosenHostId,
267
+ helperText: 'People you add are enrolled as this site, and the Consent ' + 'column reads what they agreed to with it.'
268
+ }) : null,
269
+ orgMount && !enrollHostId ? /*#__PURE__*/ _jsx(Typography, {
270
+ variant: "body2",
271
+ color: "text.secondary",
272
+ children: 'Choose the site to work this audience as. Whether someone may ' + 'be emailed depends on what they agreed to with a site, so the ' + 'members and their consent are read for one site at a time.'
273
+ }) : scope && consentGroup ? /*#__PURE__*/ _jsx(ListMembersPanel, {
274
+ hostId: enrollHostId,
236
275
  consentGroup: consentGroup,
237
276
  scope: scope,
238
277
  listId: listId,
@@ -245,13 +284,13 @@ export function ListDetailCard(props) {
245
284
  then may not reconcile away when the person stops matching.
246
285
  */ findRule: dynamic || !hasFilters ? null : rule,
247
286
  ruleSummary: summary
248
- }) : null
287
+ }, enrollHostId) : null
249
288
  ]
250
289
  }),
251
- importing ? /*#__PURE__*/ _jsx(ListImportDrawer, {
290
+ importing && enrollHostId ? /*#__PURE__*/ _jsx(ListImportDrawer, {
252
291
  open: true,
253
292
  onClose: ()=>setImporting(false),
254
- hostId: hostId,
293
+ hostId: enrollHostId,
255
294
  listId: listId,
256
295
  listName: String((_list_name1 = list['name']) != null ? _list_name1 : ''),
257
296
  onMembershipChanged: onMembershipChanged