@aglyn/plugins-inbox 1.0.0-beta.199 → 1.0.0-beta.201

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/inbox/src/lib/components/contacts-card.component.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 normalizeContactEmail,\n pluginDocsHelp,\n type ConsolePluginOrgMount,\n} from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// carries the plugin registration, and a link needs only the address grammar.\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiAccountRemoveOutline,\n mdiBullhornOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.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 { useListRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-rows-filter'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, limit, orderBy, query, where } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { orgSiteNames } from './inbox-org-sites'\n\n/**\n * How many members and how many leads the contacts table reads.\n *\n * A ceiling rather than a page size — see the two queries, which explain why\n * this one table cannot be paged by the server without breaking the dedupe\n * between them.\n */\nconst CONTACT_CEILING = 200\n\n/*\n * What the contacts grid's Filters panel offers (AGL-3317). The card holds\n * both capped windows, so it answers the panel over every contact they read;\n * the notice under the table says when a cap truncated one. Type reads\n * `typeKey`: `member`, or `lead` with the lead's `source` when it has one,\n * which is what the column draws. On the organization's Inbox, Site matches\n * any site that captured the lead.\n */\nconst CONTACT_FILTER_FIELDS = [\n inMemoryListField('email', 'text'),\n inMemoryListField('contactKind', 'select', 'typeKey'),\n]\nconst ORG_CONTACT_FILTER_FIELDS = [\n ...CONTACT_FILTER_FIELDS,\n {\n ...inMemoryListField('capturedByHostIds', 'select'),\n tokensPath: 'capturedByHostIds',\n verbatimTokens: true,\n },\n]\nconst CONTACT_FILTER_HEADERS: Readonly<Record<string, string>> = {\n email: 'Email',\n contactKind: 'Type',\n capturedByHostIds: 'Site',\n}\nconst CONTACT_SEARCH_FIELDS = ['email', 'displayName', 'name', 'source'] as const\n\n/** How a contact's type reads, from its `typeKey`. */\nconst contactTypeLabel = (typeKey: string): string =>\n typeKey === 'member'\n ? 'Member'\n : typeKey.startsWith('lead:')\n ? `Lead · ${typeKey.slice('lead:'.length)}`\n : 'Lead'\n\n/**\n * The Members & leads section of the Inbox (AGL-109): everybody a site\n * collected, in one list.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n *\n * On the organization's Inbox (`hostId: null`, AGL-3303) it lists the\n * organization's leads with the sites each was captured by. A member signs up\n * to ONE site and lives under it, so members are listed once a site is\n * picked, which is the page handing this card that site.\n */\nexport function ContactsCard({\n hostId,\n orgMount,\n}: {\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n}) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's\n // Leads section gives each one a status, an owner and a conversion.\n const routeContext = useRecordRouteContext()\n const leadHref = (contact: { $id?: unknown }): string | null =>\n routeContext\n ? pluginRecordHref('lead', routeContext, String(contact.$id))\n : null\n\n /*==========================================\n * SITE MEMBERS + LEADS (AGL-109): ORDERED AND CEILINGED, NOT PAGED BY QUERY.\n *\n * Both reads were `limit(200)` with no `orderBy` and a client sort on top —\n * the same document-id sample the submissions list used to take, and both\n * now name the order the rows are rendered in. `createdAt` is safe on both:\n * `membership-register.ts` is the only writer that CREATES a site member\n * and stamps it inside its transaction (every other membership path\n * updates an existing document), `recordVisitorLead` is the only writer\n * that creates a lead and stamps it on every `tx.create`, and neither\n * collection is in `IMPORTABLE_FIELDS`.\n *\n * What they must NOT do is page the query, and the reason is the dedupe\n * below: a lead is hidden when a MEMBER already exists on the same address.\n * That test is only correct while both windows are whole. On a ten-row\n * server page it would compare a page of leads against a page of members, so\n * somebody who signed up after leaving their address would render as a\n * Member on one page and again as a Lead on another — one person counted\n * twice, in a list a site owner uses to count people.\n *\n * So the CEILING stays and the page is a slice of the assembled rows. The\n * probe row makes \"there are more contacts than these\" a fact rather than a\n * guess from `length === CONTACT_CEILING`, which is wrong at exactly the\n * count that equals the ceiling.\n *=========================================*/\n const { data: memberDocs } = useFirestoreCollection<any>(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'siteMembers'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n /*\n * The lead silo is the org's (AGL-3275), narrowed to this site by\n * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its\n * pre-migration rows and nothing captured since.\n */\n const { orgId } = useOrgDataScope({\n hostId: hostId ?? undefined,\n orgId: hostId == null ? orgMount?.orgId : undefined,\n })\n const siteMembers = (memberDocs ?? []).slice(0, CONTACT_CEILING)\n const { data: leadDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(\n collection(firestore, 'orgs', orgId, 'leads'),\n /*\n * Every site's, on the organization's Inbox: an org-wide member\n * reads the collection unscoped — the rules short-circuit on\n * their reach, as for the CRM's own org list — so a clause here\n * would only narrow what they may already read.\n */\n ...(hostId\n ? [\n where(\n 'visibleTo',\n 'array-contains-any',\n scopeTokensForHost(hostId),\n ),\n ]\n : []),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const leads = (leadDocs ?? []).slice(0, CONTACT_CEILING)\n const contactsTruncated =\n (memberDocs?.length ?? 0) > CONTACT_CEILING ||\n (leadDocs?.length ?? 0) > CONTACT_CEILING\n /*\n * One person renders once, whichever way they came in.\n *\n * Raw `===` made `Bob@x.com` and `bob@x.com` two different people, so\n * somebody could appear as a Member on one row and a Lead on another. The\n * comparison is now the same NORMALIZATION a lead document is keyed by —\n * `personKey` is `sha256(normalizeContactEmail(email))`, and hashing an\n * already-normalized address cannot merge or split anything the normalizer\n * did not, so the two agree by construction.\n *\n * `personKey` itself cannot run here: it needs `node:crypto` and this is a\n * client component. That is why the shared half is the normalizer rather\n * than the digest.\n */\n const dedupedLeads = useMemo(() => {\n const memberKeys = new Set(\n siteMembers\n .map((member: any) => normalizeContactEmail(member.email))\n .filter(Boolean),\n )\n return leads.filter(\n (lead: any) => !memberKeys.has(normalizeContactEmail(lead.email)),\n )\n }, [leads, siteMembers])\n /*\n * ONE list of two collections — members first, then the leads that are not\n * already members — so the grid pages the concatenation rather than either\n * read, and a page stays whole across the seam between them. Both reads are\n * ceilinged and complete below the ceiling, so the grid's count is the whole\n * deduped list.\n */\n const contacts = useMemo(\n () => [\n ...siteMembers.map((member: any) => ({\n ...member,\n contactKind: 'member',\n typeKey: 'member',\n })),\n ...dedupedLeads.map((lead: any) => ({\n ...lead,\n contactKind: 'lead',\n typeKey: lead.source ? `lead:${lead.source}` : 'lead',\n })),\n ],\n [siteMembers, dedupedLeads],\n )\n /*\n * A lead's source is open-ended, so the Type choices are the ones the rows\n * hold, member and plain lead first.\n */\n const contactFilterOptions = useMemo(() => {\n const leadSources = [\n ...new Set(\n contacts\n .map((contact: any) => contact.typeKey as string)\n .filter((typeKey) => typeKey.startsWith('lead:')),\n ),\n ].sort()\n return {\n contactKind: ['member', 'lead', ...leadSources].map((value) => ({\n value,\n label: contactTypeLabel(value),\n })),\n ...(hostId == null\n ? {\n capturedByHostIds: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {}),\n }\n }, [contacts, hostId, orgMount])\n const contactFilter = useListRowsFilter({\n rows: contacts,\n fields: hostId == null ? ORG_CONTACT_FILTER_FIELDS : CONTACT_FILTER_FIELDS,\n options: contactFilterOptions,\n headers: CONTACT_FILTER_HEADERS,\n search: CONTACT_SEARCH_FIELDS,\n })\n /*\n * REMOVED BY THE ROUTE THAT OWNS MEMBER ACCOUNTS (AGL-3308), not by a\n * client delete. A member's password hash lives in a document no client can\n * reach, so deleting the profile from here would leave it behind: the route\n * deletes both, and the rules refuse a client delete of any member that\n * has one.\n */\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\n // Members are listed only under a site, so there is one to delete from.\n if (!hostId) return\n const confirmed = await confirm({\n title: 'Remove this member?',\n description: `\"${member.email}\" can no longer sign in to your site.`,\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const response = await authorizedFetch(user, '/api/membership/admin-remove', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, memberId: member.$id }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n } catch {\n enqueueSnackbar('The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n },\n [confirm, user, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n /*\n * The site a lead's attribution is asked of. Under a site it is that site;\n * across every site a lead is one org row that several sites may have met,\n * and the credit is filed by the site whose capture made it — the first in\n * `capturedByHostIds`, the site the CRM's own lead page asks as well.\n */\n const originSite = (contact: any): string | null => {\n if (hostId) return hostId\n const first = Array.isArray(contact?.capturedByHostIds)\n ? contact.capturedByHostIds[0]\n : null\n return typeof first === 'string' && first ? first : null\n }\n const leadOriginSite = leadOrigin ? originSite(leadOrigin) : null\n\n const contactActions = (contact: any): RowActionsMenuItem[] =>\n contact.contactKind === 'member'\n ? [\n {\n key: 'remove',\n label: 'Remove member',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDeleteMember(contact)(),\n },\n ]\n : [\n // Offered only where a plugin publishes a lead's address: text-less\n // rather than a link to a page this workspace cannot open.\n ...(leadHref(contact)\n ? [\n {\n key: 'crm',\n label: 'Open in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: leadHref(contact) as string,\n },\n ]\n : []),\n // A lead no site captured has no site to ask for its campaign.\n ...(originSite(contact)\n ? [\n {\n key: 'origin',\n label: 'Where this came from',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n onClick: () => setLeadOrigin(contact),\n },\n ]\n : []),\n ]\n const contactColumns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Email',\n flex: 1,\n minWidth: 240,\n /*\n The name beside the address — a member's `displayName`, and the name\n the lead writer stores (AGL-2303) — because a list of bare addresses is\n a list nobody recognizes anyone in.\n */\n renderCell: ({ row: contact }) => {\n const name =\n contact.contactKind === 'member' ? contact.displayName : contact.name\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline', minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {contact.email}\n </Typography>\n {name ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {name}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n /*\n WHERE A LEAD CAME FROM (AGL-2338). `source` has been written by both\n lead writers — `'signup'` and `'booking'` — since AGL-109, so a site\n owner can tell a membership sign-up from a booking. Falls back to the\n bare label for a row written before the field, or by a future writer\n that omits it.\n */\n field: 'contactKind',\n headerName: 'Type',\n width: 170,\n valueGetter: (_value, contact) => contact.typeKey,\n renderCell: ({ row: contact }) =>\n contact.contactKind === 'member' ? (\n <Chip label={contactTypeLabel(contact.typeKey)} color=\"primary\" size=\"small\" />\n ) : (\n <Chip label={contactTypeLabel(contact.typeKey)} size=\"small\" variant=\"outlined\" />\n ),\n },\n /*\n KNOWN BY, on the organization's Inbox: every site that captured the\n person, as the CRM's organization Leads list names them. A lead is one\n org row, so \"which site\" is a set, never one answer.\n */\n ...(hostId == null\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Site',\n flex: 1,\n minWidth: 160,\n // Sorted and drawn by name; the filter matches the ids.\n valueGetter: (_value: unknown, contact: any) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n renderCell: ({ row: contact }: { row: any }) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'createdAt',\n headerName: 'Joined',\n width: 200,\n // Sorted on the instant, drawn as a local string.\n valueGetter: (_value, contact) => contact.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: contact }) =>\n contact.createdAt?.toDate?.().toLocaleString() ?? '--',\n },\n listActionsColumn(\n (contact) => (\n <ListRowActions\n label={String(contact.email ?? contact.$id)}\n items={contactActions(contact)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <>\n <CardDisplay\n header={hostId == null ? 'Leads' : 'Site Members & Leads'}\n help={pluginDocsHelp('forms', {\n anchor: '#filter-the-inbox',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {hostId == null ? (\n <Typography\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mb: leads.length ? 2 : 1 }}\n >\n {'Every site’s leads, newest first. Members sign up to one site ' +\n 'each — choose a site to list its members beside its leads.'}\n </Typography>\n ) : null}\n {siteMembers.length === 0 && leads.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {hostId == null\n ? 'No leads on any site yet.'\n : 'No members yet — visitors can join at /signup on your ' +\n 'site; sign-ups also appear here as leads.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips {...contactFilter.chipsProps} />\n <ListTable\n aria-label=\"Site members and leads\"\n rows={contactFilter.rows}\n columns={contactFilter.filterColumns(contactColumns)}\n // The panel and the search are the grid's; the card answers\n // them over every contact both windows read.\n {...contactFilter.gridProps}\n noRowsLabel=\"No contacts match these filters\"\n // A member and a lead are two collections, so an id alone could\n // name one of each.\n getRowId={(contact: any) => `${contact.contactKind}:${contact.$id}`}\n rowHeight={TABLE_ROW_HEIGHT}\n />\n {contactsTruncated ? (\n <Alert severity=\"info\" sx={{ mt: 1 }}>\n {hostId == null\n ? `Paging the ${CONTACT_CEILING} newest leads. Your ` +\n 'sites hold more than that — the CRM’s Leads list ' +\n 'reaches every one of them.'\n : `Paging the ${CONTACT_CEILING} newest members and the ` +\n `${CONTACT_CEILING} newest leads. This site has more ` +\n 'than that — the campaign audiences still reach ' +\n 'everyone, whether or not they are listed here.'}\n </Alert>\n ) : null}\n </>\n )}\n </CardDisplay>\n {/*\n WHERE A LEAD CAME FROM.\n\n Its own dialog rather than a column, for the reason the state above\n gives: one keyed read, paid by the reader who asked the question. A\n lead has no page of its own to put this on, and giving it one to carry\n a single line would be a new record surface rather than attribution.\n */}\n <Dialog\n open={Boolean(leadOrigin)}\n onClose={() => setLeadOrigin(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{leadOrigin?.email ?? 'Lead'}</DialogTitle>\n <DialogContent>\n {leadOrigin?.$id && leadOriginSite ? (\n <InboxRecordAttributionZone\n hostId={leadOriginSite}\n recordKind=\"lead\"\n recordId={String(leadOrigin.$id)}\n />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button variant=\"contained\" onClick={() => setLeadOrigin(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nContactsCard.displayName = 'ContactsCard'\n\nexport default ContactsCard\n"],"names":["normalizeContactEmail","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordHref","mdiAccountArrowRight","mdiAccountRemoveOutline","mdiBullhornOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListRowActions","ListTable","listActionsColumn","inMemoryListField","useListRowsFilter","TABLE_ROW_HEIGHT","useSnackbar","authorizedFetch","useFirestore","useFirestoreCollection","useOrgDataScope","useUser","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","limit","orderBy","query","where","useCallback","useMemo","useState","useRecordRouteContext","scopeTokensForHost","orgSiteNames","CONTACT_CEILING","CONTACT_FILTER_FIELDS","ORG_CONTACT_FILTER_FIELDS","tokensPath","verbatimTokens","CONTACT_FILTER_HEADERS","email","contactKind","capturedByHostIds","CONTACT_SEARCH_FIELDS","contactTypeLabel","typeKey","startsWith","slice","length","ContactsCard","hostId","orgMount","firestore","data","user","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","memberDocs","idField","orgId","undefined","siteMembers","leadDocs","leads","contactsTruncated","dedupedLeads","memberKeys","Set","map","member","filter","Boolean","lead","has","contacts","source","contactFilterOptions","leadSources","sort","value","label","hosts","host","id","name","contactFilter","rows","fields","options","headers","search","handleDeleteMember","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","response","method","body","JSON","stringify","memberId","payload","json","ok","error","variant","allowDuplicate","persist","leadOrigin","setLeadOrigin","originSite","first","Array","isArray","leadOriginSite","contactActions","key","icon","path","size","destructive","onClick","href","contactColumns","field","headerName","flex","minWidth","renderCell","row","displayName","direction","spacing","sx","alignItems","noWrap","width","valueGetter","_value","createdAt","toDate","getTime","toLocaleString","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","mb","chipsProps","aria-label","columns","filterColumns","gridProps","noRowsLabel","getRowId","rowHeight","severity","mt","open","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/contacts-card.component.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 { pluginDocsHelp, type ConsolePluginOrgMount } from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// carries the plugin registration, and a link needs only the address grammar.\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n mdiAccountArrowRight,\n mdiAccountRemoveOutline,\n mdiBullhornOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, 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 ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport {\n type ListFilterOption,\n listFilterGridColumns,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n useFirestore,\n useOrgDataScope,\n useScopeTokens,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n LEAD_FILTER_HEADERS,\n LEAD_LIST_QUERY,\n LEAD_SOURCE_OPTIONS,\n ORG_LEAD_LIST_QUERY,\n SITE_MEMBER_FILTER_HEADERS,\n SITE_MEMBER_LIST_QUERY,\n leadListBase,\n LEAD_ADDRESS_SEARCH_COLUMN,\n LEAD_ADDRESS_SEARCH_NOTICE,\n leadListQueryFor,\n leadSourceLabel,\n} from '../constants/list-queries'\nimport { orgSiteNames } from './inbox-org-sites'\nimport { useRecordRouteContext } from './use-record-route-context'\n\n/** Which of the two collections the card lists. */\nexport type ContactsView = 'members' | 'leads'\n\n/** The organization's Inbox's Source and Site are picked, so the panel shows selects. */\nconst ORG_LEAD_SELECT_FIELDS = ['sources', 'capturedByHostIds']\nconst NO_SELECT_FIELDS: readonly string[] = []\n\n/**\n * Every surface a lead was captured by: `sources`, which every capture\n * `arrayUnion`s, or the single `source` a lead written before it carries.\n */\nconst leadSources = (lead: any): string[] =>\n Array.isArray(lead?.sources)\n ? lead.sources.filter((source: unknown): source is string => typeof source === 'string')\n : typeof lead?.source === 'string' && lead.source\n ? [lead.source]\n : []\n\n/**\n * The Members & leads section of the Inbox (AGL-109): the people a site\n * collected — its members, who signed up to it, and its leads, whom a form,\n * a booking or a teammate captured.\n *\n * ## Two lists, one table (AGL-3321)\n *\n * Members and leads are two collections, and the table shows one at a time,\n * chosen by the toggle above it. Each is its own Firestore query, every\n * filter and search word on it, paged by the server — so a page is a page of\n * the answer, and a site with thousands of either reaches all of them.\n *\n * It used to read the two hundred newest of each and interleave them, hiding\n * a lead whose address matched a member. That dedupe is only correct while\n * both reads are whole, which is exactly what a paged query is not, so it is\n * gone: a person who left their address and later signed up is a lead in one\n * list and a member in the other, which is what they are in each collection.\n *\n * On the organization's Inbox (`hostId: null`, AGL-3303) it lists the\n * organization's leads with the sites each was captured by. A member signs up\n * to ONE site and lives under it, so Members waits for a site to be picked,\n * which is the page handing this card that site.\n */\nexport function ContactsCard({\n hostId,\n orgMount,\n}: {\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n}) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's\n // Leads section gives each one a status, an owner and a conversion.\n const routeContext = useRecordRouteContext()\n const leadHref = (contact: { $id?: unknown }): string | null =>\n routeContext\n ? pluginRecordHref('lead', routeContext, String(contact.$id))\n : null\n\n /*\n * The list shown. Members exist only under a site, so the organization's\n * Inbox shows leads whatever was chosen, and the toggle says why.\n */\n const [chosen, setChosen] = useState<ContactsView>('members')\n const view: ContactsView = hostId ? chosen : 'leads'\n const [searchWords, setSearchWords] = useState<string[]>([])\n const gridFilter = useListGridFilter({\n selectFields: view === 'leads' && hostId == null ? ORG_LEAD_SELECT_FIELDS : NO_SELECT_FIELDS,\n search: { words: searchWords, onChange: setSearchWords },\n })\n /*\n * A switch of list starts it clean: the two lists filter by different\n * fields, and a clause carried across would be one the other list cannot\n * ask — refused on arrival, for a question the reader asked of the list\n * they just left.\n */\n const switchView = (next: ContactsView | null) => {\n if (!next || next === view) return\n gridFilter.setClauses([])\n setSearchWords([])\n setChosen(next)\n }\n const request = { clauses: gridFilter.clauses, search: gridFilter.searchWords }\n\n /*==========================================\n * SITE MEMBERS — `hosts/{hostId}/siteMembers`, newest first\n * (`SITE_MEMBER_LIST_QUERY`). `createdAt` is safe to order on:\n * `membership-register.ts` is the only writer that CREATES a site member\n * and stamps it inside its transaction, and the collection is not in\n * `IMPORTABLE_FIELDS`. No query opens while the other list is shown.\n *=========================================*/\n const members = useListQuery<any>({\n collection:\n hostId && view === 'members'\n ? collection(firestore, 'hosts', hostId, 'siteMembers')\n : null,\n declaration: SITE_MEMBER_LIST_QUERY,\n request: view === 'members' ? request : { clauses: [] },\n deps: [firestore, hostId, view],\n idField: '$id',\n })\n\n /*==========================================\n * LEADS — `orgs/{orgId}/leads`, newest first (`LEAD_LIST_QUERY`). The\n * silo is the org's (AGL-3275), narrowed to this site by `visibleTo`;\n * reading `hosts/{hostId}/leads` showed a site its pre-migration rows and\n * nothing captured since. `addHostLead`, every lead door's one writer,\n * stamps `createdAt` on every create.\n *\n * Every site's, on the organization's Inbox: an org-wide member reads the\n * collection unscoped — the rules short-circuit on their reach, as for the\n * CRM's own org list — so a clause there would only narrow what they may\n * already read.\n *=========================================*/\n const { orgId } = useOrgDataScope({\n hostId: hostId ?? undefined,\n orgId: hostId == null ? orgMount?.orgId : undefined,\n })\n /*\n * Whether the search may fold into the scope clause (`scopedSearch`): a\n * query without `visibleTo` is one the rules prove only for an ORG-WIDE\n * member. Anyone else keeps the clause, and the plan refuses their search\n * by name rather than sending a query the rules deny. Until the reach has\n * answered, the answer is no. Asked only while Leads is shown: the member\n * document is a read, and Members has no scope to fold into.\n */\n const reach = useScopeTokens(hostId && orgId && view === 'leads' ? orgId : undefined)\n const foldsScope = hostId == null || (reach.loaded && reach.orgWide)\n const leadDeclaration = hostId ? LEAD_LIST_QUERY : ORG_LEAD_LIST_QUERY\n const leadBase = useMemo(\n () => leadListBase(hostId ? scopeTokensForHost(hostId) : null),\n [hostId],\n )\n const leadQuery = useMemo(\n () =>\n leadListQueryFor(\n leadDeclaration,\n foldsScope,\n view === 'leads' ? { ...request, base: leadBase } : { clauses: [] },\n ),\n // The request is data; its JSON is its identity.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [leadDeclaration, foldsScope, view, leadBase, JSON.stringify(request)],\n )\n const leads = useListQuery<any>({\n collection:\n orgId && view === 'leads' ? collection(firestore, 'orgs', orgId, 'leads') : null,\n declaration: leadQuery.declaration,\n request: leadQuery.request,\n deps: [firestore, orgId, hostId, view],\n idField: '$id',\n })\n\n const shown = view === 'members' ? members : leads\n const declaration = view === 'members' ? SITE_MEMBER_LIST_QUERY : leadDeclaration\n const headers = view === 'members' ? SITE_MEMBER_FILTER_HEADERS : LEAD_FILTER_HEADERS\n const options = useMemo<Readonly<Record<string, readonly ListFilterOption[]>>>(\n () =>\n view === 'leads' && hostId == null\n ? {\n sources: LEAD_SOURCE_OPTIONS,\n capturedByHostIds: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {},\n [view, hostId, orgMount],\n )\n /*\n * A site collaborator's search rides the query as an address-prefix clause\n * (`leadListQueryFor`); a refusal of it is the search's, and when it is\n * served the list says what it matched and how it is now ordered.\n */\n const refusals = useMemo(\n () =>\n listQueryRefusals(\n shown.plan.refused.map((entry) =>\n entry.clause !== 'search' && entry.clause.field === LEAD_ADDRESS_SEARCH_COLUMN\n ? { ...entry, clause: 'search' as const }\n : entry,\n ),\n { fields: declaration.fields, headers, options },\n ),\n [shown.plan.refused, declaration, headers, options],\n )\n const addressSearched =\n view === 'leads' &&\n leadQuery.addressSearch &&\n leads.plan.served.some((clause) => clause.field === LEAD_ADDRESS_SEARCH_COLUMN)\n const notices = useMemo(\n () => (addressSearched ? [...shown.plan.notices, LEAD_ADDRESS_SEARCH_NOTICE] : shown.plan.notices),\n [addressSearched, shown.plan.notices],\n )\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n\n /*\n * REMOVED BY THE ROUTE THAT OWNS MEMBER ACCOUNTS (AGL-3308), not by a\n * client delete. A member's password hash lives in a document no client can\n * reach, so deleting the profile from here would leave it behind: the route\n * deletes both, and the rules refuse a client delete of any member that\n * has one.\n */\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\n // Members are listed only under a site, so there is one to delete from.\n if (!hostId) return\n const confirmed = await confirm({\n title: 'Remove this member?',\n description: `\"${member.email}\" can no longer sign in to your site.`,\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const response = await authorizedFetch(user, '/api/membership/admin-remove', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, memberId: member.$id }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n } catch {\n enqueueSnackbar('The member was not removed', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n },\n [confirm, user, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n /*\n * The site a lead's attribution is asked of. Under a site it is that site;\n * across every site a lead is one org row that several sites may have met,\n * and the credit is filed by the site whose capture made it — the first in\n * `capturedByHostIds`, the site the CRM's own lead page asks as well.\n */\n const originSite = (contact: any): string | null => {\n if (hostId) return hostId\n const first = Array.isArray(contact?.capturedByHostIds)\n ? contact.capturedByHostIds[0]\n : null\n return typeof first === 'string' && first ? first : null\n }\n const leadOriginSite = leadOrigin ? originSite(leadOrigin) : null\n\n const memberActions = (member: any): RowActionsMenuItem[] => [\n {\n key: 'remove',\n label: 'Remove member',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDeleteMember(member)(),\n },\n ]\n const leadActions = (lead: any): RowActionsMenuItem[] => [\n // Offered only where a plugin publishes a lead's address: text-less\n // rather than a link to a page this workspace cannot open.\n ...(leadHref(lead)\n ? [\n {\n key: 'crm',\n label: 'Open in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: leadHref(lead) as string,\n },\n ]\n : []),\n // A lead no site captured has no site to ask for its campaign.\n ...(originSite(lead)\n ? [\n {\n key: 'origin',\n label: 'Where this came from',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n onClick: () => setLeadOrigin(lead),\n },\n ]\n : []),\n ]\n\n const columns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Email',\n flex: 1,\n minWidth: 240,\n /*\n The name beside the address — a member's `displayName`, and the name\n the lead writer stores (AGL-2303) — because a list of bare addresses is\n a list nobody recognizes anyone in.\n */\n renderCell: ({ row: contact }) => {\n const name = view === 'members' ? contact.displayName : contact.name\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline', minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {contact.email}\n </Typography>\n {name ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {name}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n /*\n WHERE A LEAD CAME FROM (AGL-2338): every surface that captured the\n person — a form, a booking, a lead added by hand — one chip each. A lead\n written before `sources` carries the one `source` it was captured by,\n and a row with neither simply shows none.\n */\n ...(view === 'leads'\n ? [\n {\n field: 'sources',\n headerName: 'Source',\n width: 200,\n sortable: false,\n valueGetter: (_value: unknown, lead: any) =>\n leadSources(lead).map(leadSourceLabel).join(', '),\n renderCell: ({ row: lead }: { row: any }) => (\n <Stack direction=\"row\" spacing={0.5} sx={{ alignItems: 'center', height: '100%' }}>\n {[...new Set(leadSources(lead).map(leadSourceLabel))].map((label) => (\n <Chip key={label} label={label} size=\"small\" variant=\"outlined\" />\n ))}\n </Stack>\n ),\n } satisfies GridColDef,\n ]\n : []),\n /*\n KNOWN BY, on the organization's Inbox: every site that captured the\n person, as the CRM's organization Leads list names them. A lead is one\n org row, so \"which site\" is a set, never one answer.\n */\n ...(view === 'leads' && hostId == null\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Site',\n flex: 1,\n minWidth: 160,\n sortable: false,\n renderCell: ({ row: contact }: { row: any }) =>\n orgSiteNames(orgMount, contact.capturedByHostIds),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'createdAt',\n headerName: view === 'members' ? 'Joined' : 'Captured',\n width: 200,\n renderCell: ({ row: contact }) =>\n contact.createdAt?.toDate?.().toLocaleString() ?? '--',\n },\n listActionsColumn(\n (contact) => (\n <ListRowActions\n label={String(contact.email ?? contact.$id)}\n items={view === 'members' ? memberActions(contact) : leadActions(contact)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n const empty = shown.rows.length === 0 && !shown.hasMore && !filtering\n const emptyLabel =\n view === 'members'\n ? 'No members yet — visitors can join at /signup on your site.'\n : hostId == null\n ? 'No leads on any site yet.'\n : 'No leads yet — the forms and bookings that file leads add them here.'\n\n return (\n <>\n <CardDisplay\n header={hostId == null ? 'Leads' : 'Site Members & Leads'}\n help={pluginDocsHelp('forms', {\n anchor: '#filter-the-inbox',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n <Stack spacing={1.5}>\n <Stack direction=\"row\" spacing={1.5} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n value={view}\n onChange={(_event, next: ContactsView | null) => switchView(next)}\n aria-label=\"Which people to list\"\n >\n {/*\n Disabled on the organization's Inbox rather than hidden: a\n member signs up to one site, and the line beside the toggle\n says how to reach a site's members, where a missing button\n would say nothing.\n */}\n <ToggleButton value=\"members\" disabled={hostId == null}>\n {'Members'}\n </ToggleButton>\n <ToggleButton value=\"leads\">{'Leads'}</ToggleButton>\n </ToggleButtonGroup>\n {hostId == null ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Every site’s leads, newest first. Members sign up to one ' +\n 'site each — choose a site to list its members.'}\n </Typography>\n ) : null}\n </Stack>\n {empty ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {emptyLabel}\n </Typography>\n ) : (\n <>\n <ListFilterChips\n fields={declaration.fields}\n headers={headers}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={options}\n />\n <ListQueryNotices refused={refusals} notices={notices} />\n <ListTable\n // One grid per list, so the other list's search box and\n // panel state never carry across the toggle.\n key={view}\n aria-label={view === 'members' ? 'Site members' : 'Leads'}\n rows={shown.rows}\n columns={listFilterGridColumns(columns, declaration.fields, options, headers)}\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 go to the query; the grid neither\n // filters nor sorts the page it holds.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnSorting\n noRowsLabel={\n view === 'members'\n ? 'No members match these filters'\n : 'No leads match these filters'\n }\n />\n <ListPagination\n page={shown.page}\n pageSize={shown.pageSize}\n rowCount={shown.rows.length}\n hasMore={shown.hasMore}\n onPageChange={shown.setPage}\n onPageSizeChange={shown.setPageSize}\n />\n </>\n )}\n </Stack>\n </CardDisplay>\n {/*\n WHERE A LEAD CAME FROM.\n\n Its own dialog rather than a column, for the reason the state above\n gives: one keyed read, paid by the reader who asked the question. A\n lead has no page of its own to put this on, and giving it one to carry\n a single line would be a new record surface rather than attribution.\n */}\n <Dialog\n open={Boolean(leadOrigin)}\n onClose={() => setLeadOrigin(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{leadOrigin?.email ?? 'Lead'}</DialogTitle>\n <DialogContent>\n {leadOrigin?.$id && leadOriginSite ? (\n <InboxRecordAttributionZone\n hostId={leadOriginSite}\n recordKind=\"lead\"\n recordId={String(leadOrigin.$id)}\n />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button variant=\"contained\" onClick={() => setLeadOrigin(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nContactsCard.displayName = 'ContactsCard'\n\nexport default ContactsCard\n"],"names":["pluginDocsHelp","InboxRecordAttributionZone","pluginRecordHref","scopeTokensForHost","mdiAccountArrowRight","mdiAccountRemoveOutline","mdiBullhornOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","useSnackbar","authorizedFetch","useFirestore","useOrgDataScope","useScopeTokens","useUser","useListQuery","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","ToggleButton","ToggleButtonGroup","Typography","collection","useCallback","useMemo","useState","LEAD_FILTER_HEADERS","LEAD_LIST_QUERY","LEAD_SOURCE_OPTIONS","ORG_LEAD_LIST_QUERY","SITE_MEMBER_FILTER_HEADERS","SITE_MEMBER_LIST_QUERY","leadListBase","LEAD_ADDRESS_SEARCH_COLUMN","LEAD_ADDRESS_SEARCH_NOTICE","leadListQueryFor","leadSourceLabel","orgSiteNames","useRecordRouteContext","ORG_LEAD_SELECT_FIELDS","NO_SELECT_FIELDS","leadSources","lead","Array","isArray","sources","filter","source","ContactsCard","hostId","orgMount","firestore","data","user","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","chosen","setChosen","view","searchWords","setSearchWords","gridFilter","selectFields","search","words","onChange","switchView","next","setClauses","request","clauses","members","declaration","deps","idField","orgId","undefined","reach","foldsScope","loaded","orgWide","leadDeclaration","leadBase","leadQuery","base","JSON","stringify","leads","shown","headers","options","capturedByHostIds","hosts","map","host","value","id","label","name","refusals","plan","refused","entry","clause","field","fields","addressSearched","addressSearch","served","some","notices","filtering","length","word","trim","handleDeleteMember","member","confirmed","title","description","email","confirmationText","confirmationButtonProps","color","then","catch","response","method","body","memberId","payload","json","ok","error","variant","allowDuplicate","persist","leadOrigin","setLeadOrigin","originSite","first","leadOriginSite","memberActions","key","icon","path","size","destructive","onClick","leadActions","href","columns","headerName","flex","minWidth","renderCell","row","displayName","direction","spacing","sx","alignItems","noWrap","width","sortable","valueGetter","_value","join","height","Set","createdAt","toDate","toLocaleString","items","empty","rows","hasMore","emptyLabel","header","help","anchor","contentGutterX","contentGutterY","contentBordered","flexWrap","exclusive","_event","aria-label","disabled","rowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnSorting","noRowsLabel","page","pageSize","rowCount","onPageChange","setPage","onPageSizeChange","setPageSize","open","Boolean","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"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"}
@@ -29,81 +29,48 @@ import { mdiAccountArrowRight, mdiDeleteOutline, mdiEmailOpenOutline, mdiEmailOu
29
29
  import { CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
30
30
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
31
31
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
32
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
32
33
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
33
- import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
34
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
34
35
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
35
- import { usePagedRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-paged-rows-filter";
36
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
36
37
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
37
- import { useFirestore, useFirestoreCollection, usePagedCollection } from "@aglyn/tenant-feature-instance";
38
+ import { useFirestore, useFirestoreCollection, usePluginApiPost } from "@aglyn/tenant-feature-instance";
39
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
38
40
  import { Avatar, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, Stack, Tooltip, Typography } from "@mui/material";
39
- import { collection, collectionGroup, deleteDoc, doc, getDoc, limit, orderBy, query, updateDoc, where } from "firebase/firestore";
41
+ import { collection, collectionGroup, deleteDoc, doc, getDoc, limit, orderBy, query, updateDoc } from "firebase/firestore";
40
42
  import { useSearchParams } from "next/navigation";
41
43
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
42
44
  import { relativeTime, routingChips, senderHue, submissionSender } from "../model/submission-presenter.js";
45
+ import { FORM_SCOPED_SUBMISSION_LIST_QUERY, ORG_SUBMISSION_LIST_QUERY, SUBMISSION_FILTER_HEADERS, SUBMISSION_LIST_QUERY, SUBMISSION_READ_OPTIONS, formSubmissionBase, orgSubmissionBase } from "../constants/list-queries.js";
43
46
  import { orgSiteName } from "./inbox-org-sites.js";
44
47
  import SubmissionListAssignment from "./submission-list-assignment.component.js";
45
48
  import SubmissionReply from "./submission-reply.component.js";
46
49
  import { useRecordRouteContext } from "./use-record-route-context.js";
47
- /*
48
- * What the submissions grid's Filters panel offers (AGL-3317).
49
- *
50
- * Form is SERVED: its clause is the query's `where('formId', '==')`, so it
51
- * reaches every submission to that form, and the rows are not matched
52
- * against it again. It is offered only where a site's catalog is read — not
53
- * on a card scoped to one form, and not across every site. The rest match
54
- * over what the paged window has read, which the first filter widens
55
- * (`usePagedRowsFilter`): From and Message (as the columns draw them), Read
56
- * (`readKey`), and Site on the organization's Inbox.
57
- */ const FORM_FIELD = _extends({}, inMemoryListField('formId', 'select'), {
58
- operators: [
59
- 'equals'
60
- ]
61
- });
62
- const SUBMISSION_BASE_FIELDS = [
63
- inMemoryListField('from', 'text', 'senderLabel'),
64
- inMemoryListField('message', 'text', 'messageText'),
65
- inMemoryListField('read', 'select', 'readKey')
66
- ];
67
- const SUBMISSION_FILTER_HEADERS = {
68
- from: 'From',
69
- message: 'Message',
70
- read: 'Read',
71
- formId: 'Form',
72
- hostId: 'Site'
73
- };
74
- const READ_OPTIONS = [
75
- {
76
- value: 'unread',
77
- label: 'Unread'
78
- },
79
- {
80
- value: 'read',
81
- label: 'Read'
82
- }
83
- ];
84
- const SUBMISSION_SEARCH_FIELDS = [
85
- 'senderLabel',
86
- 'formName',
87
- 'messageText'
50
+ /**
51
+ * The forms plugin's recount route (AGL-3330), by address: this plugin
52
+ * shows the submissions a form counts and may not import the plugin that
53
+ * keeps the counts.
54
+ */ const FORM_STATS_API_PATH = '/api/forms/stats';
55
+ /** Read, Form and Site are picked, so the panel shows selects. */ const SUBMISSION_SELECT_FIELDS = [
56
+ 'read',
57
+ 'formId',
58
+ 'hostId'
88
59
  ];
89
60
  const SUBMISSION_HIDDEN_COLUMNS = {
90
61
  read: false,
91
62
  formId: false
92
63
  };
93
- /** The one clause the query serves. */ const servedByQuery = (clause)=>clause.field === 'formId';
94
- /** A submission with the derived values its filters and search read. */ const withFilterValues = (submission)=>{
95
- var _submission_fields;
96
- return _extends({}, submission, {
97
- senderLabel: submissionSender(submission.fields).label,
98
- messageText: Object.entries((_submission_fields = submission.fields) != null ? _submission_fields : {}).map(([key, value])=>`${key}: ${value}`).join(' · '),
99
- readKey: submission.read ? 'read' : 'unread'
100
- });
64
+ /** Every field of a message, as one line — what the Message column draws. */ const messageTextOf = (submission)=>{
65
+ var _ref;
66
+ return Object.entries((_ref = submission == null ? void 0 : submission.fields) != null ? _ref : {}).map(([key, value])=>`${key}: ${value}`).join(' · ');
101
67
  };
102
68
  export function SubmissionsCard({ hostId, formId, orgMount }) {
103
69
  var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
104
- var _clauses_find, _reader_createdAt_toDate, _reader_createdAt;
70
+ var _reader_createdAt_toDate, _reader_createdAt;
105
71
  const firestore = useFirestore();
106
72
  const { enqueueSnackbar } = useSnackbar();
73
+ const postPluginApi = usePluginApiPost();
107
74
  const { confirm } = useConfirmationContext();
108
75
  /** Scoped to one form: the subject is fixed and nothing may widen it. */ const scoped = Boolean(formId);
109
76
  /** Every site of this organization at once, when no site is named. */ const orgId = hostId == null ? (_ref = orgMount == null ? void 0 : orgMount.orgId) != null ? _ref : null : null;
@@ -157,28 +124,40 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
157
124
  }), [
158
125
  formDocs
159
126
  ]);
127
+ /** Form is offered where the site's catalog is read. */ const offersForm = !scoped && Boolean(hostId) && forms.length > 0;
160
128
  /*
161
- * The panel's clauses, held here because the Form clause is the query's.
162
- * No clause is "All forms"; a Form clause narrows to one form's
163
- * submissions.
164
- *
165
- * `formFilter` is a PRIMITIVE, deliberately: `usePagedCollection` reopens
166
- * its listener when a dep changes, and an object identity would tear down
167
- * and reopen on every render. It also resets the reader to page 1, which
168
- * is what switching subjects should do.
169
- */ const [clauses, setClauses] = useState([]);
170
- const formFilter = ((_clauses_find = clauses.find((clause)=>clause.field === 'formId' && clause.op === 'equals')) == null ? void 0 : _clauses_find.value) || null;
171
- /**
172
- * The form the query is actually narrowed to.
129
+ * WHAT THIS CARD'S LIST CAN BE ASKED (AGL-3321) — every clause and the
130
+ * search on the query, see `SUBMISSION_LIST_QUERY`:
173
131
  *
174
- * The scope wins over the panel rather than seeding it. A scoped card
175
- * offers no Form filter, so a `formFilter` that could outrank `formId`
176
- * would be a filter with no control — reachable only by a state change
177
- * nothing on screen can cause, and unclearable if one ever could.
178
- */ const activeForm = formId != null ? formId : formFilter;
132
+ * - a site's Inbox: From, Read and — where the site has forms — Form;
133
+ * - a card scoped to one form: that form is the list's own scope, and
134
+ * Form has nothing left to pick, so it is not offered. The scope wins
135
+ * over any clause: it is the base, and no clause can reach it;
136
+ * - every site's, on the organization's Inbox: From, Read and Site,
137
+ * under the `orgId` clause the rules require of the group read.
138
+ */ const declaration = useMemo(()=>{
139
+ if (scoped) return FORM_SCOPED_SUBMISSION_LIST_QUERY;
140
+ if (hostId == null) return ORG_SUBMISSION_LIST_QUERY;
141
+ return offersForm ? SUBMISSION_LIST_QUERY : _extends({}, SUBMISSION_LIST_QUERY, {
142
+ fields: SUBMISSION_LIST_QUERY.fields.filter((field)=>field.column !== 'formId')
143
+ });
144
+ }, [
145
+ scoped,
146
+ hostId,
147
+ offersForm
148
+ ]);
149
+ const base = useMemo(()=>formId ? formSubmissionBase(formId) : hostId == null && orgId ? orgSubmissionBase(orgId) : [], [
150
+ formId,
151
+ hostId,
152
+ orgId
153
+ ]);
154
+ const gridFilter = useListGridFilter({
155
+ selectFields: SUBMISSION_SELECT_FIELDS
156
+ });
179
157
  /*
180
158
  * The inbox WALKS its submissions instead of sampling them (AGL-2501,
181
- * AGL-2292).
159
+ * AGL-2292), and every filter and search word is a predicate on the walk
160
+ * (AGL-3321), so a page is a page of the matches.
182
161
  *
183
162
  * `limit(200)` carried no `orderBy`, so Firestore answered it in
184
163
  * DOCUMENT-ID order over ids `add()` generates — an arbitrary two hundred
@@ -193,51 +172,34 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
193
172
  * that creates one and stamps `createdAt: serverTimestamp()` on every add,
194
173
  * the v1 API only ever reads and deletes, and `formSubmissions` is absent
195
174
  * from `IMPORTABLE_FIELDS`, so no restore path can make one without it.
196
- */ const { data: submissionData, rows: submissionRows, hasMore: hasMoreSubmissions, page: submissionPage, setPage: setSubmissionPage, pageSize: submissionPageSize, setPageSize: setSubmissionPageSize } = usePagedCollection((pageLimit)=>hostId ? query(collection(firestore, 'hosts', hostId, 'formSubmissions'), // Served by the `formId ASC, createdAt DESC` composite index in
197
- // `cloud/firebase-firestore.indexes.json`, which must be deployed
198
- // before this ships — without it Firestore refuses the query
199
- // rather than answering it slowly.
200
- ...activeForm ? [
201
- where('formId', '==', activeForm)
202
- ] : [], orderBy('createdAt', 'desc'), limit(pageLimit)) : orgId ? query(/*
203
- * EVERY SITE'S SUBMISSIONS IN ONE BOUNDED QUERY (AGL-3303), not
204
- * a listener per site: the same page-plus-probe window, over
205
- * the collection group, newest first.
206
- *
207
- * The `orgId` clause is not a filter this card could drop. The
208
- * rules admit a group read only when it is narrowed to an org
209
- * the reader spans, so an unfiltered group query is refused
210
- * outright. Served by the `orgId ASC, createdAt DESC`
211
- * COLLECTION_GROUP index. Every submission carries `orgId`: the
212
- * submit route stamps it, and the rows written before it did
213
- * were stamped once (AGL-3303). A row without one would be in no
214
- * organization's list, though its own site's Inbox lists it
215
- * either way.
216
- */ collectionGroup(firestore, 'formSubmissions'), where('orgId', '==', orgId), orderBy('createdAt', 'desc'), limit(pageLimit)) : null, [
217
- firestore,
218
- hostId,
219
- orgId,
220
- activeForm
221
- ], {
175
+ *
176
+ * EVERY SITE'S SUBMISSIONS IN ONE QUERY on the organization's Inbox
177
+ * (AGL-3303), not a listener per site: the collection group, newest first.
178
+ * The `orgId` clause is not a filter this card could drop. The rules admit
179
+ * a group read only when it is narrowed to an org the reader spans, so an
180
+ * unfiltered group query is refused outright. Every submission carries
181
+ * `orgId`: the submit route stamps it, and the rows written before it did
182
+ * were stamped once (AGL-3303). A row without one would be in no
183
+ * organization's list, though its own site's Inbox lists it either way.
184
+ */ const { rows: submissions, hasMore: hasMoreSubmissions, page: submissionPage, setPage: setSubmissionPage, pageSize: submissionPageSize, setPageSize: setSubmissionPageSize, plan } = useListQuery({
185
+ collection: hostId ? collection(firestore, 'hosts', hostId, 'formSubmissions') : orgId ? collectionGroup(firestore, 'formSubmissions') : null,
186
+ declaration,
187
+ request: {
188
+ clauses: gridFilter.clauses,
189
+ search: gridFilter.searchWords,
190
+ base
191
+ },
192
+ deps: [
193
+ firestore,
194
+ hostId,
195
+ orgId
196
+ ],
222
197
  idField: '$id'
223
198
  });
224
- /** Form is offered where the site's catalog is read. */ const offersForm = !scoped && Boolean(hostId) && forms.length > 0;
225
- const submissionFields = useMemo(()=>[
226
- ...SUBMISSION_BASE_FIELDS,
227
- ...offersForm ? [
228
- FORM_FIELD
229
- ] : [],
230
- ...hostId == null ? [
231
- inMemoryListField('hostId', 'select')
232
- ] : []
233
- ], [
234
- offersForm,
235
- hostId
236
- ]);
237
199
  const submissionOptions = useMemo(()=>{
238
200
  var _ref;
239
201
  return _extends({
240
- read: READ_OPTIONS
202
+ read: SUBMISSION_READ_OPTIONS
241
203
  }, offersForm ? {
242
204
  formId: forms.map((form)=>({
243
205
  value: String(form.$id),
@@ -255,32 +217,18 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
255
217
  hostId,
256
218
  orgMount
257
219
  ]);
258
- const submissionWindow = useMemo(()=>submissionData == null ? void 0 : submissionData.map(withFilterValues), [
259
- submissionData
260
- ]);
261
- const submissionPageRows = useMemo(()=>submissionRows.map(withFilterValues), [
262
- submissionRows
220
+ const refusals = useMemo(()=>listQueryRefusals(plan.refused, {
221
+ fields: declaration.fields,
222
+ headers: SUBMISSION_FILTER_HEADERS,
223
+ options: submissionOptions
224
+ }), [
225
+ plan.refused,
226
+ declaration,
227
+ submissionOptions
263
228
  ]);
264
- const submissionFilter = usePagedRowsFilter({
265
- data: submissionWindow,
266
- rows: submissionPageRows,
267
- hasMore: hasMoreSubmissions,
268
- page: submissionPage,
269
- setPage: setSubmissionPage,
270
- pageSize: submissionPageSize,
271
- setPageSize: setSubmissionPageSize
272
- }, {
273
- fields: submissionFields,
274
- options: submissionOptions,
275
- headers: SUBMISSION_FILTER_HEADERS,
276
- search: SUBMISSION_SEARCH_FIELDS,
277
- served: servedByQuery,
278
- clauses,
279
- onChange: setClauses
280
- });
281
- // The rows on screen: the page, or the page of the matches.
282
- const submissions = submissionFilter.rows;
283
- /** Narrowed by nothing but the served Form clause. */ const onlyForm = Boolean(formFilter) && clauses.every(servedByQuery) && submissionFilter.gridFilter.searchWords.length === 0;
229
+ const searching = gridFilter.searchWords.some((word)=>word.trim());
230
+ const filtering = gridFilter.clauses.length > 0 || searching;
231
+ /** Narrowed by nothing but a Form clause. */ const onlyForm = !searching && gridFilter.clauses.length > 0 && gridFilter.clauses.every((clause)=>clause.field === 'formId');
284
232
  // Mail reader (AGL-104): opening a submission shows the full message and
285
233
  // marks it read.
286
234
  const [reader, setReader] = useState(null);
@@ -370,11 +318,25 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
370
318
  variant: 'success',
371
319
  persist: false
372
320
  });
321
+ /*
322
+ * The form counted this submission when it arrived (AGL-3330), and an
323
+ * increment cannot see a delete: the forms plugin recounts the form
324
+ * from the rows that are left. Best effort — the delete has happened
325
+ * either way, and the recount script catches a follow-up that failed.
326
+ */ if (typeof submission.formId === 'string' && submission.formId) {
327
+ void postPluginApi(FORM_STATS_API_PATH, {
328
+ hostId: site,
329
+ formIds: [
330
+ submission.formId
331
+ ]
332
+ });
333
+ }
373
334
  }, [
374
335
  confirm,
375
336
  firestore,
376
337
  siteOf,
377
- enqueueSnackbar
338
+ enqueueSnackbar,
339
+ postPluginApi
378
340
  ]);
379
341
  /*
380
342
  * One row per submission, newest first: who sent it, what it said and how
@@ -433,7 +395,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
433
395
  headerName: 'From',
434
396
  flex: 1,
435
397
  minWidth: 220,
436
- valueGetter: (_value, submission)=>submission.senderLabel,
398
+ valueGetter: (_value, submission)=>submissionSender(submission.fields).label,
437
399
  renderCell: ({ row: submission })=>{
438
400
  var _submission_formName;
439
401
  const sender = submissionSender(submission.fields);
@@ -511,7 +473,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
511
473
  headerName: 'Message',
512
474
  flex: 2,
513
475
  minWidth: 260,
514
- valueGetter: (_value, submission)=>submission.messageText,
476
+ valueGetter: (_value, submission)=>messageTextOf(submission),
515
477
  renderCell: ({ value })=>/*#__PURE__*/ _jsx(Typography, {
516
478
  variant: "body2",
517
479
  noWrap: true,
@@ -543,6 +505,17 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
543
505
  }
544
506
  },
545
507
  /*
508
+ READ, as the panel picks it — never drawn: the unread dot and the bold
509
+ row already say it. A boolean field has no hidden column of its own (the
510
+ grid's boolean operators are not the select's), so it is declared here,
511
+ kept hidden and out of Manage columns.
512
+ */ {
513
+ field: 'read',
514
+ headerName: 'Read',
515
+ hideable: false,
516
+ valueGetter: (_value, submission)=>String(Boolean(submission.read))
517
+ },
518
+ /*
546
519
  One overflow menu, as the Members & leads rows have: two inline buttons
547
520
  had no room for a third, and the contact link is the one a reader
548
521
  reaches for after reading. Present but disabled for a submission with no
@@ -591,28 +564,27 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
591
564
  },
592
565
  children: 'Every site’s submissions, newest first. Choose a site to ' + 'narrow them by form.'
593
566
  }) : null,
594
- submissionRows.length === 0 && !hasMoreSubmissions && !submissionFilter.filtering ? /*#__PURE__*/ _jsx(Typography, {
567
+ submissions.length === 0 && !hasMoreSubmissions && !filtering ? /*#__PURE__*/ _jsx(Typography, {
595
568
  variant: "body2",
596
569
  color: "text.secondary",
597
570
  children: scoped ? 'No submissions carry this form’s id yet. Messages this ' + 'form’s design collected before it became a form entity are ' + 'in the Inbox, filed under the name they were sent with.' : hostId == null ? 'No form submissions on any site yet.' : 'No form submissions yet. Add a Contact Form element to a ' + 'screen — visitor messages arrive here.'
598
571
  }) : /*#__PURE__*/ _jsxs(_Fragment, {
599
572
  children: [
600
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, submissionFilter.chipsProps, {
601
- servedField: "formId"
602
- })),
603
- submissionFilter.filtering && hasMoreSubmissions ? /*#__PURE__*/ _jsx(Typography, {
604
- variant: "caption",
605
- color: "text.secondary",
606
- component: "p",
607
- sx: {
608
- mb: 1
609
- },
610
- children: `Filtering the ${submissionFilter.read} submissions read so far — the next page reads more.`
611
- }) : null,
612
- /*#__PURE__*/ _jsx(ListTable, _extends({
573
+ /*#__PURE__*/ _jsx(ListFilterChips, {
574
+ fields: declaration.fields,
575
+ headers: SUBMISSION_FILTER_HEADERS,
576
+ clauses: gridFilter.clauses,
577
+ onChange: gridFilter.setClauses,
578
+ options: submissionOptions
579
+ }),
580
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
581
+ refused: refusals,
582
+ notices: plan.notices
583
+ }),
584
+ /*#__PURE__*/ _jsx(ListTable, {
613
585
  "aria-label": scoped ? 'Submissions to this form' : 'Form submissions',
614
586
  rows: submissions,
615
- columns: submissionFilter.filterColumns(submissionColumns),
587
+ columns: listFilterGridColumns(submissionColumns, declaration.fields, submissionOptions, SUBMISSION_FILTER_HEADERS),
616
588
  initialState: {
617
589
  columns: {
618
590
  columnVisibilityModel: SUBMISSION_HIDDEN_COLUMNS
@@ -630,9 +602,23 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
630
602
  }
631
603
  },
632
604
  // Paged by the footer below, so the grid must not also slice.
633
- hideFooter: true
634
- }, submissionFilter.gridProps)),
635
- /*#__PURE__*/ _jsx(ListPagination, _extends({}, submissionFilter.pagination))
605
+ hideFooter: true,
606
+ // The panel and the search go to the query; the grid neither
607
+ // filters nor sorts the page it holds.
608
+ filterMode: "server",
609
+ filterModel: gridFilter.filterModel,
610
+ onFilterModelChange: gridFilter.onFilterModelChange,
611
+ quickFilter: true,
612
+ disableColumnSorting: true
613
+ }),
614
+ /*#__PURE__*/ _jsx(ListPagination, {
615
+ page: submissionPage,
616
+ pageSize: submissionPageSize,
617
+ rowCount: submissions.length,
618
+ hasMore: hasMoreSubmissions,
619
+ onPageChange: setSubmissionPage,
620
+ onPageSizeChange: setSubmissionPageSize
621
+ })
636
622
  ]
637
623
  })
638
624
  ]