@aglyn/plugins-email 1.0.0-beta.198 → 1.0.0-beta.200

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 (29) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/email-screens-card.d.ts +8 -9
  3. package/src/lib/components/email-screens-card.js +83 -100
  4. package/src/lib/components/email-screens-card.js.map +1 -1
  5. package/src/lib/components/email-template-detail.js +3 -2
  6. package/src/lib/components/email-template-detail.js.map +1 -1
  7. package/src/lib/components/list-edit-card.js +2 -2
  8. package/src/lib/components/list-edit-card.js.map +1 -1
  9. package/src/lib/components/list-members-panel.js +72 -93
  10. package/src/lib/components/list-members-panel.js.map +1 -1
  11. package/src/lib/components/lists-card.js +70 -77
  12. package/src/lib/components/lists-card.js.map +1 -1
  13. package/src/lib/components/org-email-templates-card.d.ts +10 -6
  14. package/src/lib/components/org-email-templates-card.js +145 -90
  15. package/src/lib/components/org-email-templates-card.js.map +1 -1
  16. package/src/lib/components/suppressions-card.js +73 -69
  17. package/src/lib/components/suppressions-card.js.map +1 -1
  18. package/src/lib/constants/list-queries.d.ts +34 -0
  19. package/src/lib/constants/list-queries.js +256 -0
  20. package/src/lib/constants/list-queries.js.map +1 -0
  21. package/src/lib/server-email-drafts.js +3 -4
  22. package/src/lib/server-email-drafts.js.map +1 -1
  23. package/src/lib/server-suppressions.js +3 -1
  24. package/src/lib/server-suppressions.js.map +1 -1
  25. package/src/lib/server.js +3 -1
  26. package/src/lib/server.js.map +1 -1
  27. package/src/lib/utils/email-template-soft-delete.d.ts +39 -0
  28. package/src/lib/utils/email-template-soft-delete.js +41 -0
  29. package/src/lib/utils/email-template-soft-delete.js.map +1 -0
@@ -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 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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"}
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 { artifactRenameListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\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 // The keys the design lists find it by (AGL-3321).\n ...artifactRenameListKeys('screens', 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","artifactRenameListKeys","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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"}
@@ -64,6 +64,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
64
64
  * when it differs from what is stored — which is also how a list that never
65
65
  * had one gets the site it is first set up on.
66
66
  */ import { normalizeDynamicListRule, PageHeaderRecord, pluginDocsHelp } from "@aglyn/aglyn";
67
+ import { nameSearchFields } from "@aglyn/aglyn/app-utils/name-search";
67
68
  import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
68
69
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
69
70
  import { Alert, Button, MenuItem, Stack, TextField, Typography } from "@mui/material";
@@ -137,8 +138,7 @@ export function ListEditCard(props) {
137
138
  */ if (!next || !scope || busy) return;
138
139
  setBusy(true);
139
140
  try {
140
- await updateDoc(doc(firestore, scope[0], scope[1], 'lists', listId), _extends({
141
- name: next,
141
+ await updateDoc(doc(firestore, scope[0], scope[1], 'lists', listId), _extends({}, nameSearchFields(next), {
142
142
  kind,
143
143
  /*
144
144
  * The rule is written whichever kind was chosen. On a live list it is
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-edit-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\n/**\n * A LIST'S SETTINGS: its name, how its membership is decided, and the filters\n * that decide it.\n *\n * ## Why it is its own route\n *\n * Editing the rule that decides an audience is not a thing to do in a row of a\n * table. It is nine fields, several of them consequential enough that the\n * merchant should see what they currently say before changing them, and a\n * rule saved by accident silently re-populates the audience on the next sweep.\n * A route also means the edit is linkable and the back button leaves it.\n *\n * ## The rule is edited as a DRAFT and written once\n *\n * The controls hold text; `draftToRule` turns it into a rule; Save writes that\n * rule. Nothing is written per keystroke, because a dynamic list's rule is\n * read by a sweep every fifteen minutes, which would otherwise re-materialize\n * the audience against a half-typed intermediate state.\n *\n * ## The two outcomes, and the sentence that tells them apart\n *\n * The SAME filters serve both kinds of list, and the difference is what\n * happens afterwards:\n *\n * - **Live** keeps the rule. Membership is re-evaluated on the sweep and the\n * audience keeps growing as people start matching.\n * - **Fixed** does not. The filters are a way to FIND people; who is on the\n * list is whoever was added, and it stays that way.\n *\n * A reader must never be unsure which they have, so the control says it in\n * those words and the page repeats it under whichever is selected. Switching\n * is safe in both directions and neither drops anybody: the sweep reconciles\n * away only rows IT wrote (`via: 'rule'`), so a person somebody added by hand\n * survives becoming live, and everyone on the list survives becoming fixed.\n * The rule is kept on a fixed list rather than deleted — it is the filter that\n * found these people, and losing it on a toggle would lose the work.\n *\n * ## Whose people the filters read is a choice, not a side effect\n *\n * A list is the organization's and its rule reads ONE site's leads, members\n * and form submissions — the list's `hostId`. That is written when somebody\n * chooses it and at no other time. A rename saved from another site's page\n * must not move the filters onto that site's people: the next sweep would\n * re-draw the whole audience from somebody else's customers. So the page\n * reads the stored site, offers the change in words, and writes `hostId` only\n * when it differs from what is stored — which is also how a list that never\n * had one gets the site it is first set up on.\n */\n\nimport {\n normalizeDynamicListRule,\n PageHeaderRecord,\n pluginDocsHelp,\n} from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Button,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc, updateDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useRef, useState } from 'react'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport DynamicListRuleFields, {\n draftToRule,\n useRuleDraft,\n} from './dynamic-list-rule-fields'\nimport {\n OrgSiteSelect,\n useEmailDataScope,\n useEmailOrgMount,\n} from './email-org-mount'\n\nexport interface ListEditCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n listId: string\n /** The emails hub URL, which the audiences routes hang beneath. */\n basePath: string\n}\n\nexport function ListEditCard(props: ListEditCardProps) {\n const { hostId, listId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const orgMount = useEmailOrgMount()\n const { scope } = useEmailDataScope(hostId)\n\n const { data: list, status } = useFirestoreDoc<Record<string, any>>(\n () => (scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null),\n [firestore, scope, listId],\n )\n\n const storedName = String(list?.['name'] ?? '')\n const storedKind: 'manual' | 'dynamic' =\n list?.['kind'] === 'dynamic' ? 'dynamic' : 'manual'\n /*\n * Normalized before the form ever sees it, by the same function the\n * materializer reads it back through. A rule shown one way and evaluated\n * another is the defect this screen exists to make visible.\n */\n const storedRule = list ? normalizeDynamicListRule(list['rule']) : undefined\n\n const [name, setName] = useState('')\n const [kind, setKind] = useState<'manual' | 'dynamic'>('manual')\n const dynamic = kind === 'dynamic'\n /*\n * Seeded ONCE, when the document first arrives. The listen is live, so\n * re-seeding on every snapshot would take the field away from whoever is\n * typing in it — including on the snapshot their own save produces.\n */\n const seeded = useRef(false)\n useEffect(() => {\n if (!list || seeded.current) return\n seeded.current = true\n setName(storedName)\n setKind(storedKind)\n }, [list, storedName, storedKind])\n\n const [draft, setDraft] = useRuleDraft(storedRule, listId)\n const [busy, setBusy] = useState(false)\n\n /*\n * WHOSE PEOPLE THE FILTERS READ — see the file header.\n *\n * The stored site stands until somebody chooses another. A list with none\n * yet starts on the site whose page this is, or on the organization's page\n * on the reader's pick this session (or the only site). A stored site the\n * org's page does not list — one since deleted — is not offered as the\n * answer there, so the reader chooses a live one.\n */\n const storedHostId = String(list?.['hostId'] ?? '')\n const [chosenHostId, setChosenHostId] = useState('')\n const storedIsOffered = orgMount\n ? orgMount.hosts.some((site) => site.id === storedHostId)\n : Boolean(storedHostId)\n const ruleHostId: string =\n chosenHostId ||\n (storedIsOffered ? storedHostId : '') ||\n (hostId ?? orgMount?.pickedHostId ?? '')\n /** Under a site, the filters were last set up on a different one. */\n const drawsFromAnotherSite =\n hostId != null && Boolean(storedHostId) && storedHostId !== hostId\n\n const audiencesHref = `${basePath}/audiences`\n const detailHref = `${audiencesHref}/${listId}`\n\n const handleSave = async () => {\n const next = name.trim()\n /*\n * An empty name is not a rename. The list table orders on `name`, and\n * `orderBy` drops a document that does not carry the field — so a list\n * renamed to nothing would vanish from the page that lists it.\n */\n if (!next || !scope || busy) return\n setBusy(true)\n try {\n await updateDoc(doc(firestore, scope[0], scope[1], 'lists', listId), {\n name: next,\n kind,\n /*\n * The rule is written whichever kind was chosen. On a live list it is\n * what the sweep evaluates; on a fixed one it is the filter that finds\n * people to add, and keeping it is what lets a merchant refine the\n * search across visits instead of retyping it.\n */\n rule: draftToRule(draft),\n /*\n * WHICH SITE'S people the rule draws from.\n *\n * Lists are org-shared but leads, members and form submissions are\n * host-owned, and org contacts are read narrowed to one host. A rule\n * with no host has no silos at all, so the sweep skips it. Written\n * only when it changes — see the file header.\n */\n ...(ruleHostId && ruleHostId !== storedHostId\n ? { hostId: ruleHostId }\n : {}),\n })\n enqueueSnackbar('List saved', { variant: 'success', persist: false })\n router.push(detailHref)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The list was not saved', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n }\n\n const headerActions = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Back to the audience'}\n </Button>\n )\n\n if (!list) {\n const settled = Boolean(scope) && status !== 'loading'\n return (\n <CardDisplay\n header={'Edit list'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? 'This list could not be loaded. It may have been deleted.'\n : 'Loading this audience…'}\n </Typography>\n </CardDisplay>\n )\n }\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Edit audience'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: dynamic ? '#lists-built-from-a-rule' : '#manual-lists',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"List name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n sx={{ flexGrow: 1, minWidth: 240, maxWidth: 360 }}\n />\n {/*\n The one decision on this page that changes what the list DOES, so\n the options are named for their outcome rather than for the field\n they set. \"Dynamic\" and \"manual\" describe the implementation; \"keeps\n growing\" and \"does not change\" describe what the merchant gets.\n */}\n <TextField\n select\n size=\"small\"\n label=\"Membership\"\n value={kind}\n onChange={(event) =>\n setKind(event.target.value as 'manual' | 'dynamic')\n }\n sx={{ minWidth: 240 }}\n >\n <MenuItem value=\"dynamic\">{'Live — keeps growing'}</MenuItem>\n <MenuItem value=\"manual\">{'Fixed — does not change'}</MenuItem>\n </TextField>\n </Stack>\n\n <Alert severity={dynamic ? 'info' : 'success'}>\n {dynamic\n ? 'Everyone matching these filters is enrolled automatically, ' +\n 'about every fifteen minutes, and leaves when they stop ' +\n 'matching. Anyone added by hand stays. Being matched is not ' +\n 'consent to be emailed — a campaign still only reaches people ' +\n 'whose consent is on record.'\n : 'Membership does not change on its own. Use the filters below ' +\n 'to FIND people, then add them from the audience page — who ' +\n 'you add is who stays on the list.'}\n </Alert>\n\n {/*\n WHOSE PEOPLE, said before the filters that read them. On the org\n page it is a picker. Under a site it is said only when the stored\n site is another one, with the one control that moves it here.\n */}\n {orgMount ? (\n <OrgSiteSelect\n mount={orgMount}\n label=\"Site\"\n value={ruleHostId}\n onChange={setChosenHostId}\n helperText={\n 'The site whose leads, members and form submissions these ' +\n 'filters read. A list draws from one site’s people.'\n }\n />\n ) : drawsFromAnotherSite ? (\n <Alert\n severity=\"info\"\n action={\n <Button\n color=\"inherit\"\n size=\"small\"\n onClick={() =>\n setChosenHostId(chosenHostId ? '' : String(hostId))\n }\n >\n {chosenHostId ? 'Keep the other site' : 'Use this site'}\n </Button>\n }\n >\n {chosenHostId\n ? 'Saving moves these filters onto this site’s people. The ' +\n 'next sweep re-draws the audience from them.'\n : 'These filters read another site’s people — the site this ' +\n 'list was set up on. Saving here keeps it that way.'}\n </Alert>\n ) : null}\n\n {scope && ruleHostId ? (\n <DynamicListRuleFields\n key={ruleHostId}\n scope={scope as readonly [string, string]}\n hostId={ruleHostId}\n draft={draft}\n onChange={setDraft}\n listId={listId}\n />\n ) : orgMount ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Choose the site whose people these filters read to edit them.'}\n </Typography>\n ) : null}\n {/*\n A rule with no source matches nobody, and an audience emptied that\n way looks exactly like one whose sweep has not run yet. It is only a\n warning on a LIVE list: on a fixed one an unset filter selects\n nothing to add and takes nobody off.\n */}\n {draft.sources.length || !dynamic ? null : (\n <Alert severity=\"warning\">\n {'These filters draw from no source, so they match nobody. The ' +\n 'next sweep would empty the list of everyone the rule enrolled.'}\n </Alert>\n )}\n\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n disabled={busy || !name.trim() || !scope}\n onClick={() => void handleSave()}\n >\n {busy ? 'Saving…' : 'Save'}\n </Button>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n >\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/*\n The audience being edited. This route is one level BELOW it, and the\n way back is the card header's own button — the trail names where the\n reader is and does not link its last step.\n */}\n <PageHeaderRecord title={storedName || listId} />\n {card}\n </>\n )\n}\nListEditCard.displayName = 'ListEditCard'\n\nexport default ListEditCard\n"],"names":["normalizeDynamicListRule","PageHeaderRecord","pluginDocsHelp","AppLink","CardDisplay","useSnackbar","Alert","Button","MenuItem","Stack","TextField","Typography","doc","updateDoc","useRouter","useEffect","useRef","useState","useFirestore","useFirestoreDoc","DynamicListRuleFields","draftToRule","useRuleDraft","OrgSiteSelect","useEmailDataScope","useEmailOrgMount","ListEditCard","props","hostId","listId","basePath","firestore","router","enqueueSnackbar","orgMount","scope","data","list","status","storedName","String","storedKind","storedRule","undefined","name","setName","kind","setKind","dynamic","seeded","current","draft","setDraft","busy","setBusy","storedHostId","chosenHostId","setChosenHostId","storedIsOffered","hosts","some","site","id","Boolean","ruleHostId","pickedHostId","drawsFromAnotherSite","audiencesHref","detailHref","handleSave","next","trim","rule","variant","persist","push","error","console","headerActions","component","componentVariant","nativeButton","href","size","color","settled","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","card","contentBordered","spacing","direction","useFlexGap","sx","flexWrap","label","value","onChange","event","target","flexGrow","minWidth","maxWidth","select","severity","mount","helperText","onClick","sources","length","disabled","title","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-edit-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\n/**\n * A LIST'S SETTINGS: its name, how its membership is decided, and the filters\n * that decide it.\n *\n * ## Why it is its own route\n *\n * Editing the rule that decides an audience is not a thing to do in a row of a\n * table. It is nine fields, several of them consequential enough that the\n * merchant should see what they currently say before changing them, and a\n * rule saved by accident silently re-populates the audience on the next sweep.\n * A route also means the edit is linkable and the back button leaves it.\n *\n * ## The rule is edited as a DRAFT and written once\n *\n * The controls hold text; `draftToRule` turns it into a rule; Save writes that\n * rule. Nothing is written per keystroke, because a dynamic list's rule is\n * read by a sweep every fifteen minutes, which would otherwise re-materialize\n * the audience against a half-typed intermediate state.\n *\n * ## The two outcomes, and the sentence that tells them apart\n *\n * The SAME filters serve both kinds of list, and the difference is what\n * happens afterwards:\n *\n * - **Live** keeps the rule. Membership is re-evaluated on the sweep and the\n * audience keeps growing as people start matching.\n * - **Fixed** does not. The filters are a way to FIND people; who is on the\n * list is whoever was added, and it stays that way.\n *\n * A reader must never be unsure which they have, so the control says it in\n * those words and the page repeats it under whichever is selected. Switching\n * is safe in both directions and neither drops anybody: the sweep reconciles\n * away only rows IT wrote (`via: 'rule'`), so a person somebody added by hand\n * survives becoming live, and everyone on the list survives becoming fixed.\n * The rule is kept on a fixed list rather than deleted — it is the filter that\n * found these people, and losing it on a toggle would lose the work.\n *\n * ## Whose people the filters read is a choice, not a side effect\n *\n * A list is the organization's and its rule reads ONE site's leads, members\n * and form submissions — the list's `hostId`. That is written when somebody\n * chooses it and at no other time. A rename saved from another site's page\n * must not move the filters onto that site's people: the next sweep would\n * re-draw the whole audience from somebody else's customers. So the page\n * reads the stored site, offers the change in words, and writes `hostId` only\n * when it differs from what is stored — which is also how a list that never\n * had one gets the site it is first set up on.\n */\n\nimport {\n normalizeDynamicListRule,\n PageHeaderRecord,\n pluginDocsHelp,\n} from '@aglyn/aglyn'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Button,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc, updateDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useRef, useState } from 'react'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport DynamicListRuleFields, {\n draftToRule,\n useRuleDraft,\n} from './dynamic-list-rule-fields'\nimport {\n OrgSiteSelect,\n useEmailDataScope,\n useEmailOrgMount,\n} from './email-org-mount'\n\nexport interface ListEditCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n listId: string\n /** The emails hub URL, which the audiences routes hang beneath. */\n basePath: string\n}\n\nexport function ListEditCard(props: ListEditCardProps) {\n const { hostId, listId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const orgMount = useEmailOrgMount()\n const { scope } = useEmailDataScope(hostId)\n\n const { data: list, status } = useFirestoreDoc<Record<string, any>>(\n () => (scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null),\n [firestore, scope, listId],\n )\n\n const storedName = String(list?.['name'] ?? '')\n const storedKind: 'manual' | 'dynamic' =\n list?.['kind'] === 'dynamic' ? 'dynamic' : 'manual'\n /*\n * Normalized before the form ever sees it, by the same function the\n * materializer reads it back through. A rule shown one way and evaluated\n * another is the defect this screen exists to make visible.\n */\n const storedRule = list ? normalizeDynamicListRule(list['rule']) : undefined\n\n const [name, setName] = useState('')\n const [kind, setKind] = useState<'manual' | 'dynamic'>('manual')\n const dynamic = kind === 'dynamic'\n /*\n * Seeded ONCE, when the document first arrives. The listen is live, so\n * re-seeding on every snapshot would take the field away from whoever is\n * typing in it — including on the snapshot their own save produces.\n */\n const seeded = useRef(false)\n useEffect(() => {\n if (!list || seeded.current) return\n seeded.current = true\n setName(storedName)\n setKind(storedKind)\n }, [list, storedName, storedKind])\n\n const [draft, setDraft] = useRuleDraft(storedRule, listId)\n const [busy, setBusy] = useState(false)\n\n /*\n * WHOSE PEOPLE THE FILTERS READ — see the file header.\n *\n * The stored site stands until somebody chooses another. A list with none\n * yet starts on the site whose page this is, or on the organization's page\n * on the reader's pick this session (or the only site). A stored site the\n * org's page does not list — one since deleted — is not offered as the\n * answer there, so the reader chooses a live one.\n */\n const storedHostId = String(list?.['hostId'] ?? '')\n const [chosenHostId, setChosenHostId] = useState('')\n const storedIsOffered = orgMount\n ? orgMount.hosts.some((site) => site.id === storedHostId)\n : Boolean(storedHostId)\n const ruleHostId: string =\n chosenHostId ||\n (storedIsOffered ? storedHostId : '') ||\n (hostId ?? orgMount?.pickedHostId ?? '')\n /** Under a site, the filters were last set up on a different one. */\n const drawsFromAnotherSite =\n hostId != null && Boolean(storedHostId) && storedHostId !== hostId\n\n const audiencesHref = `${basePath}/audiences`\n const detailHref = `${audiencesHref}/${listId}`\n\n const handleSave = async () => {\n const next = name.trim()\n /*\n * An empty name is not a rename. The list table orders on `name`, and\n * `orderBy` drops a document that does not carry the field — so a list\n * renamed to nothing would vanish from the page that lists it.\n */\n if (!next || !scope || busy) return\n setBusy(true)\n try {\n await updateDoc(doc(firestore, scope[0], scope[1], 'lists', listId), {\n // The name and the search keys the lists table queries, together\n // (AGL-3321).\n ...nameSearchFields(next),\n kind,\n /*\n * The rule is written whichever kind was chosen. On a live list it is\n * what the sweep evaluates; on a fixed one it is the filter that finds\n * people to add, and keeping it is what lets a merchant refine the\n * search across visits instead of retyping it.\n */\n rule: draftToRule(draft),\n /*\n * WHICH SITE'S people the rule draws from.\n *\n * Lists are org-shared but leads, members and form submissions are\n * host-owned, and org contacts are read narrowed to one host. A rule\n * with no host has no silos at all, so the sweep skips it. Written\n * only when it changes — see the file header.\n */\n ...(ruleHostId && ruleHostId !== storedHostId\n ? { hostId: ruleHostId }\n : {}),\n })\n enqueueSnackbar('List saved', { variant: 'success', persist: false })\n router.push(detailHref)\n } catch (error) {\n console.error(error)\n enqueueSnackbar('The list was not saved', { variant: 'error' })\n } finally {\n setBusy(false)\n }\n }\n\n const headerActions = (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n color=\"primary\"\n >\n {'Back to the audience'}\n </Button>\n )\n\n if (!list) {\n const settled = Boolean(scope) && status !== 'loading'\n return (\n <CardDisplay\n header={'Edit list'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? 'This list could not be loaded. It may have been deleted.'\n : 'Loading this audience…'}\n </Typography>\n </CardDisplay>\n )\n }\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Edit audience'}\n help={pluginDocsHelp('emailCampaigns', {\n anchor: dynamic ? '#lists-built-from-a-rule' : '#manual-lists',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"List name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n sx={{ flexGrow: 1, minWidth: 240, maxWidth: 360 }}\n />\n {/*\n The one decision on this page that changes what the list DOES, so\n the options are named for their outcome rather than for the field\n they set. \"Dynamic\" and \"manual\" describe the implementation; \"keeps\n growing\" and \"does not change\" describe what the merchant gets.\n */}\n <TextField\n select\n size=\"small\"\n label=\"Membership\"\n value={kind}\n onChange={(event) =>\n setKind(event.target.value as 'manual' | 'dynamic')\n }\n sx={{ minWidth: 240 }}\n >\n <MenuItem value=\"dynamic\">{'Live — keeps growing'}</MenuItem>\n <MenuItem value=\"manual\">{'Fixed — does not change'}</MenuItem>\n </TextField>\n </Stack>\n\n <Alert severity={dynamic ? 'info' : 'success'}>\n {dynamic\n ? 'Everyone matching these filters is enrolled automatically, ' +\n 'about every fifteen minutes, and leaves when they stop ' +\n 'matching. Anyone added by hand stays. Being matched is not ' +\n 'consent to be emailed — a campaign still only reaches people ' +\n 'whose consent is on record.'\n : 'Membership does not change on its own. Use the filters below ' +\n 'to FIND people, then add them from the audience page — who ' +\n 'you add is who stays on the list.'}\n </Alert>\n\n {/*\n WHOSE PEOPLE, said before the filters that read them. On the org\n page it is a picker. Under a site it is said only when the stored\n site is another one, with the one control that moves it here.\n */}\n {orgMount ? (\n <OrgSiteSelect\n mount={orgMount}\n label=\"Site\"\n value={ruleHostId}\n onChange={setChosenHostId}\n helperText={\n 'The site whose leads, members and form submissions these ' +\n 'filters read. A list draws from one site’s people.'\n }\n />\n ) : drawsFromAnotherSite ? (\n <Alert\n severity=\"info\"\n action={\n <Button\n color=\"inherit\"\n size=\"small\"\n onClick={() =>\n setChosenHostId(chosenHostId ? '' : String(hostId))\n }\n >\n {chosenHostId ? 'Keep the other site' : 'Use this site'}\n </Button>\n }\n >\n {chosenHostId\n ? 'Saving moves these filters onto this site’s people. The ' +\n 'next sweep re-draws the audience from them.'\n : 'These filters read another site’s people — the site this ' +\n 'list was set up on. Saving here keeps it that way.'}\n </Alert>\n ) : null}\n\n {scope && ruleHostId ? (\n <DynamicListRuleFields\n key={ruleHostId}\n scope={scope as readonly [string, string]}\n hostId={ruleHostId}\n draft={draft}\n onChange={setDraft}\n listId={listId}\n />\n ) : orgMount ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Choose the site whose people these filters read to edit them.'}\n </Typography>\n ) : null}\n {/*\n A rule with no source matches nobody, and an audience emptied that\n way looks exactly like one whose sweep has not run yet. It is only a\n warning on a LIVE list: on a fixed one an unset filter selects\n nothing to add and takes nobody off.\n */}\n {draft.sources.length || !dynamic ? null : (\n <Alert severity=\"warning\">\n {'These filters draw from no source, so they match nobody. The ' +\n 'next sweep would empty the list of everyone the rule enrolled.'}\n </Alert>\n )}\n\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n disabled={busy || !name.trim() || !scope}\n onClick={() => void handleSave()}\n >\n {busy ? 'Saving…' : 'Save'}\n </Button>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={detailHref}\n size=\"small\"\n >\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/*\n The audience being edited. This route is one level BELOW it, and the\n way back is the card header's own button — the trail names where the\n reader is and does not link its last step.\n */}\n <PageHeaderRecord title={storedName || listId} />\n {card}\n </>\n )\n}\nListEditCard.displayName = 'ListEditCard'\n\nexport default ListEditCard\n"],"names":["normalizeDynamicListRule","PageHeaderRecord","pluginDocsHelp","nameSearchFields","AppLink","CardDisplay","useSnackbar","Alert","Button","MenuItem","Stack","TextField","Typography","doc","updateDoc","useRouter","useEffect","useRef","useState","useFirestore","useFirestoreDoc","DynamicListRuleFields","draftToRule","useRuleDraft","OrgSiteSelect","useEmailDataScope","useEmailOrgMount","ListEditCard","props","hostId","listId","basePath","firestore","router","enqueueSnackbar","orgMount","scope","data","list","status","storedName","String","storedKind","storedRule","undefined","name","setName","kind","setKind","dynamic","seeded","current","draft","setDraft","busy","setBusy","storedHostId","chosenHostId","setChosenHostId","storedIsOffered","hosts","some","site","id","Boolean","ruleHostId","pickedHostId","drawsFromAnotherSite","audiencesHref","detailHref","handleSave","next","trim","rule","variant","persist","push","error","console","headerActions","component","componentVariant","nativeButton","href","size","color","settled","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","card","contentBordered","spacing","direction","useFlexGap","sx","flexWrap","label","value","onChange","event","target","flexGrow","minWidth","maxWidth","select","severity","mount","helperText","onClick","sources","length","disabled","title","displayName"],"mappings":"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"}
@@ -63,16 +63,19 @@ import { mdiAccountRemoveOutline } from "@aglyn/shared-data-mdi";
63
63
  import { MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
64
64
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
65
65
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
66
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
66
67
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
67
- import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
68
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
68
69
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
69
- import { usePagedRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-paged-rows-filter";
70
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
70
71
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
71
72
  import { Alert, Box, Button, Checkbox, Chip, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
72
- import { collection, deleteDoc, doc, documentId, limit, orderBy, query } from "firebase/firestore";
73
+ import { collection, deleteDoc, doc } from "firebase/firestore";
73
74
  import { useCallback, useEffect, useMemo, useState } from "react";
74
- import { useFirestore, usePagedCollection, useUser } from "@aglyn/tenant-feature-instance";
75
+ import { useFirestore, useUser } from "@aglyn/tenant-feature-instance";
76
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
75
77
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
78
+ import { LIST_MEMBER_FILTER_HEADERS, LIST_MEMBER_FILTER_OPTIONS, LIST_MEMBER_QUERY } from "../constants/list-queries.js";
76
79
  function consentLabel(member, group) {
77
80
  const record = readMarketingBasis(member, group);
78
81
  const when = record.basisAtMs ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}` : '';
@@ -124,54 +127,14 @@ function consentLabel(member, group) {
124
127
  color: 'default'
125
128
  };
126
129
  }
127
- /*
128
- * What the membership grid's Filters panel offers (AGL-3317). The table is a
129
- * paged listener, so a filter matches over what its window has read, which
130
- * the first filter widens (`usePagedRowsFilter`). `How` and `Consent` read
131
- * the keys `withFilterKeys` derives, the same answers the columns draw.
132
- */ const MEMBER_FILTER_FIELDS = [
133
- inMemoryListField('email', 'text'),
134
- inMemoryListField('name', 'text'),
135
- inMemoryListField('via', 'select', 'viaKey'),
136
- inMemoryListField('consent', 'select', 'consentKey')
137
- ];
138
- const MEMBER_FILTER_HEADERS = {
139
- email: 'Address',
140
- name: 'Name',
141
- via: 'How',
142
- consent: 'Consent'
143
- };
144
- const VIA_LABELS = {
130
+ /** How a member got here, by the words the column draws. */ const VIA_LABELS = {
145
131
  rule: 'Rule',
146
132
  manual: 'Added'
147
133
  };
148
- const CONSENT_LABELS = {
149
- 'opted-in': 'Opted in',
150
- attested: 'Attested by your team',
151
- 'opted-out': 'Opted out',
152
- 'other-site': 'Opted in to another site',
153
- 'no-site': 'Opted in — no site recorded',
154
- none: 'No basis on record'
155
- };
156
- const MEMBER_FILTER_OPTIONS = {
157
- via: Object.entries(VIA_LABELS).map(([value, label])=>({
158
- value,
159
- label
160
- })),
161
- consent: Object.entries(CONSENT_LABELS).map(([value, label])=>({
162
- value,
163
- label
164
- }))
165
- };
166
- const MEMBER_SEARCH_FIELDS = [
167
- 'email',
168
- 'name',
169
- 'source'
134
+ /** The How filter's choices are picked, so the panel shows a select. */ const MEMBER_SELECT_FIELDS = [
135
+ 'via'
170
136
  ];
171
- const withFilterKeys = (member, group)=>_extends({}, member, {
172
- viaKey: member.via === 'rule' ? 'rule' : 'manual',
173
- consentKey: consentLabel(member, group).key
174
- });
137
+ /** How a row reads `via`: a row that predates the field was added by hand. */ const viaOf = (member)=>member.via === 'rule' ? 'rule' : 'manual';
175
138
  /** Free text — a paste, a typed address, commas or newlines — to addresses. */ export function splitAddresses(value) {
176
139
  return value.split(/[\s,;]+/).map((entry)=>entry.trim())// A pasted mail-client line is `Name <a@b.co>`; keep the address part.
177
140
  .map((entry)=>entry.replace(/^.*<|>.*$/g, '')).filter(Boolean);
@@ -193,38 +156,36 @@ export function ListMembersPanel(props) {
193
156
  * oldest members from their own list, silently, which is the failure that
194
157
  * turned an audience into a random sample (AGL-2501). The id is the one key
195
158
  * every row has.
196
- */ const { data: memberData, rows: memberRows, hasMore, page, setPage, pageSize, setPageSize } = usePagedCollection((pageLimit)=>query(collection(firestore, scope[0], scope[1], 'lists', listId, 'members'), orderBy(documentId()), limit(pageLimit)), [
197
- firestore,
198
- scope[0],
199
- scope[1],
200
- listId
201
- ], {
159
+ *
160
+ * Every filter and search word is a predicate on the same query
161
+ * (AGL-3321) — see `LIST_MEMBER_QUERY` — so a page is a page of the
162
+ * matches.
163
+ */ const gridFilter = useListGridFilter({
164
+ selectFields: MEMBER_SELECT_FIELDS
165
+ });
166
+ const { rows: members, hasMore, page, setPage, pageSize, setPageSize, plan } = useListQuery({
167
+ collection: collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),
168
+ declaration: LIST_MEMBER_QUERY,
169
+ request: {
170
+ clauses: gridFilter.clauses,
171
+ search: gridFilter.searchWords
172
+ },
173
+ deps: [
174
+ firestore,
175
+ scope[0],
176
+ scope[1],
177
+ listId
178
+ ],
202
179
  idField: '$id'
203
180
  });
204
- const memberWindow = useMemo(()=>memberData == null ? void 0 : memberData.map((member)=>withFilterKeys(member, consentGroup)), [
205
- memberData,
206
- consentGroup
181
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
182
+ const refusals = useMemo(()=>listQueryRefusals(plan.refused, {
183
+ fields: LIST_MEMBER_QUERY.fields,
184
+ headers: LIST_MEMBER_FILTER_HEADERS,
185
+ options: LIST_MEMBER_FILTER_OPTIONS
186
+ }), [
187
+ plan.refused
207
188
  ]);
208
- const memberPage = useMemo(()=>memberRows.map((member)=>withFilterKeys(member, consentGroup)), [
209
- memberRows,
210
- consentGroup
211
- ]);
212
- const memberFilter = usePagedRowsFilter({
213
- data: memberWindow,
214
- rows: memberPage,
215
- hasMore,
216
- page,
217
- setPage,
218
- pageSize,
219
- setPageSize
220
- }, {
221
- fields: MEMBER_FILTER_FIELDS,
222
- options: MEMBER_FILTER_OPTIONS,
223
- headers: MEMBER_FILTER_HEADERS,
224
- search: MEMBER_SEARCH_FIELDS
225
- });
226
- // The rows on screen: the page, or the page of the matches.
227
- const members = memberFilter.rows;
228
189
  const [addInput, setAddInput] = useState('');
229
190
  const [addName, setAddName] = useState('');
230
191
  const [preview, setPreview] = useState(null);
@@ -483,7 +444,7 @@ export function ListMembersPanel(props) {
483
444
  field: 'via',
484
445
  headerName: 'How',
485
446
  width: 200,
486
- valueGetter: (_value, row)=>row.viaKey,
447
+ valueGetter: (_value, row)=>viaOf(row),
487
448
  /*
488
449
  `via` says whether a rule put them here or a person did, which is what
489
450
  decides whether they LEAVE on their own: the materializer reconciles its
@@ -500,7 +461,7 @@ export function ListMembersPanel(props) {
500
461
  /*#__PURE__*/ _jsx(Chip, {
501
462
  size: "small",
502
463
  variant: "outlined",
503
- label: VIA_LABELS[row.viaKey]
464
+ label: VIA_LABELS[viaOf(row)]
504
465
  }),
505
466
  row.source ? /*#__PURE__*/ _jsx(Typography, {
506
467
  variant: "caption",
@@ -515,7 +476,7 @@ export function ListMembersPanel(props) {
515
476
  field: 'consent',
516
477
  headerName: 'Consent',
517
478
  width: 190,
518
- valueGetter: (_value, row)=>row.consentKey,
479
+ valueGetter: (_value, row)=>consentLabel(row, consentGroup).label,
519
480
  renderCell: ({ row })=>{
520
481
  const consent = consentLabel(row, consentGroup);
521
482
  return /*#__PURE__*/ _jsx(Chip, {
@@ -692,29 +653,47 @@ export function ListMembersPanel(props) {
692
653
  }, result.input);
693
654
  })
694
655
  }) : null,
695
- memberRows.length === 0 && !hasMore && !memberFilter.filtering ? /*#__PURE__*/ _jsx(Typography, {
656
+ members.length === 0 && !hasMore && !filtering ? /*#__PURE__*/ _jsx(Typography, {
696
657
  variant: "body2",
697
658
  color: "text.secondary",
698
659
  children: 'Nobody is on this list yet.'
699
660
  }) : /*#__PURE__*/ _jsxs(_Fragment, {
700
661
  children: [
701
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, memberFilter.chipsProps)),
702
- memberFilter.filtering && hasMore ? /*#__PURE__*/ _jsx(Typography, {
703
- variant: "caption",
704
- color: "text.secondary",
705
- children: `Filtering the ${memberFilter.read} members read so far — the next page reads more.`
706
- }) : null,
707
- /*#__PURE__*/ _jsx(ListTable, _extends({
662
+ /*#__PURE__*/ _jsx(ListFilterChips, {
663
+ fields: LIST_MEMBER_QUERY.fields,
664
+ headers: LIST_MEMBER_FILTER_HEADERS,
665
+ clauses: gridFilter.clauses,
666
+ onChange: gridFilter.setClauses,
667
+ options: LIST_MEMBER_FILTER_OPTIONS
668
+ }),
669
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
670
+ refused: refusals,
671
+ notices: plan.notices
672
+ }),
673
+ /*#__PURE__*/ _jsx(ListTable, {
708
674
  "aria-label": `Members of ${listName}`,
709
675
  rows: members,
710
- columns: memberFilter.filterColumns(columns),
676
+ columns: listFilterGridColumns(columns, LIST_MEMBER_QUERY.fields, LIST_MEMBER_FILTER_OPTIONS, LIST_MEMBER_FILTER_HEADERS),
711
677
  rowHeight: TABLE_ROW_HEIGHT,
712
678
  // Paged by the footer below, so the grid must not also slice.
713
- hideFooter: true
714
- }, memberFilter.gridProps, {
679
+ hideFooter: true,
680
+ // The panel and the search go to the query; the grid neither
681
+ // filters nor sorts the page it holds.
682
+ filterMode: "server",
683
+ filterModel: gridFilter.filterModel,
684
+ onFilterModelChange: gridFilter.onFilterModelChange,
685
+ quickFilter: true,
686
+ disableColumnSorting: true,
715
687
  noRowsLabel: "No members match these filters"
716
- })),
717
- /*#__PURE__*/ _jsx(ListPagination, _extends({}, memberFilter.pagination))
688
+ }),
689
+ /*#__PURE__*/ _jsx(ListPagination, {
690
+ page: page,
691
+ pageSize: pageSize,
692
+ rowCount: members.length,
693
+ hasMore: hasMore,
694
+ onPageChange: setPage,
695
+ onPageSizeChange: setPageSize
696
+ })
718
697
  ]
719
698
  }),
720
699
  /*#__PURE__*/ _jsx(Typography, {