@aglyn/plugins-email 1.0.0-beta.218 → 1.0.0-beta.220

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 (56) hide show
  1. package/package.json +18 -18
  2. package/src/lib/components/campaign-topic-options-widget.js +2 -2
  3. package/src/lib/components/campaign-topic-options-widget.js.map +1 -1
  4. package/src/lib/components/campaign-topic-select.js +3 -3
  5. package/src/lib/components/campaign-topic-select.js.map +1 -1
  6. package/src/lib/components/consent-group-review.js +2 -2
  7. package/src/lib/components/consent-group-review.js.map +1 -1
  8. package/src/lib/components/dynamic-list-rule-fields.d.ts +20 -4
  9. package/src/lib/components/dynamic-list-rule-fields.js +537 -439
  10. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -1
  11. package/src/lib/components/email-topic-detail.d.ts +1 -1
  12. package/src/lib/components/email-topic-detail.js +5 -5
  13. package/src/lib/components/email-topic-detail.js.map +1 -1
  14. package/src/lib/components/email-topics-card.d.ts +1 -1
  15. package/src/lib/components/email-topics-card.js +6 -5
  16. package/src/lib/components/email-topics-card.js.map +1 -1
  17. package/src/lib/components/emails-console-sections.js +3 -2
  18. package/src/lib/components/emails-console-sections.js.map +1 -1
  19. package/src/lib/components/list-import-drawer.js +2 -2
  20. package/src/lib/components/list-import-drawer.js.map +1 -1
  21. package/src/lib/components/use-org-email-topics.d.ts +4 -4
  22. package/src/lib/components/use-org-email-topics.js +6 -5
  23. package/src/lib/components/use-org-email-topics.js.map +1 -1
  24. package/src/lib/list-import.d.ts +149 -0
  25. package/src/lib/list-import.js +324 -0
  26. package/src/lib/list-import.js.map +1 -0
  27. package/src/lib/model/email-record-routes.d.ts +22 -0
  28. package/src/lib/model/email-record-routes.js +75 -0
  29. package/src/lib/model/email-record-routes.js.map +1 -0
  30. package/src/lib/model/email-topic-catalog.d.ts +36 -0
  31. package/src/lib/model/email-topic-catalog.js +36 -0
  32. package/src/lib/model/email-topic-catalog.js.map +1 -0
  33. package/src/lib/model/index.d.ts +0 -7
  34. package/src/lib/model/index.js +0 -6
  35. package/src/lib/model/index.js.map +1 -1
  36. package/src/lib/model/template-provenance.d.ts +1 -1
  37. package/src/lib/model/template-provenance.js +1 -1
  38. package/src/lib/model/template-provenance.js.map +1 -1
  39. package/src/lib/plugin.js +5 -1
  40. package/src/lib/plugin.js.map +1 -1
  41. package/src/lib/server-console.js +6 -2
  42. package/src/lib/server-console.js.map +1 -1
  43. package/src/lib/server-email-drafts.js +2 -2
  44. package/src/lib/server-email-drafts.js.map +1 -1
  45. package/src/lib/server-list-gate.js +35 -10
  46. package/src/lib/server-list-gate.js.map +1 -1
  47. package/src/lib/server-list-import.js +4 -3
  48. package/src/lib/server-list-import.js.map +1 -1
  49. package/src/lib/server-topic-index.d.ts +52 -0
  50. package/src/lib/server-topic-index.js +92 -0
  51. package/src/lib/server-topic-index.js.map +1 -0
  52. package/src/lib/server.js +11 -11
  53. package/src/lib/server.js.map +1 -1
  54. package/src/lib/unsubscribe-link.d.ts +1 -1
  55. package/src/lib/unsubscribe-link.js +2 -2
  56. package/src/lib/unsubscribe-link.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topic-detail.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 PageHeaderRecord,\n pluginDocsHelp,\n resolveCampaignTopic,\n DEFAULT_EMAIL_TOPICS,\n} from '@aglyn/aglyn'\nimport { CardDisplay, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Link from 'next/link'\nimport { useCallback, useEffect, 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 EmailTopicDetailProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n topicId: string\n /** `/…/emails`, so the page can route back to the list. */\n basePath: string\n}\n\n/**\n * One topic's own page — where every change to an existing topic is made.\n *\n * The console's shape for a list surface is create-in-a-drawer, edit-on-the\n * record's page, and this is the second half of it for topics. The list card\n * lists and routes; nothing about a saved topic is editable from a form\n * stacked above that table.\n *\n * ## Saving a built-in\n *\n * The four built-ins have no stored document until somebody changes one, so\n * this page's save is the same `setDoc` for a built-in and a custom topic:\n * the write CREATES the override document at the built-in's id. That is the\n * whole overlay design — see `email-topics.ts`.\n *\n * ## Retire, not delete\n *\n * A retired topic leaves the composer's picker and the recipient's preference\n * page, and stays resolvable everywhere else. It has to: campaigns already\n * sent under it minted unsubscribe links carrying its id, those links are in\n * inboxes, and an id that stops resolving is a preference page that cannot\n * name the message the recipient is holding. There is no delete on this page,\n * and the Firestore rules refuse one too.\n */\nexport function EmailTopicDetail(props: EmailTopicDetailProps) {\n const { hostId, topicId, basePath } = props\n const firestore = useFirestore()\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 /*\n * `resolveCampaignTopic` rather than a bare `find`, so an id that no longer\n * names anything lands on a real topic instead of an empty form. A stale\n * bookmark is the common way to arrive here with one.\n */\n const topic = resolveCampaignTopic(topicId, topics)\n const known = topics.some((it) => it.id === topicId)\n\n const [name, setName] = useState('')\n const [description, setDescription] = useState('')\n /**\n * Whether joining this stream needs a confirmation click.\n *\n * Three states, held as a string because a `Select` carries one and a\n * boolean cannot: `''` defers to the site, `'on'` and `'off'` are decisions\n * about this stream. Collapsing the first into a boolean would make \"not\n * chosen\" indistinguishable from \"off\", and the site default would then be\n * unreachable once anybody saved a topic.\n */\n const [doubleOptIn, setDoubleOptIn] = useState('')\n const [loaded, setLoaded] = useState(false)\n const [busy, setBusy] = useState(false)\n\n /*\n * Seed the fields ONCE, when the catalog first arrives.\n *\n * Re-seeding on every change of `topic` would overwrite what the operator is\n * typing the moment the collection listener fires — and it fires on this\n * page's own save.\n */\n useEffect(() => {\n if (loaded || !topics.length) return\n setName(topic.name)\n setDescription(topic.description)\n setDoubleOptIn(\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : '',\n )\n setLoaded(true)\n }, [loaded, topics, topic])\n\n const storedDoubleOptIn =\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : ''\n const dirty =\n loaded &&\n (name !== topic.name ||\n description !== topic.description ||\n doubleOptIn !== storedDoubleOptIn)\n\n /**\n * Save what is in the form, optionally changing the retired flag with it.\n *\n * The write itself is `writeEmailTopic`, which the list card's row menu also\n * goes through — a topic retired from the list and one retired here have to\n * leave the same document behind.\n */\n const save = useCallback(\n async (patch?: { archived?: boolean }) => {\n if (!scope || busy) return\n const nextName = name.trim()\n if (!nextName) {\n return void enqueueSnackbar('Give the topic a name', {\n variant: 'warning',\n persist: false,\n })\n }\n setBusy(true)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: nextName,\n description: description.trim(),\n archived: patch?.archived ?? !!topic.archived,\n // `null` clears the field, which is how \"whatever the site says\"\n // is expressed. A stored `false` is a different answer and has to\n // survive as one.\n doubleOptIn:\n doubleOptIn === 'on' ? true : doubleOptIn === 'off' ? false : null,\n })\n enqueueSnackbar('Topic saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n },\n [\n scope,\n busy,\n name,\n description,\n doubleOptIn,\n firestore,\n topic,\n enqueueSnackbar,\n ],\n )\n\n const toggleArchived = useCallback(async () => {\n if (topic.archived) return void save({ archived: false })\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new can ' +\n 'be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their unsubscribe ' +\n 'links still name this topic. You can bring it back at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on\n // the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n await save({ archived: true })\n }, [topic, confirm, save])\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={'Topic'}\n subheader={\n <Stack direction=\"row\" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>\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((it) => it.id === topic.id) ? (\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n }\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button component={Link} href={`${basePath}/topics`} size=\"small\">\n {'All topics'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\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 <>\n {!known && loaded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'That topic no longer exists, so this is the one campaigns ' +\n 'fall back to.'}\n </Typography>\n ) : null}\n <TextField\n label=\"Name\"\n size=\"small\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"What recipients see on the preference page.\"\n />\n <TextField\n label=\"Description\"\n size=\"small\"\n multiline\n minRows={2}\n value={description}\n onChange={(event) => setDescription(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"One sentence telling them what this stream is.\"\n />\n {/*\n CONFIRMATION — off unless somebody chooses it.\n\n No jurisdiction verified requires a double opt-in by statute.\n They require prior express consent plus a burden of proof on\n the sender, and a confirmation click is the proof the courts\n have so far accepted — so this is an option a merchant reaches\n for, never a default the product imposes.\n\n A three-way select rather than a switch, because the third\n state is real: this topic can defer to the site's own setting,\n and a switch has nowhere to put that.\n */}\n <TextField\n select\n label=\"Confirmation\"\n size=\"small\"\n value={doubleOptIn}\n onChange={(event) => setDoubleOptIn(event.target.value)}\n disabled={busy || !loaded}\n helperText={\n 'Ask new subscribers to confirm by email before anything is ' +\n 'sent to them. Until they do, nothing on this stream reaches ' +\n 'them and they stay on your list.'\n }\n slotProps={{ select: { native: true } }}\n >\n <option value=\"\">{'Use the site setting'}</option>\n <option value=\"on\">{'Require a confirmation click'}</option>\n <option value=\"off\">{'No confirmation for this stream'}</option>\n </TextField>\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n disabled={!dirty || busy}\n onClick={() => void save()}\n >\n {'Save'}\n </Button>\n <Button\n color={topic.archived ? 'primary' : 'error'}\n disabled={busy || !loaded}\n onClick={() => void toggleArchived()}\n >\n {topic.archived ? 'Restore' : 'Retire'}\n </Button>\n </Stack>\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the topic; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={topic.name || topic.id} />\n {card}\n </>\n )\n}\n\nexport default EmailTopicDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","resolveCampaignTopic","DEFAULT_EMAIL_TOPICS","CardDisplay","useConfirmationContext","useSnackbar","Button","Chip","Stack","TextField","Typography","Link","useCallback","useEffect","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicDetail","props","hostId","topicId","basePath","firestore","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","topic","known","some","it","id","name","setName","description","setDescription","doubleOptIn","setDoubleOptIn","loaded","setLoaded","busy","setBusy","length","storedDoubleOptIn","dirty","save","patch","nextName","trim","variant","persist","archived","error","console","toggleArchived","accepted","title","confirmationText","then","catch","card","header","subheader","direction","spacing","sx","mt","alignItems","size","color","label","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","component","href","value","onChange","event","target","disabled","helperText","multiline","minRows","select","slotProps","native","option","onClick"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topic-detail.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 PageHeaderRecord,\n pluginDocsHelp,\n resolveSubscriptionTopic,\n DECLARED_SUBSCRIPTION_TOPICS,\n} from '@aglyn/aglyn'\nimport { CardDisplay, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport Link from 'next/link'\nimport { useCallback, useEffect, 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 EmailTopicDetailProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n topicId: string\n /** `/…/emails`, so the page can route back to the list. */\n basePath: string\n}\n\n/**\n * One topic's own page — where every change to an existing topic is made.\n *\n * The console's shape for a list surface is create-in-a-drawer, edit-on-the\n * record's page, and this is the second half of it for topics. The list card\n * lists and routes; nothing about a saved topic is editable from a form\n * stacked above that table.\n *\n * ## Saving a built-in\n *\n * The four built-ins have no stored document until somebody changes one, so\n * this page's save is the same `setDoc` for a built-in and a custom topic:\n * the write CREATES the override document at the built-in's id. That is the\n * whole overlay design — see `subscription-topics.ts`.\n *\n * ## Retire, not delete\n *\n * A retired topic leaves the composer's picker and the recipient's preference\n * page, and stays resolvable everywhere else. It has to: campaigns already\n * sent under it minted unsubscribe links carrying its id, those links are in\n * inboxes, and an id that stops resolving is a preference page that cannot\n * name the message the recipient is holding. There is no delete on this page,\n * and the Firestore rules refuse one too.\n */\nexport function EmailTopicDetail(props: EmailTopicDetailProps) {\n const { hostId, topicId, basePath } = props\n const firestore = useFirestore()\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 /*\n * `resolveSubscriptionTopic` rather than a bare `find`, so an id that no longer\n * names anything lands on a real topic instead of an empty form. A stale\n * bookmark is the common way to arrive here with one.\n */\n const topic = resolveSubscriptionTopic(topicId, topics)\n const known = topics.some((it) => it.id === topicId)\n\n const [name, setName] = useState('')\n const [description, setDescription] = useState('')\n /**\n * Whether joining this stream needs a confirmation click.\n *\n * Three states, held as a string because a `Select` carries one and a\n * boolean cannot: `''` defers to the site, `'on'` and `'off'` are decisions\n * about this stream. Collapsing the first into a boolean would make \"not\n * chosen\" indistinguishable from \"off\", and the site default would then be\n * unreachable once anybody saved a topic.\n */\n const [doubleOptIn, setDoubleOptIn] = useState('')\n const [loaded, setLoaded] = useState(false)\n const [busy, setBusy] = useState(false)\n\n /*\n * Seed the fields ONCE, when the catalog first arrives.\n *\n * Re-seeding on every change of `topic` would overwrite what the operator is\n * typing the moment the collection listener fires — and it fires on this\n * page's own save.\n */\n useEffect(() => {\n if (loaded || !topics.length) return\n setName(topic.name)\n setDescription(topic.description)\n setDoubleOptIn(\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : '',\n )\n setLoaded(true)\n }, [loaded, topics, topic])\n\n const storedDoubleOptIn =\n typeof topic.doubleOptIn === 'boolean'\n ? topic.doubleOptIn\n ? 'on'\n : 'off'\n : ''\n const dirty =\n loaded &&\n (name !== topic.name ||\n description !== topic.description ||\n doubleOptIn !== storedDoubleOptIn)\n\n /**\n * Save what is in the form, optionally changing the retired flag with it.\n *\n * The write itself is `writeEmailTopic`, which the list card's row menu also\n * goes through — a topic retired from the list and one retired here have to\n * leave the same document behind.\n */\n const save = useCallback(\n async (patch?: { archived?: boolean }) => {\n if (!scope || busy) return\n const nextName = name.trim()\n if (!nextName) {\n return void enqueueSnackbar('Give the topic a name', {\n variant: 'warning',\n persist: false,\n })\n }\n setBusy(true)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: nextName,\n description: description.trim(),\n archived: patch?.archived ?? !!topic.archived,\n // `null` clears the field, which is how \"whatever the site says\"\n // is expressed. A stored `false` is a different answer and has to\n // survive as one.\n doubleOptIn:\n doubleOptIn === 'on' ? true : doubleOptIn === 'off' ? false : null,\n })\n enqueueSnackbar('Topic saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n },\n [\n scope,\n busy,\n name,\n description,\n doubleOptIn,\n firestore,\n topic,\n enqueueSnackbar,\n ],\n )\n\n const toggleArchived = useCallback(async () => {\n if (topic.archived) return void save({ archived: false })\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new can ' +\n 'be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their unsubscribe ' +\n 'links still name this topic. You can bring it back at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on\n // the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n await save({ archived: true })\n }, [topic, confirm, save])\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={'Topic'}\n subheader={\n <Stack direction=\"row\" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DECLARED_SUBSCRIPTION_TOPICS.some((it) => it.id === topic.id) ? (\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n }\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button component={Link} href={`${basePath}/topics`} size=\"small\">\n {'All topics'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\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 <>\n {!known && loaded ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'That topic no longer exists, so this is the one campaigns ' +\n 'fall back to.'}\n </Typography>\n ) : null}\n <TextField\n label=\"Name\"\n size=\"small\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"What recipients see on the preference page.\"\n />\n <TextField\n label=\"Description\"\n size=\"small\"\n multiline\n minRows={2}\n value={description}\n onChange={(event) => setDescription(event.target.value)}\n disabled={busy || !loaded}\n helperText=\"One sentence telling them what this stream is.\"\n />\n {/*\n CONFIRMATION — off unless somebody chooses it.\n\n No jurisdiction verified requires a double opt-in by statute.\n They require prior express consent plus a burden of proof on\n the sender, and a confirmation click is the proof the courts\n have so far accepted — so this is an option a merchant reaches\n for, never a default the product imposes.\n\n A three-way select rather than a switch, because the third\n state is real: this topic can defer to the site's own setting,\n and a switch has nowhere to put that.\n */}\n <TextField\n select\n label=\"Confirmation\"\n size=\"small\"\n value={doubleOptIn}\n onChange={(event) => setDoubleOptIn(event.target.value)}\n disabled={busy || !loaded}\n helperText={\n 'Ask new subscribers to confirm by email before anything is ' +\n 'sent to them. Until they do, nothing on this stream reaches ' +\n 'them and they stay on your list.'\n }\n slotProps={{ select: { native: true } }}\n >\n <option value=\"\">{'Use the site setting'}</option>\n <option value=\"on\">{'Require a confirmation click'}</option>\n <option value=\"off\">{'No confirmation for this stream'}</option>\n </TextField>\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n disabled={!dirty || busy}\n onClick={() => void save()}\n >\n {'Save'}\n </Button>\n <Button\n color={topic.archived ? 'primary' : 'error'}\n disabled={busy || !loaded}\n onClick={() => void toggleArchived()}\n >\n {topic.archived ? 'Restore' : 'Retire'}\n </Button>\n </Stack>\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the topic; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={topic.name || topic.id} />\n {card}\n </>\n )\n}\n\nexport default EmailTopicDetail\n"],"names":["PageHeaderRecord","pluginDocsHelp","resolveSubscriptionTopic","DECLARED_SUBSCRIPTION_TOPICS","CardDisplay","useConfirmationContext","useSnackbar","Button","Chip","Stack","TextField","Typography","Link","useCallback","useEffect","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicDetail","props","hostId","topicId","basePath","firestore","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","topic","known","some","it","id","name","setName","description","setDescription","doubleOptIn","setDoubleOptIn","loaded","setLoaded","busy","setBusy","length","storedDoubleOptIn","dirty","save","patch","nextName","trim","variant","persist","archived","error","console","toggleArchived","accepted","title","confirmationText","then","catch","card","header","subheader","direction","spacing","sx","mt","alignItems","size","color","label","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","component","href","value","onChange","event","target","disabled","helperText","multiline","minRows","select","slotProps","native","option","onClick"],"mappings":"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"}
@@ -19,7 +19,7 @@ export interface EmailTopicsCardProps {
19
19
  *
20
20
  * Every org that exists today has an empty `emailTopics` collection, and a
21
21
  * seeding migration would leave any org created while it ran with none. So
22
- * `DEFAULT_EMAIL_TOPICS` is the FLOOR of the catalog rather than its initial
22
+ * `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog rather than its initial
23
23
  * contents: the four are present for every org with no write anywhere, and
24
24
  * saving one on its detail page writes a document at the same id that renames
25
25
  * or retires it. A built-in with no stored document is not a missing row, it
@@ -15,7 +15,8 @@
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
17
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
- import { createResourceUid, isEmailTopicId, pluginDocsHelp, DEFAULT_EMAIL_TOPICS, EMAIL_TOPICS_COLLECTION } from "@aglyn/aglyn";
18
+ import { createResourceUid, isSubscriptionTopicId, pluginDocsHelp, DECLARED_SUBSCRIPTION_TOPICS } from "@aglyn/aglyn";
19
+ import { EMAIL_TOPICS_COLLECTION } from "../model/email-topic-catalog.js";
19
20
  import { mdiArchiveArrowUpOutline, mdiArchiveOutline, mdiPencilOutline } from "@aglyn/shared-data-mdi";
20
21
  import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
21
22
  import RowActionsMenu from "@aglyn/shared-ui-jsx/components/row-actions-menu.component";
@@ -44,7 +45,7 @@ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
44
45
  *
45
46
  * Every org that exists today has an empty `emailTopics` collection, and a
46
47
  * seeding migration would leave any org created while it ran with none. So
47
- * `DEFAULT_EMAIL_TOPICS` is the FLOOR of the catalog rather than its initial
48
+ * `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog rather than its initial
48
49
  * contents: the four are present for every org with no write anywhere, and
49
50
  * saving one on its detail page writes a document at the same id that renames
50
51
  * or retires it. A built-in with no stored document is not a missing row, it
@@ -91,12 +92,12 @@ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
91
92
  const name = String((_ref = values == null ? void 0 : values.displayName) != null ? _ref : '').trim();
92
93
  if (!name) return;
93
94
  // `createResourceUid` is nanoid's `A-Za-z0-9_-`, so the id it mints
94
- // always satisfies `isEmailTopicId`. Checked anyway, because this id is
95
+ // always satisfies `isSubscriptionTopicId`. Checked anyway, because this id is
95
96
  // signed into every unsubscribe link the topic ever mints and the send
96
97
  // path refuses one that is not usable — a topic nothing can be sent
97
98
  // under is a row the merchant would have no way to diagnose.
98
99
  const id = createResourceUid();
99
- if (!isEmailTopicId(id)) {
100
+ if (!isSubscriptionTopicId(id)) {
100
101
  return void enqueueSnackbar('Could not create the topic', {
101
102
  variant: 'error'
102
103
  });
@@ -276,7 +277,7 @@ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
276
277
  color: "warning",
277
278
  label: "Retired"
278
279
  }) : null,
279
- DEFAULT_EMAIL_TOPICS.some((it)=>it.id === topic.id) ? /*#__PURE__*/ _jsx(Typography, {
280
+ DECLARED_SUBSCRIPTION_TOPICS.some((it)=>it.id === topic.id) ? /*#__PURE__*/ _jsx(Typography, {
280
281
  variant: "overline",
281
282
  color: "text.secondary",
282
283
  children: 'Built in'
@@ -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 { 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
+ {"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 isSubscriptionTopicId,\n pluginDocsHelp,\n DECLARED_SUBSCRIPTION_TOPICS,\n} from '@aglyn/aglyn'\nimport { EMAIL_TOPICS_COLLECTION } from '../model/email-topic-catalog'\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 * `DECLARED_SUBSCRIPTION_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 `isSubscriptionTopicId`. 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 (!isSubscriptionTopicId(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 {DECLARED_SUBSCRIPTION_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","isSubscriptionTopicId","pluginDocsHelp","DECLARED_SUBSCRIPTION_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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"}
@@ -43,8 +43,9 @@
43
43
  * uses for the record: a message's own page, a template's sends table and
44
44
  * the campaign detail all call it that.
45
45
  *
46
- * `/emails/emails/{id}` is answered by a permanent redirect in the console's
47
- * `next.config.js`, on the terms the Workflows → Automation move set: a URL
46
+ * `/emails/emails/{id}` is answered by a permanent redirect this plugin
47
+ * declares (`consoleRedirects` in plugins.config.json), on the terms the
48
+ * Workflows → Automation move set: a URL
48
49
  * somebody pasted keeps resolving, and it resolves to the one address the
49
50
  * console generates.
50
51
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/emails-console-sections.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 */\n\nimport type { ConsoleNavSection } from '@aglyn/aglyn'\n\nexport type EmailsConsoleSectionId =\n | 'messages'\n | 'templates'\n | 'audiences'\n | 'topics'\n | 'sending'\n | 'consent-groups'\n | 'suppressions'\n\n/**\n * The emails console's sections, in rail order (AGL-2501).\n *\n * One list, read twice and never copied: `plugin.ts` registers it on the nav\n * item so the shell can route and gate each section, and the page switches its\n * body on the id the shell resolves back.\n *\n * Ids appear in links people keep — treat them as persisted, and prefer\n * renaming a LABEL over an id. Where the two have to move together, the old\n * path is kept alive by a redirect rather than by an id that no longer says\n * what the section is. `audiences` and `suppressions` are also the `?tab=`\n * ids this page deep-linked by before its sections became routes, so a\n * bookmark that named one of those tabs still names the same section.\n *\n * No `navTabId` on any of them: every section ships with the surface, so they\n * inherit the Emails nav item's gate.\n */\nexport const EMAILS_CONSOLE_SECTIONS: readonly ConsoleNavSection[] = [\n /*\n * The individual messages: one row per email that was or will be sent, each\n * with its own report.\n *\n * A message is what this surface is about, and this section is named for\n * the record rather than for the surface holding it. The shell prints the\n * surface and the section together — in the header, in the breadcrumb and\n * in the browser tab — so a section whose name repeats its surface's says\n * nothing about which section is open, which is the one job a section\n * name has. `messages` is also the word the rest of this surface already\n * uses for the record: a message's own page, a template's sends table and\n * the campaign detail all call it that.\n *\n * `/emails/emails/{id}` is answered by a permanent redirect in the console's\n * `next.config.js`, on the terms the Workflows → Automation move set: a URL\n * somebody pasted keeps resolving, and it resolves to the one address the\n * console generates.\n *\n * The CAMPAIGN that groups messages is a marketing object — a window of\n * dates, a set of lists, a topic and revenue attribution, which happens to\n * reach people by email — so it is a section of the Marketing console and a\n * message's page links out to it.\n */\n { id: 'messages', label: 'Messages' },\n /*\n * The reusable besigner documents a message is built from.\n *\n * Id and label say the same word on purpose: a template is the vocabulary\n * everywhere else in this surface — an email names the template it renders,\n * and the marketplace publishes them — so a URL saying anything else would\n * be the one place the reader has to translate.\n */\n { id: 'templates', label: 'Templates' },\n { id: 'audiences', label: 'Audiences' },\n // Between the audiences and the suppressions, which is where a topic sits\n // conceptually: an audience is who you may reach, a suppression is who you\n // may not, and a topic is the stream a recipient can leave without becoming\n // either.\n { id: 'topics', label: 'Topics' },\n /*\n * WHO THE MAIL COMES FROM, as against who it goes to.\n *\n * After the three audience sections and before Suppressions, which is where\n * the question sits: everything above decides who is reached, and this one\n * decides what they see in the `From:` line. It is also the section a\n * merchant is sent to from the composer when a send is refused for an\n * unverified identity, so it has to be a route of its own.\n */\n { id: 'sending', label: 'Sending' },\n /*\n * WHICH SITES ARE ONE SENDER (AGL-3320).\n *\n * After Sending, because it answers the question beside it: Sending says\n * what the `From:` line shows, this says which sites a signup, an\n * unsubscribe and a CRM record are shared across. Before Suppressions,\n * whose lists it decides the reach of — an address on one site's list is\n * skipped by every site in that site's group. The organization declares\n * the groups here; a site's own page says which one it is in.\n */\n { id: 'consent-groups', label: 'Consent groups' },\n // Beside the audiences rather than inside them (AGL-2410): a suppression is\n // not a list you build, it is the reason a list you built did not all get\n // mailed.\n { id: 'suppressions', label: 'Suppressions' },\n]\n\n/*\n * Rail ORDER decides where `/emails` lands: the shell redirects a bare hub\n * URL to the first section in this list the reader may open (AGL-2501). There\n * is deliberately no separate default constant — a second place to say which\n * section is first is a second place for it to disagree with the rail.\n */\n"],"names":["EMAILS_CONSOLE_SECTIONS","id","label"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAaD;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,MAAMA,0BAAwD;IACnE;;;;;;;;;;;;;;;;;;;;;;GAsBC,GACD;QAAEC,IAAI;QAAYC,OAAO;IAAW;IACpC;;;;;;;GAOC,GACD;QAAED,IAAI;QAAaC,OAAO;IAAY;IACtC;QAAED,IAAI;QAAaC,OAAO;IAAY;IACtC,0EAA0E;IAC1E,2EAA2E;IAC3E,4EAA4E;IAC5E,UAAU;IACV;QAAED,IAAI;QAAUC,OAAO;IAAS;IAChC;;;;;;;;GAQC,GACD;QAAED,IAAI;QAAWC,OAAO;IAAU;IAClC;;;;;;;;;GASC,GACD;QAAED,IAAI;QAAkBC,OAAO;IAAiB;IAChD,4EAA4E;IAC5E,0EAA0E;IAC1E,UAAU;IACV;QAAED,IAAI;QAAgBC,OAAO;IAAe;CAC7C,CAED;;;;;CAKC,IAPA"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/emails-console-sections.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 */\n\nimport type { ConsoleNavSection } from '@aglyn/aglyn'\n\nexport type EmailsConsoleSectionId =\n | 'messages'\n | 'templates'\n | 'audiences'\n | 'topics'\n | 'sending'\n | 'consent-groups'\n | 'suppressions'\n\n/**\n * The emails console's sections, in rail order (AGL-2501).\n *\n * One list, read twice and never copied: `plugin.ts` registers it on the nav\n * item so the shell can route and gate each section, and the page switches its\n * body on the id the shell resolves back.\n *\n * Ids appear in links people keep — treat them as persisted, and prefer\n * renaming a LABEL over an id. Where the two have to move together, the old\n * path is kept alive by a redirect rather than by an id that no longer says\n * what the section is. `audiences` and `suppressions` are also the `?tab=`\n * ids this page deep-linked by before its sections became routes, so a\n * bookmark that named one of those tabs still names the same section.\n *\n * No `navTabId` on any of them: every section ships with the surface, so they\n * inherit the Emails nav item's gate.\n */\nexport const EMAILS_CONSOLE_SECTIONS: readonly ConsoleNavSection[] = [\n /*\n * The individual messages: one row per email that was or will be sent, each\n * with its own report.\n *\n * A message is what this surface is about, and this section is named for\n * the record rather than for the surface holding it. The shell prints the\n * surface and the section together — in the header, in the breadcrumb and\n * in the browser tab — so a section whose name repeats its surface's says\n * nothing about which section is open, which is the one job a section\n * name has. `messages` is also the word the rest of this surface already\n * uses for the record: a message's own page, a template's sends table and\n * the campaign detail all call it that.\n *\n * `/emails/emails/{id}` is answered by a permanent redirect this plugin\n * declares (`consoleRedirects` in plugins.config.json), on the terms the\n * Workflows → Automation move set: a URL\n * somebody pasted keeps resolving, and it resolves to the one address the\n * console generates.\n *\n * The CAMPAIGN that groups messages is a marketing object — a window of\n * dates, a set of lists, a topic and revenue attribution, which happens to\n * reach people by email — so it is a section of the Marketing console and a\n * message's page links out to it.\n */\n { id: 'messages', label: 'Messages' },\n /*\n * The reusable besigner documents a message is built from.\n *\n * Id and label say the same word on purpose: a template is the vocabulary\n * everywhere else in this surface — an email names the template it renders,\n * and the marketplace publishes them — so a URL saying anything else would\n * be the one place the reader has to translate.\n */\n { id: 'templates', label: 'Templates' },\n { id: 'audiences', label: 'Audiences' },\n // Between the audiences and the suppressions, which is where a topic sits\n // conceptually: an audience is who you may reach, a suppression is who you\n // may not, and a topic is the stream a recipient can leave without becoming\n // either.\n { id: 'topics', label: 'Topics' },\n /*\n * WHO THE MAIL COMES FROM, as against who it goes to.\n *\n * After the three audience sections and before Suppressions, which is where\n * the question sits: everything above decides who is reached, and this one\n * decides what they see in the `From:` line. It is also the section a\n * merchant is sent to from the composer when a send is refused for an\n * unverified identity, so it has to be a route of its own.\n */\n { id: 'sending', label: 'Sending' },\n /*\n * WHICH SITES ARE ONE SENDER (AGL-3320).\n *\n * After Sending, because it answers the question beside it: Sending says\n * what the `From:` line shows, this says which sites a signup, an\n * unsubscribe and a CRM record are shared across. Before Suppressions,\n * whose lists it decides the reach of — an address on one site's list is\n * skipped by every site in that site's group. The organization declares\n * the groups here; a site's own page says which one it is in.\n */\n { id: 'consent-groups', label: 'Consent groups' },\n // Beside the audiences rather than inside them (AGL-2410): a suppression is\n // not a list you build, it is the reason a list you built did not all get\n // mailed.\n { id: 'suppressions', label: 'Suppressions' },\n]\n\n/*\n * Rail ORDER decides where `/emails` lands: the shell redirects a bare hub\n * URL to the first section in this list the reader may open (AGL-2501). There\n * is deliberately no separate default constant — a second place to say which\n * section is first is a second place for it to disagree with the rail.\n */\n"],"names":["EMAILS_CONSOLE_SECTIONS","id","label"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAaD;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,MAAMA,0BAAwD;IACnE;;;;;;;;;;;;;;;;;;;;;;;GAuBC,GACD;QAAEC,IAAI;QAAYC,OAAO;IAAW;IACpC;;;;;;;GAOC,GACD;QAAED,IAAI;QAAaC,OAAO;IAAY;IACtC;QAAED,IAAI;QAAaC,OAAO;IAAY;IACtC,0EAA0E;IAC1E,2EAA2E;IAC3E,4EAA4E;IAC5E,UAAU;IACV;QAAED,IAAI;QAAUC,OAAO;IAAS;IAChC;;;;;;;;GAQC,GACD;QAAED,IAAI;QAAWC,OAAO;IAAU;IAClC;;;;;;;;;GASC,GACD;QAAED,IAAI;QAAkBC,OAAO;IAAiB;IAChD,4EAA4E;IAC5E,0EAA0E;IAC1E,UAAU;IACV;QAAED,IAAI;QAAgBC,OAAO;IAAe;CAC7C,CAED;;;;;CAKC,IAPA"}
@@ -55,7 +55,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
55
55
  * than a spinner that either finishes or does not. Closing the drawer stops
56
56
  * the loop and loses nothing: the job is durable, and reopening offers to
57
57
  * resume it from where it stopped.
58
- */ import { LIST_IMPORT_MAX_CHARACTERS, pluginDocsHelp } from "@aglyn/aglyn";
58
+ */ import { CSV_UPLOAD_MAX_CHARACTERS, pluginDocsHelp } from "@aglyn/aglyn";
59
59
  import { HelpTip } from "@aglyn/shared-ui-jsx";
60
60
  import { ICON_VARIANT_CLOSE } from "@aglyn/shared-data-enums";
61
61
  import { Container, MdiIcon, SrOnly } from "@aglyn/shared-ui-jsx";
@@ -152,7 +152,7 @@ export function ListImportDrawer(props) {
152
152
  const handleFile = useCallback(async (file)=>{
153
153
  if (!file) return;
154
154
  const contents = await file.text().catch(()=>'');
155
- if (contents.length > LIST_IMPORT_MAX_CHARACTERS) {
155
+ if (contents.length > CSV_UPLOAD_MAX_CHARACTERS) {
156
156
  return void enqueueSnackbar('That file is too large to read in one go. Split it and import the ' + 'pieces.', {
157
157
  variant: 'warning',
158
158
  allowDuplicate: true
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-import-drawer.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 * BRINGING AN EXISTING LIST IN — the screen.\n *\n * `docs/specs/email-competitive-gaps.md` G5: a customer arriving from another\n * product has a list and, until now, no way to bring it. The server half is\n * `server-list-import.ts`; this is the surface that walks an operator through\n * it.\n *\n * ## A drawer, because an import is three decisions and not a field\n *\n * Choose the file, read what is in it, then say whether you have these\n * people's permission. Stacked above the membership table those three steps\n * would have nowhere to grow — which is exactly how the audience card's\n * create form came to offer four of a rule's nine fields — and the middle\n * step is the one that must not be cramped: it is where the screening\n * warnings and the consent readout are shown, and both exist to be READ\n * before the operator attests.\n *\n * Creating is a drawer and picking is a dialog, so this composes\n * `NavigationDrawerComponent` directly, the same way the suppressions card's\n * Add does — the console's wrapper around it lives in an application a plugin\n * library may not import.\n *\n * ## The numbers on screen are the server's, always\n *\n * Nothing here parses a file, screens it, or decides anything about consent.\n * Every count and every sentence comes back from a route that read the\n * merchant's actual contacts and both suppression lists. A screen that\n * computed its own would be a second copy of the rule on the one surface\n * whose job is to tell the operator the truth about what is about to happen.\n *\n * ## The run is a loop of bounded requests, and it says so\n *\n * A large file is not one request. The drawer calls `email/list-import-run`\n * until it answers `complete`, showing the cursor against the total as it\n * goes, so an import of forty thousand addresses is a progress bar rather\n * than a spinner that either finishes or does not. Closing the drawer stops\n * the loop and loses nothing: the job is durable, and reopening offers to\n * resume it from where it stopped.\n */\n\nimport { LIST_IMPORT_MAX_CHARACTERS, pluginDocsHelp } from '@aglyn/aglyn'\nimport { HelpTip } from '@aglyn/shared-ui-jsx'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { Container, MdiIcon, SrOnly } from '@aglyn/shared-ui-jsx'\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n AlertTitle,\n Button,\n Checkbox,\n Chip,\n FormControlLabel,\n IconButton,\n LinearProgress,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { useUser } from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\n\nexport interface ListImportDrawerProps {\n open: boolean\n onClose: () => void\n hostId: string\n listId: string\n listName: string\n /**\n * Called whenever the run enrolls anybody.\n *\n * The audience page's subscriber total is a server aggregate taken once, so\n * an import that ran to completion behind a stale count would read as\n * having done nothing. Fired per run rather than once at the end: a large\n * import is watched, and a total that only moves at the end is a total that\n * looks stuck.\n */\n onMembershipChanged?: () => void\n}\n\n/** One address's verdict, as the preview reports it. */\ninterface AddressVerdict {\n input: string\n email: string | null\n refusal: string | null\n requiresAttestation: boolean\n summary: string\n}\n\n/** What the mechanical screening found. Signals, not verdicts. */\ninterface Screening {\n roleAccounts: number\n roleAccountSamples: string[]\n purchaseTellColumns: string[]\n declaresBasis: boolean\n}\n\n/** `email/list-import-preview`'s answer. */\ninterface ImportPreview {\n columns: string[]\n usable: number\n unusable: number\n duplicates: number\n overCeiling: boolean\n ceiling: number\n unusableSamples: string[]\n screening: Screening\n sampleSize: number\n verdicts: AddressVerdict[]\n optedIn: number\n needAttestation: number\n refused: number\n}\n\n/** The durable job, as `status` and `run` report it. */\ninterface ImportJob {\n importId: string\n status: string\n total: number\n cursor: number\n enrolled: number\n refused: number\n refusals: Record<string, number>\n attested: boolean\n}\n\n/** What a refusal reason is called on screen. */\nconst REFUSAL_LABELS: Record<string, string> = {\n declined: 'Declined marketing email',\n 'no-basis': 'No opt-in on record',\n 'suppressed-host': 'Unsubscribed from this site',\n 'suppressed-platform': 'Bounced or reported as spam',\n 'unroutable-address': 'Not a valid address',\n 'no-address': 'No address',\n}\n\nexport function ListImportDrawer(props: ListImportDrawerProps) {\n const { open, onClose, hostId, listId, listName, onMembershipChanged } = props\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n\n const [text, setText] = useState('')\n const [fileName, setFileName] = useState('')\n const [preview, setPreview] = useState<ImportPreview | null>(null)\n const [attested, setAttested] = useState(false)\n const [job, setJob] = useState<ImportJob | null>(null)\n const [busy, setBusy] = useState(false)\n /**\n * Set when the drawer closes mid-run, read by the loop.\n *\n * A ref and not state: the loop is an `async` function that has already\n * captured its closure, so a state value it read would be the value from\n * the render that started it — the loop would go on issuing requests after\n * the operator closed the drawer, which is the one thing \"closing stops it\"\n * has to mean.\n */\n const abandoned = useRef(false)\n\n const post = useCallback(\n async (route: string, body: Record<string, unknown>) => {\n const response = await authorizedFetch(user, `/api/email/${route}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, listId, ...body }),\n })\n const payload = await response.json().catch(() => ({}))\n return { ok: response.ok, payload }\n },\n [user, hostId, listId],\n )\n\n /*\n * An unfinished import is looked for when the DRAWER OPENS and at no other\n * time — never on the page's mount. A merchant reading their audiences pays\n * nothing for a feature they did not reach for, which is the read-cost rule\n * `emails-console-read-cost.spec.tsx` meters.\n */\n useEffect(() => {\n if (!open) return undefined\n abandoned.current = false\n let active = true\n void post('list-import-status', {}).then(({ ok, payload }) => {\n if (!active || !ok) return\n const found = payload?.job as ImportJob | null\n if (found && found.status !== 'complete') setJob(found)\n })\n return () => {\n active = false\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [open])\n\n const reset = useCallback(() => {\n setText('')\n setFileName('')\n setPreview(null)\n setAttested(false)\n setJob(null)\n }, [])\n\n const handleClose = useCallback(() => {\n // Stops the loop; the job survives and the next open offers to resume it.\n abandoned.current = true\n onClose()\n }, [onClose])\n\n const handleFile = useCallback(\n async (file: File | null | undefined) => {\n if (!file) return\n const contents = await file.text().catch(() => '')\n if (contents.length > LIST_IMPORT_MAX_CHARACTERS) {\n return void enqueueSnackbar(\n 'That file is too large to read in one go. Split it and import the ' +\n 'pieces.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setFileName(file.name)\n setText(contents)\n setPreview(null)\n setAttested(false)\n },\n [enqueueSnackbar],\n )\n\n const handlePreview = useCallback(async () => {\n if (busy || !text.trim()) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-import-preview', { text })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The file could not be read.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setPreview(payload as ImportPreview)\n setAttested(false)\n } catch {\n enqueueSnackbar('The file could not be read.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, text, post, enqueueSnackbar])\n\n /**\n * Drives one job to completion, one bounded request at a time.\n *\n * The loop's exit conditions are `complete`, an error, and the operator\n * closing the drawer. It never guesses how many runs a file needs — the\n * server answers `complete` when the cursor has reached the total, which is\n * the only party that knows.\n */\n const runToCompletion = useCallback(\n async (importId: string) => {\n setBusy(true)\n try {\n for (;;) {\n if (abandoned.current) return\n const { ok, payload } = await post('list-import-run', { importId })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The import could not continue.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setJob((previous) => ({\n importId,\n status: payload?.complete ? 'complete' : 'running',\n total: Number(payload?.total ?? previous?.total ?? 0),\n cursor: Number(payload?.cursor ?? 0),\n enrolled:\n (previous?.enrolled ?? 0) + Number(payload?.ranEnrolled ?? 0),\n refused:\n (previous?.refused ?? 0) + Number(payload?.ranRefused ?? 0),\n refusals: mergeCounts(\n previous?.refusals ?? {},\n (payload?.refusals ?? {}) as Record<string, number>,\n ),\n attested: previous?.attested ?? false,\n }))\n if (Number(payload?.ranEnrolled ?? 0)) onMembershipChanged?.()\n if (payload?.complete) {\n return void enqueueSnackbar('Import finished', {\n variant: 'success',\n persist: false,\n })\n }\n }\n } finally {\n setBusy(false)\n }\n },\n [post, enqueueSnackbar, onMembershipChanged],\n )\n\n const handleStart = useCallback(async () => {\n if (busy || !preview) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-import-start', {\n text,\n // The operator's assertion, not a basis they named: the server derives\n // a pass-through from each person's own record, and this flag can only\n // ever add the attributable kind.\n attestConsent: attested,\n })\n if (!ok) {\n setBusy(false)\n return void enqueueSnackbar(\n payload?.error ?? 'The import could not be started.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const started: ImportJob = {\n importId: String(payload?.importId ?? ''),\n status: 'running',\n total: Number(payload?.total ?? 0),\n cursor: 0,\n enrolled: 0,\n refused: 0,\n refusals: {},\n attested: payload?.attested === true,\n }\n setJob(started)\n setBusy(false)\n await runToCompletion(started.importId)\n } catch {\n setBusy(false)\n enqueueSnackbar('The import could not be started.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n }, [busy, preview, text, attested, post, enqueueSnackbar, runToCompletion])\n\n const running = !!job && job.status !== 'complete'\n const finished = !!job && job.status === 'complete'\n\n return (\n <NavigationDrawerComponent\n open={open}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={handleClose}\n AppBarProps={{ color: 'surface' }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={handleClose}\n sx={{ mr: 2 }}\n >\n <MdiIcon path={ICON_VARIANT_CLOSE.path} />\n <SrOnly>{'close drawer'}</SrOnly>\n </IconButton>\n <Typography variant=\"h6\" component=\"div\">\n {`Import into \"${listName}\"`}\n </Typography>\n {/*\n The help affordance every console surface carries, pointed at the\n section that explains what happens to an imported address — which\n is the question an operator has at exactly this moment.\n */}\n <HelpTip\n {...pluginDocsHelp('emailCampaigns', { anchor: '#import-a-list' })}\n />\n </>\n }\n appBarRight={\n <Button variant=\"outlined\" color=\"inherit\" onClick={handleClose}>\n {finished ? 'Done' : 'Cancel'}\n </Button>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n {job ? (\n <ImportProgressPanel\n job={job}\n busy={busy}\n onResume={() => void runToCompletion(job.importId)}\n onAnother={reset}\n />\n ) : (\n <>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'A CSV with an email column, or one address per line. Every ' +\n 'address goes through the same checks as one you type in ' +\n 'by hand: anyone who unsubscribed, bounced, or told you ' +\n 'they do not want marketing email is refused, and nothing ' +\n 'you upload can override that.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button variant=\"outlined\" component=\"label\" disabled={busy}>\n {'Choose file'}\n <input\n type=\"file\"\n accept=\".csv,.txt,text/csv,text/plain\"\n hidden\n onChange={(event) =>\n void handleFile(event.target.files?.[0])\n }\n />\n </Button>\n {fileName ? (\n <Chip size=\"small\" variant=\"outlined\" label={fileName} />\n ) : null}\n </Stack>\n <TextField\n label=\"Or paste the list\"\n value={text}\n onChange={(event) => {\n setText(event.target.value)\n setFileName('')\n /*\n * Any edit invalidates the answer. A preview belongs to the\n * exact text it was run for, and leaving a stale one on\n * screen would show counts, and an attestation checkbox,\n * for a file the operator has since changed.\n */\n setPreview(null)\n setAttested(false)\n }}\n multiline\n minRows={4}\n maxRows={12}\n fullWidth\n helperText={\n 'One address per line, or paste the whole CSV including ' +\n 'its header row.'\n }\n />\n {preview ? (\n <ImportReviewPanel\n preview={preview}\n attested={attested}\n onAttestedChange={setAttested}\n />\n ) : null}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ justifyContent: 'flex-end' }}\n >\n {preview ? (\n <Button\n variant=\"contained\"\n disabled={busy || preview.usable === 0}\n onClick={() => void handleStart()}\n >\n {attested\n ? `Import ${preview.usable} addresses`\n : `Import the ${preview.optedIn} with an opt-in`}\n </Button>\n ) : (\n <Button\n variant=\"contained\"\n disabled={busy || !text.trim()}\n onClick={() => void handlePreview()}\n >\n {busy ? 'Checking…' : 'Check this file'}\n </Button>\n )}\n </Stack>\n </>\n )}\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n )\n}\nListImportDrawer.displayName = 'ListImportDrawer'\n\n/** Adds two refusal tallies together. */\nfunction mergeCounts(\n base: Record<string, number>,\n extra: Record<string, number>,\n): Record<string, number> {\n const merged = { ...base }\n for (const [key, value] of Object.entries(extra)) {\n merged[key] = (merged[key] ?? 0) + Number(value ?? 0)\n }\n return merged\n}\n\n/**\n * WHAT IS IN THE FILE, and the one question the operator has to answer.\n *\n * Three blocks in the order they should be read: what the file contains, what\n * the screening noticed, and what the consent gate said about a sample. The\n * attestation control comes last on purpose — it is the act, and an act\n * offered above its own evidence is an act taken without it.\n */\nfunction ImportReviewPanel(props: {\n preview: ImportPreview\n attested: boolean\n onAttestedChange: (value: boolean) => void\n}) {\n const { preview, attested, onAttestedChange } = props\n const { screening } = preview\n return (\n <Stack spacing={2}>\n <Alert severity=\"info\">\n <AlertTitle>{`${preview.usable} addresses in this file`}</AlertTitle>\n <Stack spacing={0.5}>\n {preview.duplicates ? (\n <Typography variant=\"body2\">\n {`${preview.duplicates} repeated addresses were collapsed — the ` +\n 'same person twice is one member.'}\n </Typography>\n ) : null}\n {preview.unusable ? (\n <Typography variant=\"body2\">\n {`${preview.unusable} lines are not email addresses and will be ` +\n `skipped${\n preview.unusableSamples.length\n ? `, starting with: ${preview.unusableSamples\n .slice(0, 3)\n .join(', ')}`\n : ''\n }.`}\n </Typography>\n ) : null}\n {preview.overCeiling ? (\n <Typography variant=\"body2\">\n {`This file names more than ${preview.ceiling} addresses. Only ` +\n 'the first are staged — split the file and import the rest ' +\n 'separately. Nothing already on the list is affected.'}\n </Typography>\n ) : null}\n </Stack>\n </Alert>\n\n {screening.purchaseTellColumns.length ? (\n <Alert severity=\"warning\">\n <AlertTitle>{'This file looks bought or appended'}</AlertTitle>\n {`Its columns include ${screening.purchaseTellColumns.join(', ')}. ` +\n 'Purchased, rented and appended lists are not allowed, and ' +\n 'importing one puts every site sending through this domain at ' +\n 'risk. If the column name is a coincidence, carry on.'}\n </Alert>\n ) : null}\n\n {screening.roleAccounts ? (\n <Alert severity=\"warning\">\n <AlertTitle>\n {`${screening.roleAccounts} shared mailboxes`}\n </AlertTitle>\n {`Addresses like ${screening.roleAccountSamples\n .slice(0, 3)\n .join(', ')} reach a mailbox several people read, or nobody. ` +\n 'They are a common sign of a list built by collecting addresses ' +\n 'rather than by people signing up. They will be imported if you ' +\n 'go ahead.'}\n </Alert>\n ) : null}\n\n <Alert severity={preview.needAttestation ? 'warning' : 'success'}>\n <AlertTitle>\n {preview.sampleSize < preview.usable\n ? `Checked the first ${preview.sampleSize} of ${preview.usable}`\n : 'Checked every address'}\n </AlertTitle>\n <Stack spacing={0.5}>\n <Typography variant=\"body2\">\n {`${preview.optedIn} already have an opt-in on record, ` +\n `${preview.needAttestation} have no opt-in on record, and ` +\n `${preview.refused} cannot be added at all.`}\n </Typography>\n {preview.sampleSize < preview.usable ? (\n <Typography variant=\"body2\">\n {'The rest are checked the same way as the import runs, and ' +\n 'the result is reported as it goes.'}\n </Typography>\n ) : null}\n </Stack>\n </Alert>\n\n {preview.verdicts.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Address'}</TableCell>\n <TableCell>{'What we know'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {preview.verdicts.slice(0, 10).map((verdict, index) => (\n <TableRow key={`${verdict.input}-${index}`}>\n <TableCell>{verdict.email ?? verdict.input}</TableCell>\n <TableCell>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {verdict.summary}\n </Typography>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : null}\n\n {preview.needAttestation ? (\n <FormControlLabel\n control={\n <Checkbox\n checked={attested}\n onChange={(event) => onAttestedChange(event.target.checked)}\n />\n }\n label={\n <Typography variant=\"body2\">\n {'I have these people’s permission to send them marketing ' +\n 'email, and I can produce the record of it if asked. This ' +\n 'statement is stored against my account, with today’s date, ' +\n 'on every address it admits.'}\n {screening.declaresBasis\n ? ' The opt-in source and date your file declares are kept with it.'\n : ''}\n </Typography>\n }\n />\n ) : null}\n {preview.needAttestation && !attested ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Without that statement the import adds only the addresses that ' +\n 'already have an opt-in on record. Nobody else is added, and ' +\n 'nothing is deleted.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\n\n/** How far the run has got, and what it did. */\nfunction ImportProgressPanel(props: {\n job: ImportJob\n busy: boolean\n onResume: () => void\n onAnother: () => void\n}) {\n const { job, busy, onResume, onAnother } = props\n const done = job.status === 'complete'\n const percent = job.total ? Math.min((job.cursor / job.total) * 100, 100) : 0\n const refusals = Object.entries(job.refusals).filter(([, count]) => count > 0)\n return (\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {done\n ? `Finished. ${job.enrolled} of ${job.total} addresses were added.`\n : `${job.cursor} of ${job.total} addresses checked.`}\n </Typography>\n <LinearProgress\n variant=\"determinate\"\n value={percent}\n sx={{ borderRadius: 1 }}\n />\n {refusals.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Not added'}</TableCell>\n <TableCell align=\"right\">{'Addresses'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {refusals.map(([reason, count]) => (\n <TableRow key={reason}>\n <TableCell>{REFUSAL_LABELS[reason] ?? reason}</TableCell>\n <TableCell align=\"right\">{count}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : null}\n {!done ? (\n <Alert severity=\"info\">\n {'Large files are added in batches, so this takes a few minutes. ' +\n 'You can close this — the import picks up where it stopped when ' +\n 'you come back.'}\n </Alert>\n ) : null}\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n {done ? (\n <Button variant=\"outlined\" onClick={onAnother}>\n {'Import another file'}\n </Button>\n ) : (\n <Button variant=\"contained\" disabled={busy} onClick={onResume}>\n {busy ? 'Importing…' : 'Resume import'}\n </Button>\n )}\n </Stack>\n </Stack>\n )\n}\n\nexport default ListImportDrawer\n"],"names":["LIST_IMPORT_MAX_CHARACTERS","pluginDocsHelp","HelpTip","ICON_VARIANT_CLOSE","Container","MdiIcon","SrOnly","NavigationDrawerComponent","ScrollTable","useSnackbar","Alert","AlertTitle","Button","Checkbox","Chip","FormControlLabel","IconButton","LinearProgress","Stack","TableBody","TableCell","TableHead","TableRow","TextField","Typography","useCallback","useEffect","useRef","useState","useUser","authorizedFetch","REFUSAL_LABELS","declined","ListImportDrawer","props","open","onClose","hostId","listId","listName","onMembershipChanged","data","user","enqueueSnackbar","text","setText","fileName","setFileName","preview","setPreview","attested","setAttested","job","setJob","busy","setBusy","abandoned","post","route","body","response","method","headers","JSON","stringify","payload","json","catch","ok","undefined","current","active","then","found","status","reset","handleClose","handleFile","file","contents","length","variant","allowDuplicate","name","handlePreview","trim","error","runToCompletion","importId","previous","complete","total","Number","cursor","enrolled","ranEnrolled","refused","ranRefused","refusals","mergeCounts","persist","handleStart","attestConsent","started","String","running","finished","anchor","AppBarProps","color","appBarLeft","edge","onClick","sx","mr","path","component","appBarRight","gutterY","spacing","ImportProgressPanel","onResume","onAnother","direction","alignItems","disabled","input","type","accept","hidden","onChange","event","target","files","size","label","value","multiline","minRows","maxRows","fullWidth","helperText","ImportReviewPanel","onAttestedChange","justifyContent","usable","optedIn","displayName","base","extra","merged","key","Object","entries","screening","severity","duplicates","unusable","unusableSamples","slice","join","overCeiling","ceiling","purchaseTellColumns","roleAccounts","roleAccountSamples","needAttestation","sampleSize","verdicts","map","verdict","index","email","summary","control","checked","declaresBasis","done","percent","Math","min","filter","count","borderRadius","align","reason"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-import-drawer.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 * BRINGING AN EXISTING LIST IN — the screen.\n *\n * `docs/specs/email-competitive-gaps.md` G5: a customer arriving from another\n * product has a list and, until now, no way to bring it. The server half is\n * `server-list-import.ts`; this is the surface that walks an operator through\n * it.\n *\n * ## A drawer, because an import is three decisions and not a field\n *\n * Choose the file, read what is in it, then say whether you have these\n * people's permission. Stacked above the membership table those three steps\n * would have nowhere to grow — which is exactly how the audience card's\n * create form came to offer four of a rule's nine fields — and the middle\n * step is the one that must not be cramped: it is where the screening\n * warnings and the consent readout are shown, and both exist to be READ\n * before the operator attests.\n *\n * Creating is a drawer and picking is a dialog, so this composes\n * `NavigationDrawerComponent` directly, the same way the suppressions card's\n * Add does — the console's wrapper around it lives in an application a plugin\n * library may not import.\n *\n * ## The numbers on screen are the server's, always\n *\n * Nothing here parses a file, screens it, or decides anything about consent.\n * Every count and every sentence comes back from a route that read the\n * merchant's actual contacts and both suppression lists. A screen that\n * computed its own would be a second copy of the rule on the one surface\n * whose job is to tell the operator the truth about what is about to happen.\n *\n * ## The run is a loop of bounded requests, and it says so\n *\n * A large file is not one request. The drawer calls `email/list-import-run`\n * until it answers `complete`, showing the cursor against the total as it\n * goes, so an import of forty thousand addresses is a progress bar rather\n * than a spinner that either finishes or does not. Closing the drawer stops\n * the loop and loses nothing: the job is durable, and reopening offers to\n * resume it from where it stopped.\n */\n\nimport { CSV_UPLOAD_MAX_CHARACTERS, pluginDocsHelp } from '@aglyn/aglyn'\nimport { HelpTip } from '@aglyn/shared-ui-jsx'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { Container, MdiIcon, SrOnly } from '@aglyn/shared-ui-jsx'\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n AlertTitle,\n Button,\n Checkbox,\n Chip,\n FormControlLabel,\n IconButton,\n LinearProgress,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { useUser } from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\n\nexport interface ListImportDrawerProps {\n open: boolean\n onClose: () => void\n hostId: string\n listId: string\n listName: string\n /**\n * Called whenever the run enrolls anybody.\n *\n * The audience page's subscriber total is a server aggregate taken once, so\n * an import that ran to completion behind a stale count would read as\n * having done nothing. Fired per run rather than once at the end: a large\n * import is watched, and a total that only moves at the end is a total that\n * looks stuck.\n */\n onMembershipChanged?: () => void\n}\n\n/** One address's verdict, as the preview reports it. */\ninterface AddressVerdict {\n input: string\n email: string | null\n refusal: string | null\n requiresAttestation: boolean\n summary: string\n}\n\n/** What the mechanical screening found. Signals, not verdicts. */\ninterface Screening {\n roleAccounts: number\n roleAccountSamples: string[]\n purchaseTellColumns: string[]\n declaresBasis: boolean\n}\n\n/** `email/list-import-preview`'s answer. */\ninterface ImportPreview {\n columns: string[]\n usable: number\n unusable: number\n duplicates: number\n overCeiling: boolean\n ceiling: number\n unusableSamples: string[]\n screening: Screening\n sampleSize: number\n verdicts: AddressVerdict[]\n optedIn: number\n needAttestation: number\n refused: number\n}\n\n/** The durable job, as `status` and `run` report it. */\ninterface ImportJob {\n importId: string\n status: string\n total: number\n cursor: number\n enrolled: number\n refused: number\n refusals: Record<string, number>\n attested: boolean\n}\n\n/** What a refusal reason is called on screen. */\nconst REFUSAL_LABELS: Record<string, string> = {\n declined: 'Declined marketing email',\n 'no-basis': 'No opt-in on record',\n 'suppressed-host': 'Unsubscribed from this site',\n 'suppressed-platform': 'Bounced or reported as spam',\n 'unroutable-address': 'Not a valid address',\n 'no-address': 'No address',\n}\n\nexport function ListImportDrawer(props: ListImportDrawerProps) {\n const { open, onClose, hostId, listId, listName, onMembershipChanged } = props\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n\n const [text, setText] = useState('')\n const [fileName, setFileName] = useState('')\n const [preview, setPreview] = useState<ImportPreview | null>(null)\n const [attested, setAttested] = useState(false)\n const [job, setJob] = useState<ImportJob | null>(null)\n const [busy, setBusy] = useState(false)\n /**\n * Set when the drawer closes mid-run, read by the loop.\n *\n * A ref and not state: the loop is an `async` function that has already\n * captured its closure, so a state value it read would be the value from\n * the render that started it — the loop would go on issuing requests after\n * the operator closed the drawer, which is the one thing \"closing stops it\"\n * has to mean.\n */\n const abandoned = useRef(false)\n\n const post = useCallback(\n async (route: string, body: Record<string, unknown>) => {\n const response = await authorizedFetch(user, `/api/email/${route}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, listId, ...body }),\n })\n const payload = await response.json().catch(() => ({}))\n return { ok: response.ok, payload }\n },\n [user, hostId, listId],\n )\n\n /*\n * An unfinished import is looked for when the DRAWER OPENS and at no other\n * time — never on the page's mount. A merchant reading their audiences pays\n * nothing for a feature they did not reach for, which is the read-cost rule\n * `emails-console-read-cost.spec.tsx` meters.\n */\n useEffect(() => {\n if (!open) return undefined\n abandoned.current = false\n let active = true\n void post('list-import-status', {}).then(({ ok, payload }) => {\n if (!active || !ok) return\n const found = payload?.job as ImportJob | null\n if (found && found.status !== 'complete') setJob(found)\n })\n return () => {\n active = false\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [open])\n\n const reset = useCallback(() => {\n setText('')\n setFileName('')\n setPreview(null)\n setAttested(false)\n setJob(null)\n }, [])\n\n const handleClose = useCallback(() => {\n // Stops the loop; the job survives and the next open offers to resume it.\n abandoned.current = true\n onClose()\n }, [onClose])\n\n const handleFile = useCallback(\n async (file: File | null | undefined) => {\n if (!file) return\n const contents = await file.text().catch(() => '')\n if (contents.length > CSV_UPLOAD_MAX_CHARACTERS) {\n return void enqueueSnackbar(\n 'That file is too large to read in one go. Split it and import the ' +\n 'pieces.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setFileName(file.name)\n setText(contents)\n setPreview(null)\n setAttested(false)\n },\n [enqueueSnackbar],\n )\n\n const handlePreview = useCallback(async () => {\n if (busy || !text.trim()) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-import-preview', { text })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The file could not be read.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setPreview(payload as ImportPreview)\n setAttested(false)\n } catch {\n enqueueSnackbar('The file could not be read.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, text, post, enqueueSnackbar])\n\n /**\n * Drives one job to completion, one bounded request at a time.\n *\n * The loop's exit conditions are `complete`, an error, and the operator\n * closing the drawer. It never guesses how many runs a file needs — the\n * server answers `complete` when the cursor has reached the total, which is\n * the only party that knows.\n */\n const runToCompletion = useCallback(\n async (importId: string) => {\n setBusy(true)\n try {\n for (;;) {\n if (abandoned.current) return\n const { ok, payload } = await post('list-import-run', { importId })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The import could not continue.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setJob((previous) => ({\n importId,\n status: payload?.complete ? 'complete' : 'running',\n total: Number(payload?.total ?? previous?.total ?? 0),\n cursor: Number(payload?.cursor ?? 0),\n enrolled:\n (previous?.enrolled ?? 0) + Number(payload?.ranEnrolled ?? 0),\n refused:\n (previous?.refused ?? 0) + Number(payload?.ranRefused ?? 0),\n refusals: mergeCounts(\n previous?.refusals ?? {},\n (payload?.refusals ?? {}) as Record<string, number>,\n ),\n attested: previous?.attested ?? false,\n }))\n if (Number(payload?.ranEnrolled ?? 0)) onMembershipChanged?.()\n if (payload?.complete) {\n return void enqueueSnackbar('Import finished', {\n variant: 'success',\n persist: false,\n })\n }\n }\n } finally {\n setBusy(false)\n }\n },\n [post, enqueueSnackbar, onMembershipChanged],\n )\n\n const handleStart = useCallback(async () => {\n if (busy || !preview) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-import-start', {\n text,\n // The operator's assertion, not a basis they named: the server derives\n // a pass-through from each person's own record, and this flag can only\n // ever add the attributable kind.\n attestConsent: attested,\n })\n if (!ok) {\n setBusy(false)\n return void enqueueSnackbar(\n payload?.error ?? 'The import could not be started.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const started: ImportJob = {\n importId: String(payload?.importId ?? ''),\n status: 'running',\n total: Number(payload?.total ?? 0),\n cursor: 0,\n enrolled: 0,\n refused: 0,\n refusals: {},\n attested: payload?.attested === true,\n }\n setJob(started)\n setBusy(false)\n await runToCompletion(started.importId)\n } catch {\n setBusy(false)\n enqueueSnackbar('The import could not be started.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n }, [busy, preview, text, attested, post, enqueueSnackbar, runToCompletion])\n\n const running = !!job && job.status !== 'complete'\n const finished = !!job && job.status === 'complete'\n\n return (\n <NavigationDrawerComponent\n open={open}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={handleClose}\n AppBarProps={{ color: 'surface' }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={handleClose}\n sx={{ mr: 2 }}\n >\n <MdiIcon path={ICON_VARIANT_CLOSE.path} />\n <SrOnly>{'close drawer'}</SrOnly>\n </IconButton>\n <Typography variant=\"h6\" component=\"div\">\n {`Import into \"${listName}\"`}\n </Typography>\n {/*\n The help affordance every console surface carries, pointed at the\n section that explains what happens to an imported address — which\n is the question an operator has at exactly this moment.\n */}\n <HelpTip\n {...pluginDocsHelp('emailCampaigns', { anchor: '#import-a-list' })}\n />\n </>\n }\n appBarRight={\n <Button variant=\"outlined\" color=\"inherit\" onClick={handleClose}>\n {finished ? 'Done' : 'Cancel'}\n </Button>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n {job ? (\n <ImportProgressPanel\n job={job}\n busy={busy}\n onResume={() => void runToCompletion(job.importId)}\n onAnother={reset}\n />\n ) : (\n <>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'A CSV with an email column, or one address per line. Every ' +\n 'address goes through the same checks as one you type in ' +\n 'by hand: anyone who unsubscribed, bounced, or told you ' +\n 'they do not want marketing email is refused, and nothing ' +\n 'you upload can override that.'}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button variant=\"outlined\" component=\"label\" disabled={busy}>\n {'Choose file'}\n <input\n type=\"file\"\n accept=\".csv,.txt,text/csv,text/plain\"\n hidden\n onChange={(event) =>\n void handleFile(event.target.files?.[0])\n }\n />\n </Button>\n {fileName ? (\n <Chip size=\"small\" variant=\"outlined\" label={fileName} />\n ) : null}\n </Stack>\n <TextField\n label=\"Or paste the list\"\n value={text}\n onChange={(event) => {\n setText(event.target.value)\n setFileName('')\n /*\n * Any edit invalidates the answer. A preview belongs to the\n * exact text it was run for, and leaving a stale one on\n * screen would show counts, and an attestation checkbox,\n * for a file the operator has since changed.\n */\n setPreview(null)\n setAttested(false)\n }}\n multiline\n minRows={4}\n maxRows={12}\n fullWidth\n helperText={\n 'One address per line, or paste the whole CSV including ' +\n 'its header row.'\n }\n />\n {preview ? (\n <ImportReviewPanel\n preview={preview}\n attested={attested}\n onAttestedChange={setAttested}\n />\n ) : null}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ justifyContent: 'flex-end' }}\n >\n {preview ? (\n <Button\n variant=\"contained\"\n disabled={busy || preview.usable === 0}\n onClick={() => void handleStart()}\n >\n {attested\n ? `Import ${preview.usable} addresses`\n : `Import the ${preview.optedIn} with an opt-in`}\n </Button>\n ) : (\n <Button\n variant=\"contained\"\n disabled={busy || !text.trim()}\n onClick={() => void handlePreview()}\n >\n {busy ? 'Checking…' : 'Check this file'}\n </Button>\n )}\n </Stack>\n </>\n )}\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n )\n}\nListImportDrawer.displayName = 'ListImportDrawer'\n\n/** Adds two refusal tallies together. */\nfunction mergeCounts(\n base: Record<string, number>,\n extra: Record<string, number>,\n): Record<string, number> {\n const merged = { ...base }\n for (const [key, value] of Object.entries(extra)) {\n merged[key] = (merged[key] ?? 0) + Number(value ?? 0)\n }\n return merged\n}\n\n/**\n * WHAT IS IN THE FILE, and the one question the operator has to answer.\n *\n * Three blocks in the order they should be read: what the file contains, what\n * the screening noticed, and what the consent gate said about a sample. The\n * attestation control comes last on purpose — it is the act, and an act\n * offered above its own evidence is an act taken without it.\n */\nfunction ImportReviewPanel(props: {\n preview: ImportPreview\n attested: boolean\n onAttestedChange: (value: boolean) => void\n}) {\n const { preview, attested, onAttestedChange } = props\n const { screening } = preview\n return (\n <Stack spacing={2}>\n <Alert severity=\"info\">\n <AlertTitle>{`${preview.usable} addresses in this file`}</AlertTitle>\n <Stack spacing={0.5}>\n {preview.duplicates ? (\n <Typography variant=\"body2\">\n {`${preview.duplicates} repeated addresses were collapsed — the ` +\n 'same person twice is one member.'}\n </Typography>\n ) : null}\n {preview.unusable ? (\n <Typography variant=\"body2\">\n {`${preview.unusable} lines are not email addresses and will be ` +\n `skipped${\n preview.unusableSamples.length\n ? `, starting with: ${preview.unusableSamples\n .slice(0, 3)\n .join(', ')}`\n : ''\n }.`}\n </Typography>\n ) : null}\n {preview.overCeiling ? (\n <Typography variant=\"body2\">\n {`This file names more than ${preview.ceiling} addresses. Only ` +\n 'the first are staged — split the file and import the rest ' +\n 'separately. Nothing already on the list is affected.'}\n </Typography>\n ) : null}\n </Stack>\n </Alert>\n\n {screening.purchaseTellColumns.length ? (\n <Alert severity=\"warning\">\n <AlertTitle>{'This file looks bought or appended'}</AlertTitle>\n {`Its columns include ${screening.purchaseTellColumns.join(', ')}. ` +\n 'Purchased, rented and appended lists are not allowed, and ' +\n 'importing one puts every site sending through this domain at ' +\n 'risk. If the column name is a coincidence, carry on.'}\n </Alert>\n ) : null}\n\n {screening.roleAccounts ? (\n <Alert severity=\"warning\">\n <AlertTitle>\n {`${screening.roleAccounts} shared mailboxes`}\n </AlertTitle>\n {`Addresses like ${screening.roleAccountSamples\n .slice(0, 3)\n .join(', ')} reach a mailbox several people read, or nobody. ` +\n 'They are a common sign of a list built by collecting addresses ' +\n 'rather than by people signing up. They will be imported if you ' +\n 'go ahead.'}\n </Alert>\n ) : null}\n\n <Alert severity={preview.needAttestation ? 'warning' : 'success'}>\n <AlertTitle>\n {preview.sampleSize < preview.usable\n ? `Checked the first ${preview.sampleSize} of ${preview.usable}`\n : 'Checked every address'}\n </AlertTitle>\n <Stack spacing={0.5}>\n <Typography variant=\"body2\">\n {`${preview.optedIn} already have an opt-in on record, ` +\n `${preview.needAttestation} have no opt-in on record, and ` +\n `${preview.refused} cannot be added at all.`}\n </Typography>\n {preview.sampleSize < preview.usable ? (\n <Typography variant=\"body2\">\n {'The rest are checked the same way as the import runs, and ' +\n 'the result is reported as it goes.'}\n </Typography>\n ) : null}\n </Stack>\n </Alert>\n\n {preview.verdicts.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Address'}</TableCell>\n <TableCell>{'What we know'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {preview.verdicts.slice(0, 10).map((verdict, index) => (\n <TableRow key={`${verdict.input}-${index}`}>\n <TableCell>{verdict.email ?? verdict.input}</TableCell>\n <TableCell>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {verdict.summary}\n </Typography>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : null}\n\n {preview.needAttestation ? (\n <FormControlLabel\n control={\n <Checkbox\n checked={attested}\n onChange={(event) => onAttestedChange(event.target.checked)}\n />\n }\n label={\n <Typography variant=\"body2\">\n {'I have these people’s permission to send them marketing ' +\n 'email, and I can produce the record of it if asked. This ' +\n 'statement is stored against my account, with today’s date, ' +\n 'on every address it admits.'}\n {screening.declaresBasis\n ? ' The opt-in source and date your file declares are kept with it.'\n : ''}\n </Typography>\n }\n />\n ) : null}\n {preview.needAttestation && !attested ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Without that statement the import adds only the addresses that ' +\n 'already have an opt-in on record. Nobody else is added, and ' +\n 'nothing is deleted.'}\n </Typography>\n ) : null}\n </Stack>\n )\n}\n\n/** How far the run has got, and what it did. */\nfunction ImportProgressPanel(props: {\n job: ImportJob\n busy: boolean\n onResume: () => void\n onAnother: () => void\n}) {\n const { job, busy, onResume, onAnother } = props\n const done = job.status === 'complete'\n const percent = job.total ? Math.min((job.cursor / job.total) * 100, 100) : 0\n const refusals = Object.entries(job.refusals).filter(([, count]) => count > 0)\n return (\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {done\n ? `Finished. ${job.enrolled} of ${job.total} addresses were added.`\n : `${job.cursor} of ${job.total} addresses checked.`}\n </Typography>\n <LinearProgress\n variant=\"determinate\"\n value={percent}\n sx={{ borderRadius: 1 }}\n />\n {refusals.length ? (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Not added'}</TableCell>\n <TableCell align=\"right\">{'Addresses'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {refusals.map(([reason, count]) => (\n <TableRow key={reason}>\n <TableCell>{REFUSAL_LABELS[reason] ?? reason}</TableCell>\n <TableCell align=\"right\">{count}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n ) : null}\n {!done ? (\n <Alert severity=\"info\">\n {'Large files are added in batches, so this takes a few minutes. ' +\n 'You can close this — the import picks up where it stopped when ' +\n 'you come back.'}\n </Alert>\n ) : null}\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n {done ? (\n <Button variant=\"outlined\" onClick={onAnother}>\n {'Import another file'}\n </Button>\n ) : (\n <Button variant=\"contained\" disabled={busy} onClick={onResume}>\n {busy ? 'Importing…' : 'Resume import'}\n </Button>\n )}\n </Stack>\n </Stack>\n )\n}\n\nexport default ListImportDrawer\n"],"names":["CSV_UPLOAD_MAX_CHARACTERS","pluginDocsHelp","HelpTip","ICON_VARIANT_CLOSE","Container","MdiIcon","SrOnly","NavigationDrawerComponent","ScrollTable","useSnackbar","Alert","AlertTitle","Button","Checkbox","Chip","FormControlLabel","IconButton","LinearProgress","Stack","TableBody","TableCell","TableHead","TableRow","TextField","Typography","useCallback","useEffect","useRef","useState","useUser","authorizedFetch","REFUSAL_LABELS","declined","ListImportDrawer","props","open","onClose","hostId","listId","listName","onMembershipChanged","data","user","enqueueSnackbar","text","setText","fileName","setFileName","preview","setPreview","attested","setAttested","job","setJob","busy","setBusy","abandoned","post","route","body","response","method","headers","JSON","stringify","payload","json","catch","ok","undefined","current","active","then","found","status","reset","handleClose","handleFile","file","contents","length","variant","allowDuplicate","name","handlePreview","trim","error","runToCompletion","importId","previous","complete","total","Number","cursor","enrolled","ranEnrolled","refused","ranRefused","refusals","mergeCounts","persist","handleStart","attestConsent","started","String","running","finished","anchor","AppBarProps","color","appBarLeft","edge","onClick","sx","mr","path","component","appBarRight","gutterY","spacing","ImportProgressPanel","onResume","onAnother","direction","alignItems","disabled","input","type","accept","hidden","onChange","event","target","files","size","label","value","multiline","minRows","maxRows","fullWidth","helperText","ImportReviewPanel","onAttestedChange","justifyContent","usable","optedIn","displayName","base","extra","merged","key","Object","entries","screening","severity","duplicates","unusable","unusableSamples","slice","join","overCeiling","ceiling","purchaseTellColumns","roleAccounts","roleAccountSamples","needAttestation","sampleSize","verdicts","map","verdict","index","email","summary","control","checked","declaresBasis","done","percent","Math","min","filter","count","borderRadius","align","reason"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { type EmailTopic } from '@aglyn/aglyn';
1
+ import { type SubscriptionTopic } from '@aglyn/aglyn';
2
2
  import type { Firestore } from 'firebase/firestore';
3
3
  /**
4
4
  * Write one topic's document — the ONE writer of the topic catalog.
@@ -18,7 +18,7 @@ import type { Firestore } from 'firebase/firestore';
18
18
  *
19
19
  * ## Why a built-in saves the same way
20
20
  *
21
- * `DEFAULT_EMAIL_TOPICS` is the FLOOR of the catalog, not its initial
21
+ * `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog, not its initial
22
22
  * contents: the four built-ins have no stored document until somebody changes
23
23
  * one. `setDoc` at the built-in's own id is what creates the override, so the
24
24
  * same call serves a custom topic and a built-in being retired for the first
@@ -52,7 +52,7 @@ export declare function writeEmailTopic(firestore: Firestore, scope: readonly [s
52
52
  * different merge rules is how the composer comes to send a campaign under a
53
53
  * topic nobody can unsubscribe from.
54
54
  *
55
- * ORG-scoped, following `lists` (AGL-254) — see `email-topics.ts` for why the
55
+ * ORG-scoped, following `lists` (AGL-254) — see `subscription-topics.ts` for why the
56
56
  * definitions are org-shared while the recipient's opt-outs are per site.
57
57
  *
58
58
  * Ordered by the server rather than capped and re-sorted here (AGL-2501,
@@ -81,7 +81,7 @@ hostId: string | null, options?: {
81
81
  */
82
82
  orgId?: string | null;
83
83
  }): {
84
- topics: EmailTopic[];
84
+ topics: SubscriptionTopic[];
85
85
  /** `['orgs', orgId]`, or null until the org lookup settles. */
86
86
  scope: readonly [string, string] | null;
87
87
  };