@aglyn/plugins-email 1.0.0-beta.173 → 1.0.0-beta.175
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.
- package/package.json +17 -17
- package/src/lib/components/dynamic-list-rule-fields.d.ts +4 -3
- package/src/lib/components/dynamic-list-rule-fields.js +1 -1
- package/src/lib/components/dynamic-list-rule-fields.js.map +1 -1
- package/src/lib/components/email-template-detail.js +22 -7
- package/src/lib/components/email-template-detail.js.map +1 -1
- package/src/lib/server.js +37 -19
- package/src/lib/server.js.map +1 -1
|
@@ -1 +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-jsx/components/measured-figures.component'\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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"}
|
|
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 useOrgDataScope,\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 campaignSendVisibleTo,\n orgCampaignSendsPath,\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-jsx/components/measured-figures.component'\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, and the\n * rows are sorted by date below over a window this component already holds.\n * The index the filters need is `visibleTo` + `templateScreenId`, declared\n * in `cloud/firebase-firestore.indexes.json`.\n *\n * One MORE than the ceiling, so truncation is a fact rather than a guess.\n *\n * The sends are the ORGANIZATION'S, each stamped with the one site it is\n * sent as. The `visibleTo` clause names this site: it keeps a sibling\n * site's sends of a same-id design out, and it is what makes the read\n * provable for a collaborator scoped to this site — the rules evaluate a\n * list per document, and an unfiltered one is refused whole. Nothing is\n * read until the site's org is known.\n */\n const { orgId } = useOrgDataScope({ hostId })\n const sendScope = useMemo(() => campaignSendVisibleTo(hostId), [hostId])\n const { data: messageDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(\n collection(firestore, ...orgCampaignSendsPath(orgId)),\n where('visibleTo', 'array-contains-any', sendScope),\n where('templateScreenId', '==', screenId),\n orderBy(documentId()),\n limit(TEMPLATE_CAMPAIGN_CEILING + 1),\n )\n : null,\n [firestore, orgId, sendScope, 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","useOrgDataScope","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","campaignSendVisibleTo","orgCampaignSendsPath","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","orgId","sendScope","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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"}
|
package/src/lib/server.js
CHANGED
|
@@ -22,7 +22,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
22
22
|
* module is a bundle `app-router-graph.spec.ts` refuses. Every name here is a
|
|
23
23
|
* pure function or a constant that lives in one leaf file.
|
|
24
24
|
*/ import { activeEmailTopics, mergeEmailTopics, normalizeEmailTopic, readTopicSubscriptionState, resolveCampaignTopic, EMAIL_TOPICS_COLLECTION, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
|
|
25
|
-
import { confirmTopicSubscription, EMAIL_FREQUENCY_SUBCOLLECTION, firebaseAdmin, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
|
|
25
|
+
import { confirmTopicSubscription, EMAIL_FREQUENCY_SUBCOLLECTION, firebaseAdmin, resolveCampaignSendRef, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
|
|
26
26
|
import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
|
|
27
27
|
/*
|
|
28
28
|
* The pure cadence rule from the shared email library, where the SEND path
|
|
@@ -208,19 +208,9 @@ const unsubscribeHandler = async (req, res)=>{
|
|
|
208
208
|
* the delivery webhook orders its writes the same way: the suppression is
|
|
209
209
|
* the write that must happen, and a statistic must never be able to cost
|
|
210
210
|
* one. A lost increment understates an unsubscribe rate; a lost
|
|
211
|
-
* suppression mails somebody who asked us not to.
|
|
212
|
-
*
|
|
213
|
-
|
|
214
|
-
* map and nothing else — for an unsubscribe arriving after the merchant
|
|
215
|
-
* deleted it, which is the fault the delivery webhook records against
|
|
216
|
-
* this exact shape. `update()` refuses a missing document, which is the
|
|
217
|
-
* behavior wanted: the count for a campaign nobody can open has no
|
|
218
|
-
* reader.
|
|
219
|
-
*/ if (created && isCampaignPathId(campaignId)) {
|
|
220
|
-
await firestore.collection('hosts').doc(hostId).collection('campaigns').doc(campaignId).update({
|
|
221
|
-
'stats.unsubscribes': FieldValue.increment(1)
|
|
222
|
-
}).catch(()=>undefined);
|
|
223
|
-
}
|
|
211
|
+
* suppression mails somebody who asked us not to. Where the send is, and
|
|
212
|
+
* why the write never creates it, is {@link countSendUnsubscribe}'s.
|
|
213
|
+
*/ if (created) await countSendUnsubscribe(firestore, hostId, campaignId);
|
|
224
214
|
return void sendPage(res, page(successBadge(brand.pal) + heading("You're unsubscribed") + paragraph(`You won't receive further emails from ${escapeHtml(brand.name)}.`, 20) + // Same signed params, so the click that just proved this is really
|
|
225
215
|
// this recipient's link doubles as the resubscribe link — no new
|
|
226
216
|
// token, no second email round-trip (AGL-2499).
|
|
@@ -230,6 +220,38 @@ const unsubscribeHandler = async (req, res)=>{
|
|
|
230
220
|
return void res.status(500).send('Unsubscribe failed — please try again');
|
|
231
221
|
}
|
|
232
222
|
};
|
|
223
|
+
/**
|
|
224
|
+
* One more unsubscribe on the send a link named (`cid`), never throwing.
|
|
225
|
+
*
|
|
226
|
+
* The link carries the site and the send id it was signed over, and nothing
|
|
227
|
+
* else; the send itself is the organization's
|
|
228
|
+
* (`orgs/{orgId}/campaigns/{sendId}`), or still the site's when the
|
|
229
|
+
* migration has not reached it, and `resolveCampaignSendRef` finds whichever
|
|
230
|
+
* holds it. A send in neither place was discarded, and there is nothing to
|
|
231
|
+
* count against.
|
|
232
|
+
*
|
|
233
|
+
* `update()`, never a merge-set: a merge-set would re-create a send deleted
|
|
234
|
+
* between the resolve and the write as a husk holding one `stats` map, and
|
|
235
|
+
* `update()` refuses a missing document — the count for a send nobody can
|
|
236
|
+
* open has no reader. Every failure is swallowed, because the suppression
|
|
237
|
+
* has already been written and a statistic must never be able to cost one.
|
|
238
|
+
*/ async function countSendUnsubscribe(firestore, hostId, campaignId) {
|
|
239
|
+
if (!isCampaignPathId(campaignId)) return;
|
|
240
|
+
try {
|
|
241
|
+
const orgId = await resolveOrgIdForHost(hostId);
|
|
242
|
+
const sendRef = await resolveCampaignSendRef({
|
|
243
|
+
hostId,
|
|
244
|
+
sendId: campaignId,
|
|
245
|
+
orgId,
|
|
246
|
+
firestore
|
|
247
|
+
});
|
|
248
|
+
await (sendRef == null ? void 0 : sendRef.update({
|
|
249
|
+
'stats.unsubscribes': FieldValue.increment(1)
|
|
250
|
+
}));
|
|
251
|
+
} catch (unused) {
|
|
252
|
+
// The suppression is the write that mattered, and it has landed.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
233
255
|
/**
|
|
234
256
|
* The whole-site suppression, written the same way from both routes that
|
|
235
257
|
* write one.
|
|
@@ -449,11 +471,7 @@ const unsubscribeHandler = async (req, res)=>{
|
|
|
449
471
|
campaignId,
|
|
450
472
|
topicId
|
|
451
473
|
});
|
|
452
|
-
if (created
|
|
453
|
-
await firestore.collection('hosts').doc(hostId).collection('campaigns').doc(campaignId).update({
|
|
454
|
-
'stats.unsubscribes': FieldValue.increment(1)
|
|
455
|
-
}).catch(()=>undefined);
|
|
456
|
-
}
|
|
474
|
+
if (created) await countSendUnsubscribe(firestore, hostId, campaignId);
|
|
457
475
|
return void sendPage(res, page(successBadge(brand.pal) + heading('Sorry to see you go') + paragraph(`<strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> ` + 'has been unsubscribed from every email ' + `${escapeHtml(brand.name)} sends.`, 20) + paragraph('Changed your mind? ' + `<a href="/api/email/resubscribe?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'Resubscribe</a>, or ' + `<a href="/api/email/preferences?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'pick just the emails you want</a>.', 0), 420, brand));
|
|
458
476
|
}
|
|
459
477
|
/*
|