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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,79 +29,40 @@ 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 } 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
+ /** Read, Form and Site are picked, so the panel shows selects. */ const SUBMISSION_SELECT_FIELDS = [
51
+ 'read',
52
+ 'formId',
53
+ 'hostId'
88
54
  ];
89
55
  const SUBMISSION_HIDDEN_COLUMNS = {
90
56
  read: false,
91
57
  formId: false
92
58
  };
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
- });
59
+ /** Every field of a message, as one line — what the Message column draws. */ const messageTextOf = (submission)=>{
60
+ var _ref;
61
+ return Object.entries((_ref = submission == null ? void 0 : submission.fields) != null ? _ref : {}).map(([key, value])=>`${key}: ${value}`).join(' · ');
101
62
  };
102
63
  export function SubmissionsCard({ hostId, formId, orgMount }) {
103
64
  var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
104
- var _clauses_find, _reader_createdAt_toDate, _reader_createdAt;
65
+ var _reader_createdAt_toDate, _reader_createdAt;
105
66
  const firestore = useFirestore();
106
67
  const { enqueueSnackbar } = useSnackbar();
107
68
  const { confirm } = useConfirmationContext();
@@ -157,28 +118,40 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
157
118
  }), [
158
119
  formDocs
159
120
  ]);
121
+ /** Form is offered where the site's catalog is read. */ const offersForm = !scoped && Boolean(hostId) && forms.length > 0;
160
122
  /*
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.
123
+ * WHAT THIS CARD'S LIST CAN BE ASKED (AGL-3321) — every clause and the
124
+ * search on the query, see `SUBMISSION_LIST_QUERY`:
173
125
  *
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;
126
+ * - a site's Inbox: From, Read and — where the site has forms — Form;
127
+ * - a card scoped to one form: that form is the list's own scope, and
128
+ * Form has nothing left to pick, so it is not offered. The scope wins
129
+ * over any clause: it is the base, and no clause can reach it;
130
+ * - every site's, on the organization's Inbox: From, Read and Site,
131
+ * under the `orgId` clause the rules require of the group read.
132
+ */ const declaration = useMemo(()=>{
133
+ if (scoped) return FORM_SCOPED_SUBMISSION_LIST_QUERY;
134
+ if (hostId == null) return ORG_SUBMISSION_LIST_QUERY;
135
+ return offersForm ? SUBMISSION_LIST_QUERY : _extends({}, SUBMISSION_LIST_QUERY, {
136
+ fields: SUBMISSION_LIST_QUERY.fields.filter((field)=>field.column !== 'formId')
137
+ });
138
+ }, [
139
+ scoped,
140
+ hostId,
141
+ offersForm
142
+ ]);
143
+ const base = useMemo(()=>formId ? formSubmissionBase(formId) : hostId == null && orgId ? orgSubmissionBase(orgId) : [], [
144
+ formId,
145
+ hostId,
146
+ orgId
147
+ ]);
148
+ const gridFilter = useListGridFilter({
149
+ selectFields: SUBMISSION_SELECT_FIELDS
150
+ });
179
151
  /*
180
152
  * The inbox WALKS its submissions instead of sampling them (AGL-2501,
181
- * AGL-2292).
153
+ * AGL-2292), and every filter and search word is a predicate on the walk
154
+ * (AGL-3321), so a page is a page of the matches.
182
155
  *
183
156
  * `limit(200)` carried no `orderBy`, so Firestore answered it in
184
157
  * DOCUMENT-ID order over ids `add()` generates — an arbitrary two hundred
@@ -193,51 +166,34 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
193
166
  * that creates one and stamps `createdAt: serverTimestamp()` on every add,
194
167
  * the v1 API only ever reads and deletes, and `formSubmissions` is absent
195
168
  * 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
- ], {
169
+ *
170
+ * EVERY SITE'S SUBMISSIONS IN ONE QUERY on the organization's Inbox
171
+ * (AGL-3303), not a listener per site: the collection group, newest first.
172
+ * The `orgId` clause is not a filter this card could drop. The rules admit
173
+ * a group read only when it is narrowed to an org the reader spans, so an
174
+ * unfiltered group query is refused outright. Every submission carries
175
+ * `orgId`: the submit route stamps it, and the rows written before it did
176
+ * were stamped once (AGL-3303). A row without one would be in no
177
+ * organization's list, though its own site's Inbox lists it either way.
178
+ */ const { rows: submissions, hasMore: hasMoreSubmissions, page: submissionPage, setPage: setSubmissionPage, pageSize: submissionPageSize, setPageSize: setSubmissionPageSize, plan } = useListQuery({
179
+ collection: hostId ? collection(firestore, 'hosts', hostId, 'formSubmissions') : orgId ? collectionGroup(firestore, 'formSubmissions') : null,
180
+ declaration,
181
+ request: {
182
+ clauses: gridFilter.clauses,
183
+ search: gridFilter.searchWords,
184
+ base
185
+ },
186
+ deps: [
187
+ firestore,
188
+ hostId,
189
+ orgId
190
+ ],
222
191
  idField: '$id'
223
192
  });
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
193
  const submissionOptions = useMemo(()=>{
238
194
  var _ref;
239
195
  return _extends({
240
- read: READ_OPTIONS
196
+ read: SUBMISSION_READ_OPTIONS
241
197
  }, offersForm ? {
242
198
  formId: forms.map((form)=>({
243
199
  value: String(form.$id),
@@ -255,32 +211,18 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
255
211
  hostId,
256
212
  orgMount
257
213
  ]);
258
- const submissionWindow = useMemo(()=>submissionData == null ? void 0 : submissionData.map(withFilterValues), [
259
- submissionData
260
- ]);
261
- const submissionPageRows = useMemo(()=>submissionRows.map(withFilterValues), [
262
- submissionRows
214
+ const refusals = useMemo(()=>listQueryRefusals(plan.refused, {
215
+ fields: declaration.fields,
216
+ headers: SUBMISSION_FILTER_HEADERS,
217
+ options: submissionOptions
218
+ }), [
219
+ plan.refused,
220
+ declaration,
221
+ submissionOptions
263
222
  ]);
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;
223
+ const searching = gridFilter.searchWords.some((word)=>word.trim());
224
+ const filtering = gridFilter.clauses.length > 0 || searching;
225
+ /** Narrowed by nothing but a Form clause. */ const onlyForm = !searching && gridFilter.clauses.length > 0 && gridFilter.clauses.every((clause)=>clause.field === 'formId');
284
226
  // Mail reader (AGL-104): opening a submission shows the full message and
285
227
  // marks it read.
286
228
  const [reader, setReader] = useState(null);
@@ -433,7 +375,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
433
375
  headerName: 'From',
434
376
  flex: 1,
435
377
  minWidth: 220,
436
- valueGetter: (_value, submission)=>submission.senderLabel,
378
+ valueGetter: (_value, submission)=>submissionSender(submission.fields).label,
437
379
  renderCell: ({ row: submission })=>{
438
380
  var _submission_formName;
439
381
  const sender = submissionSender(submission.fields);
@@ -511,7 +453,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
511
453
  headerName: 'Message',
512
454
  flex: 2,
513
455
  minWidth: 260,
514
- valueGetter: (_value, submission)=>submission.messageText,
456
+ valueGetter: (_value, submission)=>messageTextOf(submission),
515
457
  renderCell: ({ value })=>/*#__PURE__*/ _jsx(Typography, {
516
458
  variant: "body2",
517
459
  noWrap: true,
@@ -543,6 +485,17 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
543
485
  }
544
486
  },
545
487
  /*
488
+ READ, as the panel picks it — never drawn: the unread dot and the bold
489
+ row already say it. A boolean field has no hidden column of its own (the
490
+ grid's boolean operators are not the select's), so it is declared here,
491
+ kept hidden and out of Manage columns.
492
+ */ {
493
+ field: 'read',
494
+ headerName: 'Read',
495
+ hideable: false,
496
+ valueGetter: (_value, submission)=>String(Boolean(submission.read))
497
+ },
498
+ /*
546
499
  One overflow menu, as the Members & leads rows have: two inline buttons
547
500
  had no room for a third, and the contact link is the one a reader
548
501
  reaches for after reading. Present but disabled for a submission with no
@@ -591,28 +544,27 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
591
544
  },
592
545
  children: 'Every site’s submissions, newest first. Choose a site to ' + 'narrow them by form.'
593
546
  }) : null,
594
- submissionRows.length === 0 && !hasMoreSubmissions && !submissionFilter.filtering ? /*#__PURE__*/ _jsx(Typography, {
547
+ submissions.length === 0 && !hasMoreSubmissions && !filtering ? /*#__PURE__*/ _jsx(Typography, {
595
548
  variant: "body2",
596
549
  color: "text.secondary",
597
550
  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
551
  }) : /*#__PURE__*/ _jsxs(_Fragment, {
599
552
  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({
553
+ /*#__PURE__*/ _jsx(ListFilterChips, {
554
+ fields: declaration.fields,
555
+ headers: SUBMISSION_FILTER_HEADERS,
556
+ clauses: gridFilter.clauses,
557
+ onChange: gridFilter.setClauses,
558
+ options: submissionOptions
559
+ }),
560
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
561
+ refused: refusals,
562
+ notices: plan.notices
563
+ }),
564
+ /*#__PURE__*/ _jsx(ListTable, {
613
565
  "aria-label": scoped ? 'Submissions to this form' : 'Form submissions',
614
566
  rows: submissions,
615
- columns: submissionFilter.filterColumns(submissionColumns),
567
+ columns: listFilterGridColumns(submissionColumns, declaration.fields, submissionOptions, SUBMISSION_FILTER_HEADERS),
616
568
  initialState: {
617
569
  columns: {
618
570
  columnVisibilityModel: SUBMISSION_HIDDEN_COLUMNS
@@ -630,9 +582,23 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
630
582
  }
631
583
  },
632
584
  // 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))
585
+ hideFooter: true,
586
+ // The panel and the search go to the query; the grid neither
587
+ // filters nor sorts the page it holds.
588
+ filterMode: "server",
589
+ filterModel: gridFilter.filterModel,
590
+ onFilterModelChange: gridFilter.onFilterModelChange,
591
+ quickFilter: true,
592
+ disableColumnSorting: true
593
+ }),
594
+ /*#__PURE__*/ _jsx(ListPagination, {
595
+ page: submissionPage,
596
+ pageSize: submissionPageSize,
597
+ rowCount: submissions.length,
598
+ hasMore: hasMoreSubmissions,
599
+ onPageChange: setSubmissionPage,
600
+ onPageSizeChange: setSubmissionPageSize
601
+ })
636
602
  ]
637
603
  })
638
604
  ]