@aglyn/plugins-email 1.0.0-beta.143

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 (144) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +58 -0
  4. package/src/index.d.ts +35 -0
  5. package/src/index.js +35 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-design-create-widget.d.ts +21 -0
  8. package/src/lib/components/campaign-design-create-widget.js +61 -0
  9. package/src/lib/components/campaign-design-create-widget.js.map +1 -0
  10. package/src/lib/components/campaign-sender-editor-widget.d.ts +18 -0
  11. package/src/lib/components/campaign-sender-editor-widget.js +39 -0
  12. package/src/lib/components/campaign-sender-editor-widget.js.map +1 -0
  13. package/src/lib/components/campaign-topic-options-widget.d.ts +19 -0
  14. package/src/lib/components/campaign-topic-options-widget.js +48 -0
  15. package/src/lib/components/campaign-topic-options-widget.js.map +1 -0
  16. package/src/lib/components/campaign-topic-select.d.ts +32 -0
  17. package/src/lib/components/campaign-topic-select.js +87 -0
  18. package/src/lib/components/campaign-topic-select.js.map +1 -0
  19. package/src/lib/components/dynamic-list-rule-fields.d.ts +173 -0
  20. package/src/lib/components/dynamic-list-rule-fields.js +1473 -0
  21. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -0
  22. package/src/lib/components/email-blocks.d.ts +111 -0
  23. package/src/lib/components/email-blocks.js +875 -0
  24. package/src/lib/components/email-blocks.js.map +1 -0
  25. package/src/lib/components/email-design-preview.d.ts +62 -0
  26. package/src/lib/components/email-design-preview.js +174 -0
  27. package/src/lib/components/email-design-preview.js.map +1 -0
  28. package/src/lib/components/email-screens-card.d.ts +42 -0
  29. package/src/lib/components/email-screens-card.js +277 -0
  30. package/src/lib/components/email-screens-card.js.map +1 -0
  31. package/src/lib/components/email-template-detail.d.ts +48 -0
  32. package/src/lib/components/email-template-detail.js +681 -0
  33. package/src/lib/components/email-template-detail.js.map +1 -0
  34. package/src/lib/components/email-topic-detail.d.ts +32 -0
  35. package/src/lib/components/email-topic-detail.js +293 -0
  36. package/src/lib/components/email-topic-detail.js.map +1 -0
  37. package/src/lib/components/email-topics-card.d.ts +46 -0
  38. package/src/lib/components/email-topics-card.js +327 -0
  39. package/src/lib/components/email-topics-card.js.map +1 -0
  40. package/src/lib/components/email-zones.d.ts +28 -0
  41. package/src/lib/components/email-zones.js +20 -0
  42. package/src/lib/components/email-zones.js.map +1 -0
  43. package/src/lib/components/emails-console-page.d.ts +32 -0
  44. package/src/lib/components/emails-console-page.js +229 -0
  45. package/src/lib/components/emails-console-page.js.map +1 -0
  46. package/src/lib/components/emails-console-sections.d.ts +36 -0
  47. package/src/lib/components/emails-console-sections.js +108 -0
  48. package/src/lib/components/emails-console-sections.js.map +1 -0
  49. package/src/lib/components/list-detail-card.d.ts +47 -0
  50. package/src/lib/components/list-detail-card.js +273 -0
  51. package/src/lib/components/list-detail-card.js.map +1 -0
  52. package/src/lib/components/list-edit-card.d.ts +11 -0
  53. package/src/lib/components/list-edit-card.js +287 -0
  54. package/src/lib/components/list-edit-card.js.map +1 -0
  55. package/src/lib/components/list-import-drawer.d.ts +22 -0
  56. package/src/lib/components/list-import-drawer.js +662 -0
  57. package/src/lib/components/list-import-drawer.js.map +1 -0
  58. package/src/lib/components/list-members-panel.d.ts +94 -0
  59. package/src/lib/components/list-members-panel.js +686 -0
  60. package/src/lib/components/list-members-panel.js.map +1 -0
  61. package/src/lib/components/lists-card.d.ts +28 -0
  62. package/src/lib/components/lists-card.js +377 -0
  63. package/src/lib/components/lists-card.js.map +1 -0
  64. package/src/lib/components/sending-domain-detail.d.ts +26 -0
  65. package/src/lib/components/sending-domain-detail.js +496 -0
  66. package/src/lib/components/sending-domain-detail.js.map +1 -0
  67. package/src/lib/components/sending-domains-card.d.ts +33 -0
  68. package/src/lib/components/sending-domains-card.js +962 -0
  69. package/src/lib/components/sending-domains-card.js.map +1 -0
  70. package/src/lib/components/sending-sender-drawer.d.ts +94 -0
  71. package/src/lib/components/sending-sender-drawer.js +543 -0
  72. package/src/lib/components/sending-sender-drawer.js.map +1 -0
  73. package/src/lib/components/suppressions-card.d.ts +49 -0
  74. package/src/lib/components/suppressions-card.js +639 -0
  75. package/src/lib/components/suppressions-card.js.map +1 -0
  76. package/src/lib/components/use-org-email-topics.d.ts +79 -0
  77. package/src/lib/components/use-org-email-topics.js +111 -0
  78. package/src/lib/components/use-org-email-topics.js.map +1 -0
  79. package/src/lib/constants/bundle-common.d.ts +18 -0
  80. package/src/lib/constants/bundle-common.js +18 -0
  81. package/src/lib/constants/bundle-common.js.map +1 -0
  82. package/src/lib/hooks/use-org-company-options.d.ts +20 -0
  83. package/src/lib/hooks/use-org-company-options.js +138 -0
  84. package/src/lib/hooks/use-org-company-options.js.map +1 -0
  85. package/src/lib/hooks/use-org-contact-fields.d.ts +40 -0
  86. package/src/lib/hooks/use-org-contact-fields.js +91 -0
  87. package/src/lib/hooks/use-org-contact-fields.js.map +1 -0
  88. package/src/lib/hooks/use-org-contact-segments.d.ts +16 -0
  89. package/src/lib/hooks/use-org-contact-segments.js +55 -0
  90. package/src/lib/hooks/use-org-contact-segments.js.map +1 -0
  91. package/src/lib/hooks/use-org-crm-views.d.ts +8 -0
  92. package/src/lib/hooks/use-org-crm-views.js +74 -0
  93. package/src/lib/hooks/use-org-crm-views.js.map +1 -0
  94. package/src/lib/hooks/use-org-lists.d.ts +8 -0
  95. package/src/lib/hooks/use-org-lists.js +47 -0
  96. package/src/lib/hooks/use-org-lists.js.map +1 -0
  97. package/src/lib/model/email-design-document.d.ts +52 -0
  98. package/src/lib/model/email-design-document.js +62 -0
  99. package/src/lib/model/email-design-document.js.map +1 -0
  100. package/src/lib/model/index.d.ts +64 -0
  101. package/src/lib/model/index.js +71 -0
  102. package/src/lib/model/index.js.map +1 -0
  103. package/src/lib/model/sending-domain-status.d.ts +99 -0
  104. package/src/lib/model/sending-domain-status.js +196 -0
  105. package/src/lib/model/sending-domain-status.js.map +1 -0
  106. package/src/lib/model/template-provenance.d.ts +113 -0
  107. package/src/lib/model/template-provenance.js +107 -0
  108. package/src/lib/model/template-provenance.js.map +1 -0
  109. package/src/lib/model/template-report.d.ts +158 -0
  110. package/src/lib/model/template-report.js +249 -0
  111. package/src/lib/model/template-report.js.map +1 -0
  112. package/src/lib/plugin.d.ts +27 -0
  113. package/src/lib/plugin.js +163 -0
  114. package/src/lib/plugin.js.map +1 -0
  115. package/src/lib/server-console.d.ts +116 -0
  116. package/src/lib/server-console.js +422 -0
  117. package/src/lib/server-console.js.map +1 -0
  118. package/src/lib/server-email-drafts.d.ts +104 -0
  119. package/src/lib/server-email-drafts.js +381 -0
  120. package/src/lib/server-email-drafts.js.map +1 -0
  121. package/src/lib/server-list-gate.d.ts +183 -0
  122. package/src/lib/server-list-gate.js +365 -0
  123. package/src/lib/server-list-gate.js.map +1 -0
  124. package/src/lib/server-list-import.d.ts +199 -0
  125. package/src/lib/server-list-import.js +632 -0
  126. package/src/lib/server-list-import.js.map +1 -0
  127. package/src/lib/server-suppressions.d.ts +135 -0
  128. package/src/lib/server-suppressions.js +295 -0
  129. package/src/lib/server-suppressions.js.map +1 -0
  130. package/src/lib/server.d.ts +19 -0
  131. package/src/lib/server.js +834 -0
  132. package/src/lib/server.js.map +1 -0
  133. package/src/lib/site.d.ts +26 -0
  134. package/src/lib/site.js +81 -0
  135. package/src/lib/site.js.map +1 -0
  136. package/src/lib/unsubscribe-link.d.ts +311 -0
  137. package/src/lib/unsubscribe-link.js +398 -0
  138. package/src/lib/unsubscribe-link.js.map +1 -0
  139. package/src/lib/utils/create-email-screen.d.ts +59 -0
  140. package/src/lib/utils/create-email-screen.js +59 -0
  141. package/src/lib/utils/create-email-screen.js.map +1 -0
  142. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  143. package/src/lib/utils/generate-preset-id.js +25 -0
  144. package/src/lib/utils/generate-preset-id.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-template-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 buildRoute,\n PageHeaderRecord,\n pluginDocsHelp,\n Route,\n} from '@aglyn/aglyn'\nimport { ICON_VARIANT_BESIGNER } from '@aglyn/shared-data-enums'\nimport { mdiBullhornOutline, mdiEyeOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n doc,\n documentId,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { Suspense, useMemo, useState } from 'react'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport {\n emailSendTimeMs,\n} from '@aglyn/shared-ui-email-campaigns/model/email-record'\nimport {\n campaignSendDisplay,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-container'\nimport { templateProvenance } from '../model/template-provenance'\nimport {\n templateReport,\n type TemplateCampaign,\n} from '../model/template-report'\nimport EmailDesignPreview from './email-design-preview'\nimport { EMAIL_TEMPLATE_RECIPIENTS_ZONE } from './email-zones'\nimport {\n Figure,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-email-campaigns/components/report-figures'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\n\n/**\n * How many of a template's messages one read covers.\n *\n * A CEILING on the read, not a page size: the report is a single figure per\n * quantity, so there is nothing to page through. Past it every total is a\n * floor and the report says so — the same treatment `audienceSizeTruncated`\n * gets, and for the same reason.\n */\nexport const TEMPLATE_CAMPAIGN_CEILING = 100\n\nconst previewDocsHelp = pluginDocsHelp('designedEmails', {\n anchor: '#send-it',\n excerpt:\n 'The template as an inbox receives it, drawn by the same renderer the ' +\n 'send path uses. Merge tokens are left standing — a real send fills them ' +\n 'from each recipient.',\n})\n\nconst templateDocsHelp = pluginDocsHelp('designedEmails', {\n anchor: '#send-it',\n excerpt:\n 'One email template: what it looks like, every message sent from it, ' +\n 'and what those messages did — each rate over the population it was ' +\n 'measured against.',\n})\n\nexport interface EmailTemplateDetailProps {\n hostId: string\n /** The `kind: 'email'` screen document this page is about. */\n screenId: string\n /** The emails hub URL, for the way back to the templates list. */\n basePath: string\n}\n\n/**\n * A message row on this page: what the report needs, plus what the ROW needs.\n *\n * `TemplateCampaign` is the report's shape and stays that — it is the input to\n * `templateReport`, and a field the figures do not read has no business in it.\n * The send time this table sorts on and the campaign its row menu offers are\n * both properties of the row rather than of the report.\n */\ntype TemplateMessage = TemplateCampaign & {\n scheduledForMs: number\n /** Absent on a message written before campaigns grouped their emails. */\n emailCampaignId?: string\n}\n\n/**\n * ONE EMAIL TEMPLATE: what it looks like, and what it has done.\n *\n * ## What was missing\n *\n * A template had a row in a list and no page. The only way to see one was to\n * open the besigner, which is the editor — it draws canvas nodes in a\n * browser, not the HTML an inbox receives — and the only way to see how it\n * performed was to find each message sent from it and read those reports one\n * at a time. A template used by six messages had its engagement in six places\n * and nowhere.\n *\n * ## Not necessarily this org's template\n *\n * A template can be installed from a marketplace listing, in which case its\n * content is versioned by somebody else and can be withdrawn. Provenance and\n * standing are read off the document itself — see `template-provenance.ts` —\n * so this page costs no marketplace read and still never presents an\n * installed template as locally authored.\n *\n * ## What this page reads\n *\n * The screen document, its published version, and the messages naming this\n * template — bounded at {@link TEMPLATE_CAMPAIGN_CEILING}. The recipients\n * table is its own card and its own request, so a reader who came for the\n * preview does not pay for the delivery-log query.\n */\nexport function EmailTemplateDetail(props: EmailTemplateDetailProps) {\n const { hostId, screenId, basePath } = props\n const { orgSlug, subdomain } = useConsoleHostRoute(hostId)\n /*\n * A campaign's page is another plugin's, so its address is asked for: the\n * owner publishes where a `campaign` is read and this page links there.\n * Null where nobody does, or before the console's URL has resolved.\n */\n const campaignHref = (campaignId: string): string | null =>\n orgSlug && subdomain\n ? pluginRecordHref('campaign', { orgSlug, host: subdomain }, campaignId)\n : null\n const RecipientsZone = useConsoleWidgetSlot()\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n\n /*==========================================\n * RENAMING THE DESIGN, on its own page.\n *\n * The name is the only thing that tells one design from another in the\n * campaign composer's picker, and until now nothing could change it — so a\n * site accumulated indistinguishable \"Untitled email\" rows. Edit lives\n * here, on the record's detail page, like every other record edit.\n *\n * `null` means \"not editing\": the field shows the live document's name\n * until the reader types, so an untouched field never writes a stale seed\n * back — only a value the reader actually entered is ever saved, and only\n * the one field.\n *=========================================*/\n const [nameDraft, setNameDraft] = useState<string | null>(null)\n const [renaming, setRenaming] = useState(false)\n const handleRename = async () => {\n const next = (nameDraft ?? '').trim()\n if (!next || renaming) return\n setRenaming(true)\n try {\n await updateDoc(doc(firestore, 'hosts', hostId, 'screens', screenId), {\n displayName: next,\n })\n setNameDraft(null)\n enqueueSnackbar('Design renamed', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The design could not be renamed', { variant: 'error' })\n } finally {\n setRenaming(false)\n }\n }\n\n const { data: screen, status } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId, 'screens', screenId),\n [firestore, hostId, screenId],\n { idField: '$id' },\n )\n // Three states, not two: a document still loading and one that does not\n // exist both arrive as `undefined`, and rendering an empty report for the\n // second is how a mistyped id reads as a template nobody ever sent.\n const notFound = status !== 'loading' && !screen\n const versionId: string | undefined = screen?.versionId\n\n const { data: version } = useFirestoreDoc<any>(\n () =>\n versionId\n ? doc(\n firestore,\n 'hosts',\n hostId,\n 'screens',\n screenId,\n 'versions',\n versionId,\n )\n : null,\n [firestore, hostId, screenId, versionId],\n )\n\n /*\n * The messages sent from this template, ordered by DOCUMENT ID.\n *\n * Not by date, and that is forced rather than chosen: a sent message\n * carries `sentAt`, a scheduled one carries `sendAtMs`, and no writer\n * stamps a `createdAt` — so `orderBy` on either would not mis-sort this\n * list, it would DROP every message written by the other branch. Ordering\n * on the document id is the one ordering every message satisfies, it needs\n * no composite index beside the equality filter, and the rows are sorted by\n * date below over a window this component already holds.\n *\n * One MORE than the ceiling, so truncation is a fact rather than a guess.\n */\n const { data: messageDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'campaigns'),\n where('templateScreenId', '==', screenId),\n orderBy(documentId()),\n limit(TEMPLATE_CAMPAIGN_CEILING + 1),\n ),\n [firestore, hostId, screenId],\n { idField: '$id' },\n )\n\n const truncated = (messageDocs?.length ?? 0) > TEMPLATE_CAMPAIGN_CEILING\n const messages = useMemo<TemplateCampaign[]>(\n () =>\n (messageDocs ?? [])\n .slice(0, TEMPLATE_CAMPAIGN_CEILING)\n .map((message: any) => ({\n campaignId: String(message.$id),\n subject: String(message.subject || 'Untitled email'),\n /*\n * A message that has not gone out has NO send time, and that is\n * what `null` means here — not \"unknown\". The report measures\n * engagement over sent messages only, so a scheduled one\n * contributes to the list below and to no figure above it.\n */\n sentAtMs:\n String(message.status ?? '') === 'sent'\n ? emailSendTimeMs(message) || null\n : null,\n status: String(message.status ?? 'sent'),\n audience: String(message.audience ?? ''),\n ...(message.listId ? { listId: String(message.listId) } : {}),\n ...(message.listName ? { listName: String(message.listName) } : {}),\n stats: message.stats,\n scheduledForMs: emailSendTimeMs(message),\n /*\n * The campaign this message belongs to, carried through so the row\n * can offer it. A message written before campaigns grouped anything\n * names no container, which is why it is optional rather than\n * defaulted to the message's own id — a menu item that navigated to\n * the message you are already looking at would be worse than one\n * that says the message belongs to no campaign.\n */\n ...(message[CAMPAIGN_SEND_CONTAINER_FIELD]\n ? {\n emailCampaignId: String(\n message[CAMPAIGN_SEND_CONTAINER_FIELD],\n ),\n }\n : {}),\n })) as TemplateMessage[],\n [messageDocs],\n )\n const orderedMessages = useMemo(\n () =>\n [...(messages as TemplateMessage[])].sort(\n (a, b) => b.scheduledForMs - a.scheduledForMs,\n ),\n [messages],\n )\n const report = useMemo(\n () => templateReport(messages, truncated),\n [messages, truncated],\n )\n const provenance = useMemo(() => templateProvenance(screen), [screen])\n\n const messageHref = (message: TemplateMessage) =>\n `${basePath}/messages/${message.campaignId}`\n\n /**\n * What one message sent from this template can be opened into.\n *\n * Its own report, and the campaign that grouped it. The template is the page\n * the reader is standing on, so it is not offered a third time. A message\n * sent before campaigns existed belongs to no container, and the item says\n * so rather than vanishing.\n */\n const messageActions = (message: TemplateMessage): RowActionsMenuItem[] => [\n {\n key: 'details',\n label: 'Open report',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: messageHref(message),\n },\n {\n key: 'campaign',\n label: 'Open its campaign',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n // The one href here that is not under this surface's own base path.\n href:\n (message.emailCampaignId && campaignHref(message.emailCampaignId)) ||\n undefined,\n disabled:\n !message.emailCampaignId || !campaignHref(message.emailCampaignId),\n disabledReason: message.emailCampaignId\n ? 'This site’s console URL has not resolved yet'\n : 'Sent before campaigns grouped their emails, so it belongs to none',\n },\n ]\n\n const listUrl = `${basePath}/templates`\n const besignerUrl =\n orgSlug && subdomain && versionId\n ? buildRoute(Route.SCREEN_BESIGNER, {\n orgSlug,\n host: subdomain,\n screenId,\n versionId,\n })\n : ''\n\n /*\n * The header's actions: the way back, and the way in.\n *\n * The same pair the screen, component, template and layout detail pages\n * carry — a link to the listing and a contained button into the besigner\n * wearing the besigner icon. It hangs off `CardDisplay`'s header rather\n * than the page header because Emails is a surface with a SECTION RAIL: an\n * action beside the page title would read as the whole surface's, and this\n * one belongs to the template the section is showing. The rule is the\n * console's own — page header where there are no sections, section card\n * header where there are — and it is why the Forms detail surface, which\n * has no rail, publishes its besigner button upward instead.\n */\n const headerActions = (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={listUrl}\n size=\"small\"\n color=\"primary\"\n >\n {'All templates'}\n </Button>\n {/*\n * A LINK once there is somewhere to go, and a plain disabled button\n * until then.\n *\n * Not one control with `disabled` on it: `disabled` renders an anchor\n * greyed out and still followable, so a template that has never been\n * saved would offer a besigner URL missing its version — a 404, which\n * reads as a broken console rather than as an empty template.\n */}\n {besignerUrl ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={besignerUrl}\n size=\"small\"\n variant=\"contained\"\n startIcon={\n <MdiIcon color=\"inherit\" path={ICON_VARIANT_BESIGNER.path} />\n }\n >\n {'Edit in besigner'}\n </Button>\n ) : (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled\n startIcon={\n <MdiIcon color=\"inherit\" path={ICON_VARIANT_BESIGNER.path} />\n }\n >\n {'Edit in besigner'}\n </Button>\n )}\n </Stack>\n )\n\n if (notFound) {\n return (\n <CardDisplay\n header={'Email template'}\n help={templateDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This template could not be loaded. It may have been deleted.'}\n </Typography>\n </CardDisplay>\n )\n }\n\n return (\n <Stack spacing={3}>\n {/* The page heading and the trail name the template; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord\n title={screen ? screen.displayName || screenId : undefined}\n />\n <CardDisplay\n header={'Email template'}\n help={templateDocsHelp}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={3}>\n {provenance.note ? (\n <Alert severity={provenance.warn ? 'warning' : 'info'}>\n {provenance.note}\n </Alert>\n ) : null}\n {report.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n\n <Section title=\"Name\">\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'flex-start' }}>\n <TextField\n label=\"Design name\"\n size=\"small\"\n value={nameDraft ?? String(screen?.displayName ?? '')}\n onChange={(event) => setNameDraft(event.target.value)}\n sx={{ minWidth: 280 }}\n helperText=\"What the campaign composer's design picker shows\"\n />\n <Button\n size=\"small\"\n disabled={nameDraft === null || !nameDraft.trim() || renaming}\n onClick={() => void handleRename()}\n >\n {renaming ? 'Saving…' : 'Rename'}\n </Button>\n </Stack>\n </Section>\n\n <Divider />\n\n {/*==========================================\n * WHAT HAPPENED TO THE MAIL, SUMMED.\n *\n * Counts first and rates second, because a rate is only readable\n * once you know what it was taken over — and because a count here\n * covers every message while a rate covers only the messages that\n * recorded its denominator. The rate labels say which.\n *=========================================*/}\n <Section title=\"Delivery\">\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Emails\"\n value={report.sentCampaigns}\n note=\"sent from this template\"\n />\n <Figure\n label=\"Addressed\"\n value={report.recipients}\n note=\"after each send's cap\"\n />\n <Figure\n label=\"Sent\"\n value={report.sent}\n note=\"accepted by the provider\"\n />\n <Figure\n label=\"Delivered\"\n value={report.delivered}\n note=\"accepted by the receiving server\"\n />\n <Figure label=\"Bounced\" value={report.bounced} note=\"of sent\" />\n <Figure\n label=\"Marked as spam\"\n value={report.complained}\n note=\"of delivered\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Engagement\">\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Opens\"\n value={report.opens}\n note=\"every open, repeats included\"\n />\n <Figure\n label=\"Readers who opened\"\n value={report.uniqueOpens}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Clicks\"\n value={report.clicks}\n note=\"every click, repeats included\"\n />\n <Figure\n label=\"Readers who clicked\"\n value={report.uniqueClicks}\n note=\"distinct recipients\"\n />\n <Figure\n label=\"Unsubscribed\"\n value={report.unsubscribes}\n note=\"through these emails' links\"\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Rates\">\n <Stack spacing={1}>\n <RateRow label=\"Delivery rate\" rate={report.rates.delivery} />\n <RateRow label=\"Open rate\" rate={report.rates.open} />\n <RateRow label=\"Click rate\" rate={report.rates.click} />\n <RateRow\n label=\"Click-to-open rate\"\n rate={report.rates.clickToOpen}\n />\n <RateRow label=\"Bounce rate\" rate={report.rates.bounce} />\n <RateRow label=\"Complaint rate\" rate={report.rates.complaint} />\n <RateRow\n label=\"Unsubscribe rate\"\n rate={report.rates.unsubscribe}\n />\n </Stack>\n </Section>\n\n <Divider />\n\n <Section title=\"Who this went to\">\n {report.audiences.length ? (\n <>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Audience'}</TableCell>\n <TableCell align=\"right\">{'Emails'}</TableCell>\n <TableCell align=\"right\">{'Addressed'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {report.audiences.map((audience) => (\n <TableRow key={audience.id}>\n <TableCell>{audience.label}</TableCell>\n <TableCell align=\"right\">\n {audience.campaigns.toLocaleString()}\n </TableCell>\n <TableCell align=\"right\" sx={{ fontWeight: 'bold' }}>\n {audience.addressed.toLocaleString()}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n {/*\n * The naming rule, stated rather than left to be discovered\n * from a list whose name no longer matches the one in the\n * audiences section.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Each list is named as it was when the email was sent, so ' +\n 'renaming or deleting a list does not rewrite what a past ' +\n 'send went to.'}\n </Typography>\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No email sent from this template has gone out yet.'}\n </Typography>\n )}\n </Section>\n\n {orderedMessages.length ? (\n <>\n <Divider />\n <Section title=\"Emails using this template\">\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Subject'}</TableCell>\n <TableCell>{'State'}</TableCell>\n <TableCell>{'When'}</TableCell>\n <TableCell align=\"right\">{'Addressed'}</TableCell>\n <TableCell align=\"right\">{'Opens'}</TableCell>\n <TableCell align=\"right\">{'Clicks'}</TableCell>\n <TableCell align=\"right\" />\n </TableRow>\n </TableHead>\n <TableBody>\n {orderedMessages.map((message) => (\n <TableRow\n key={message.campaignId}\n hover\n onClick={() => router.push(messageHref(message))}\n sx={{ cursor: 'pointer' }}\n >\n <TableCell>\n {/*\n The row's own handler would fire too and push the\n same route twice — one history entry per back\n press.\n */}\n <AppLink\n href={messageHref(message)}\n onClick={(event: {\n stopPropagation: () => void\n }) => event.stopPropagation()}\n >\n {message.subject}\n </AppLink>\n </TableCell>\n <TableCell>\n {/*\n What the message is DOING. One delivering an\n audience larger than one batch is written back as\n `scheduled` between runs, so the stored status\n reads \"Scheduled\" about a send already in progress.\n */}\n <Chip\n size=\"small\"\n label={campaignSendDisplay(message as never).label}\n />\n </TableCell>\n <TableCell>\n {message.scheduledForMs\n ? new Date(\n message.scheduledForMs,\n ).toLocaleString()\n : '—'}\n </TableCell>\n <TableCell align=\"right\">\n {Number(\n message.stats?.recipients ?? 0,\n ).toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {Number(message.stats?.opens ?? 0).toLocaleString()}\n </TableCell>\n <TableCell align=\"right\">\n {Number(message.stats?.clicks ?? 0).toLocaleString()}\n </TableCell>\n <TableCell\n align=\"right\"\n sx={{ width: 56 }}\n onClick={(event) => event.stopPropagation()}\n >\n <RowActionsMenu\n label={String(message.subject)}\n items={messageActions(message)}\n />\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n\n {/*\n Its own boundary: the table's code arrives after the report above it\n has drawn, and must not send that report back to a loading state.\n */}\n {RecipientsZone ? (\n <Suspense fallback={null}>\n <RecipientsZone\n slot={EMAIL_TEMPLATE_RECIPIENTS_ZONE.id}\n hostId={hostId}\n screenId={screenId}\n />\n </Suspense>\n ) : null}\n\n {/*\n * Last, and its own card — the same order the email's own page uses.\n * The figures are what a reader opens either page for and the frame is\n * the tallest thing on both, so at the top it pushes every number below\n * the fold.\n */}\n <CardDisplay\n header={'Preview'}\n help={previewDocsHelp}\n contentGutterX\n contentGutterY\n >\n <EmailDesignPreview\n hostId={hostId}\n nodes={version?.nodes}\n loading={version === undefined && Boolean(versionId)}\n subject={String(screen?.emailSubject ?? '')}\n preheader={String(screen?.emailPreheader ?? '')}\n emptyMessage={\n 'This template has nothing in it yet. Open it in the ' +\n 'besigner to build the email.'\n }\n />\n </CardDisplay>\n </Stack>\n )\n}\nEmailTemplateDetail.displayName = 'EmailTemplateDetail'\n\nexport default EmailTemplateDetail\n"],"names":["buildRoute","PageHeaderRecord","pluginDocsHelp","Route","ICON_VARIANT_BESIGNER","mdiBullhornOutline","mdiEyeOutline","AppLink","CardDisplay","MdiIcon","RowActionsMenu","ScrollTable","useConsoleHostRoute","useFirestore","useFirestoreCollection","useFirestoreDoc","Alert","Button","Chip","Divider","Stack","TableBody","TableCell","TableHead","TableRow","TextField","Typography","collection","doc","documentId","limit","orderBy","query","updateDoc","where","useRouter","Suspense","useMemo","useState","useSnackbar","CAMPAIGN_SEND_CONTAINER_FIELD","emailSendTimeMs","campaignSendDisplay","templateProvenance","templateReport","EmailDesignPreview","EMAIL_TEMPLATE_RECIPIENTS_ZONE","Figure","RateRow","Section","useConsoleWidgetSlot","pluginRecordHref","TEMPLATE_CAMPAIGN_CEILING","previewDocsHelp","anchor","excerpt","templateDocsHelp","EmailTemplateDetail","props","hostId","screenId","basePath","orgSlug","subdomain","campaignHref","campaignId","host","RecipientsZone","firestore","router","enqueueSnackbar","nameDraft","setNameDraft","renaming","setRenaming","handleRename","next","trim","displayName","variant","persist","error","console","data","screen","status","idField","notFound","versionId","version","messageDocs","truncated","length","messages","slice","map","message","String","$id","subject","sentAtMs","audience","listId","listName","stats","scheduledForMs","emailCampaignId","orderedMessages","sort","a","b","report","provenance","messageHref","messageActions","key","label","icon","path","size","href","undefined","disabled","disabledReason","listUrl","besignerUrl","SCREEN_BESIGNER","headerActions","direction","spacing","sx","alignItems","component","componentVariant","nativeButton","color","startIcon","header","help","contentGutterX","contentGutterY","HeaderProps","action","title","note","severity","warn","caveats","caveat","id","value","onChange","event","target","minWidth","helperText","onClick","useFlexGap","flexWrap","sentCampaigns","recipients","sent","delivered","bounced","complained","opens","uniqueOpens","clicks","uniqueClicks","unsubscribes","rate","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","audiences","align","campaigns","toLocaleString","fontWeight","addressed","hover","push","cursor","stopPropagation","Date","Number","width","items","fallback","slot","nodes","loading","Boolean","emailSubject","preheader","emailPreheader","emptyMessage"],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ export interface EmailTopicDetailProps {
2
+ hostId: string;
3
+ topicId: string;
4
+ /** `/…/emails`, so the page can route back to the list. */
5
+ basePath: string;
6
+ }
7
+ /**
8
+ * One topic's own page — where every change to an existing topic is made.
9
+ *
10
+ * The console's shape for a list surface is create-in-a-drawer, edit-on-the
11
+ * record's page, and this is the second half of it for topics. The list card
12
+ * lists and routes; nothing about a saved topic is editable from a form
13
+ * stacked above that table.
14
+ *
15
+ * ## Saving a built-in
16
+ *
17
+ * The four built-ins have no stored document until somebody changes one, so
18
+ * this page's save is the same `setDoc` for a built-in and a custom topic:
19
+ * the write CREATES the override document at the built-in's id. That is the
20
+ * whole overlay design — see `email-topics.ts`.
21
+ *
22
+ * ## Retire, not delete
23
+ *
24
+ * A retired topic leaves the composer's picker and the recipient's preference
25
+ * page, and stays resolvable everywhere else. It has to: campaigns already
26
+ * sent under it minted unsubscribe links carrying its id, those links are in
27
+ * inboxes, and an id that stops resolving is a preference page that cannot
28
+ * name the message the recipient is holding. There is no delete on this page,
29
+ * and the Firestore rules refuse one too.
30
+ */
31
+ export declare function EmailTopicDetail(props: EmailTopicDetailProps): import("react").JSX.Element;
32
+ export default EmailTopicDetail;
@@ -0,0 +1,293 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
+ import { PageHeaderRecord, pluginDocsHelp, resolveCampaignTopic, DEFAULT_EMAIL_TOPICS } from "@aglyn/aglyn";
19
+ import { CardDisplay, useConfirmationContext } from "@aglyn/shared-ui-jsx";
20
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
21
+ import { Button, Chip, Stack, TextField, Typography } from "@mui/material";
22
+ import Link from "next/link";
23
+ import { useCallback, useEffect, useState } from "react";
24
+ import { useFirestore } from "@aglyn/tenant-feature-instance";
25
+ import { useOrgEmailTopics, writeEmailTopic } from "./use-org-email-topics.js";
26
+ /**
27
+ * One topic's own page — where every change to an existing topic is made.
28
+ *
29
+ * The console's shape for a list surface is create-in-a-drawer, edit-on-the
30
+ * record's page, and this is the second half of it for topics. The list card
31
+ * lists and routes; nothing about a saved topic is editable from a form
32
+ * stacked above that table.
33
+ *
34
+ * ## Saving a built-in
35
+ *
36
+ * The four built-ins have no stored document until somebody changes one, so
37
+ * this page's save is the same `setDoc` for a built-in and a custom topic:
38
+ * the write CREATES the override document at the built-in's id. That is the
39
+ * whole overlay design — see `email-topics.ts`.
40
+ *
41
+ * ## Retire, not delete
42
+ *
43
+ * A retired topic leaves the composer's picker and the recipient's preference
44
+ * page, and stays resolvable everywhere else. It has to: campaigns already
45
+ * sent under it minted unsubscribe links carrying its id, those links are in
46
+ * inboxes, and an id that stops resolving is a preference page that cannot
47
+ * name the message the recipient is holding. There is no delete on this page,
48
+ * and the Firestore rules refuse one too.
49
+ */ export function EmailTopicDetail(props) {
50
+ const { hostId, topicId, basePath } = props;
51
+ const firestore = useFirestore();
52
+ const { enqueueSnackbar } = useSnackbar();
53
+ const { confirm } = useConfirmationContext();
54
+ const { topics, scope } = useOrgEmailTopics(hostId);
55
+ /*
56
+ * `resolveCampaignTopic` rather than a bare `find`, so an id that no longer
57
+ * names anything lands on a real topic instead of an empty form. A stale
58
+ * bookmark is the common way to arrive here with one.
59
+ */ const topic = resolveCampaignTopic(topicId, topics);
60
+ const known = topics.some((it)=>it.id === topicId);
61
+ const [name, setName] = useState('');
62
+ const [description, setDescription] = useState('');
63
+ /**
64
+ * Whether joining this stream needs a confirmation click.
65
+ *
66
+ * Three states, held as a string because a `Select` carries one and a
67
+ * boolean cannot: `''` defers to the site, `'on'` and `'off'` are decisions
68
+ * about this stream. Collapsing the first into a boolean would make "not
69
+ * chosen" indistinguishable from "off", and the site default would then be
70
+ * unreachable once anybody saved a topic.
71
+ */ const [doubleOptIn, setDoubleOptIn] = useState('');
72
+ const [loaded, setLoaded] = useState(false);
73
+ const [busy, setBusy] = useState(false);
74
+ /*
75
+ * Seed the fields ONCE, when the catalog first arrives.
76
+ *
77
+ * Re-seeding on every change of `topic` would overwrite what the operator is
78
+ * typing the moment the collection listener fires — and it fires on this
79
+ * page's own save.
80
+ */ useEffect(()=>{
81
+ if (loaded || !topics.length) return;
82
+ setName(topic.name);
83
+ setDescription(topic.description);
84
+ setDoubleOptIn(typeof topic.doubleOptIn === 'boolean' ? topic.doubleOptIn ? 'on' : 'off' : '');
85
+ setLoaded(true);
86
+ }, [
87
+ loaded,
88
+ topics,
89
+ topic
90
+ ]);
91
+ const storedDoubleOptIn = typeof topic.doubleOptIn === 'boolean' ? topic.doubleOptIn ? 'on' : 'off' : '';
92
+ const dirty = loaded && (name !== topic.name || description !== topic.description || doubleOptIn !== storedDoubleOptIn);
93
+ /**
94
+ * Save what is in the form, optionally changing the retired flag with it.
95
+ *
96
+ * The write itself is `writeEmailTopic`, which the list card's row menu also
97
+ * goes through — a topic retired from the list and one retired here have to
98
+ * leave the same document behind.
99
+ */ const save = useCallback(async (patch)=>{
100
+ if (!scope || busy) return;
101
+ const nextName = name.trim();
102
+ if (!nextName) {
103
+ return void enqueueSnackbar('Give the topic a name', {
104
+ variant: 'warning',
105
+ persist: false
106
+ });
107
+ }
108
+ setBusy(true);
109
+ try {
110
+ var _ref;
111
+ await writeEmailTopic(firestore, scope, {
112
+ id: topic.id,
113
+ name: nextName,
114
+ description: description.trim(),
115
+ archived: (_ref = patch == null ? void 0 : patch.archived) != null ? _ref : !!topic.archived,
116
+ // `null` clears the field, which is how "whatever the site says"
117
+ // is expressed. A stored `false` is a different answer and has to
118
+ // survive as one.
119
+ doubleOptIn: doubleOptIn === 'on' ? true : doubleOptIn === 'off' ? false : null
120
+ });
121
+ enqueueSnackbar('Topic saved', {
122
+ variant: 'success',
123
+ persist: false
124
+ });
125
+ } catch (error) {
126
+ console.error(error);
127
+ enqueueSnackbar('An error has occurred', {
128
+ variant: 'error'
129
+ });
130
+ } finally{
131
+ setBusy(false);
132
+ }
133
+ }, [
134
+ scope,
135
+ busy,
136
+ name,
137
+ description,
138
+ doubleOptIn,
139
+ firestore,
140
+ topic,
141
+ enqueueSnackbar
142
+ ]);
143
+ const toggleArchived = useCallback(async ()=>{
144
+ if (topic.archived) return void save({
145
+ archived: false
146
+ });
147
+ const accepted = await confirm({
148
+ title: `Retire “${topic.name}”?`,
149
+ description: 'It leaves the composer and the preference page, so nothing new can ' + 'be sent under it and recipients stop seeing it as a choice. ' + 'Campaigns already sent under it keep working — their unsubscribe ' + 'links still name this topic. You can bring it back at any time.',
150
+ confirmationText: 'Retire'
151
+ })// `confirm` resolves with NO VALUE and REJECTS on cancel, so gating on
152
+ // the resolved value alone makes this always return (AGL-950).
153
+ .then(()=>true).catch(()=>false);
154
+ if (!accepted) return;
155
+ await save({
156
+ archived: true
157
+ });
158
+ }, [
159
+ topic,
160
+ confirm,
161
+ save
162
+ ]);
163
+ /* The card, named so the page chrome above it is a plain list of
164
+ what this surface publishes upward. */ const card = /*#__PURE__*/ _jsx(CardDisplay, {
165
+ header: 'Topic',
166
+ subheader: /*#__PURE__*/ _jsxs(Stack, {
167
+ direction: "row",
168
+ spacing: 1,
169
+ sx: {
170
+ mt: 1,
171
+ alignItems: 'center'
172
+ },
173
+ children: [
174
+ topic.archived ? /*#__PURE__*/ _jsx(Chip, {
175
+ size: "small",
176
+ variant: "outlined",
177
+ color: "warning",
178
+ label: "Retired"
179
+ }) : null,
180
+ DEFAULT_EMAIL_TOPICS.some((it)=>it.id === topic.id) ? /*#__PURE__*/ _jsx(Typography, {
181
+ variant: "overline",
182
+ color: "text.secondary",
183
+ children: 'Built in'
184
+ }) : null
185
+ ]
186
+ }),
187
+ help: pluginDocsHelp('emailCampaigns', {
188
+ anchor: '#topics'
189
+ }),
190
+ contentGutterX: true,
191
+ contentGutterY: true,
192
+ contentBordered: "all",
193
+ HeaderProps: {
194
+ action: /*#__PURE__*/ _jsx(Button, {
195
+ component: Link,
196
+ href: `${basePath}/topics`,
197
+ size: "small",
198
+ children: 'All topics'
199
+ })
200
+ },
201
+ children: /*#__PURE__*/ _jsx(Stack, {
202
+ spacing: 2,
203
+ children: !scope ? /*#__PURE__*/ _jsx(Typography, {
204
+ variant: "body2",
205
+ color: "text.secondary",
206
+ children: 'This site has no organization, so it has no topic list.'
207
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
208
+ children: [
209
+ !known && loaded ? /*#__PURE__*/ _jsx(Typography, {
210
+ variant: "body2",
211
+ color: "text.secondary",
212
+ children: 'That topic no longer exists, so this is the one campaigns ' + 'fall back to.'
213
+ }) : null,
214
+ /*#__PURE__*/ _jsx(TextField, {
215
+ label: "Name",
216
+ size: "small",
217
+ value: name,
218
+ onChange: (event)=>setName(event.target.value),
219
+ disabled: busy || !loaded,
220
+ helperText: "What recipients see on the preference page."
221
+ }),
222
+ /*#__PURE__*/ _jsx(TextField, {
223
+ label: "Description",
224
+ size: "small",
225
+ multiline: true,
226
+ minRows: 2,
227
+ value: description,
228
+ onChange: (event)=>setDescription(event.target.value),
229
+ disabled: busy || !loaded,
230
+ helperText: "One sentence telling them what this stream is."
231
+ }),
232
+ /*#__PURE__*/ _jsxs(TextField, {
233
+ select: true,
234
+ label: "Confirmation",
235
+ size: "small",
236
+ value: doubleOptIn,
237
+ onChange: (event)=>setDoubleOptIn(event.target.value),
238
+ disabled: busy || !loaded,
239
+ helperText: 'Ask new subscribers to confirm by email before anything is ' + 'sent to them. Until they do, nothing on this stream reaches ' + 'them and they stay on your list.',
240
+ slotProps: {
241
+ select: {
242
+ native: true
243
+ }
244
+ },
245
+ children: [
246
+ /*#__PURE__*/ _jsx("option", {
247
+ value: "",
248
+ children: 'Use the site setting'
249
+ }),
250
+ /*#__PURE__*/ _jsx("option", {
251
+ value: "on",
252
+ children: 'Require a confirmation click'
253
+ }),
254
+ /*#__PURE__*/ _jsx("option", {
255
+ value: "off",
256
+ children: 'No confirmation for this stream'
257
+ })
258
+ ]
259
+ }),
260
+ /*#__PURE__*/ _jsxs(Stack, {
261
+ direction: "row",
262
+ spacing: 1,
263
+ children: [
264
+ /*#__PURE__*/ _jsx(Button, {
265
+ variant: "contained",
266
+ disabled: !dirty || busy,
267
+ onClick: ()=>void save(),
268
+ children: 'Save'
269
+ }),
270
+ /*#__PURE__*/ _jsx(Button, {
271
+ color: topic.archived ? 'primary' : 'error',
272
+ disabled: busy || !loaded,
273
+ onClick: ()=>void toggleArchived(),
274
+ children: topic.archived ? 'Restore' : 'Retire'
275
+ })
276
+ ]
277
+ })
278
+ ]
279
+ })
280
+ })
281
+ });
282
+ return /*#__PURE__*/ _jsxs(_Fragment, {
283
+ children: [
284
+ /*#__PURE__*/ _jsx(PageHeaderRecord, {
285
+ title: topic.name || topic.id
286
+ }),
287
+ card
288
+ ]
289
+ });
290
+ }
291
+ export default EmailTopicDetail;
292
+
293
+ //# sourceMappingURL=email-topic-detail.js.map
@@ -0,0 +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 { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicDetailProps {\n hostId: string\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 const { topics, scope } = useOrgEmailTopics(hostId)\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","useOrgEmailTopics","writeEmailTopic","EmailTopicDetail","props","hostId","topicId","basePath","firestore","enqueueSnackbar","confirm","topics","scope","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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"}
@@ -0,0 +1,46 @@
1
+ export interface EmailTopicsCardProps {
2
+ hostId: string;
3
+ /** The emails hub URL, which every topic route hangs beneath. */
4
+ basePath: string;
5
+ }
6
+ /**
7
+ * Email topics: the streams a recipient can leave one at a time.
8
+ *
9
+ * ## What this closes
10
+ *
11
+ * `docs/specs/email-competitive-gaps.md` §1f is a table of what six
12
+ * competitors have, and the preference-center row was the one where every one
13
+ * of them had something and we had nothing: unsubscribe was all-or-nothing per
14
+ * site. This card is the merchant's half of the fix — the recipient's half is
15
+ * the hosted preference page the campaign footer now links to.
16
+ *
17
+ * ## Why the four built-ins are not documents
18
+ *
19
+ * Every org that exists today has an empty `emailTopics` collection, and a
20
+ * seeding migration would leave any org created while it ran with none. So
21
+ * `DEFAULT_EMAIL_TOPICS` is the FLOOR of the catalog rather than its initial
22
+ * contents: the four are present for every org with no write anywhere, and
23
+ * saving one on its detail page writes a document at the same id that renames
24
+ * or retires it. A built-in with no stored document is not a missing row, it
25
+ * is a row nobody has needed to change.
26
+ *
27
+ * ## Create is a drawer; edit is the topic's own page
28
+ *
29
+ * The console's standing shape for a list surface: creating opens the shared
30
+ * `CreateArtifactDrawer`, the same one Screens, Components, Layouts and
31
+ * Templates use, and changing an existing record happens on that record's
32
+ * page rather than in a form stacked above the table. So this card lists and
33
+ * routes, and `email-topic-detail.tsx` edits.
34
+ *
35
+ * ## The row grammar the rest of the surface uses
36
+ *
37
+ * The same one the audiences table sets: the row opens the topic, the topic's
38
+ * name is ALSO a real link so it can be middle-clicked and copied, and the
39
+ * secondary actions sit behind the shared `RowActionsMenu` rather than as text
40
+ * buttons in the row. Retire is the reason the menu has more than one entry —
41
+ * it is the only thing that can be done to a topic without opening it, and it
42
+ * is destructive enough that it should not sit one mis-click from the row's
43
+ * own open handler.
44
+ */
45
+ export declare function EmailTopicsCard(props: EmailTopicsCardProps): import("react").JSX.Element;
46
+ export default EmailTopicsCard;