@aglyn/plugins-email 1.0.0-beta.189 → 1.0.0-beta.191

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/emails-console-page.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n consentGroupForHost,\n type ConsentGroup,\n type ConsolePluginOrgMount,\n type ConsolePluginPageProps,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport { useMemo, type ReactNode } from 'react'\nimport EmailScreensCard from './email-screens-card'\nimport EmailTemplateDetail from './email-template-detail'\nimport EmailTopicDetail from './email-topic-detail'\nimport EmailTopicsCard from './email-topics-card'\nimport ListDetailCard from './list-detail-card'\nimport ListEditCard from './list-edit-card'\nimport ListsCard from './lists-card'\nimport OrgEmailTemplatesCard from './org-email-templates-card'\nimport OrgSendingCard from './org-sending-card'\nimport OrgSendingDomainDetail from './org-sending-domain-detail'\nimport OrgSiteSuppressions from './org-site-suppressions'\nimport OrgSuppressionsCard from './org-suppressions-card'\nimport SendingDomainDetail from './sending-domain-detail'\nimport SendingDomainsCard from './sending-domains-card'\nimport SuppressionsCard from './suppressions-card'\nimport type { EmailsConsoleSectionId } from './emails-console-sections'\nimport { EmailOrgMountProvider } from './email-org-mount'\nimport { EMAIL_MESSAGES_ZONE, type EmailMessagesZoneProps } from './email-zones'\n\n/**\n * The Messages section: a zone, drawn by whichever plugin owns campaigns.\n *\n * Its own component because a zone's renderer comes from a hook and\n * `sectionBody` is a plain function — and so that, like every other branch\n * there, it is constructed only while Messages is the section being read.\n */\nfunction EmailMessagesSection(props: EmailMessagesZoneProps) {\n const Zone = useConsoleWidgetSlot()\n return Zone ? <Zone slot={EMAIL_MESSAGES_ZONE.id} {...props} /> : null\n}\n\n/**\n * The body of one emails section, built only when that section is the one\n * being read (AGL-2501).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT every section on every render, and each card opens its\n * Firestore listens on mount — which is the entire cost this page exists to\n * stop paying. Only the returned branch is ever built.\n */\nfunction sectionBody(\n section: EmailsConsoleSectionId,\n hostId: string,\n /**\n * The section's OWN segments — `segments[1]` onward, already sliced by the\n * caller. A section that owns deeper routes reads them here; one that does\n * not simply ignores them.\n */\n detail: readonly string[],\n basePath: string,\n /**\n * The controller the surface is being viewed as, resolved once by the page.\n *\n * Only the audience detail needs it today — it is what its membership table\n * reads consent FOR — but it is resolved at the page because the answer is a\n * property of the org and this is the one place holding the org document.\n */\n consentGroup: ConsentGroup,\n): ReactNode {\n switch (section) {\n case 'messages':\n /*\n * `/emails/messages/{emailId}` is ONE MESSAGE — the thing that was or\n * will be sent, as against the campaign that groups messages and the\n * template they are built from. A ROUTE rather than an expanded row: it\n * is linkable, which is what a merchant wants to paste into a message\n * about last week's send, and its preview, link table and recipient\n * list are reads the list above it must not pay for.\n *\n * The page links OUT to the campaign this message belongs to, which is\n * a section of the Marketing console.\n */\n /*\n * `…/{emailId}/edit` WRITES the email and `…/{emailId}` reports on\n * it — two jobs with two shapes, and creating stays a drawer on the\n * list. Which of the three is drawn is decided from `detail` by the\n * widget, which builds only the one it is asked for: the composer's\n * listens are not paid for by somebody reading a report.\n */\n return (\n <EmailMessagesSection\n hostId={hostId}\n basePath={basePath}\n detail={detail}\n />\n )\n case 'templates':\n /*\n * `/emails/templates/{screenId}` is one TEMPLATE's page.\n *\n * A route for the same reasons a message's page is one: it is\n * linkable, and the listing above it is a cheaper surface a reader who\n * came for one template should not have to mount. The preview, the\n * aggregate figures and the recipients table all hang off this branch,\n * so none of them is constructed while the list is what is being read.\n */\n return detail[0] ? (\n <EmailTemplateDetail\n hostId={hostId}\n screenId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailScreensCard hostId={hostId} basePath={basePath} />\n )\n case 'audiences':\n /*\n * A list is a resource with its own pages, on the same terms a message\n * is: `/emails/audiences/{listId}` is one audience, and `…/edit` is its\n * settings.\n *\n * The membership used to unfold inside the audiences table. That made a\n * list unlinkable, put the back button one press from leaving the whole\n * surface, and asked the reader of a list of lists to hold the table\n * that lists them AND the table of one list's subscribers on the same\n * screen. The subscribers are also the expensive read here — one PII\n * document per person — so putting them behind a route is what stops\n * them being paid for by somebody who came to see which audiences exist.\n *\n * Ternaries rather than a lookup: only the branch taken is CONSTRUCTED,\n * which is the cost this whole function is shaped around.\n */\n return detail[0] ? (\n detail[1] === 'edit' ? (\n <ListEditCard\n hostId={hostId}\n listId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <ListDetailCard\n hostId={hostId}\n consentGroup={consentGroup}\n listId={detail[0]}\n basePath={basePath}\n />\n )\n ) : (\n <ListsCard hostId={hostId} basePath={basePath} />\n )\n case 'topics':\n // Create is a drawer on the list; EDIT is the topic's own route, which\n // is the section owning its own subtree exactly as `messages` does.\n return detail[0] ? (\n <EmailTopicDetail\n hostId={hostId}\n topicId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailTopicsCard hostId={hostId} basePath={basePath} />\n )\n case 'sending':\n /*\n * `/emails/sending/{domain}` is one domain's page, and it is a route\n * for the reason every other detail here is: the DNS records, the DMARC\n * read and the verification button are what somebody came for, and a\n * link to them is what they paste to whoever actually edits the zone.\n *\n * The domain is the document id, so it is also the segment. It is\n * decoded because a URL carries it encoded and the record is keyed on\n * the bare name.\n */\n return detail[0] ? (\n <SendingDomainDetail\n hostId={hostId}\n domain={decodeURIComponent(detail[0])}\n basePath={basePath}\n />\n ) : (\n <SendingDomainsCard hostId={hostId} basePath={basePath} />\n )\n case 'suppressions':\n return <SuppressionsCard hostId={hostId} />\n default:\n return null\n }\n}\n\n/**\n * The body of one ORGANIZATION-level section, at `/[orgSlug]/emails`.\n *\n * The same six sections as a site's, each answering over the organization.\n * Two of them are already the org's — an audience and a topic are shared by\n * every site — so their cards render unchanged with no site. The other four\n * are site facts: a message is sent as one site, a template is one site's\n * screen, a sending identity and a suppression list are one site's. Those\n * sections read each site in turn, a page of sites at a time, and link a row\n * into the site that holds it.\n *\n * Built only when that section is the one being read, for the reason\n * {@link sectionBody} is: every card opens its reads on mount.\n */\nfunction orgSectionBody(\n section: EmailsConsoleSectionId,\n detail: readonly string[],\n basePath: string,\n orgMount: ConsolePluginOrgMount,\n /** The org document, from which an audience's consent group is resolved. */\n org: Record<string, unknown> | undefined,\n): ReactNode {\n switch (section) {\n case 'messages':\n // Every site's messages, drawn by the plugin that sends; it is handed\n // the org mount so it can say which site each was sent as.\n return (\n <EmailMessagesSection\n hostId={null}\n orgMount={orgMount}\n basePath={basePath}\n detail={detail}\n />\n )\n case 'templates':\n /*\n * One table over every site's templates. A template is a screen on one\n * site, so its own page stays that site's: each row links there, and\n * nothing beneath this section is an org route.\n */\n return <OrgEmailTemplatesCard />\n case 'audiences':\n return detail[0] ? (\n detail[1] === 'edit' ? (\n <ListEditCard hostId={null} listId={detail[0]} basePath={basePath} />\n ) : (\n <ListDetailCard\n hostId={null}\n org={org}\n listId={detail[0]}\n basePath={basePath}\n />\n )\n ) : (\n <ListsCard hostId={null} basePath={basePath} />\n )\n case 'topics':\n return detail[0] ? (\n <EmailTopicDetail\n hostId={null}\n topicId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailTopicsCard hostId={null} basePath={basePath} />\n )\n case 'sending':\n /*\n * A domain is proved by the organization, so its page is an org route\n * here as it is under a site. What each SITE sends as is the other half,\n * and the section's own page lists it per site.\n */\n return detail[0] ? (\n <OrgSendingDomainDetail\n domain={decodeURIComponent(detail[0])}\n basePath={basePath}\n />\n ) : (\n <OrgSendingCard basePath={basePath} />\n )\n case 'suppressions':\n /*\n * `…/suppressions` totals every site's list; `…/suppressions/{hostId}`\n * is one site's list itself, the same card a site's own page draws.\n */\n return detail[0] ? (\n <OrgSiteSuppressions hostId={detail[0]} />\n ) : (\n <OrgSuppressionsCard />\n )\n default:\n return null\n }\n}\n\n/**\n * Emails page (AGL-395): the console surface owned by the email plugin,\n * rendered by the shell's generic plugin route.\n *\n * Two of its sections are two different things a merchant calls \"an email\":\n * an EMAIL is one message that was or will be sent, and a TEMPLATE is the\n * reusable besigner document a message is built from. Keeping them apart is\n * what lets each carry its own report — a message's own numbers, and a\n * template's summed across every message sent from it. Audience lists, the\n * topic catalog, the sending identities and the suppression list complete the\n * surface.\n *\n * The CAMPAIGN that groups messages is not here. It is a container with a\n * window of dates, a set of lists, a topic and revenue attribution — a\n * marketing object that reaches people by email — so it is a section of the\n * Marketing console, and a message's page links out to the campaign it\n * belongs to.\n *\n * Sections are ROUTES (AGL-2501). `HubTabs lazy` already mounted one panel, so\n * this is not a read saving — `emails-console-read-cost.spec.tsx` was written\n * BEFORE the conversion precisely to hold that line, and reports the same\n * counts after. What routing adds is that the URL names the section: it is\n * linkable, the back button walks sections, the breadcrumb says where you are,\n * and \"mount only what is open\" is structural rather than a `lazy` flag\n * somebody has to remember on the next surface.\n *\n * The same page is the ORGANIZATION's Emails page at `/[orgSlug]/emails`.\n * Handed no site, it publishes the org mount to its cards and renders\n * {@link orgSectionBody} instead: the same six sections, over every site.\n */\nexport function EmailsConsolePage(props: ConsolePluginPageProps) {\n const { hostId, orgMount, org, section, sections, basePath, segments } =\n props\n\n /*==========================================\n * THE CONTROLLER THIS SURFACE IS BEING VIEWED AS.\n *\n * The declared group of sites that are one sender, or this site alone. Every\n * consent question asked below is asked FOR a controller and not for a site:\n * a grant is looked up under the asking site, a refusal is honored across\n * the whole group, and a grant held by a site outside it belongs to somebody\n * else. It is the same group `performCampaignSend` resolves, so the audience\n * table and the send agree about the same document by construction.\n *\n * Pure, from the org document the shell already passed, so it costs no read.\n * An absent org resolves to the group of one, which is the narrow answer.\n *\n * Only under a site. The organization's page has no site to resolve a group\n * FOR, and `consentGroupForHost` refuses to invent one — the audience page\n * there resolves it for the site the reader enrolls as, once one is chosen.\n *=========================================*/\n const consentGroup = useMemo(\n () =>\n hostId == null\n ? null\n : consentGroupForHost(org as Record<string, unknown>, hostId),\n [org, hostId],\n )\n\n /*\n * Nothing, deliberately, while the redirect is in flight. Rendering the\n * default section here would issue its listens on a URL about to be\n * replaced — on every arrival at `/emails`, which is every nav-tab click.\n */\n if (!section || !sections?.length || !basePath) return null\n // `segments[0]` IS the section — the shell resolved it into `section`\n // already — so what a section owns is everything after it.\n const detail = (segments ?? []).slice(1)\n\n if (hostId == null) {\n // No site and no org to stand in for it: nothing this page can scope.\n if (!orgMount) return null\n return (\n <EmailOrgMountProvider mount={orgMount} basePath={basePath}>\n <HubSections sections={sections}>\n {orgSectionBody(\n section as EmailsConsoleSectionId,\n detail,\n basePath,\n orgMount,\n org as Record<string, unknown> | undefined,\n )}\n </HubSections>\n </EmailOrgMountProvider>\n )\n }\n\n return (\n <HubSections sections={sections}>\n {sectionBody(\n section as EmailsConsoleSectionId,\n hostId,\n detail,\n basePath,\n consentGroup as ConsentGroup,\n )}\n </HubSections>\n )\n}\nEmailsConsolePage.displayName = 'EmailsConsolePage'\n\nexport default EmailsConsolePage\n"],"names":["consentGroupForHost","useConsoleWidgetSlot","HubSections","useMemo","EmailScreensCard","EmailTemplateDetail","EmailTopicDetail","EmailTopicsCard","ListDetailCard","ListEditCard","ListsCard","OrgEmailTemplatesCard","OrgSendingCard","OrgSendingDomainDetail","OrgSiteSuppressions","OrgSuppressionsCard","SendingDomainDetail","SendingDomainsCard","SuppressionsCard","EmailOrgMountProvider","EMAIL_MESSAGES_ZONE","EmailMessagesSection","props","Zone","slot","id","sectionBody","section","hostId","detail","basePath","consentGroup","screenId","listId","topicId","domain","decodeURIComponent","orgSectionBody","orgMount","org","EmailsConsolePage","sections","segments","length","slice","mount","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/emails-console-page.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n consentGroupForHost,\n type ConsentGroup,\n type ConsolePluginOrgMount,\n type ConsolePluginPageProps,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport { Stack } from '@mui/material'\nimport { useMemo, type ReactNode } from 'react'\nimport ConsentGroupConfirmationCard from './consent-group-confirmation-card'\nimport EmailScreensCard from './email-screens-card'\nimport EmailTemplateDetail from './email-template-detail'\nimport EmailTopicDetail from './email-topic-detail'\nimport EmailTopicsCard from './email-topics-card'\nimport ListDetailCard from './list-detail-card'\nimport ListEditCard from './list-edit-card'\nimport ListsCard from './lists-card'\nimport OrgEmailTemplatesCard from './org-email-templates-card'\nimport OrgSendingCard from './org-sending-card'\nimport OrgSendingDomainDetail from './org-sending-domain-detail'\nimport OrgSiteSuppressions from './org-site-suppressions'\nimport OrgSuppressionsCard from './org-suppressions-card'\nimport SendingDomainDetail from './sending-domain-detail'\nimport SendingDomainsCard from './sending-domains-card'\nimport SuppressionsCard from './suppressions-card'\nimport type { EmailsConsoleSectionId } from './emails-console-sections'\nimport { EmailOrgMountProvider } from './email-org-mount'\nimport { EMAIL_MESSAGES_ZONE, type EmailMessagesZoneProps } from './email-zones'\n\n/**\n * The Messages section: a zone, drawn by whichever plugin owns campaigns.\n *\n * Its own component because a zone's renderer comes from a hook and\n * `sectionBody` is a plain function — and so that, like every other branch\n * there, it is constructed only while Messages is the section being read.\n */\nfunction EmailMessagesSection(props: EmailMessagesZoneProps) {\n const Zone = useConsoleWidgetSlot()\n return Zone ? <Zone slot={EMAIL_MESSAGES_ZONE.id} {...props} /> : null\n}\n\n/**\n * The body of one emails section, built only when that section is the one\n * being read (AGL-2501).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT every section on every render, and each card opens its\n * Firestore listens on mount — which is the entire cost this page exists to\n * stop paying. Only the returned branch is ever built.\n */\nfunction sectionBody(\n section: EmailsConsoleSectionId,\n hostId: string,\n /**\n * The section's OWN segments — `segments[1]` onward, already sliced by the\n * caller. A section that owns deeper routes reads them here; one that does\n * not simply ignores them.\n */\n detail: readonly string[],\n basePath: string,\n /**\n * The controller the surface is being viewed as, resolved once by the page.\n *\n * Only the audience detail needs it today — it is what its membership table\n * reads consent FOR — but it is resolved at the page because the answer is a\n * property of the org and this is the one place holding the org document.\n */\n consentGroup: ConsentGroup,\n): ReactNode {\n switch (section) {\n case 'messages':\n /*\n * `/emails/messages/{emailId}` is ONE MESSAGE — the thing that was or\n * will be sent, as against the campaign that groups messages and the\n * template they are built from. A ROUTE rather than an expanded row: it\n * is linkable, which is what a merchant wants to paste into a message\n * about last week's send, and its preview, link table and recipient\n * list are reads the list above it must not pay for.\n *\n * The page links OUT to the campaign this message belongs to, which is\n * a section of the Marketing console.\n */\n /*\n * `…/{emailId}/edit` WRITES the email and `…/{emailId}` reports on\n * it — two jobs with two shapes, and creating stays a drawer on the\n * list. Which of the three is drawn is decided from `detail` by the\n * widget, which builds only the one it is asked for: the composer's\n * listens are not paid for by somebody reading a report.\n */\n return (\n <EmailMessagesSection\n hostId={hostId}\n basePath={basePath}\n detail={detail}\n />\n )\n case 'templates':\n /*\n * `/emails/templates/{screenId}` is one TEMPLATE's page.\n *\n * A route for the same reasons a message's page is one: it is\n * linkable, and the listing above it is a cheaper surface a reader who\n * came for one template should not have to mount. The preview, the\n * aggregate figures and the recipients table all hang off this branch,\n * so none of them is constructed while the list is what is being read.\n */\n return detail[0] ? (\n <EmailTemplateDetail\n hostId={hostId}\n screenId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailScreensCard hostId={hostId} basePath={basePath} />\n )\n case 'audiences':\n /*\n * A list is a resource with its own pages, on the same terms a message\n * is: `/emails/audiences/{listId}` is one audience, and `…/edit` is its\n * settings.\n *\n * The membership used to unfold inside the audiences table. That made a\n * list unlinkable, put the back button one press from leaving the whole\n * surface, and asked the reader of a list of lists to hold the table\n * that lists them AND the table of one list's subscribers on the same\n * screen. The subscribers are also the expensive read here — one PII\n * document per person — so putting them behind a route is what stops\n * them being paid for by somebody who came to see which audiences exist.\n *\n * Ternaries rather than a lookup: only the branch taken is CONSTRUCTED,\n * which is the cost this whole function is shaped around.\n */\n return detail[0] ? (\n detail[1] === 'edit' ? (\n <ListEditCard\n hostId={hostId}\n listId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <ListDetailCard\n hostId={hostId}\n consentGroup={consentGroup}\n listId={detail[0]}\n basePath={basePath}\n />\n )\n ) : (\n <ListsCard hostId={hostId} basePath={basePath} />\n )\n case 'topics':\n // Create is a drawer on the list; EDIT is the topic's own route, which\n // is the section owning its own subtree exactly as `messages` does.\n return detail[0] ? (\n <EmailTopicDetail\n hostId={hostId}\n topicId={detail[0]}\n basePath={basePath}\n />\n ) : (\n <EmailTopicsCard hostId={hostId} basePath={basePath} />\n )\n case 'sending':\n /*\n * `/emails/sending/{domain}` is one domain's page, and it is a route\n * for the reason every other detail here is: the DNS records, the DMARC\n * read and the verification button are what somebody came for, and a\n * link to them is what they paste to whoever actually edits the zone.\n *\n * The domain is the document id, so it is also the segment. It is\n * decoded because a URL carries it encoded and the record is keyed on\n * the bare name.\n */\n return detail[0] ? (\n <SendingDomainDetail\n hostId={hostId}\n domain={decodeURIComponent(detail[0])}\n basePath={basePath}\n />\n ) : (\n <SendingDomainsCard hostId={hostId} basePath={basePath} />\n )\n case 'suppressions':\n return <SuppressionsCard hostId={hostId} />\n default:\n return null\n }\n}\n\n/**\n * The body of one ORGANIZATION-level section, at `/[orgSlug]/emails`.\n *\n * The same six sections as a site's, each answering over the organization.\n * Two of them are already the org's — an audience and a topic are shared by\n * every site — so their cards render unchanged with no site. The other four\n * are site facts: a message is sent as one site, a template is one site's\n * screen, a sending identity and a suppression list are one site's. Those\n * sections read each site in turn, a page of sites at a time, and link a row\n * into the site that holds it.\n *\n * Built only when that section is the one being read, for the reason\n * {@link sectionBody} is: every card opens its reads on mount.\n */\nfunction orgSectionBody(\n section: EmailsConsoleSectionId,\n detail: readonly string[],\n basePath: string,\n orgMount: ConsolePluginOrgMount,\n /** The org document, from which an audience's consent group is resolved. */\n org: Record<string, unknown> | undefined,\n): ReactNode {\n switch (section) {\n case 'messages':\n // Every site's messages, drawn by the plugin that sends; it is handed\n // the org mount so it can say which site each was sent as.\n return (\n <EmailMessagesSection\n hostId={null}\n orgMount={orgMount}\n basePath={basePath}\n detail={detail}\n />\n )\n case 'templates':\n /*\n * One table over every site's templates. A template is a screen on one\n * site, so its own page stays that site's: each row links there, and\n * nothing beneath this section is an org route.\n */\n return <OrgEmailTemplatesCard />\n case 'audiences':\n return detail[0] ? (\n detail[1] === 'edit' ? (\n <ListEditCard hostId={null} listId={detail[0]} basePath={basePath} />\n ) : (\n <ListDetailCard\n hostId={null}\n org={org}\n listId={detail[0]}\n basePath={basePath}\n />\n )\n ) : (\n <ListsCard hostId={null} basePath={basePath} />\n )\n case 'topics':\n return detail[0] ? (\n <EmailTopicDetail\n hostId={null}\n topicId={detail[0]}\n basePath={basePath}\n />\n ) : (\n /*\n * The catalog, whose topics each set their own confirmation, and under\n * it the organization's one switch over how a declared consent group\n * treats a confirmation one of its sites is waiting on. Only here: the\n * switch is the organization's, so a site's Topics page has none.\n */\n <Stack spacing={3}>\n <EmailTopicsCard hostId={null} basePath={basePath} />\n <ConsentGroupConfirmationCard org={org} />\n </Stack>\n )\n case 'sending':\n /*\n * A domain is proved by the organization, so its page is an org route\n * here as it is under a site. What each SITE sends as is the other half,\n * and the section's own page lists it per site.\n */\n return detail[0] ? (\n <OrgSendingDomainDetail\n domain={decodeURIComponent(detail[0])}\n basePath={basePath}\n />\n ) : (\n <OrgSendingCard basePath={basePath} />\n )\n case 'suppressions':\n /*\n * `…/suppressions` totals every site's list; `…/suppressions/{hostId}`\n * is one site's list itself, the same card a site's own page draws.\n */\n return detail[0] ? (\n <OrgSiteSuppressions hostId={detail[0]} />\n ) : (\n <OrgSuppressionsCard />\n )\n default:\n return null\n }\n}\n\n/**\n * Emails page (AGL-395): the console surface owned by the email plugin,\n * rendered by the shell's generic plugin route.\n *\n * Two of its sections are two different things a merchant calls \"an email\":\n * an EMAIL is one message that was or will be sent, and a TEMPLATE is the\n * reusable besigner document a message is built from. Keeping them apart is\n * what lets each carry its own report — a message's own numbers, and a\n * template's summed across every message sent from it. Audience lists, the\n * topic catalog, the sending identities and the suppression list complete the\n * surface.\n *\n * The CAMPAIGN that groups messages is not here. It is a container with a\n * window of dates, a set of lists, a topic and revenue attribution — a\n * marketing object that reaches people by email — so it is a section of the\n * Marketing console, and a message's page links out to the campaign it\n * belongs to.\n *\n * Sections are ROUTES (AGL-2501). `HubTabs lazy` already mounted one panel, so\n * this is not a read saving — `emails-console-read-cost.spec.tsx` was written\n * BEFORE the conversion precisely to hold that line, and reports the same\n * counts after. What routing adds is that the URL names the section: it is\n * linkable, the back button walks sections, the breadcrumb says where you are,\n * and \"mount only what is open\" is structural rather than a `lazy` flag\n * somebody has to remember on the next surface.\n *\n * The same page is the ORGANIZATION's Emails page at `/[orgSlug]/emails`.\n * Handed no site, it publishes the org mount to its cards and renders\n * {@link orgSectionBody} instead: the same six sections, over every site.\n */\nexport function EmailsConsolePage(props: ConsolePluginPageProps) {\n const { hostId, orgMount, org, section, sections, basePath, segments } =\n props\n\n /*==========================================\n * THE CONTROLLER THIS SURFACE IS BEING VIEWED AS.\n *\n * The declared group of sites that are one sender, or this site alone. Every\n * consent question asked below is asked FOR a controller and not for a site:\n * a grant is looked up under the asking site, a refusal is honored across\n * the whole group, and a grant held by a site outside it belongs to somebody\n * else. It is the same group `performCampaignSend` resolves, so the audience\n * table and the send agree about the same document by construction.\n *\n * Pure, from the org document the shell already passed, so it costs no read.\n * An absent org resolves to the group of one, which is the narrow answer.\n *\n * Only under a site. The organization's page has no site to resolve a group\n * FOR, and `consentGroupForHost` refuses to invent one — the audience page\n * there resolves it for the site the reader enrolls as, once one is chosen.\n *=========================================*/\n const consentGroup = useMemo(\n () =>\n hostId == null\n ? null\n : consentGroupForHost(org as Record<string, unknown>, hostId),\n [org, hostId],\n )\n\n /*\n * Nothing, deliberately, while the redirect is in flight. Rendering the\n * default section here would issue its listens on a URL about to be\n * replaced — on every arrival at `/emails`, which is every nav-tab click.\n */\n if (!section || !sections?.length || !basePath) return null\n // `segments[0]` IS the section — the shell resolved it into `section`\n // already — so what a section owns is everything after it.\n const detail = (segments ?? []).slice(1)\n\n if (hostId == null) {\n // No site and no org to stand in for it: nothing this page can scope.\n if (!orgMount) return null\n return (\n <EmailOrgMountProvider mount={orgMount} basePath={basePath}>\n <HubSections sections={sections}>\n {orgSectionBody(\n section as EmailsConsoleSectionId,\n detail,\n basePath,\n orgMount,\n org as Record<string, unknown> | undefined,\n )}\n </HubSections>\n </EmailOrgMountProvider>\n )\n }\n\n return (\n <HubSections sections={sections}>\n {sectionBody(\n section as EmailsConsoleSectionId,\n hostId,\n detail,\n basePath,\n consentGroup as ConsentGroup,\n )}\n </HubSections>\n )\n}\nEmailsConsolePage.displayName = 'EmailsConsolePage'\n\nexport default EmailsConsolePage\n"],"names":["consentGroupForHost","useConsoleWidgetSlot","HubSections","Stack","useMemo","ConsentGroupConfirmationCard","EmailScreensCard","EmailTemplateDetail","EmailTopicDetail","EmailTopicsCard","ListDetailCard","ListEditCard","ListsCard","OrgEmailTemplatesCard","OrgSendingCard","OrgSendingDomainDetail","OrgSiteSuppressions","OrgSuppressionsCard","SendingDomainDetail","SendingDomainsCard","SuppressionsCard","EmailOrgMountProvider","EMAIL_MESSAGES_ZONE","EmailMessagesSection","props","Zone","slot","id","sectionBody","section","hostId","detail","basePath","consentGroup","screenId","listId","topicId","domain","decodeURIComponent","orgSectionBody","orgMount","org","spacing","EmailsConsolePage","sections","segments","length","slice","mount","displayName"],"mappings":"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"}
@@ -61,52 +61,24 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
61
61
  */ import { readMarketingBasis } from "@aglyn/aglyn";
62
62
  import { mdiAccountRemoveOutline } from "@aglyn/shared-data-mdi";
63
63
  import { MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
64
+ import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
64
65
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
65
66
  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";
66
68
  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";
67
70
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
68
71
  import { Alert, Box, Button, Checkbox, Chip, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
69
72
  import { collection, deleteDoc, doc, documentId, limit, orderBy, query } from "firebase/firestore";
70
- import { useCallback, useEffect, useState } from "react";
73
+ import { useCallback, useEffect, useMemo, useState } from "react";
71
74
  import { useFirestore, usePagedCollection, useUser } from "@aglyn/tenant-feature-instance";
72
75
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
73
- /**
74
- * How the basis reads on screen, for the group this console is viewed as.
75
- *
76
- * ## Read through `readMarketingBasis`, never off the row
77
- *
78
- * This picked `marketingConsent`, `marketingConsentBasis` and
79
- * `marketingConsentAtMs` off the top of the membership document, which is
80
- * where `enrollListMember` used to put them. They now live in an entry per
81
- * site under `marketingConsentByHost`, because a basis runs to the brand it
82
- * was given to rather than to the account that holds the row. Nothing here
83
- * moved with them, so every top-level read answered `undefined` and every
84
- * member of every list — including people carrying a perfectly good attested
85
- * basis — was reported as having no basis on record, on the one screen an
86
- * operator consults before mailing them. `performCampaignSend` reads the same
87
- * documents through the function below and mailed them, correctly, at the same
88
- * time.
89
- *
90
- * The fix is not a second field name. It is that the send path's reader is the
91
- * only reader: a surface that parses the stored shape itself agrees with the
92
- * sender only until the shape moves, and it moves without anybody thinking to
93
- * look here.
94
- *
95
- * ## Five states, because the model carries five
96
- *
97
- * An attestation and an opt-in are not the same fact — one is a person's own
98
- * decision, the other a claim an account made on their behalf — and a table
99
- * rendering both as a tick is the conflation `list-members.ts` stores the
100
- * basis to prevent. The three that are not a grant are not one state either.
101
- * Absence is not refusal, and neither of them is "opted in, to somebody else
102
- * in this account": that third one is a person the sender deliberately leaves
103
- * alone, and an operator who cannot see why reads it as the product having
104
- * lost their subscriber.
105
- */ function consentLabel(member, group) {
76
+ function consentLabel(member, group) {
106
77
  const record = readMarketingBasis(member, group);
107
78
  const when = record.basisAtMs ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}` : '';
108
79
  if (record.basis === 'declined') {
109
80
  return {
81
+ key: 'opted-out',
110
82
  label: `Opted out${when}`,
111
83
  color: 'error'
112
84
  };
@@ -118,9 +90,11 @@ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
118
90
  * `marketingConsentBasis`, and the provenance object a backfill writes —
119
91
  * and the reader has already resolved both into this one answer.
120
92
  */ return record.assertedBy === 'operator' ? {
93
+ key: 'attested',
121
94
  label: `Attested by your team${when}`,
122
95
  color: 'warning'
123
96
  } : {
97
+ key: 'opted-in',
124
98
  label: `Opted in${when}`,
125
99
  color: 'default'
126
100
  };
@@ -132,21 +106,72 @@ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
132
106
  * of another brand in this account and this brand may not borrow it.
133
107
  */ if (record.otherGrant === 'other-host') {
134
108
  return {
109
+ key: 'other-site',
135
110
  label: 'Opted in to another site',
136
111
  color: 'warning'
137
112
  };
138
113
  }
139
114
  if (record.otherGrant === 'unscoped') {
140
115
  return {
116
+ key: 'no-site',
141
117
  label: 'Opted in — no site recorded',
142
118
  color: 'warning'
143
119
  };
144
120
  }
145
121
  return {
122
+ key: 'none',
146
123
  label: 'No basis on record',
147
124
  color: 'default'
148
125
  };
149
126
  }
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 = {
145
+ rule: 'Rule',
146
+ manual: 'Added'
147
+ };
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'
170
+ ];
171
+ const withFilterKeys = (member, group)=>_extends({}, member, {
172
+ viaKey: member.via === 'rule' ? 'rule' : 'manual',
173
+ consentKey: consentLabel(member, group).key
174
+ });
150
175
  /** Free text — a paste, a typed address, commas or newlines — to addresses. */ export function splitAddresses(value) {
151
176
  return value.split(/[\s,;]+/).map((entry)=>entry.trim())// A pasted mail-client line is `Name <a@b.co>`; keep the address part.
152
177
  .map((entry)=>entry.replace(/^.*<|>.*$/g, '')).filter(Boolean);
@@ -168,7 +193,7 @@ export function ListMembersPanel(props) {
168
193
  * oldest members from their own list, silently, which is the failure that
169
194
  * turned an audience into a random sample (AGL-2501). The id is the one key
170
195
  * every row has.
171
- */ const { rows: members, hasMore, page, setPage, pageSize, setPageSize } = usePagedCollection((pageLimit)=>query(collection(firestore, scope[0], scope[1], 'lists', listId, 'members'), orderBy(documentId()), limit(pageLimit)), [
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)), [
172
197
  firestore,
173
198
  scope[0],
174
199
  scope[1],
@@ -176,6 +201,30 @@ export function ListMembersPanel(props) {
176
201
  ], {
177
202
  idField: '$id'
178
203
  });
204
+ const memberWindow = useMemo(()=>memberData == null ? void 0 : memberData.map((member)=>withFilterKeys(member, consentGroup)), [
205
+ memberData,
206
+ consentGroup
207
+ ]);
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;
179
228
  const [addInput, setAddInput] = useState('');
180
229
  const [addName, setAddName] = useState('');
181
230
  const [preview, setPreview] = useState(null);
@@ -434,13 +483,13 @@ export function ListMembersPanel(props) {
434
483
  field: 'via',
435
484
  headerName: 'How',
436
485
  width: 200,
437
- valueGetter: (_value, row)=>row.via === 'rule' ? 'Rule' : 'Added',
486
+ valueGetter: (_value, row)=>row.viaKey,
438
487
  /*
439
488
  `via` says whether a rule put them here or a person did, which is what
440
489
  decides whether they LEAVE on their own: the materializer reconciles its
441
490
  own rows away and never touches a manual one. `source` is the finer
442
491
  provenance underneath it.
443
- */ renderCell: ({ row, value })=>/*#__PURE__*/ _jsxs(Stack, {
492
+ */ renderCell: ({ row })=>/*#__PURE__*/ _jsxs(Stack, {
444
493
  direction: "row",
445
494
  spacing: 1,
446
495
  sx: {
@@ -451,7 +500,7 @@ export function ListMembersPanel(props) {
451
500
  /*#__PURE__*/ _jsx(Chip, {
452
501
  size: "small",
453
502
  variant: "outlined",
454
- label: value
503
+ label: VIA_LABELS[row.viaKey]
455
504
  }),
456
505
  row.source ? /*#__PURE__*/ _jsx(Typography, {
457
506
  variant: "caption",
@@ -466,7 +515,7 @@ export function ListMembersPanel(props) {
466
515
  field: 'consent',
467
516
  headerName: 'Consent',
468
517
  width: 190,
469
- valueGetter: (_value, row)=>consentLabel(row, consentGroup).label,
518
+ valueGetter: (_value, row)=>row.consentKey,
470
519
  renderCell: ({ row })=>{
471
520
  const consent = consentLabel(row, consentGroup);
472
521
  return /*#__PURE__*/ _jsx(Chip, {
@@ -643,28 +692,29 @@ export function ListMembersPanel(props) {
643
692
  }, result.input);
644
693
  })
645
694
  }) : null,
646
- members.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
695
+ memberRows.length === 0 && !hasMore && !memberFilter.filtering ? /*#__PURE__*/ _jsx(Typography, {
647
696
  variant: "body2",
648
697
  color: "text.secondary",
649
698
  children: 'Nobody is on this list yet.'
650
699
  }) : /*#__PURE__*/ _jsxs(_Fragment, {
651
700
  children: [
652
- /*#__PURE__*/ _jsx(ListTable, {
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({
653
708
  "aria-label": `Members of ${listName}`,
654
709
  rows: members,
655
- columns: columns,
710
+ columns: memberFilter.filterColumns(columns),
656
711
  rowHeight: TABLE_ROW_HEIGHT,
657
712
  // Paged by the footer below, so the grid must not also slice.
658
713
  hideFooter: true
659
- }),
660
- /*#__PURE__*/ _jsx(ListPagination, {
661
- page: page,
662
- pageSize: pageSize,
663
- rowCount: members.length,
664
- hasMore: hasMore,
665
- onPageChange: setPage,
666
- onPageSizeChange: setPageSize
667
- })
714
+ }, memberFilter.gridProps, {
715
+ noRowsLabel: "No members match these filters"
716
+ })),
717
+ /*#__PURE__*/ _jsx(ListPagination, _extends({}, memberFilter.pagination))
668
718
  ]
669
719
  }),
670
720
  /*#__PURE__*/ _jsx(Typography, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-members-panel.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 * Who is on one list, and the three things you can do about it.\n *\n * The audience card could create a list and delete a list. It could not show\n * you a single name on one. This panel is the rest of it: read the membership,\n * add somebody, take somebody off. The list's own settings — its name, and the\n * rule behind a dynamic one — belong to the edit route, because they are\n * facts about the LIST rather than about who is on it.\n *\n * ## It mounts only on a list's own page\n *\n * The audience detail route renders this for the ONE list being read. A\n * membership listener per row would open one per list on the audiences table,\n * on a surface where most visits are to read the list of lists — the\n * read-on-mount shape `emails-console-read-cost.spec.tsx` meters. Nothing here\n * reads anything until a list is opened, and the add form reads nothing until\n * an address is typed.\n *\n * ## Whether somebody may be ADDED is the server's answer, always\n *\n * Every sentence about an address being typed in is `email/list-members-preview`\n * answering from that person's actual record and both suppression lists. This\n * component decides none of it. A screen that decided for itself would be a\n * second copy of the rule on the one surface whose job is to tell the operator\n * the truth about what is about to happen — including, and especially, that\n * the product knows nothing about this person's permission.\n *\n * ## The membership table reads consent through the SEND-TIME reader\n *\n * The `Consent` column answers the same question `performCampaignSend` asks of\n * the same document, so it asks it with the same function —\n * {@link readMarketingBasis}, against the consent group this console is being\n * viewed as. A column that picked the fields out of the row itself is a second\n * reader of a stored shape, and the two only agree until the shape moves.\n *\n * ## Removing is not suppressing, and the screen says so\n *\n * Taking somebody off a list is the operator changing their mind about an\n * audience. It is not the person asking to stop being mailed, it does not stop\n * another list reaching them, and it writes nothing to the suppression list.\n * Those are different acts with different records, and a console that let them\n * blur is a console where \"remove\" is quietly relied on as an unsubscribe.\n */\n\nimport {\n readMarketingBasis,\n type ConsentGroup,\n type DynamicListRule,\n} from '@aglyn/aglyn'\nimport { mdiAccountRemoveOutline } from '@aglyn/shared-data-mdi'\nimport { MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Box,\n Button,\n Checkbox,\n Chip,\n FormControlLabel,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n documentId,\n limit,\n orderBy,\n query,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport {\n useFirestore,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\n\nexport interface ListMembersPanelProps {\n hostId: string\n /**\n * The controller this membership is being read AS — the declared group of\n * sites that are one sender, or this site alone.\n *\n * Required and undefaulted, for the reason {@link readMarketingBasis} makes\n * it required: a basis runs to a brand, so a surface that has not said which\n * brand is asking cannot report one. Defaulting to the group of one here\n * would compile and would then answer a declared group's questions with a\n * single site's facts — reporting a sibling's refusal as no record at all,\n * and a pooled grant as another brand's.\n */\n consentGroup: ConsentGroup\n /** `['orgs', orgId]` — the resolved org scope the card already holds. */\n scope: readonly [string, string]\n listId: string\n listName: string\n /**\n * Called after a membership is added or removed.\n *\n * The audience page's subscriber total is a server aggregate, taken once.\n * Left alone it would go on reporting the figure from before the add — a\n * stale total directly above a live table that disagrees with it, which\n * reads as the add having failed. The page re-takes the aggregate on this\n * signal and at no other time, so nothing is re-counted while somebody is\n * only reading.\n */\n onMembershipChanged?: () => void\n /**\n * The list's saved filters, when it has any and its membership is FIXED.\n *\n * Present, the panel offers to find the people they select and add them —\n * the same act as typing addresses, with the search doing the typing. Absent\n * (a live list, or one with no filters saved), the add form is the address\n * box alone: a live list's membership is the sweep's to decide, and offering\n * to add its own matches by hand would produce hand-added copies of rows the\n * sweep already owns.\n */\n findRule?: DynamicListRule | null\n /** The same filters in sentences, so the button says what it will find. */\n ruleSummary?: readonly string[]\n}\n\n/**\n * One member row, as stored.\n *\n * The consent fields are deliberately NOT enumerated here. They live one level\n * down, in an entry per site under `marketingConsentByHost`, and a row type\n * that named them at the top would be an invitation to read them there — which\n * is exactly what this table did while `enrollListMember` wrote them nested,\n * so every membership on every list reported \"No basis on record\" while the\n * send path mailed the same people on a basis it could see perfectly well. The\n * index signature is what {@link readMarketingBasis} takes; it is the only\n * reader of those fields on this surface.\n */\ninterface MemberRow {\n $id: string\n email?: string\n name?: string\n source?: string\n via?: 'manual' | 'rule'\n addedAt?: { toDate?: () => Date }\n [field: string]: unknown\n}\n\n/** What `email/list-members-preview` answers for one address. */\ninterface AddressVerdict {\n input: string\n email: string | null\n refusal: string | null\n requiresAttestation: boolean\n summary: string\n}\n\ninterface Preview {\n verdicts: AddressVerdict[]\n optedIn: number\n needAttestation: number\n refused: number\n}\n\n/** What a filter search found, as `email/list-rule-preview` reports it. */\ninterface FoundPeople extends Preview {\n /** People the filters matched — NOT the length of `emails`. */\n matched: number\n /** `emails` is one batch of a larger match. */\n truncated: boolean\n /** False when the scan hit its budget, so `matched` is itself a floor. */\n complete: boolean\n emails: string[]\n}\n\n/** What one address's outcome was, after the add. */\ninterface AddResult {\n input: string\n email: string | null\n enrolled: boolean\n error?: string\n}\n\n/**\n * How the basis reads on screen, for the group this console is viewed as.\n *\n * ## Read through `readMarketingBasis`, never off the row\n *\n * This picked `marketingConsent`, `marketingConsentBasis` and\n * `marketingConsentAtMs` off the top of the membership document, which is\n * where `enrollListMember` used to put them. They now live in an entry per\n * site under `marketingConsentByHost`, because a basis runs to the brand it\n * was given to rather than to the account that holds the row. Nothing here\n * moved with them, so every top-level read answered `undefined` and every\n * member of every list — including people carrying a perfectly good attested\n * basis — was reported as having no basis on record, on the one screen an\n * operator consults before mailing them. `performCampaignSend` reads the same\n * documents through the function below and mailed them, correctly, at the same\n * time.\n *\n * The fix is not a second field name. It is that the send path's reader is the\n * only reader: a surface that parses the stored shape itself agrees with the\n * sender only until the shape moves, and it moves without anybody thinking to\n * look here.\n *\n * ## Five states, because the model carries five\n *\n * An attestation and an opt-in are not the same fact — one is a person's own\n * decision, the other a claim an account made on their behalf — and a table\n * rendering both as a tick is the conflation `list-members.ts` stores the\n * basis to prevent. The three that are not a grant are not one state either.\n * Absence is not refusal, and neither of them is \"opted in, to somebody else\n * in this account\": that third one is a person the sender deliberately leaves\n * alone, and an operator who cannot see why reads it as the product having\n * lost their subscriber.\n */\nfunction consentLabel(\n member: MemberRow,\n group: ConsentGroup,\n): { label: string; color: 'default' | 'warning' | 'error' } {\n const record = readMarketingBasis(member as Record<string, unknown>, group)\n const when = record.basisAtMs\n ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}`\n : ''\n if (record.basis === 'declined') {\n return { label: `Opted out${when}`, color: 'error' }\n }\n if (record.basis === 'granted') {\n /*\n * `assertedBy` rather than the stored basis field, because there are two\n * ways a record says an operator asserted it — a membership's\n * `marketingConsentBasis`, and the provenance object a backfill writes —\n * and the reader has already resolved both into this one answer.\n */\n return record.assertedBy === 'operator'\n ? { label: `Attested by your team${when}`, color: 'warning' }\n : { label: `Opted in${when}`, color: 'default' }\n }\n /*\n * A grant this site may not use. Reported rather than folded into \"no\n * basis\" because the two are different situations with different answers:\n * nobody has ever asked this person, versus somebody asked them on behalf\n * of another brand in this account and this brand may not borrow it.\n */\n if (record.otherGrant === 'other-host') {\n return { label: 'Opted in to another site', color: 'warning' }\n }\n if (record.otherGrant === 'unscoped') {\n return { label: 'Opted in — no site recorded', color: 'warning' }\n }\n return { label: 'No basis on record', color: 'default' }\n}\n\n/** Free text — a paste, a typed address, commas or newlines — to addresses. */\nexport function splitAddresses(value: string): string[] {\n return value\n .split(/[\\s,;]+/)\n .map((entry) => entry.trim())\n // A pasted mail-client line is `Name <a@b.co>`; keep the address part.\n .map((entry) => entry.replace(/^.*<|>.*$/g, ''))\n .filter(Boolean)\n}\n\nexport function ListMembersPanel(props: ListMembersPanelProps) {\n const {\n hostId,\n consentGroup,\n scope,\n listId,\n listName,\n onMembershipChanged,\n findRule,\n ruleSummary,\n } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n /*\n * Ordered by DOCUMENT ID and paged, not capped and re-sorted here.\n *\n * `__name__` rather than `addedAt`, which is the column a reader would\n * assume: `orderBy` FILTERS as well as sorts, so a document missing the\n * field is not in the result at all — and `addedAt` is stamped only when a\n * membership is CREATED, so every row written under the two legacy ids\n * `list-members.ts` still adopts predates it. Ordering on it would drop the\n * oldest members from their own list, silently, which is the failure that\n * turned an audience into a random sample (AGL-2501). The id is the one key\n * every row has.\n */\n const {\n rows: members,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<MemberRow>(\n (pageLimit) =>\n query(\n collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n orderBy(documentId()),\n limit(pageLimit),\n ),\n [firestore, scope[0], scope[1], listId],\n { idField: '$id' },\n )\n\n const [addInput, setAddInput] = useState('')\n const [addName, setAddName] = useState('')\n const [preview, setPreview] = useState<Preview | null>(null)\n const [attested, setAttested] = useState(false)\n const [results, setResults] = useState<AddResult[] | null>(null)\n const [busy, setBusy] = useState(false)\n /*\n * The addresses a SEARCH found, when the operator ran one.\n *\n * A second way to fill the same candidate set, not a second way to add. Once\n * this holds addresses, everything below it — the consent readout, the\n * attestation, the Add button — is the code the typed path already used, so\n * there is exactly one place in this component where somebody is put on a\n * list and exactly one gate in front of it.\n */\n const [found, setFound] = useState<FoundPeople | null>(null)\n\n const addresses = found ? found.emails : splitAddresses(addInput)\n\n /*\n * Any edit to the box invalidates the answer.\n *\n * A preview belongs to the exact addresses it was run for. Leaving a stale\n * one on screen would show a count, and an attestation checkbox, for a set\n * the operator has since changed — so the number they stand behind would\n * not be the number that acts. Typing also abandons a search: the two are\n * alternative ways of naming a candidate set, and holding both at once\n * would leave the Add button acting on whichever the code happened to\n * prefer.\n */\n useEffect(() => {\n setPreview(null)\n setAttested(false)\n setResults(null)\n setFound(null)\n }, [addInput])\n\n const post = useCallback(\n async (route: string, body: Record<string, unknown>) => {\n const response = await authorizedFetch(user, `/api/email/${route}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, listId, ...body }),\n })\n const payload = await response.json().catch(() => ({}))\n return { ok: response.ok, payload }\n },\n [user, hostId, listId],\n )\n\n const handlePreview = useCallback(async () => {\n if (busy || !addresses.length) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-preview', {\n emails: addresses,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The addresses could not be checked.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setPreview(payload as Preview)\n } catch {\n enqueueSnackbar('The addresses could not be checked.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, addresses, post, enqueueSnackbar])\n\n const handleFind = useCallback(async () => {\n if (busy || !findRule) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-rule-preview', {\n rule: findRule,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The audience could not be worked out.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n /*\n * The verdicts come back from the SAME resolution the typed path runs,\n * so what lands in `preview` here is the same object the consent\n * readout below already knows how to draw. A search that returned its\n * own shaped summary would be a second consent readout, and the second\n * one is the one nobody reviews.\n */\n setFound(payload as FoundPeople)\n setPreview(payload as Preview)\n setAttested(false)\n setResults(null)\n if (!Number(payload?.matched ?? 0)) {\n enqueueSnackbar('These filters match nobody', {\n variant: 'info',\n persist: false,\n })\n }\n } catch {\n enqueueSnackbar('The audience could not be worked out.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, findRule, post, enqueueSnackbar])\n\n const handleAdd = useCallback(async () => {\n if (busy || !preview) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-add', {\n emails: addresses,\n ...(addresses.length === 1 && addName.trim()\n ? { name: addName.trim() }\n : {}),\n // The operator's assertion, not a basis they named: the server\n // derives a pass-through from each person's own record, and this flag\n // can only ever add the attributable kind.\n attestConsent: attested,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'Nobody was added to the list',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const added = Number(payload?.added ?? 0)\n setResults((payload?.results ?? []) as AddResult[])\n setPreview(null)\n if (added) {\n setAddInput('')\n setAddName('')\n setFound(null)\n onMembershipChanged?.()\n }\n enqueueSnackbar(\n added === 1 ? 'One person added' : `${added} people added`,\n { variant: added ? 'success' : 'warning', persist: false },\n )\n } catch {\n enqueueSnackbar('Nobody was added to the list', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [\n busy,\n preview,\n addresses,\n addName,\n attested,\n post,\n enqueueSnackbar,\n onMembershipChanged,\n ])\n\n const handleRemove = useCallback(\n async (member: MemberRow) => {\n const accepted = await confirm({\n title: 'Remove from this list?',\n description:\n `${member.email ?? 'This person'} stops being part of \"${listName}\" ` +\n 'and campaigns to this list stop reaching them. This is NOT an ' +\n 'unsubscribe: it does not add them to your suppression list, and ' +\n 'any other list they are on still reaches them. If they asked to ' +\n 'stop hearing from you, suppress the address instead.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone would make this always proceed (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n try {\n await deleteDoc(\n doc(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n listId,\n 'members',\n member.$id,\n ),\n )\n onMembershipChanged?.()\n enqueueSnackbar('Removed from the list', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('They were not removed', { variant: 'error' })\n }\n },\n [\n confirm,\n listName,\n firestore,\n scope,\n listId,\n enqueueSnackbar,\n onMembershipChanged,\n ],\n )\n\n const enrollable = preview\n ? preview.optedIn + preview.needAttestation\n : 0\n\n const columns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => row.email ?? '—',\n },\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 160,\n valueGetter: (_value, row) => row.name ?? '—',\n },\n {\n field: 'addedAt',\n headerName: 'Joined',\n width: 120,\n // Sorted on the instant, drawn as a local date.\n valueGetter: (_value, row) => row.addedAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row }) =>\n row.addedAt?.toDate ? row.addedAt.toDate().toLocaleDateString() : '—',\n },\n {\n field: 'via',\n headerName: 'How',\n width: 200,\n valueGetter: (_value, row) => (row.via === 'rule' ? 'Rule' : 'Added'),\n /*\n `via` says whether a rule put them here or a person did, which is what\n decides whether they LEAVE on their own: the materializer reconciles its\n own rows away and never touches a manual one. `source` is the finer\n provenance underneath it.\n */\n renderCell: ({ row, value }) => (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Chip size=\"small\" variant=\"outlined\" label={value} />\n {row.source ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.source}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'consent',\n headerName: 'Consent',\n width: 190,\n valueGetter: (_value, row) => consentLabel(row, consentGroup).label,\n renderCell: ({ row }) => {\n const consent = consentLabel(row, consentGroup)\n return (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={consent.color}\n label={consent.label}\n />\n )\n },\n },\n /*\n A SUBSCRIBER HAS NO PAGE of their own here, so the row opens nothing —\n but taking somebody off an audience is the one act performed on one, and\n it belongs in the trailing cluster with every other list's actions rather\n than as a red text button sitting in the row.\n */\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.email ?? row.$id)}\n items={[\n {\n key: 'remove',\n label: 'Remove from this list',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <Stack spacing={2} sx={{ py: 2 }}>\n {/*\n FINDING people, for a list whose membership is fixed.\n The filters are the same ones a live list is swept by; here they run\n once, on request, and what they find is offered for adding rather than\n enrolled. The count and the consent readout arrive together, because\n the second is the thing that decides how much of the first can act.\n */}\n {findRule ? (\n <Stack\n spacing={1}\n sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 1.5 }}\n >\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Find people'}\n </Typography>\n {(ruleSummary ?? []).map((clause) => (\n <Typography key={clause} variant=\"body2\">\n {clause}\n </Typography>\n ))}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy}\n onClick={() => void handleFind()}\n >\n {busy ? 'Searching…' : 'Find matching people'}\n </Button>\n </Box>\n {found ? (\n <Alert severity={found.matched ? 'info' : 'warning'}>\n {(found.matched === 1\n ? '1 person matches'\n : `${found.matched.toLocaleString()} people match`) +\n (found.truncated\n ? ` — this batch covers the first ${found.emails.length}. ` +\n 'Add them, then search again for the rest.'\n : '') +\n (found.complete\n ? ''\n : ' The search stopped at its read budget, so this is at ' +\n 'least that many rather than exactly.')}\n </Alert>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whoever you add stays on this list. It does not keep growing — ' +\n 'change the membership to live on the edit page if you want ' +\n 'new matches enrolled automatically.'}\n </Typography>\n </Stack>\n ) : null}\n\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Add someone by typing their address, or paste a column of them. ' +\n 'Every address is checked against their consent record and both ' +\n 'suppression lists before anyone is added.'}\n </Typography>\n\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"Email addresses\"\n placeholder=\"someone@example.com\"\n multiline\n minRows={1}\n maxRows={6}\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n helperText={\n addresses.length > 1\n ? `${addresses.length} addresses`\n : 'One address, or paste many'\n }\n sx={{ flexGrow: 1, minWidth: 260 }}\n />\n {addresses.length > 1 ? null : (\n <TextField\n size=\"small\"\n label=\"Name\"\n value={addName}\n onChange={(event) => setAddName(event.target.value)}\n sx={{ minWidth: 180 }}\n />\n )}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy || !addresses.length || Boolean(preview)}\n onClick={() => void handlePreview()}\n >\n {busy ? 'Checking…' : 'Check'}\n </Button>\n </Box>\n </Stack>\n\n {/*\n The count, in front of the operator, BEFORE the attestation control\n exists. One assertion covers the batch, so the number it covers has to\n be on screen when it is given — an attestation offered above an\n unexamined paste is a signature on a blank page.\n */}\n {preview ? (\n <Stack spacing={1}>\n <Alert severity={enrollable ? 'info' : 'warning'}>\n {[\n preview.optedIn\n ? `${preview.optedIn} already opted in`\n : '',\n preview.needAttestation\n ? `${preview.needAttestation} with no opt-in on record`\n : '',\n preview.refused\n ? `${preview.refused} cannot be added at all`\n : '',\n ]\n .filter(Boolean)\n .join(' · ')}\n </Alert>\n {/*\n The HARD refusals only. An address that merely needs the\n attestation is not a refusal — listing it here would put the very\n people the checkbox below is about under a heading that says they\n cannot be added.\n */}\n {preview.verdicts\n .filter((verdict) => verdict.refusal)\n .map((verdict) => (\n <Typography\n key={verdict.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${verdict.input} — ${verdict.summary}`}\n </Typography>\n ))}\n {preview.needAttestation ? (\n <FormControlLabel\n control={\n <Checkbox\n checked={attested}\n onChange={(event) => setAttested(event.target.checked)}\n />\n }\n label={\n <Typography variant=\"body2\">\n {`I have permission to send marketing email to the ` +\n `${preview.needAttestation} ` +\n `${preview.needAttestation === 1 ? 'person' : 'people'} ` +\n 'above with no opt-in on record. This is recorded against ' +\n 'my account, with the date.'}\n </Typography>\n }\n />\n ) : null}\n <Box>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={\n busy ||\n !enrollable ||\n (preview.needAttestation > 0 && !attested)\n }\n onClick={() => void handleAdd()}\n >\n {busy ? 'Adding…' : `Add ${enrollable}`}\n </Button>\n </Box>\n </Stack>\n ) : null}\n\n {/*\n What actually happened, per address. A batch where one address was\n suppressed and forty were fine is not a failure, and a bare count would\n leave the operator to work out which of their lines is missing.\n */}\n {results?.length ? (\n <Stack spacing={0.5}>\n {results\n .filter((result) => !result.enrolled)\n .map((result) => (\n <Typography\n key={result.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${result.input} — not added. ${result.error ?? ''}`}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {members.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is on this list yet.'}\n </Typography>\n ) : (\n <>\n <ListTable\n aria-label={`Members of ${listName}`}\n rows={members}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={members.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n\n {/*\n What the column MEANS, and what to do about the one state an operator\n can act on. \"No basis on record\" is a fact about this site's records,\n not a refusal and not a dead end — the remedy is the add form above,\n which merges onto the membership that is already here rather than\n creating a second one. Whether a basis-less member is actually mailed\n is the org's consent policy to decide and is deliberately not claimed\n here: this surface does not hold that setting, and a caption that\n guessed it would be wrong for every org on the other mode.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'\"No basis on record\" means nobody has recorded this person\\'s ' +\n 'permission for this site — it is not a refusal. To record one, put ' +\n 'their address in the box above and state that you have their ' +\n 'permission; the statement is kept against your account, with the ' +\n 'date.'}\n </Typography>\n\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Removing someone from a list is not an unsubscribe. It does not add ' +\n 'them to your suppression list, and it does not stop any other list ' +\n 'reaching them. If somebody asked you to stop, suppress the address.'}\n </Typography>\n </Stack>\n )\n}\nListMembersPanel.displayName = 'ListMembersPanel'\n\nexport default ListMembersPanel\n"],"names":["readMarketingBasis","mdiAccountRemoveOutline","MdiIcon","useConfirmationContext","ListPagination","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","useSnackbar","Alert","Box","Button","Checkbox","Chip","FormControlLabel","Stack","TextField","Typography","collection","deleteDoc","doc","documentId","limit","orderBy","query","useCallback","useEffect","useState","useFirestore","usePagedCollection","useUser","authorizedFetch","consentLabel","member","group","record","when","basisAtMs","Date","toLocaleDateString","basis","label","color","assertedBy","otherGrant","splitAddresses","value","split","map","entry","trim","replace","filter","Boolean","ListMembersPanel","props","hostId","consentGroup","scope","listId","listName","onMembershipChanged","findRule","ruleSummary","firestore","data","user","enqueueSnackbar","confirm","rows","members","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","addInput","setAddInput","addName","setAddName","preview","setPreview","attested","setAttested","results","setResults","busy","setBusy","found","setFound","addresses","emails","post","route","body","response","method","headers","JSON","stringify","payload","json","catch","ok","handlePreview","length","error","variant","allowDuplicate","handleFind","rule","Number","matched","persist","handleAdd","name","attestConsent","added","handleRemove","accepted","title","description","email","confirmationText","confirmationButtonProps","then","$id","console","enrollable","optedIn","needAttestation","columns","field","headerName","flex","minWidth","valueGetter","_value","row","width","addedAt","toDate","getTime","renderCell","via","direction","spacing","sx","alignItems","size","source","noWrap","consent","String","items","key","icon","path","destructive","onClick","py","border","borderColor","borderRadius","p","clause","disabled","severity","toLocaleString","truncated","complete","useFlexGap","flexWrap","placeholder","multiline","minRows","maxRows","onChange","event","target","helperText","flexGrow","refused","join","verdicts","verdict","refusal","input","summary","control","checked","result","enrolled","aria-label","rowHeight","hideFooter","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-members-panel.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 * Who is on one list, and the three things you can do about it.\n *\n * The audience card could create a list and delete a list. It could not show\n * you a single name on one. This panel is the rest of it: read the membership,\n * add somebody, take somebody off. The list's own settings — its name, and the\n * rule behind a dynamic one — belong to the edit route, because they are\n * facts about the LIST rather than about who is on it.\n *\n * ## It mounts only on a list's own page\n *\n * The audience detail route renders this for the ONE list being read. A\n * membership listener per row would open one per list on the audiences table,\n * on a surface where most visits are to read the list of lists — the\n * read-on-mount shape `emails-console-read-cost.spec.tsx` meters. Nothing here\n * reads anything until a list is opened, and the add form reads nothing until\n * an address is typed.\n *\n * ## Whether somebody may be ADDED is the server's answer, always\n *\n * Every sentence about an address being typed in is `email/list-members-preview`\n * answering from that person's actual record and both suppression lists. This\n * component decides none of it. A screen that decided for itself would be a\n * second copy of the rule on the one surface whose job is to tell the operator\n * the truth about what is about to happen — including, and especially, that\n * the product knows nothing about this person's permission.\n *\n * ## The membership table reads consent through the SEND-TIME reader\n *\n * The `Consent` column answers the same question `performCampaignSend` asks of\n * the same document, so it asks it with the same function —\n * {@link readMarketingBasis}, against the consent group this console is being\n * viewed as. A column that picked the fields out of the row itself is a second\n * reader of a stored shape, and the two only agree until the shape moves.\n *\n * ## Removing is not suppressing, and the screen says so\n *\n * Taking somebody off a list is the operator changing their mind about an\n * audience. It is not the person asking to stop being mailed, it does not stop\n * another list reaching them, and it writes nothing to the suppression list.\n * Those are different acts with different records, and a console that let them\n * blur is a console where \"remove\" is quietly relied on as an unsubscribe.\n */\n\nimport {\n readMarketingBasis,\n type ConsentGroup,\n type DynamicListRule,\n} from '@aglyn/aglyn'\nimport { mdiAccountRemoveOutline } from '@aglyn/shared-data-mdi'\nimport { MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { inMemoryListField } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { usePagedRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-paged-rows-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Box,\n Button,\n Checkbox,\n Chip,\n FormControlLabel,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n documentId,\n limit,\n orderBy,\n query,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport {\n useFirestore,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\n\nexport interface ListMembersPanelProps {\n hostId: string\n /**\n * The controller this membership is being read AS — the declared group of\n * sites that are one sender, or this site alone.\n *\n * Required and undefaulted, for the reason {@link readMarketingBasis} makes\n * it required: a basis runs to a brand, so a surface that has not said which\n * brand is asking cannot report one. Defaulting to the group of one here\n * would compile and would then answer a declared group's questions with a\n * single site's facts — reporting a sibling's refusal as no record at all,\n * and a pooled grant as another brand's.\n */\n consentGroup: ConsentGroup\n /** `['orgs', orgId]` — the resolved org scope the card already holds. */\n scope: readonly [string, string]\n listId: string\n listName: string\n /**\n * Called after a membership is added or removed.\n *\n * The audience page's subscriber total is a server aggregate, taken once.\n * Left alone it would go on reporting the figure from before the add — a\n * stale total directly above a live table that disagrees with it, which\n * reads as the add having failed. The page re-takes the aggregate on this\n * signal and at no other time, so nothing is re-counted while somebody is\n * only reading.\n */\n onMembershipChanged?: () => void\n /**\n * The list's saved filters, when it has any and its membership is FIXED.\n *\n * Present, the panel offers to find the people they select and add them —\n * the same act as typing addresses, with the search doing the typing. Absent\n * (a live list, or one with no filters saved), the add form is the address\n * box alone: a live list's membership is the sweep's to decide, and offering\n * to add its own matches by hand would produce hand-added copies of rows the\n * sweep already owns.\n */\n findRule?: DynamicListRule | null\n /** The same filters in sentences, so the button says what it will find. */\n ruleSummary?: readonly string[]\n}\n\n/**\n * One member row, as stored.\n *\n * The consent fields are deliberately NOT enumerated here. They live one level\n * down, in an entry per site under `marketingConsentByHost`, and a row type\n * that named them at the top would be an invitation to read them there — which\n * is exactly what this table did while `enrollListMember` wrote them nested,\n * so every membership on every list reported \"No basis on record\" while the\n * send path mailed the same people on a basis it could see perfectly well. The\n * index signature is what {@link readMarketingBasis} takes; it is the only\n * reader of those fields on this surface.\n */\ninterface MemberRow {\n $id: string\n email?: string\n name?: string\n source?: string\n via?: 'manual' | 'rule'\n addedAt?: { toDate?: () => Date }\n [field: string]: unknown\n}\n\n/** What `email/list-members-preview` answers for one address. */\ninterface AddressVerdict {\n input: string\n email: string | null\n refusal: string | null\n requiresAttestation: boolean\n summary: string\n}\n\ninterface Preview {\n verdicts: AddressVerdict[]\n optedIn: number\n needAttestation: number\n refused: number\n}\n\n/** What a filter search found, as `email/list-rule-preview` reports it. */\ninterface FoundPeople extends Preview {\n /** People the filters matched — NOT the length of `emails`. */\n matched: number\n /** `emails` is one batch of a larger match. */\n truncated: boolean\n /** False when the scan hit its budget, so `matched` is itself a floor. */\n complete: boolean\n emails: string[]\n}\n\n/** What one address's outcome was, after the add. */\ninterface AddResult {\n input: string\n email: string | null\n enrolled: boolean\n error?: string\n}\n\n/**\n * How the basis reads on screen, for the group this console is viewed as.\n *\n * ## Read through `readMarketingBasis`, never off the row\n *\n * This picked `marketingConsent`, `marketingConsentBasis` and\n * `marketingConsentAtMs` off the top of the membership document, which is\n * where `enrollListMember` used to put them. They now live in an entry per\n * site under `marketingConsentByHost`, because a basis runs to the brand it\n * was given to rather than to the account that holds the row. Nothing here\n * moved with them, so every top-level read answered `undefined` and every\n * member of every list — including people carrying a perfectly good attested\n * basis — was reported as having no basis on record, on the one screen an\n * operator consults before mailing them. `performCampaignSend` reads the same\n * documents through the function below and mailed them, correctly, at the same\n * time.\n *\n * The fix is not a second field name. It is that the send path's reader is the\n * only reader: a surface that parses the stored shape itself agrees with the\n * sender only until the shape moves, and it moves without anybody thinking to\n * look here.\n *\n * ## Five states, because the model carries five\n *\n * An attestation and an opt-in are not the same fact — one is a person's own\n * decision, the other a claim an account made on their behalf — and a table\n * rendering both as a tick is the conflation `list-members.ts` stores the\n * basis to prevent. The three that are not a grant are not one state either.\n * Absence is not refusal, and neither of them is \"opted in, to somebody else\n * in this account\": that third one is a person the sender deliberately leaves\n * alone, and an operator who cannot see why reads it as the product having\n * lost their subscriber.\n */\ntype ConsentKey =\n | 'opted-out'\n | 'attested'\n | 'opted-in'\n | 'other-site'\n | 'no-site'\n | 'none'\n\nfunction consentLabel(\n member: MemberRow,\n group: ConsentGroup,\n): { key: ConsentKey; label: string; color: 'default' | 'warning' | 'error' } {\n const record = readMarketingBasis(member as Record<string, unknown>, group)\n const when = record.basisAtMs\n ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}`\n : ''\n if (record.basis === 'declined') {\n return { key: 'opted-out', label: `Opted out${when}`, color: 'error' }\n }\n if (record.basis === 'granted') {\n /*\n * `assertedBy` rather than the stored basis field, because there are two\n * ways a record says an operator asserted it — a membership's\n * `marketingConsentBasis`, and the provenance object a backfill writes —\n * and the reader has already resolved both into this one answer.\n */\n return record.assertedBy === 'operator'\n ? { key: 'attested', label: `Attested by your team${when}`, color: 'warning' }\n : { key: 'opted-in', label: `Opted in${when}`, color: 'default' }\n }\n /*\n * A grant this site may not use. Reported rather than folded into \"no\n * basis\" because the two are different situations with different answers:\n * nobody has ever asked this person, versus somebody asked them on behalf\n * of another brand in this account and this brand may not borrow it.\n */\n if (record.otherGrant === 'other-host') {\n return { key: 'other-site', label: 'Opted in to another site', color: 'warning' }\n }\n if (record.otherGrant === 'unscoped') {\n return { key: 'no-site', label: 'Opted in — no site recorded', color: 'warning' }\n }\n return { key: 'none', label: 'No basis on record', color: 'default' }\n}\n\n/*\n * What the membership grid's Filters panel offers (AGL-3317). The table is a\n * paged listener, so a filter matches over what its window has read, which\n * the first filter widens (`usePagedRowsFilter`). `How` and `Consent` read\n * the keys `withFilterKeys` derives, the same answers the columns draw.\n */\nconst MEMBER_FILTER_FIELDS = [\n inMemoryListField('email', 'text'),\n inMemoryListField('name', 'text'),\n inMemoryListField('via', 'select', 'viaKey'),\n inMemoryListField('consent', 'select', 'consentKey'),\n]\nconst MEMBER_FILTER_HEADERS: Readonly<Record<string, string>> = {\n email: 'Address',\n name: 'Name',\n via: 'How',\n consent: 'Consent',\n}\nconst VIA_LABELS: Readonly<Record<'rule' | 'manual', string>> = {\n rule: 'Rule',\n manual: 'Added',\n}\nconst CONSENT_LABELS: Readonly<Record<ConsentKey, string>> = {\n 'opted-in': 'Opted in',\n attested: 'Attested by your team',\n 'opted-out': 'Opted out',\n 'other-site': 'Opted in to another site',\n 'no-site': 'Opted in — no site recorded',\n none: 'No basis on record',\n}\nconst MEMBER_FILTER_OPTIONS = {\n via: Object.entries(VIA_LABELS).map(([value, label]) => ({ value, label })),\n consent: Object.entries(CONSENT_LABELS).map(([value, label]) => ({ value, label })),\n}\nconst MEMBER_SEARCH_FIELDS = ['email', 'name', 'source'] as const\n\n/** A member row with the keys its `How` and `Consent` filters match on. */\ntype FilterableMemberRow = MemberRow & { viaKey: 'rule' | 'manual'; consentKey: ConsentKey }\n\nconst withFilterKeys = (member: MemberRow, group: ConsentGroup): FilterableMemberRow => ({\n ...member,\n viaKey: member.via === 'rule' ? 'rule' : 'manual',\n consentKey: consentLabel(member, group).key,\n})\n\n/** Free text — a paste, a typed address, commas or newlines — to addresses. */\nexport function splitAddresses(value: string): string[] {\n return value\n .split(/[\\s,;]+/)\n .map((entry) => entry.trim())\n // A pasted mail-client line is `Name <a@b.co>`; keep the address part.\n .map((entry) => entry.replace(/^.*<|>.*$/g, ''))\n .filter(Boolean)\n}\n\nexport function ListMembersPanel(props: ListMembersPanelProps) {\n const {\n hostId,\n consentGroup,\n scope,\n listId,\n listName,\n onMembershipChanged,\n findRule,\n ruleSummary,\n } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n /*\n * Ordered by DOCUMENT ID and paged, not capped and re-sorted here.\n *\n * `__name__` rather than `addedAt`, which is the column a reader would\n * assume: `orderBy` FILTERS as well as sorts, so a document missing the\n * field is not in the result at all — and `addedAt` is stamped only when a\n * membership is CREATED, so every row written under the two legacy ids\n * `list-members.ts` still adopts predates it. Ordering on it would drop the\n * oldest members from their own list, silently, which is the failure that\n * turned an audience into a random sample (AGL-2501). The id is the one key\n * every row has.\n */\n const {\n data: memberData,\n rows: memberRows,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<MemberRow>(\n (pageLimit) =>\n query(\n collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n orderBy(documentId()),\n limit(pageLimit),\n ),\n [firestore, scope[0], scope[1], listId],\n { idField: '$id' },\n )\n const memberWindow = useMemo(\n () => memberData?.map((member) => withFilterKeys(member, consentGroup)),\n [memberData, consentGroup],\n )\n const memberPage = useMemo(\n () => memberRows.map((member) => withFilterKeys(member, consentGroup)),\n [memberRows, consentGroup],\n )\n const memberFilter = usePagedRowsFilter<FilterableMemberRow>(\n {\n data: memberWindow,\n rows: memberPage,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n },\n {\n fields: MEMBER_FILTER_FIELDS,\n options: MEMBER_FILTER_OPTIONS,\n headers: MEMBER_FILTER_HEADERS,\n search: MEMBER_SEARCH_FIELDS,\n },\n )\n // The rows on screen: the page, or the page of the matches.\n const members = memberFilter.rows\n\n const [addInput, setAddInput] = useState('')\n const [addName, setAddName] = useState('')\n const [preview, setPreview] = useState<Preview | null>(null)\n const [attested, setAttested] = useState(false)\n const [results, setResults] = useState<AddResult[] | null>(null)\n const [busy, setBusy] = useState(false)\n /*\n * The addresses a SEARCH found, when the operator ran one.\n *\n * A second way to fill the same candidate set, not a second way to add. Once\n * this holds addresses, everything below it — the consent readout, the\n * attestation, the Add button — is the code the typed path already used, so\n * there is exactly one place in this component where somebody is put on a\n * list and exactly one gate in front of it.\n */\n const [found, setFound] = useState<FoundPeople | null>(null)\n\n const addresses = found ? found.emails : splitAddresses(addInput)\n\n /*\n * Any edit to the box invalidates the answer.\n *\n * A preview belongs to the exact addresses it was run for. Leaving a stale\n * one on screen would show a count, and an attestation checkbox, for a set\n * the operator has since changed — so the number they stand behind would\n * not be the number that acts. Typing also abandons a search: the two are\n * alternative ways of naming a candidate set, and holding both at once\n * would leave the Add button acting on whichever the code happened to\n * prefer.\n */\n useEffect(() => {\n setPreview(null)\n setAttested(false)\n setResults(null)\n setFound(null)\n }, [addInput])\n\n const post = useCallback(\n async (route: string, body: Record<string, unknown>) => {\n const response = await authorizedFetch(user, `/api/email/${route}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, listId, ...body }),\n })\n const payload = await response.json().catch(() => ({}))\n return { ok: response.ok, payload }\n },\n [user, hostId, listId],\n )\n\n const handlePreview = useCallback(async () => {\n if (busy || !addresses.length) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-preview', {\n emails: addresses,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The addresses could not be checked.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setPreview(payload as Preview)\n } catch {\n enqueueSnackbar('The addresses could not be checked.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, addresses, post, enqueueSnackbar])\n\n const handleFind = useCallback(async () => {\n if (busy || !findRule) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-rule-preview', {\n rule: findRule,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The audience could not be worked out.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n /*\n * The verdicts come back from the SAME resolution the typed path runs,\n * so what lands in `preview` here is the same object the consent\n * readout below already knows how to draw. A search that returned its\n * own shaped summary would be a second consent readout, and the second\n * one is the one nobody reviews.\n */\n setFound(payload as FoundPeople)\n setPreview(payload as Preview)\n setAttested(false)\n setResults(null)\n if (!Number(payload?.matched ?? 0)) {\n enqueueSnackbar('These filters match nobody', {\n variant: 'info',\n persist: false,\n })\n }\n } catch {\n enqueueSnackbar('The audience could not be worked out.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, findRule, post, enqueueSnackbar])\n\n const handleAdd = useCallback(async () => {\n if (busy || !preview) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-add', {\n emails: addresses,\n ...(addresses.length === 1 && addName.trim()\n ? { name: addName.trim() }\n : {}),\n // The operator's assertion, not a basis they named: the server\n // derives a pass-through from each person's own record, and this flag\n // can only ever add the attributable kind.\n attestConsent: attested,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'Nobody was added to the list',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const added = Number(payload?.added ?? 0)\n setResults((payload?.results ?? []) as AddResult[])\n setPreview(null)\n if (added) {\n setAddInput('')\n setAddName('')\n setFound(null)\n onMembershipChanged?.()\n }\n enqueueSnackbar(\n added === 1 ? 'One person added' : `${added} people added`,\n { variant: added ? 'success' : 'warning', persist: false },\n )\n } catch {\n enqueueSnackbar('Nobody was added to the list', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [\n busy,\n preview,\n addresses,\n addName,\n attested,\n post,\n enqueueSnackbar,\n onMembershipChanged,\n ])\n\n const handleRemove = useCallback(\n async (member: MemberRow) => {\n const accepted = await confirm({\n title: 'Remove from this list?',\n description:\n `${member.email ?? 'This person'} stops being part of \"${listName}\" ` +\n 'and campaigns to this list stop reaching them. This is NOT an ' +\n 'unsubscribe: it does not add them to your suppression list, and ' +\n 'any other list they are on still reaches them. If they asked to ' +\n 'stop hearing from you, suppress the address instead.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone would make this always proceed (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n try {\n await deleteDoc(\n doc(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n listId,\n 'members',\n member.$id,\n ),\n )\n onMembershipChanged?.()\n enqueueSnackbar('Removed from the list', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('They were not removed', { variant: 'error' })\n }\n },\n [\n confirm,\n listName,\n firestore,\n scope,\n listId,\n enqueueSnackbar,\n onMembershipChanged,\n ],\n )\n\n const enrollable = preview\n ? preview.optedIn + preview.needAttestation\n : 0\n\n const columns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => row.email ?? '—',\n },\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 160,\n valueGetter: (_value, row) => row.name ?? '—',\n },\n {\n field: 'addedAt',\n headerName: 'Joined',\n width: 120,\n // Sorted on the instant, drawn as a local date.\n valueGetter: (_value, row) => row.addedAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row }) =>\n row.addedAt?.toDate ? row.addedAt.toDate().toLocaleDateString() : '—',\n },\n {\n field: 'via',\n headerName: 'How',\n width: 200,\n valueGetter: (_value, row) => row.viaKey,\n /*\n `via` says whether a rule put them here or a person did, which is what\n decides whether they LEAVE on their own: the materializer reconciles its\n own rows away and never touches a manual one. `source` is the finer\n provenance underneath it.\n */\n renderCell: ({ row }) => (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Chip size=\"small\" variant=\"outlined\" label={VIA_LABELS[row.viaKey as 'rule' | 'manual']} />\n {row.source ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.source}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'consent',\n headerName: 'Consent',\n width: 190,\n valueGetter: (_value, row) => row.consentKey,\n renderCell: ({ row }) => {\n const consent = consentLabel(row, consentGroup)\n return (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={consent.color}\n label={consent.label}\n />\n )\n },\n },\n /*\n A SUBSCRIBER HAS NO PAGE of their own here, so the row opens nothing —\n but taking somebody off an audience is the one act performed on one, and\n it belongs in the trailing cluster with every other list's actions rather\n than as a red text button sitting in the row.\n */\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.email ?? row.$id)}\n items={[\n {\n key: 'remove',\n label: 'Remove from this list',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <Stack spacing={2} sx={{ py: 2 }}>\n {/*\n FINDING people, for a list whose membership is fixed.\n The filters are the same ones a live list is swept by; here they run\n once, on request, and what they find is offered for adding rather than\n enrolled. The count and the consent readout arrive together, because\n the second is the thing that decides how much of the first can act.\n */}\n {findRule ? (\n <Stack\n spacing={1}\n sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 1.5 }}\n >\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Find people'}\n </Typography>\n {(ruleSummary ?? []).map((clause) => (\n <Typography key={clause} variant=\"body2\">\n {clause}\n </Typography>\n ))}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy}\n onClick={() => void handleFind()}\n >\n {busy ? 'Searching…' : 'Find matching people'}\n </Button>\n </Box>\n {found ? (\n <Alert severity={found.matched ? 'info' : 'warning'}>\n {(found.matched === 1\n ? '1 person matches'\n : `${found.matched.toLocaleString()} people match`) +\n (found.truncated\n ? ` — this batch covers the first ${found.emails.length}. ` +\n 'Add them, then search again for the rest.'\n : '') +\n (found.complete\n ? ''\n : ' The search stopped at its read budget, so this is at ' +\n 'least that many rather than exactly.')}\n </Alert>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whoever you add stays on this list. It does not keep growing — ' +\n 'change the membership to live on the edit page if you want ' +\n 'new matches enrolled automatically.'}\n </Typography>\n </Stack>\n ) : null}\n\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Add someone by typing their address, or paste a column of them. ' +\n 'Every address is checked against their consent record and both ' +\n 'suppression lists before anyone is added.'}\n </Typography>\n\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"Email addresses\"\n placeholder=\"someone@example.com\"\n multiline\n minRows={1}\n maxRows={6}\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n helperText={\n addresses.length > 1\n ? `${addresses.length} addresses`\n : 'One address, or paste many'\n }\n sx={{ flexGrow: 1, minWidth: 260 }}\n />\n {addresses.length > 1 ? null : (\n <TextField\n size=\"small\"\n label=\"Name\"\n value={addName}\n onChange={(event) => setAddName(event.target.value)}\n sx={{ minWidth: 180 }}\n />\n )}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy || !addresses.length || Boolean(preview)}\n onClick={() => void handlePreview()}\n >\n {busy ? 'Checking…' : 'Check'}\n </Button>\n </Box>\n </Stack>\n\n {/*\n The count, in front of the operator, BEFORE the attestation control\n exists. One assertion covers the batch, so the number it covers has to\n be on screen when it is given — an attestation offered above an\n unexamined paste is a signature on a blank page.\n */}\n {preview ? (\n <Stack spacing={1}>\n <Alert severity={enrollable ? 'info' : 'warning'}>\n {[\n preview.optedIn\n ? `${preview.optedIn} already opted in`\n : '',\n preview.needAttestation\n ? `${preview.needAttestation} with no opt-in on record`\n : '',\n preview.refused\n ? `${preview.refused} cannot be added at all`\n : '',\n ]\n .filter(Boolean)\n .join(' · ')}\n </Alert>\n {/*\n The HARD refusals only. An address that merely needs the\n attestation is not a refusal — listing it here would put the very\n people the checkbox below is about under a heading that says they\n cannot be added.\n */}\n {preview.verdicts\n .filter((verdict) => verdict.refusal)\n .map((verdict) => (\n <Typography\n key={verdict.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${verdict.input} — ${verdict.summary}`}\n </Typography>\n ))}\n {preview.needAttestation ? (\n <FormControlLabel\n control={\n <Checkbox\n checked={attested}\n onChange={(event) => setAttested(event.target.checked)}\n />\n }\n label={\n <Typography variant=\"body2\">\n {`I have permission to send marketing email to the ` +\n `${preview.needAttestation} ` +\n `${preview.needAttestation === 1 ? 'person' : 'people'} ` +\n 'above with no opt-in on record. This is recorded against ' +\n 'my account, with the date.'}\n </Typography>\n }\n />\n ) : null}\n <Box>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={\n busy ||\n !enrollable ||\n (preview.needAttestation > 0 && !attested)\n }\n onClick={() => void handleAdd()}\n >\n {busy ? 'Adding…' : `Add ${enrollable}`}\n </Button>\n </Box>\n </Stack>\n ) : null}\n\n {/*\n What actually happened, per address. A batch where one address was\n suppressed and forty were fine is not a failure, and a bare count would\n leave the operator to work out which of their lines is missing.\n */}\n {results?.length ? (\n <Stack spacing={0.5}>\n {results\n .filter((result) => !result.enrolled)\n .map((result) => (\n <Typography\n key={result.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${result.input} — not added. ${result.error ?? ''}`}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {memberRows.length === 0 && !hasMore && !memberFilter.filtering ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is on this list yet.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips {...memberFilter.chipsProps} />\n {memberFilter.filtering && hasMore ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Filtering the ${memberFilter.read} members read so far — the next page reads more.`}\n </Typography>\n ) : null}\n <ListTable\n aria-label={`Members of ${listName}`}\n rows={members}\n columns={memberFilter.filterColumns(columns)}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel and the search are the grid's; the panel answers\n // them over what its window read.\n {...memberFilter.gridProps}\n noRowsLabel=\"No members match these filters\"\n />\n <ListPagination {...memberFilter.pagination} />\n </>\n )}\n\n {/*\n What the column MEANS, and what to do about the one state an operator\n can act on. \"No basis on record\" is a fact about this site's records,\n not a refusal and not a dead end — the remedy is the add form above,\n which merges onto the membership that is already here rather than\n creating a second one. Whether a basis-less member is actually mailed\n is the org's consent policy to decide and is deliberately not claimed\n here: this surface does not hold that setting, and a caption that\n guessed it would be wrong for every org on the other mode.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'\"No basis on record\" means nobody has recorded this person\\'s ' +\n 'permission for this site — it is not a refusal. To record one, put ' +\n 'their address in the box above and state that you have their ' +\n 'permission; the statement is kept against your account, with the ' +\n 'date.'}\n </Typography>\n\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Removing someone from a list is not an unsubscribe. It does not add ' +\n 'them to your suppression list, and it does not stop any other list ' +\n 'reaching them. If somebody asked you to stop, suppress the address.'}\n </Typography>\n </Stack>\n )\n}\nListMembersPanel.displayName = 'ListMembersPanel'\n\nexport default ListMembersPanel\n"],"names":["readMarketingBasis","mdiAccountRemoveOutline","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListRowActions","ListTable","listActionsColumn","inMemoryListField","TABLE_ROW_HEIGHT","usePagedRowsFilter","useSnackbar","Alert","Box","Button","Checkbox","Chip","FormControlLabel","Stack","TextField","Typography","collection","deleteDoc","doc","documentId","limit","orderBy","query","useCallback","useEffect","useMemo","useState","useFirestore","usePagedCollection","useUser","authorizedFetch","consentLabel","member","group","record","when","basisAtMs","Date","toLocaleDateString","basis","key","label","color","assertedBy","otherGrant","MEMBER_FILTER_FIELDS","MEMBER_FILTER_HEADERS","email","name","via","consent","VIA_LABELS","rule","manual","CONSENT_LABELS","attested","none","MEMBER_FILTER_OPTIONS","Object","entries","map","value","MEMBER_SEARCH_FIELDS","withFilterKeys","viaKey","consentKey","splitAddresses","split","entry","trim","replace","filter","Boolean","ListMembersPanel","props","hostId","consentGroup","scope","listId","listName","onMembershipChanged","findRule","ruleSummary","firestore","data","user","enqueueSnackbar","confirm","memberData","rows","memberRows","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","memberWindow","memberPage","memberFilter","fields","options","headers","search","members","addInput","setAddInput","addName","setAddName","preview","setPreview","setAttested","results","setResults","busy","setBusy","found","setFound","addresses","emails","post","route","body","response","method","JSON","stringify","payload","json","catch","ok","handlePreview","length","error","variant","allowDuplicate","handleFind","Number","matched","persist","handleAdd","attestConsent","added","handleRemove","accepted","title","description","confirmationText","confirmationButtonProps","then","$id","console","enrollable","optedIn","needAttestation","columns","field","headerName","flex","minWidth","valueGetter","_value","row","width","addedAt","toDate","getTime","renderCell","direction","spacing","sx","alignItems","size","source","noWrap","String","items","icon","path","destructive","onClick","py","border","borderColor","borderRadius","p","clause","disabled","severity","toLocaleString","truncated","complete","useFlexGap","flexWrap","placeholder","multiline","minRows","maxRows","onChange","event","target","helperText","flexGrow","refused","join","verdicts","verdict","refusal","input","summary","control","checked","result","enrolled","filtering","chipsProps","read","aria-label","filterColumns","rowHeight","hideFooter","gridProps","noRowsLabel","pagination","displayName"],"mappings":"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"}