@aglyn/plugins-inbox 1.0.0-beta.167 → 1.0.0-beta.168

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-inbox",
3
- "version": "1.0.0-beta.167",
3
+ "version": "1.0.0-beta.168",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,15 +25,15 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.167",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.167",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.167",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.167",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.167",
33
- "@aglyn/shared-util-email": "1.0.0-beta.167",
34
- "@aglyn/shared-util-http": "1.0.0-beta.167",
35
- "@aglyn/tenant-data-admin": "1.0.0-beta.167",
36
- "@aglyn/tenant-feature-instance": "1.0.0-beta.167",
28
+ "@aglyn/aglyn": "1.0.0-beta.168",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.168",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.168",
31
+ "@aglyn/shared-ui-next": "1.0.0-beta.168",
32
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.168",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.168",
34
+ "@aglyn/shared-util-http": "1.0.0-beta.168",
35
+ "@aglyn/tenant-data-admin": "1.0.0-beta.168",
36
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.168",
37
37
  "@swc/helpers": "0.5.23"
38
38
  },
39
39
  "peerDependencies": {
@@ -30,11 +30,12 @@ import { CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-j
30
30
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
31
31
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
32
32
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
33
- import { useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
33
+ import { useFirestore, useFirestoreCollection, useOrgDataScope } from "@aglyn/tenant-feature-instance";
34
34
  import { Alert, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Typography } from "@mui/material";
35
- import { collection, deleteDoc, doc, limit, orderBy, query } from "firebase/firestore";
35
+ import { collection, deleteDoc, doc, limit, orderBy, query, where } from "firebase/firestore";
36
36
  import { useCallback, useMemo, useState } from "react";
37
37
  import { useRecordRouteContext } from "./use-record-route-context.js";
38
+ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
38
39
  /**
39
40
  * How many members and how many leads the contacts table reads.
40
41
  *
@@ -89,9 +90,17 @@ import { useRecordRouteContext } from "./use-record-route-context.js";
89
90
  ], {
90
91
  idField: '$id'
91
92
  });
93
+ /*
94
+ * The lead silo is the org's (AGL-3275), narrowed to this site by
95
+ * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its
96
+ * pre-migration rows and nothing captured since.
97
+ */ const { orgId } = useOrgDataScope({
98
+ hostId
99
+ });
92
100
  const siteMembers = (memberDocs != null ? memberDocs : []).slice(0, CONTACT_CEILING);
93
- const { data: leadDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'leads'), orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)), [
101
+ const { data: leadDocs } = useFirestoreCollection(()=>orgId ? query(collection(firestore, 'orgs', orgId, 'leads'), where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)), orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)) : null, [
94
102
  firestore,
103
+ orgId,
95
104
  hostId
96
105
  ], {
97
106
  idField: '$id'
@@ -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 { normalizeContactEmail, pluginDocsHelp } 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 {\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 { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\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 {\n collection,\n deleteDoc,\n doc,\n limit,\n orderBy,\n query,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRecordRouteContext } from './use-record-route-context'\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 * 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 */\nexport function ContactsCard({ hostId }: { hostId: string }) {\n const firestore = useFirestore()\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 query(\n collection(firestore, 'hosts', hostId, 'siteMembers'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const siteMembers = (memberDocs ?? []).slice(0, CONTACT_CEILING)\n const { data: leadDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'leads'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n ),\n [firestore, 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) => ({ ...member, contactKind: 'member' })),\n ...dedupedLeads.map((lead: any) => ({ ...lead, contactKind: 'lead' })),\n ],\n [siteMembers, dedupedLeads],\n )\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\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 await deleteDoc(doc(firestore, 'hosts', hostId, 'siteMembers', member.$id))\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n },\n [confirm, firestore, 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 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 {\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 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) =>\n contact.contactKind === 'member'\n ? 'Member'\n : contact.source\n ? `Lead · ${contact.source}`\n : 'Lead',\n renderCell: ({ row: contact, value }) =>\n contact.contactKind === 'member' ? (\n <Chip label={value} color=\"primary\" size=\"small\" />\n ) : (\n <Chip label={value} size=\"small\" variant=\"outlined\" />\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={'Site Members & Leads'}\n help={pluginDocsHelp('membersOnly', {\n anchor: '#manage-your-members',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {siteMembers.length === 0 && leads.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\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 <ListTable\n aria-label=\"Site members and leads\"\n rows={contacts}\n columns={contactColumns}\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 {`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 ? (\n <InboxRecordAttributionZone\n hostId={hostId}\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","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","useSnackbar","useFirestore","useFirestoreCollection","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","deleteDoc","doc","limit","orderBy","query","useCallback","useMemo","useState","useRecordRouteContext","CONTACT_CEILING","ContactsCard","hostId","firestore","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","data","memberDocs","idField","siteMembers","slice","leadDocs","leads","contactsTruncated","length","dedupedLeads","memberKeys","Set","map","member","email","filter","Boolean","lead","has","contacts","contactKind","handleDeleteMember","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","variant","persist","leadOrigin","setLeadOrigin","contactActions","key","label","icon","path","size","destructive","onClick","href","contactColumns","field","headerName","flex","minWidth","renderCell","row","name","displayName","direction","spacing","sx","alignItems","noWrap","width","valueGetter","_value","source","value","createdAt","toDate","getTime","toLocaleString","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","aria-label","rows","columns","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 { normalizeContactEmail, pluginDocsHelp } 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 {\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 { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\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 {\n collection,\n deleteDoc,\n doc,\n limit,\n orderBy,\n query,\n where,\n} 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'\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 * 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 */\nexport function ContactsCard({ hostId }: { hostId: string }) {\n const firestore = useFirestore()\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 query(\n collection(firestore, 'hosts', hostId, 'siteMembers'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n ),\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({ hostId })\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 where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)),\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) => ({ ...member, contactKind: 'member' })),\n ...dedupedLeads.map((lead: any) => ({ ...lead, contactKind: 'lead' })),\n ],\n [siteMembers, dedupedLeads],\n )\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\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 await deleteDoc(doc(firestore, 'hosts', hostId, 'siteMembers', member.$id))\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n },\n [confirm, firestore, 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 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 {\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 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) =>\n contact.contactKind === 'member'\n ? 'Member'\n : contact.source\n ? `Lead · ${contact.source}`\n : 'Lead',\n renderCell: ({ row: contact, value }) =>\n contact.contactKind === 'member' ? (\n <Chip label={value} color=\"primary\" size=\"small\" />\n ) : (\n <Chip label={value} size=\"small\" variant=\"outlined\" />\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={'Site Members & Leads'}\n help={pluginDocsHelp('membersOnly', {\n anchor: '#manage-your-members',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {siteMembers.length === 0 && leads.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\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 <ListTable\n aria-label=\"Site members and leads\"\n rows={contacts}\n columns={contactColumns}\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 {`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 ? (\n <InboxRecordAttributionZone\n hostId={hostId}\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","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","useSnackbar","useFirestore","useFirestoreCollection","useOrgDataScope","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","deleteDoc","doc","limit","orderBy","query","where","useCallback","useMemo","useState","useRecordRouteContext","scopeTokensForHost","CONTACT_CEILING","ContactsCard","hostId","firestore","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","data","memberDocs","idField","orgId","siteMembers","slice","leadDocs","leads","contactsTruncated","length","dedupedLeads","memberKeys","Set","map","member","email","filter","Boolean","lead","has","contacts","contactKind","handleDeleteMember","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","variant","persist","leadOrigin","setLeadOrigin","contactActions","key","label","icon","path","size","destructive","onClick","href","contactColumns","field","headerName","flex","minWidth","renderCell","row","name","displayName","direction","spacing","sx","alignItems","noWrap","width","valueGetter","_value","source","value","createdAt","toDate","getTime","toLocaleString","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","aria-label","rows","columns","getRowId","rowHeight","severity","mt","open","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"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"}
@@ -26,9 +26,10 @@ import { Avatar, Box, Button, Stack, Typography } from "@mui/material";
26
26
  import { collection, getCountFromServer, limit, orderBy, query, where } from "firebase/firestore";
27
27
  import { useParams } from "next/navigation";
28
28
  import { useEffect, useState } from "react";
29
- import { useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
29
+ import { useOrgDataScope, useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
30
30
  import { useRecordRouteContext } from "./use-record-route-context.js";
31
31
  import { relativeTime, senderHue, submissionSender } from "../model/submission-presenter.js";
32
+ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
32
33
  /**
33
34
  * Rows on the card, plus one so it can say the inbox holds more.
34
35
  *
@@ -78,13 +79,22 @@ import { relativeTime, senderHue, submissionSender } from "../model/submission-p
78
79
  * rather than drawn as zero.
79
80
  */ const routeContext = useRecordRouteContext();
80
81
  const leadsHref = routeContext ? pluginRecordListHref('lead', routeContext) : null;
82
+ /*
83
+ * The lead silo is the org's (AGL-3275), narrowed to this site by
84
+ * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its
85
+ * pre-migration rows and nothing captured since.
86
+ */ const { orgId } = useOrgDataScope({
87
+ hostId
88
+ });
81
89
  const [openLeads, setOpenLeads] = useState(null);
82
90
  useEffect(()=>{
83
91
  let active = true;
84
- const leads = collection(firestore, 'hosts', hostId, 'leads');
92
+ if (!orgId) return undefined;
93
+ const leads = collection(firestore, 'orgs', orgId, 'leads');
94
+ const scoped = where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId));
85
95
  void Promise.all([
86
- getCountFromServer(query(leads)),
87
- getCountFromServer(query(leads, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)))
96
+ getCountFromServer(query(leads, scoped)),
97
+ getCountFromServer(query(leads, scoped, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)))
88
98
  ]).then(([all, closed])=>{
89
99
  if (!active) return;
90
100
  setOpenLeads(openLeadsFromCounts(all.data().count, closed.data().count));
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/inbox-glance-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 buildRoute,\n CRM_LEAD_CLOSED_STATUSES,\n openLeadsFromCounts,\n pluginDocsHelp,\n Route,\n} from '@aglyn/aglyn'\n// The CRM's route builder by its leaf path, not the plugin barrel: the\n// barrel is the CRM's site entry point, and a dashboard card named there\n// would ship to every published page.\nimport { pluginRecordListHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Avatar, Box, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useParams } from 'next/navigation'\nimport { useEffect, useState } from 'react'\nimport { useFirestore, useFirestoreCollection } from '@aglyn/tenant-feature-instance'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport {\n relativeTime,\n senderHue,\n submissionSender,\n} from '../model/submission-presenter'\n\n/**\n * Rows on the card, plus one so it can say the inbox holds more.\n *\n * The extra document is never rendered — it is what turns \"there may be\n * more\" into a fact, the same probe the console's paged lists use.\n */\nconst PREVIEW_ROWS = 3\n\n/**\n * The inbox at a glance: who wrote in, how long ago, and how many are\n * unread.\n *\n * A dashboard is where a site owner looks first, and a form submission is\n * the one thing on it that is waiting for a REPLY — until now it was two\n * clicks away with nothing on the dashboard saying it had arrived.\n *\n * Renders nothing until the site has a submission, the same rule the other\n * capability glances follow: an empty card about forms on a site with no\n * form is an advertisement, not a summary.\n *\n * `orderBy('createdAt', 'desc')` is checked against the writer rather than\n * assumed — an `orderBy` drops documents missing the field, which on a\n * newest-first list is invisible. `apps/tenant/app/api/forms/submit/route.ts`\n * is the only path that creates one and stamps `createdAt: serverTimestamp()`\n * on every add, the v1 API only reads and deletes, and `formSubmissions` is\n * absent from `IMPORTABLE_FIELDS`, so no restore can mint one without it.\n */\nexport function InboxGlanceCard(props: { hostId: string }) {\n const { hostId } = props\n const firestore = useFirestore()\n const { orgSlug, host } = useParams<{ orgSlug: string; host: string }>()\n const { data: submissionDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'formSubmissions'),\n orderBy('createdAt', 'desc'),\n limit(PREVIEW_ROWS + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n /*\n * How many leads are waiting to be worked (AGL-2622), so the dashboard\n * says where the Inbox's captures went and the CRM's Leads list is one\n * click from here. Two server-side counts rather than a window: a lead\n * nobody has touched carries no status field at all — it is `new` by\n * absence, the rule `crmLeadStatus` reads — and Firestore can neither\n * count an absent field nor exclude the closed statuses without dropping\n * the unstamped rows too. So the open count is every lead less the ones\n * closed by a stamped status — `openLeadsFromCounts`, the subtraction the\n * CRM's own glance card makes — and both counts are aggregations that cost\n * one read per thousand index entries, not one per lead. `null` until\n * they land, and left null on a refused read so the line is withheld\n * rather than drawn as zero.\n */\n const routeContext = useRecordRouteContext()\n const leadsHref = routeContext\n ? pluginRecordListHref('lead', routeContext)\n : null\n const [openLeads, setOpenLeads] = useState<number | null>(null)\n useEffect(() => {\n let active = true\n const leads = collection(firestore, 'hosts', hostId, 'leads')\n void Promise.all([\n getCountFromServer(query(leads)),\n getCountFromServer(query(leads, where('status', 'in', CRM_LEAD_CLOSED_STATUSES))),\n ])\n .then(([all, closed]) => {\n if (!active) return\n setOpenLeads(openLeadsFromCounts(all.data().count, closed.data().count))\n })\n .catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId])\n\n const submissions = submissionDocs ?? []\n if (!submissions.length && !openLeads) return null\n const rows = submissions.slice(0, PREVIEW_ROWS)\n const unread = rows.filter((submission: any) => !submission.read).length\n\n return (\n <CardDisplay\n header={'Inbox'}\n help={pluginDocsHelp('forms', {\n anchor: '#the-inbox',\n excerpt:\n 'The newest form submissions on this site — the Inbox has the ' +\n 'full list, the reader, and the routing each one took.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={buildRoute(Route.HOST_INBOX, { orgSlug, host })}\n size=\"small\"\n color=\"primary\"\n >\n {'Open inbox'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1}>\n {rows.map((submission: any) => {\n const sender = submissionSender(submission.fields)\n const hue = senderHue(sender.label)\n return (\n <Stack\n key={submission.$id}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n {/*\n The unread DOT, as the Inbox itself draws it — same fact, same\n shape, so a reader recognizes the row when they open it.\n */}\n <Box\n aria-label={submission.read ? undefined : 'Unread'}\n sx={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: submission.read ? 'transparent' : 'primary.main',\n flexShrink: 0,\n }}\n />\n <Avatar\n sx={{\n width: 28,\n height: 28,\n typography: 'caption',\n bgcolor: `hsl(${hue} 55% 45%)`,\n }}\n >\n {sender.initials}\n </Avatar>\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {sender.label}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {submission.formName ?? 'Form'}\n </Typography>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {relativeTime(submission.createdAt?.toDate?.().getTime())}\n </Typography>\n </Stack>\n )\n })}\n {/*\n What the card is NOT showing. `submissions` holds the probe row, so\n a fourth document is a fact rather than an inference from a full\n page — and the unread count is stated over the rows on screen, not\n over the collection, because counting the collection is a read this\n card has no reason to pay for.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {[\n unread ? `${unread} unread here` : null,\n submissions.length > PREVIEW_ROWS ? 'more in the Inbox' : null,\n ]\n .filter(Boolean)\n .join(' · ') || 'All caught up'}\n </Typography>\n {/*\n Where the captures went (AGL-2622): the leads still to be worked,\n and the Leads list where they are, which the plugin that keeps\n them publishes. Text alone until the route params settle or where\n no plugin publishes one, so the line never links to nowhere.\n */}\n {openLeads ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${openLeads.toLocaleString()} open lead${openLeads === 1 ? '' : 's'} · `}\n {leadsHref ? (\n <AppLink href={leadsHref}>\n {'Work them in the CRM'}\n </AppLink>\n ) : (\n 'in the CRM'\n )}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nInboxGlanceCard.displayName = 'InboxGlanceCard'\n\nexport default InboxGlanceCard\n"],"names":["buildRoute","CRM_LEAD_CLOSED_STATUSES","openLeadsFromCounts","pluginDocsHelp","Route","pluginRecordListHref","AppLink","CardDisplay","Avatar","Box","Button","Stack","Typography","collection","getCountFromServer","limit","orderBy","query","where","useParams","useEffect","useState","useFirestore","useFirestoreCollection","useRecordRouteContext","relativeTime","senderHue","submissionSender","PREVIEW_ROWS","InboxGlanceCard","props","hostId","firestore","orgSlug","host","data","submissionDocs","idField","routeContext","leadsHref","openLeads","setOpenLeads","active","leads","Promise","all","then","closed","count","catch","undefined","submissions","length","rows","slice","unread","filter","submission","read","header","help","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","HOST_INBOX","size","color","spacing","map","sender","fields","hue","label","direction","sx","alignItems","aria-label","width","height","borderRadius","bgcolor","flexShrink","typography","initials","flex","minWidth","variant","noWrap","formName","createdAt","toDate","getTime","$id","Boolean","join","toLocaleString","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/inbox-glance-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 buildRoute,\n CRM_LEAD_CLOSED_STATUSES,\n openLeadsFromCounts,\n pluginDocsHelp,\n Route,\n} from '@aglyn/aglyn'\n// The CRM's route builder by its leaf path, not the plugin barrel: the\n// barrel is the CRM's site entry point, and a dashboard card named there\n// would ship to every published page.\nimport { pluginRecordListHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Avatar, Box, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useParams } from 'next/navigation'\nimport { useEffect, useState } from 'react'\nimport { useOrgDataScope, useFirestore, useFirestoreCollection } from '@aglyn/tenant-feature-instance'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport {\n relativeTime,\n senderHue,\n submissionSender,\n} from '../model/submission-presenter'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n\n/**\n * Rows on the card, plus one so it can say the inbox holds more.\n *\n * The extra document is never rendered — it is what turns \"there may be\n * more\" into a fact, the same probe the console's paged lists use.\n */\nconst PREVIEW_ROWS = 3\n\n/**\n * The inbox at a glance: who wrote in, how long ago, and how many are\n * unread.\n *\n * A dashboard is where a site owner looks first, and a form submission is\n * the one thing on it that is waiting for a REPLY — until now it was two\n * clicks away with nothing on the dashboard saying it had arrived.\n *\n * Renders nothing until the site has a submission, the same rule the other\n * capability glances follow: an empty card about forms on a site with no\n * form is an advertisement, not a summary.\n *\n * `orderBy('createdAt', 'desc')` is checked against the writer rather than\n * assumed — an `orderBy` drops documents missing the field, which on a\n * newest-first list is invisible. `apps/tenant/app/api/forms/submit/route.ts`\n * is the only path that creates one and stamps `createdAt: serverTimestamp()`\n * on every add, the v1 API only reads and deletes, and `formSubmissions` is\n * absent from `IMPORTABLE_FIELDS`, so no restore can mint one without it.\n */\nexport function InboxGlanceCard(props: { hostId: string }) {\n const { hostId } = props\n const firestore = useFirestore()\n const { orgSlug, host } = useParams<{ orgSlug: string; host: string }>()\n const { data: submissionDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'formSubmissions'),\n orderBy('createdAt', 'desc'),\n limit(PREVIEW_ROWS + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n /*\n * How many leads are waiting to be worked (AGL-2622), so the dashboard\n * says where the Inbox's captures went and the CRM's Leads list is one\n * click from here. Two server-side counts rather than a window: a lead\n * nobody has touched carries no status field at all — it is `new` by\n * absence, the rule `crmLeadStatus` reads — and Firestore can neither\n * count an absent field nor exclude the closed statuses without dropping\n * the unstamped rows too. So the open count is every lead less the ones\n * closed by a stamped status — `openLeadsFromCounts`, the subtraction the\n * CRM's own glance card makes — and both counts are aggregations that cost\n * one read per thousand index entries, not one per lead. `null` until\n * they land, and left null on a refused read so the line is withheld\n * rather than drawn as zero.\n */\n const routeContext = useRecordRouteContext()\n const leadsHref = routeContext\n ? pluginRecordListHref('lead', routeContext)\n : null\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({ hostId })\n const [openLeads, setOpenLeads] = useState<number | null>(null)\n useEffect(() => {\n let active = true\n if (!orgId) return undefined\n const leads = collection(firestore, 'orgs', orgId, 'leads')\n const scoped = where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))\n void Promise.all([\n getCountFromServer(query(leads, scoped)),\n getCountFromServer(\n query(leads, scoped, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)),\n ),\n ])\n .then(([all, closed]) => {\n if (!active) return\n setOpenLeads(openLeadsFromCounts(all.data().count, closed.data().count))\n })\n .catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId])\n\n const submissions = submissionDocs ?? []\n if (!submissions.length && !openLeads) return null\n const rows = submissions.slice(0, PREVIEW_ROWS)\n const unread = rows.filter((submission: any) => !submission.read).length\n\n return (\n <CardDisplay\n header={'Inbox'}\n help={pluginDocsHelp('forms', {\n anchor: '#the-inbox',\n excerpt:\n 'The newest form submissions on this site — the Inbox has the ' +\n 'full list, the reader, and the routing each one took.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={buildRoute(Route.HOST_INBOX, { orgSlug, host })}\n size=\"small\"\n color=\"primary\"\n >\n {'Open inbox'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1}>\n {rows.map((submission: any) => {\n const sender = submissionSender(submission.fields)\n const hue = senderHue(sender.label)\n return (\n <Stack\n key={submission.$id}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n {/*\n The unread DOT, as the Inbox itself draws it — same fact, same\n shape, so a reader recognizes the row when they open it.\n */}\n <Box\n aria-label={submission.read ? undefined : 'Unread'}\n sx={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: submission.read ? 'transparent' : 'primary.main',\n flexShrink: 0,\n }}\n />\n <Avatar\n sx={{\n width: 28,\n height: 28,\n typography: 'caption',\n bgcolor: `hsl(${hue} 55% 45%)`,\n }}\n >\n {sender.initials}\n </Avatar>\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {sender.label}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {submission.formName ?? 'Form'}\n </Typography>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {relativeTime(submission.createdAt?.toDate?.().getTime())}\n </Typography>\n </Stack>\n )\n })}\n {/*\n What the card is NOT showing. `submissions` holds the probe row, so\n a fourth document is a fact rather than an inference from a full\n page — and the unread count is stated over the rows on screen, not\n over the collection, because counting the collection is a read this\n card has no reason to pay for.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {[\n unread ? `${unread} unread here` : null,\n submissions.length > PREVIEW_ROWS ? 'more in the Inbox' : null,\n ]\n .filter(Boolean)\n .join(' · ') || 'All caught up'}\n </Typography>\n {/*\n Where the captures went (AGL-2622): the leads still to be worked,\n and the Leads list where they are, which the plugin that keeps\n them publishes. Text alone until the route params settle or where\n no plugin publishes one, so the line never links to nowhere.\n */}\n {openLeads ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${openLeads.toLocaleString()} open lead${openLeads === 1 ? '' : 's'} · `}\n {leadsHref ? (\n <AppLink href={leadsHref}>\n {'Work them in the CRM'}\n </AppLink>\n ) : (\n 'in the CRM'\n )}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nInboxGlanceCard.displayName = 'InboxGlanceCard'\n\nexport default InboxGlanceCard\n"],"names":["buildRoute","CRM_LEAD_CLOSED_STATUSES","openLeadsFromCounts","pluginDocsHelp","Route","pluginRecordListHref","AppLink","CardDisplay","Avatar","Box","Button","Stack","Typography","collection","getCountFromServer","limit","orderBy","query","where","useParams","useEffect","useState","useOrgDataScope","useFirestore","useFirestoreCollection","useRecordRouteContext","relativeTime","senderHue","submissionSender","scopeTokensForHost","PREVIEW_ROWS","InboxGlanceCard","props","hostId","firestore","orgSlug","host","data","submissionDocs","idField","routeContext","leadsHref","orgId","openLeads","setOpenLeads","active","undefined","leads","scoped","Promise","all","then","closed","count","catch","submissions","length","rows","slice","unread","filter","submission","read","header","help","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","HOST_INBOX","size","color","spacing","map","sender","fields","hue","label","direction","sx","alignItems","aria-label","width","height","borderRadius","bgcolor","flexShrink","typography","initials","flex","minWidth","variant","noWrap","formName","createdAt","toDate","getTime","$id","Boolean","join","toLocaleString","displayName"],"mappings":"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"}