@aglyn/plugins-inbox 1.0.0-beta.188 → 1.0.0-beta.190
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.
|
|
3
|
+
"version": "1.0.0-beta.190",
|
|
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.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
35
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
36
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.190",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.190",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.190",
|
|
31
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.190",
|
|
32
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.190",
|
|
33
|
+
"@aglyn/shared-util-email": "1.0.0-beta.190",
|
|
34
|
+
"@aglyn/shared-util-http": "1.0.0-beta.190",
|
|
35
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.190",
|
|
36
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.190",
|
|
37
37
|
"@swc/helpers": "0.5.23"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
@@ -27,7 +27,10 @@ import { InboxRecordAttributionZone } from "./inbox-attribution-zone.js";
|
|
|
27
27
|
import { pluginRecordHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
|
|
28
28
|
import { mdiAccountArrowRight, mdiAccountRemoveOutline, mdiBullhornOutline } from "@aglyn/shared-data-mdi";
|
|
29
29
|
import { CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
30
|
+
import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
|
|
30
31
|
import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
|
|
32
|
+
import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
|
|
33
|
+
import { useListRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-rows-filter";
|
|
31
34
|
import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
32
35
|
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
33
36
|
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
@@ -45,6 +48,36 @@ import { orgSiteNames } from "./inbox-org-sites.js";
|
|
|
45
48
|
* this one table cannot be paged by the server without breaking the dedupe
|
|
46
49
|
* between them.
|
|
47
50
|
*/ const CONTACT_CEILING = 200;
|
|
51
|
+
/*
|
|
52
|
+
* What the contacts grid's Filters panel offers (AGL-3317). The card holds
|
|
53
|
+
* both capped windows, so it answers the panel over every contact they read;
|
|
54
|
+
* the notice under the table says when a cap truncated one. Type reads
|
|
55
|
+
* `typeKey`: `member`, or `lead` with the lead's `source` when it has one,
|
|
56
|
+
* which is what the column draws. On the organization's Inbox, Site matches
|
|
57
|
+
* any site that captured the lead.
|
|
58
|
+
*/ const CONTACT_FILTER_FIELDS = [
|
|
59
|
+
inMemoryListField('email', 'text'),
|
|
60
|
+
inMemoryListField('contactKind', 'select', 'typeKey')
|
|
61
|
+
];
|
|
62
|
+
const ORG_CONTACT_FILTER_FIELDS = [
|
|
63
|
+
...CONTACT_FILTER_FIELDS,
|
|
64
|
+
_extends({}, inMemoryListField('capturedByHostIds', 'select'), {
|
|
65
|
+
tokensPath: 'capturedByHostIds',
|
|
66
|
+
verbatimTokens: true
|
|
67
|
+
})
|
|
68
|
+
];
|
|
69
|
+
const CONTACT_FILTER_HEADERS = {
|
|
70
|
+
email: 'Email',
|
|
71
|
+
contactKind: 'Type',
|
|
72
|
+
capturedByHostIds: 'Site'
|
|
73
|
+
};
|
|
74
|
+
const CONTACT_SEARCH_FIELDS = [
|
|
75
|
+
'email',
|
|
76
|
+
'displayName',
|
|
77
|
+
'name',
|
|
78
|
+
'source'
|
|
79
|
+
];
|
|
80
|
+
/** How a contact's type reads, from its `typeKey`. */ const contactTypeLabel = (typeKey)=>typeKey === 'member' ? 'Member' : typeKey.startsWith('lead:') ? `Lead · ${typeKey.slice('lead:'.length)}` : 'Lead';
|
|
48
81
|
/**
|
|
49
82
|
* The Members & leads section of the Inbox (AGL-109): everybody a site
|
|
50
83
|
* collected, in one list.
|
|
@@ -151,16 +184,53 @@ import { orgSiteNames } from "./inbox-org-sites.js";
|
|
|
151
184
|
* deduped list.
|
|
152
185
|
*/ const contacts = useMemo(()=>[
|
|
153
186
|
...siteMembers.map((member)=>_extends({}, member, {
|
|
154
|
-
contactKind: 'member'
|
|
187
|
+
contactKind: 'member',
|
|
188
|
+
typeKey: 'member'
|
|
155
189
|
})),
|
|
156
190
|
...dedupedLeads.map((lead)=>_extends({}, lead, {
|
|
157
|
-
contactKind: 'lead'
|
|
191
|
+
contactKind: 'lead',
|
|
192
|
+
typeKey: lead.source ? `lead:${lead.source}` : 'lead'
|
|
158
193
|
}))
|
|
159
194
|
], [
|
|
160
195
|
siteMembers,
|
|
161
196
|
dedupedLeads
|
|
162
197
|
]);
|
|
163
198
|
/*
|
|
199
|
+
* A lead's source is open-ended, so the Type choices are the ones the rows
|
|
200
|
+
* hold, member and plain lead first.
|
|
201
|
+
*/ const contactFilterOptions = useMemo(()=>{
|
|
202
|
+
var _ref;
|
|
203
|
+
const leadSources = [
|
|
204
|
+
...new Set(contacts.map((contact)=>contact.typeKey).filter((typeKey)=>typeKey.startsWith('lead:')))
|
|
205
|
+
].sort();
|
|
206
|
+
return _extends({
|
|
207
|
+
contactKind: [
|
|
208
|
+
'member',
|
|
209
|
+
'lead',
|
|
210
|
+
...leadSources
|
|
211
|
+
].map((value)=>({
|
|
212
|
+
value,
|
|
213
|
+
label: contactTypeLabel(value)
|
|
214
|
+
}))
|
|
215
|
+
}, hostId == null ? {
|
|
216
|
+
capturedByHostIds: ((_ref = orgMount == null ? void 0 : orgMount.hosts) != null ? _ref : []).map((host)=>({
|
|
217
|
+
value: host.id,
|
|
218
|
+
label: host.name || host.id
|
|
219
|
+
}))
|
|
220
|
+
} : {});
|
|
221
|
+
}, [
|
|
222
|
+
contacts,
|
|
223
|
+
hostId,
|
|
224
|
+
orgMount
|
|
225
|
+
]);
|
|
226
|
+
const contactFilter = useListRowsFilter({
|
|
227
|
+
rows: contacts,
|
|
228
|
+
fields: hostId == null ? ORG_CONTACT_FILTER_FIELDS : CONTACT_FILTER_FIELDS,
|
|
229
|
+
options: contactFilterOptions,
|
|
230
|
+
headers: CONTACT_FILTER_HEADERS,
|
|
231
|
+
search: CONTACT_SEARCH_FIELDS
|
|
232
|
+
});
|
|
233
|
+
/*
|
|
164
234
|
* REMOVED BY THE ROUTE THAT OWNS MEMBER ACCOUNTS (AGL-3308), not by a
|
|
165
235
|
* client delete. A member's password hash lives in a document no client can
|
|
166
236
|
* reach, so deleting the profile from here would leave it behind: the route
|
|
@@ -316,13 +386,13 @@ import { orgSiteNames } from "./inbox-org-sites.js";
|
|
|
316
386
|
*/ field: 'contactKind',
|
|
317
387
|
headerName: 'Type',
|
|
318
388
|
width: 170,
|
|
319
|
-
valueGetter: (_value, contact)=>contact.
|
|
320
|
-
renderCell: ({ row: contact
|
|
321
|
-
label:
|
|
389
|
+
valueGetter: (_value, contact)=>contact.typeKey,
|
|
390
|
+
renderCell: ({ row: contact })=>contact.contactKind === 'member' ? /*#__PURE__*/ _jsx(Chip, {
|
|
391
|
+
label: contactTypeLabel(contact.typeKey),
|
|
322
392
|
color: "primary",
|
|
323
393
|
size: "small"
|
|
324
394
|
}) : /*#__PURE__*/ _jsx(Chip, {
|
|
325
|
-
label:
|
|
395
|
+
label: contactTypeLabel(contact.typeKey),
|
|
326
396
|
size: "small",
|
|
327
397
|
variant: "outlined"
|
|
328
398
|
})
|
|
@@ -337,7 +407,9 @@ import { orgSiteNames } from "./inbox-org-sites.js";
|
|
|
337
407
|
headerName: 'Site',
|
|
338
408
|
flex: 1,
|
|
339
409
|
minWidth: 160,
|
|
340
|
-
|
|
410
|
+
// Sorted and drawn by name; the filter matches the ids.
|
|
411
|
+
valueGetter: (_value, contact)=>orgSiteNames(orgMount, contact.capturedByHostIds),
|
|
412
|
+
renderCell: ({ row: contact })=>orgSiteNames(orgMount, contact.capturedByHostIds)
|
|
341
413
|
}
|
|
342
414
|
] : [],
|
|
343
415
|
{
|
|
@@ -391,15 +463,18 @@ import { orgSiteNames } from "./inbox-org-sites.js";
|
|
|
391
463
|
children: hostId == null ? 'No leads on any site yet.' : 'No members yet — visitors can join at /signup on your ' + 'site; sign-ups also appear here as leads.'
|
|
392
464
|
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
393
465
|
children: [
|
|
394
|
-
/*#__PURE__*/ _jsx(
|
|
466
|
+
/*#__PURE__*/ _jsx(ListFilterChips, _extends({}, contactFilter.chipsProps)),
|
|
467
|
+
/*#__PURE__*/ _jsx(ListTable, _extends({
|
|
395
468
|
"aria-label": "Site members and leads",
|
|
396
|
-
rows:
|
|
397
|
-
columns: contactColumns
|
|
469
|
+
rows: contactFilter.rows,
|
|
470
|
+
columns: contactFilter.filterColumns(contactColumns)
|
|
471
|
+
}, contactFilter.gridProps, {
|
|
472
|
+
noRowsLabel: "No contacts match these filters",
|
|
398
473
|
// A member and a lead are two collections, so an id alone could
|
|
399
474
|
// name one of each.
|
|
400
475
|
getRowId: (contact)=>`${contact.contactKind}:${contact.$id}`,
|
|
401
476
|
rowHeight: TABLE_ROW_HEIGHT
|
|
402
|
-
}),
|
|
477
|
+
})),
|
|
403
478
|
contactsTruncated ? /*#__PURE__*/ _jsx(Alert, {
|
|
404
479
|
severity: "info",
|
|
405
480
|
sx: {
|
|
@@ -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 {\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 { 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 * 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) => ({ ...member, contactKind: 'member' })),\n ...dedupedLeads.map((lead: any) => ({ ...lead, contactKind: 'lead' })),\n ],\n [siteMembers, dedupedLeads],\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) =>\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 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 valueGetter: (_value: unknown, contact: 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('membersOnly', {\n anchor: '#manage-your-members',\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 <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 {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","ListRowActions","ListTable","listActionsColumn","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","ContactsCard","hostId","orgMount","firestore","data","user","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","memberDocs","idField","orgId","undefined","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","response","method","headers","body","JSON","stringify","memberId","payload","json","ok","error","variant","allowDuplicate","persist","leadOrigin","setLeadOrigin","originSite","first","Array","isArray","capturedByHostIds","leadOriginSite","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","mb","aria-label","rows","columns","getRowId","rowHeight","severity","mt","open","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SACEA,qBAAqB,EACrBC,cAAc,QAET,eAAc;AACrB,2EAA2E;AAC3E,0EAA0E;AAC1E,6EAA6E;AAC7E,wEAAwE;AACxE,SAASC,0BAA0B,QAAQ,8BAA0B;AACrE,8EAA8E;AAC9E,8EAA8E;AAC9E,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SACEC,oBAAoB,EACpBC,uBAAuB,EACvBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,WAAW,EAAEC,OAAO,EAAEC,sBAAsB,QAAQ,uBAAsB;AACnF,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAE7D,SAASC,gBAAgB,QAAQ,8CAA6C;AAC9E,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SACEC,YAAY,EACZC,sBAAsB,EACtBC,eAAe,EACfC,OAAO,QACF,iCAAgC;AACvC,SACEC,KAAK,EACLC,MAAM,EACNC,IAAI,EACJC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,WAAW,EACXC,KAAK,EACLC,UAAU,QACL,gBAAe;AAEtB,SAASC,UAAU,EAAEC,KAAK,EAAEC,OAAO,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7E,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,qBAAqB,QAAQ,gCAA4B;AAClE,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,YAAY,QAAQ,uBAAmB;AAEhD;;;;;;CAMC,GACD,MAAMC,kBAAkB;AAExB;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,aAAa,EAC3BC,MAAM,EACNC,QAAQ,EAKT;;IACC,MAAMC,YAAY5B;IAClB,MAAM,EAAE6B,MAAMC,IAAI,EAAE,GAAG3B;IACvB,MAAM,EAAE4B,eAAe,EAAE,GAAGjC;IAC5B,MAAM,EAAEkC,OAAO,EAAE,GAAGvC;IACpB,sEAAsE;IACtE,oEAAoE;IACpE,MAAMwC,eAAeZ;IACrB,MAAMa,WAAW,CAACC,UAChBF,eACI9C,iBAAiB,QAAQ8C,cAAcG,OAAOD,QAAQE,GAAG,KACzD;IAEN;;;;;;;;;;;;;;;;;;;;;;;;6CAwB2C,GAC3C,MAAM,EAAER,MAAMS,UAAU,EAAE,GAAGrC,uBAC3B,IACEyB,SACIV,MACEH,WAAWe,WAAW,SAASF,QAAQ,gBACvCX,QAAQ,aAAa,SACrBD,MAAMU,kBAAkB,MAE1B,MACN;QAACI;QAAWF;KAAO,EACnB;QAAEa,SAAS;IAAM;IAEnB;;;;GAIC,GACD,MAAM,EAAEC,KAAK,EAAE,GAAGtC,gBAAgB;QAChCwB,MAAM,EAAEA,iBAAAA,SAAUe;QAClBD,OAAOd,UAAU,OAAOC,4BAAAA,SAAUa,KAAK,GAAGC;IAC5C;IACA,MAAMC,cAAc,CAACJ,qBAAAA,aAAc,EAAE,EAAEK,KAAK,CAAC,GAAGnB;IAChD,MAAM,EAAEK,MAAMe,QAAQ,EAAE,GAAG3C,uBACzB,IACEuC,QACIxB,MACEH,WAAWe,WAAW,QAAQY,OAAO,UACrC;;;;;aAKC,MACGd,SACA;YACET,MACE,aACA,sBACAK,mBAAmBI;SAEtB,GACD,EAAE,EACNX,QAAQ,aAAa,SACrBD,MAAMU,kBAAkB,MAE1B,MACN;QAACI;QAAWY;QAAOd;KAAO,EAC1B;QAAEa,SAAS;IAAM;IAEnB,MAAMM,QAAQ,CAACD,mBAAAA,WAAY,EAAE,EAAED,KAAK,CAAC,GAAGnB;IACxC,MAAMsB,oBACJ,SAACR,8BAAAA,WAAYS,MAAM,mBAAI,KAAKvB,mBAC5B,UAACoB,4BAAAA,SAAUG,MAAM,oBAAI,KAAKvB;IAC5B;;;;;;;;;;;;;GAaC,GACD,MAAMwB,eAAe7B,QAAQ;QAC3B,MAAM8B,aAAa,IAAIC,IACrBR,YACGS,GAAG,CAAC,CAACC,SAAgBpE,sBAAsBoE,OAAOC,KAAK,GACvDC,MAAM,CAACC;QAEZ,OAAOV,MAAMS,MAAM,CACjB,CAACE,OAAc,CAACP,WAAWQ,GAAG,CAACzE,sBAAsBwE,KAAKH,KAAK;IAEnE,GAAG;QAACR;QAAOH;KAAY;IACvB;;;;;;GAMC,GACD,MAAMgB,WAAWvC,QACf,IAAM;eACDuB,YAAYS,GAAG,CAAC,CAACC,SAAiB,aAAKA;oBAAQO,aAAa;;eAC5DX,aAAaG,GAAG,CAAC,CAACK,OAAe,aAAKA;oBAAMG,aAAa;;SAC7D,EACD;QAACjB;QAAaM;KAAa;IAE7B;;;;;;GAMC,GACD,MAAMY,qBAAqB1C,YACzB,CAACkC,SAAgB;YACf,wEAAwE;YACxE,IAAI,CAAC1B,QAAQ;YACb,MAAMmC,YAAY,MAAM7B,QAAQ;gBAC9B8B,OAAO;gBACPC,aAAa,CAAC,CAAC,EAAEX,OAAOC,KAAK,CAAC,qCAAqC,CAAC;gBACpEW,kBAAkB;gBAClBC,yBAAyB;oBAAEC,OAAO;gBAAQ;YAC5C,GACGC,IAAI,CAAC,IAAM,MACXC,KAAK,CAAC,IAAM;YACf,IAAI,CAACP,WAAW;YAChB,IAAI;gBACF,MAAMQ,WAAW,MAAMtE,gBAAgB+B,MAAM,gCAAgC;oBAC3EwC,QAAQ;oBACRC,SAAS;wBAAE,gBAAgB;oBAAmB;oBAC9CC,MAAMC,KAAKC,SAAS,CAAC;wBAAEhD;wBAAQiD,UAAUvB,OAAOf,GAAG;oBAAC;gBACtD;gBACA,MAAMuC,UAAU,MAAMP,SAASQ,IAAI,GAAGT,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;gBACpD,IAAI,CAACC,SAASS,EAAE,EAAE;;oBAChB,OAAO,KAAK/C,wBAAgB6C,2BAAAA,QAASG,KAAK,mBAAI,8BAA8B;wBAC1EC,SAAS;wBACTC,gBAAgB;oBAClB;gBACF;gBACAlD,gBAAgB,kBAAkB;oBAAEiD,SAAS;oBAAWE,SAAS;gBAAM;YACzE,EAAE,eAAM;gBACNnD,gBAAgB,8BAA8B;oBAC5CiD,SAAS;oBACTC,gBAAgB;gBAClB;YACF;QACF,GACA;QAACjD;QAASF;QAAMJ;QAAQK;KAAgB;IAG1C;;;;;;;;GAQC,GACD,MAAM,CAACoD,YAAYC,cAAc,GAAGhE,SAAqB;IACzD;;;;;GAKC,GACD,MAAMiE,aAAa,CAAClD;QAClB,IAAIT,QAAQ,OAAOA;QACnB,MAAM4D,QAAQC,MAAMC,OAAO,CAACrD,2BAAAA,QAASsD,iBAAiB,IAClDtD,QAAQsD,iBAAiB,CAAC,EAAE,GAC5B;QACJ,OAAO,OAAOH,UAAU,YAAYA,QAAQA,QAAQ;IACtD;IACA,MAAMI,iBAAiBP,aAAaE,WAAWF,cAAc;IAE7D,MAAMQ,iBAAiB,CAACxD,UACtBA,QAAQwB,WAAW,KAAK,WACpB;YACE;gBACEiC,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAACtG;oBAAQuG,MAAM1G,wBAAwB0G,IAAI;oBAAEC,MAAM;;gBACzDC,aAAa;gBACbC,SAAS,IAAM,KAAKtC,mBAAmBzB;YACzC;SACD,GACD;YACE,oEAAoE;YACpE,2DAA2D;eACvDD,SAASC,WACT;gBACE;oBACEyD,KAAK;oBACLC,OAAO;oBACPC,oBAAM,KAACtG;wBAAQuG,MAAM3G,qBAAqB2G,IAAI;wBAAEC,MAAM;;oBACtDG,MAAMjE,SAASC;gBACjB;aACD,GACD,EAAE;YACN,+DAA+D;eAC3DkD,WAAWlD,WACX;gBACE;oBACEyD,KAAK;oBACLC,OAAO;oBACPC,oBAAM,KAACtG;wBAAQuG,MAAMzG,mBAAmByG,IAAI;wBAAEC,MAAM;;oBACpDE,SAAS,IAAMd,cAAcjD;gBAC/B;aACD,GACD,EAAE;SACP;IACP,MAAMiE,iBAA+B;QACnC;YACEC,OAAO;YACPC,YAAY;YACZC,MAAM;YACNC,UAAU;YACV;;;;OAIC,GACDC,YAAY,CAAC,EAAEC,KAAKvE,OAAO,EAAE;gBAC3B,MAAMwE,OACJxE,QAAQwB,WAAW,KAAK,WAAWxB,QAAQyE,WAAW,GAAGzE,QAAQwE,IAAI;gBACvE,qBACE,MAAChG;oBAAMkG,WAAU;oBAAMC,SAAS;oBAAGC,IAAI;wBAAEC,YAAY;wBAAYR,UAAU;oBAAE;;sCAC3E,KAAC5F;4BAAWoE,SAAQ;4BAAQiC,MAAM;sCAC/B9E,QAAQkB,KAAK;;wBAEfsD,qBACC,KAAC/F;4BAAWoE,SAAQ;4BAAUd,OAAM;4BAAiB+C,MAAM;sCACxDN;6BAED;;;YAGV;QACF;QACA;YACE;;;;;;OAMC,GACDN,OAAO;YACPC,YAAY;YACZY,OAAO;YACPC,aAAa,CAACC,QAAQjF,UACpBA,QAAQwB,WAAW,KAAK,WACpB,WACAxB,QAAQkF,MAAM,GACZ,CAAC,OAAO,EAAElF,QAAQkF,MAAM,EAAE,GAC1B;YACRZ,YAAY,CAAC,EAAEC,KAAKvE,OAAO,EAAEmF,KAAK,EAAE,GAClCnF,QAAQwB,WAAW,KAAK,yBACtB,KAACrD;oBAAKuF,OAAOyB;oBAAOpD,OAAM;oBAAU8B,MAAK;mCAEzC,KAAC1F;oBAAKuF,OAAOyB;oBAAOtB,MAAK;oBAAQhB,SAAQ;;QAE/C;QACA;;;;KAIC,MACGtD,UAAU,OACV;YACE;gBACE2E,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVW,aAAa,CAACC,QAAiBjF,UAC7BZ,aAAaI,UAAUQ,QAAQsD,iBAAiB;YACpD;SACD,GACD,EAAE;QACN;YACEY,OAAO;YACPC,YAAY;YACZY,OAAO;YACP,kDAAkD;YAClDC,aAAa,CAACC,QAAQjF;;oBAAYA,mCAAAA,2BAAAA,4BAAAA;gCAAAA,qBAAAA,QAAQoF,SAAS,sBAAjBpF,6BAAAA,mBAAmBqF,MAAM,sBAAzBrF,4BAAAA,gCAAAA,yCAAAA,oCAAAA,0BAA+BsF,OAAO,qBAAtCtF,uCAAAA,6CAA8C;;YAChFsE,YAAY,CAAC,EAAEC,KAAKvE,OAAO,EAAE;;oBAC3BA,2BAAAA;gCAAAA,qBAAAA,QAAQoF,SAAS,sBAAjBpF,4BAAAA,mBAAmBqF,MAAM,qBAAzBrF,+BAAAA,oBAA8BuF,cAAc,qBAAM;;QACtD;QACA9H,kBACE,CAACuC;gBAEiBA;iCADhB,KAACzC;gBACCmG,OAAOzD,QAAOD,iBAAAA,QAAQkB,KAAK,YAAblB,iBAAiBA,QAAQE,GAAG;gBAC1CsF,OAAOhC,eAAexD;;WAG1B;YAAE+E,OAAO;QAAG;KAEf;IAED,qBACE;;0BACE,MAAC3H;gBACCqI,QAAQlG,UAAU,OAAO,UAAU;gBACnCmG,MAAM5I,eAAe,eAAe;oBAClC6I,QAAQ;gBACV;gBACAC,cAAc;gBACdC,cAAc;gBACdC,iBAAgB;;oBAEfvG,UAAU,qBACT,KAACd;wBACCoE,SAAQ;wBACRd,OAAM;wBACN6C,IAAI;4BAAEmB,IAAIrF,MAAME,MAAM,GAAG,IAAI;wBAAE;kCAE9B,mEACC;yBAEF;oBACHL,YAAYK,MAAM,KAAK,KAAKF,MAAME,MAAM,KAAK,kBAC5C,KAACnC;wBAAWoE,SAAQ;wBAAQd,OAAM;kCAC/BxC,UAAU,OACP,8BACA,2DACA;uCAGN;;0CACE,KAAC/B;gCACCwI,cAAW;gCACXC,MAAM1E;gCACN2E,SAASjC;gCACT,gEAAgE;gCAChE,oBAAoB;gCACpBkC,UAAU,CAACnG,UAAiB,GAAGA,QAAQwB,WAAW,CAAC,CAAC,EAAExB,QAAQE,GAAG,EAAE;gCACnEkG,WAAW1I;;4BAEZiD,kCACC,KAAC1C;gCAAMoI,UAAS;gCAAOzB,IAAI;oCAAE0B,IAAI;gCAAE;0CAChC/G,UAAU,OACP,CAAC,WAAW,EAAEF,gBAAgB,oBAAoB,CAAC,GACnD,sDACA,+BACA,CAAC,WAAW,EAAEA,gBAAgB,wBAAwB,CAAC,GACvD,GAAGA,gBAAgB,kCAAkC,CAAC,GACtD,oDACA;iCAEJ;;;;;0BAYV,MAACjB;gBACCmI,MAAMnF,QAAQ4B;gBACdwD,SAAS,IAAMvD,cAAc;gBAC7BwD,UAAS;gBACTC,SAAS;;kCAET,KAACnI;2CAAayE,8BAAAA,WAAY9B,KAAK,oBAAI;;kCACnC,KAAC5C;kCACE0E,CAAAA,8BAAAA,WAAY9C,GAAG,KAAIqD,+BAClB,KAACxG;4BACCwC,QAAQgE;4BACRoD,YAAW;4BACXC,UAAU3G,OAAO+C,WAAW9C,GAAG;6BAE/B;;kCAEN,KAAC7B;kCACC,cAAA,KAACH;4BAAO2E,SAAQ;4BAAYkB,SAAS,IAAMd,cAAc;sCACtD;;;;;;;AAMb;AACA3D,aAAamF,WAAW,GAAG;AAE3B,eAAenF,aAAY"}
|
|
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('membersOnly', {\n anchor: '#manage-your-members',\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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"}
|
|
@@ -27,12 +27,15 @@ import { InboxRecordAttributionZone } from "./inbox-attribution-zone.js";
|
|
|
27
27
|
import { pluginRecordByEmailHref, pluginRecordListHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
|
|
28
28
|
import { mdiAccountArrowRight, mdiDeleteOutline, mdiEmailOpenOutline, mdiEmailOutline } from "@aglyn/shared-data-mdi";
|
|
29
29
|
import { CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
30
|
+
import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
|
|
30
31
|
import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
|
|
31
32
|
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";
|
|
32
34
|
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";
|
|
33
36
|
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
34
37
|
import { useFirestore, useFirestoreCollection, usePagedCollection } from "@aglyn/tenant-feature-instance";
|
|
35
|
-
import { Avatar, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider,
|
|
38
|
+
import { Avatar, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, Stack, Tooltip, Typography } from "@mui/material";
|
|
36
39
|
import { collection, collectionGroup, deleteDoc, doc, getDoc, limit, orderBy, query, updateDoc, where } from "firebase/firestore";
|
|
37
40
|
import { useSearchParams } from "next/navigation";
|
|
38
41
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
@@ -41,9 +44,64 @@ import { orgSiteName } from "./inbox-org-sites.js";
|
|
|
41
44
|
import SubmissionListAssignment from "./submission-list-assignment.component.js";
|
|
42
45
|
import SubmissionReply from "./submission-reply.component.js";
|
|
43
46
|
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'
|
|
88
|
+
];
|
|
89
|
+
const SUBMISSION_HIDDEN_COLUMNS = {
|
|
90
|
+
read: false,
|
|
91
|
+
formId: false
|
|
92
|
+
};
|
|
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
|
+
});
|
|
101
|
+
};
|
|
44
102
|
export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
45
103
|
var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
|
|
46
|
-
var _reader_createdAt_toDate, _reader_createdAt;
|
|
104
|
+
var _clauses_find, _reader_createdAt_toDate, _reader_createdAt;
|
|
47
105
|
const firestore = useFirestore();
|
|
48
106
|
const { enqueueSnackbar } = useSnackbar();
|
|
49
107
|
const { confirm } = useConfirmationContext();
|
|
@@ -100,20 +158,23 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
100
158
|
formDocs
|
|
101
159
|
]);
|
|
102
160
|
/*
|
|
103
|
-
*
|
|
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.
|
|
104
164
|
*
|
|
105
|
-
*
|
|
106
|
-
* a dep changes, and an object identity would tear down
|
|
107
|
-
* render. It also resets the reader to page 1, which
|
|
108
|
-
* subjects should do.
|
|
109
|
-
*/ const [
|
|
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;
|
|
110
171
|
/**
|
|
111
172
|
* The form the query is actually narrowed to.
|
|
112
173
|
*
|
|
113
|
-
* The scope wins over the
|
|
114
|
-
*
|
|
115
|
-
* a filter with no control — reachable only by a state change
|
|
116
|
-
* screen can cause, and unclearable if one ever could.
|
|
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.
|
|
117
178
|
*/ const activeForm = formId != null ? formId : formFilter;
|
|
118
179
|
/*
|
|
119
180
|
* The inbox WALKS its submissions instead of sampling them (AGL-2501,
|
|
@@ -132,7 +193,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
132
193
|
* that creates one and stamps `createdAt: serverTimestamp()` on every add,
|
|
133
194
|
* the v1 API only ever reads and deletes, and `formSubmissions` is absent
|
|
134
195
|
* from `IMPORTABLE_FIELDS`, so no restore path can make one without it.
|
|
135
|
-
*/ const { rows:
|
|
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
|
|
136
197
|
// `cloud/firebase-firestore.indexes.json`, which must be deployed
|
|
137
198
|
// before this ships — without it Firestore refuses the query
|
|
138
199
|
// rather than answering it slowly.
|
|
@@ -160,6 +221,66 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
160
221
|
], {
|
|
161
222
|
idField: '$id'
|
|
162
223
|
});
|
|
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
|
+
const submissionOptions = useMemo(()=>{
|
|
238
|
+
var _ref;
|
|
239
|
+
return _extends({
|
|
240
|
+
read: READ_OPTIONS
|
|
241
|
+
}, offersForm ? {
|
|
242
|
+
formId: forms.map((form)=>({
|
|
243
|
+
value: String(form.$id),
|
|
244
|
+
label: String(form.displayName || form.$id)
|
|
245
|
+
}))
|
|
246
|
+
} : {}, hostId == null ? {
|
|
247
|
+
hostId: ((_ref = orgMount == null ? void 0 : orgMount.hosts) != null ? _ref : []).map((host)=>({
|
|
248
|
+
value: host.id,
|
|
249
|
+
label: host.name || host.id
|
|
250
|
+
}))
|
|
251
|
+
} : {});
|
|
252
|
+
}, [
|
|
253
|
+
offersForm,
|
|
254
|
+
forms,
|
|
255
|
+
hostId,
|
|
256
|
+
orgMount
|
|
257
|
+
]);
|
|
258
|
+
const submissionWindow = useMemo(()=>submissionData == null ? void 0 : submissionData.map(withFilterValues), [
|
|
259
|
+
submissionData
|
|
260
|
+
]);
|
|
261
|
+
const submissionPageRows = useMemo(()=>submissionRows.map(withFilterValues), [
|
|
262
|
+
submissionRows
|
|
263
|
+
]);
|
|
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;
|
|
163
284
|
// Mail reader (AGL-104): opening a submission shows the full message and
|
|
164
285
|
// marks it read.
|
|
165
286
|
const [reader, setReader] = useState(null);
|
|
@@ -312,7 +433,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
312
433
|
headerName: 'From',
|
|
313
434
|
flex: 1,
|
|
314
435
|
minWidth: 220,
|
|
315
|
-
valueGetter: (_value, submission)=>
|
|
436
|
+
valueGetter: (_value, submission)=>submission.senderLabel,
|
|
316
437
|
renderCell: ({ row: submission })=>{
|
|
317
438
|
var _submission_formName;
|
|
318
439
|
const sender = submissionSender(submission.fields);
|
|
@@ -380,7 +501,9 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
380
501
|
headerName: 'Site',
|
|
381
502
|
flex: 1,
|
|
382
503
|
minWidth: 140,
|
|
383
|
-
|
|
504
|
+
// Sorted and drawn by name; the filter matches the id.
|
|
505
|
+
valueGetter: (_value, submission)=>orgSiteName(orgMount, submission.hostId),
|
|
506
|
+
renderCell: ({ row: submission })=>orgSiteName(orgMount, submission.hostId)
|
|
384
507
|
}
|
|
385
508
|
] : [],
|
|
386
509
|
{
|
|
@@ -388,10 +511,7 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
388
511
|
headerName: 'Message',
|
|
389
512
|
flex: 2,
|
|
390
513
|
minWidth: 260,
|
|
391
|
-
valueGetter: (_value, submission)=>
|
|
392
|
-
var _submission_fields;
|
|
393
|
-
return Object.entries((_submission_fields = submission.fields) != null ? _submission_fields : {}).map(([key, value])=>`${key}: ${value}`).join(' · ');
|
|
394
|
-
},
|
|
514
|
+
valueGetter: (_value, submission)=>submission.messageText,
|
|
395
515
|
renderCell: ({ value })=>/*#__PURE__*/ _jsx(Typography, {
|
|
396
516
|
variant: "body2",
|
|
397
517
|
noWrap: true,
|
|
@@ -454,27 +574,14 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
454
574
|
contentGutterY: true,
|
|
455
575
|
contentBordered: "all",
|
|
456
576
|
children: [
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
value: formFilter != null ? formFilter : '',
|
|
462
|
-
onChange: (event)=>setFormFilter(event.target.value || null),
|
|
463
|
-
helperText: formsTruncated ? `Showing the first ${FORMS_MAX_PER_HOST.toLocaleString()} ` + 'forms. Narrow by form is incomplete; All forms still ' + 'covers every submission.' : undefined,
|
|
577
|
+
offersForm && formsTruncated ? /*#__PURE__*/ _jsx(Typography, {
|
|
578
|
+
variant: "caption",
|
|
579
|
+
color: "text.secondary",
|
|
580
|
+
component: "p",
|
|
464
581
|
sx: {
|
|
465
|
-
mb:
|
|
466
|
-
minWidth: 240
|
|
582
|
+
mb: 1
|
|
467
583
|
},
|
|
468
|
-
children:
|
|
469
|
-
/*#__PURE__*/ _jsx(MenuItem, {
|
|
470
|
-
value: "",
|
|
471
|
-
children: 'All forms'
|
|
472
|
-
}),
|
|
473
|
-
forms.map((form)=>/*#__PURE__*/ _jsx(MenuItem, {
|
|
474
|
-
value: form.$id,
|
|
475
|
-
children: form.displayName || form.$id
|
|
476
|
-
}, form.$id))
|
|
477
|
-
]
|
|
584
|
+
children: `The Form filter offers the first ${FORMS_MAX_PER_HOST.toLocaleString()} ` + 'forms. Narrow by form is incomplete; with no Form filter the ' + 'list still covers every submission.'
|
|
478
585
|
}) : null,
|
|
479
586
|
hostId == null ? /*#__PURE__*/ _jsx(Typography, {
|
|
480
587
|
variant: "body2",
|
|
@@ -484,16 +591,34 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
484
591
|
},
|
|
485
592
|
children: 'Every site’s submissions, newest first. Choose a site to ' + 'narrow them by form.'
|
|
486
593
|
}) : null,
|
|
487
|
-
|
|
594
|
+
submissionRows.length === 0 && !hasMoreSubmissions && !submissionFilter.filtering ? /*#__PURE__*/ _jsx(Typography, {
|
|
488
595
|
variant: "body2",
|
|
489
596
|
color: "text.secondary",
|
|
490
|
-
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.' :
|
|
597
|
+
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.'
|
|
491
598
|
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
492
599
|
children: [
|
|
493
|
-
/*#__PURE__*/ _jsx(
|
|
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({
|
|
494
613
|
"aria-label": scoped ? 'Submissions to this form' : 'Form submissions',
|
|
495
614
|
rows: submissions,
|
|
496
|
-
columns: submissionColumns,
|
|
615
|
+
columns: submissionFilter.filterColumns(submissionColumns),
|
|
616
|
+
initialState: {
|
|
617
|
+
columns: {
|
|
618
|
+
columnVisibilityModel: SUBMISSION_HIDDEN_COLUMNS
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
noRowsLabel: onlyForm ? 'No submissions for this form yet. Submissions sent before ' + 'the form was created are listed with no Form filter.' : 'No submissions match these filters',
|
|
497
622
|
rowHeight: TABLE_ROW_HEIGHT,
|
|
498
623
|
onOpen: (_id, submission)=>handleOpenReader(submission)(),
|
|
499
624
|
// An unread submission reads bold across its row, which is why
|
|
@@ -506,15 +631,8 @@ export function SubmissionsCard({ hostId, formId, orgMount }) {
|
|
|
506
631
|
},
|
|
507
632
|
// Paged by the footer below, so the grid must not also slice.
|
|
508
633
|
hideFooter: true
|
|
509
|
-
}),
|
|
510
|
-
/*#__PURE__*/ _jsx(ListPagination, {
|
|
511
|
-
page: submissionPage,
|
|
512
|
-
pageSize: submissionPageSize,
|
|
513
|
-
rowCount: submissions.length,
|
|
514
|
-
hasMore: hasMoreSubmissions,
|
|
515
|
-
onPageChange: setSubmissionPage,
|
|
516
|
-
onPageSizeChange: setSubmissionPageSize
|
|
517
|
-
})
|
|
634
|
+
}, submissionFilter.gridProps)),
|
|
635
|
+
/*#__PURE__*/ _jsx(ListPagination, _extends({}, submissionFilter.pagination))
|
|
518
636
|
]
|
|
519
637
|
})
|
|
520
638
|
]
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/submissions-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 FORMS_MAX_PER_HOST,\n INBOX_SUBMISSION_PARAM,\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, for the reason above: the barrel\n// is the plugin's site entry point.\nimport {\n pluginRecordByEmailHref,\n pluginRecordListHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiDeleteOutline,\n mdiEmailOpenOutline,\n mdiEmailOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport 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 usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Avatar,\n Box,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Divider,\n MenuItem,\n Stack,\n TextField,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n collectionGroup,\n deleteDoc,\n doc,\n getDoc,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useSearchParams } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport {\n relativeTime,\n routingChips,\n senderHue,\n submissionSender,\n} from '../model/submission-presenter'\nimport { orgSiteName } from './inbox-org-sites'\nimport SubmissionListAssignment from './submission-list-assignment.component'\nimport SubmissionReply from './submission-reply.component'\nimport { useRecordRouteContext } from './use-record-route-context'\n\n/**\n * The Submissions section of the Inbox (AGL-77/104/109 → AGL-395): the form\n * messages a site collected, newest first, and the reader that opens one.\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 interface SubmissionsCardProps {\n /**\n * The site whose submissions are listed, or `null` on the organization's\n * Inbox, where the card lists every site's at once (AGL-3303) and each row\n * is acted on as the site it was sent to.\n */\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n /**\n * Narrow this card to ONE form, permanently.\n *\n * What the forms plugin's detail surface renders instead of a second\n * submissions table. The reader has already chosen the subject by being on\n * that page, so scoping here does three things a copy would have had to\n * re-derive: the form picker is not rendered (there is nothing to pick),\n * the site's `forms` collection is not read at all (the picker was its only\n * consumer), and the empty state names the form rather than the site.\n *\n * Everything else — the paged walk, the reader dialog, read/unread,\n * delete, reply, list assignment, attribution — is the same code answering\n * a narrower query. That is the reason this is a prop rather than a second\n * component: a per-form table written separately would be a second reader\n * to keep in step, and it would have been the one to reintroduce the\n * unordered `limit()` this card's own comment exists to warn about.\n */\n formId?: string\n}\n\nexport function SubmissionsCard({\n hostId,\n formId,\n orgMount,\n}: SubmissionsCardProps) {\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n /** Scoped to one form: the subject is fixed and nothing may widen it. */\n const scoped = Boolean(formId)\n /** Every site of this organization at once, when no site is named. */\n const orgId = hostId == null ? (orgMount?.orgId ?? null) : null\n /*\n * The site a row lives on, which is where every act on it is addressed:\n * this card's own site, or — across every site — the `hostId` the submit\n * route stamps on the row. That stamp is frozen against a client update and\n * written from the row's own path, so it names the document's real parent;\n * the rules check the host role on that site either way.\n */\n const siteOf = useCallback(\n (submission: any): string | null =>\n hostId ??\n (typeof submission?.hostId === 'string' && submission.hostId\n ? submission.hostId\n : null),\n [hostId],\n )\n /*\n * Where the sender's CONTACT is (AGL-2612). A submission that carried an\n * address updated a contact in the CRM at stage Lead; the row links to\n * it by that address, and the Contacts list — which holds the id nothing\n * here does — opens the record. The same hub path the Members & leads\n * rows use to open a lead.\n */\n const routeContext = useRecordRouteContext()\n\n /*\n * The site's forms, for the Submissions filter.\n *\n * `FORMS_MAX_PER_HOST` is a read WINDOW, not a cap on the collection — how\n * many forms a site may hold is `formsPerHost`, enforced at the create in\n * `/api/hosts/resources`, and a staff-set per-org override can raise it\n * past this window. So the window can be smaller than the catalog, and one\n * more document than fits is read on purpose: a filter that quietly listed\n * the first N would report \"this form does not exist\" and \"this form is\n * past the window\" as the same empty answer.\n *\n * Ordered by `__name__`. `displayName` would be the nicer order and is the\n * wrong instrument: `orderBy` on a data field DROPS every document missing\n * it, invisibly, so a form saved without a name would vanish from its own\n * filter. The list is sorted for display below, where a missing name is\n * merely ugly.\n */\n const { data: formDocs } = useFirestoreCollection<any>(\n () =>\n // Forms are one site's catalog. Across every site there is no single\n // catalog to filter by, and picking a site is how a reader gets one.\n scoped || !hostId\n ? null\n : query(\n collection(firestore, 'hosts', hostId, 'forms'),\n orderBy('__name__'),\n limit(FORMS_MAX_PER_HOST + 1),\n ),\n [firestore, hostId, scoped],\n { idField: '$id' },\n )\n /** More forms exist than the window shows; the filter has to say so. */\n const formsTruncated = (formDocs?.length ?? 0) > FORMS_MAX_PER_HOST\n const forms = useMemo(\n () =>\n [...(formDocs ?? [])]\n .slice(0, FORMS_MAX_PER_HOST)\n .sort((left: any, right: any) =>\n String(left.displayName ?? left.$id).localeCompare(\n String(right.displayName ?? right.$id),\n ),\n ),\n [formDocs],\n )\n /*\n * `null` is \"All forms\"; a form id narrows to one form's submissions.\n *\n * A PRIMITIVE, deliberately: `usePagedCollection` reopens its listener when\n * a dep changes, and an object identity would tear down and reopen on every\n * render. It also resets the reader to page 1, which is what switching\n * subjects should do.\n */\n const [formFilter, setFormFilter] = useState<string | null>(null)\n /**\n * The form the query is actually narrowed to.\n *\n * The scope wins over the picker rather than seeding it. A scoped card\n * renders no picker, so a `formFilter` that could outrank `formId` would be\n * a filter with no control — reachable only by a state change nothing on\n * screen can cause, and unclearable if one ever could.\n */\n const activeForm = formId ?? formFilter\n\n /*\n * The inbox WALKS its submissions instead of sampling them (AGL-2501,\n * AGL-2292).\n *\n * `limit(200)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids `add()` generates — an arbitrary two hundred\n * of the site's messages, which the client sort then arranged newest-first\n * so the result looked like a feed. A site past two hundred submissions\n * could not reach the rest, and the messages missing left no gap: the row\n * dates on screen simply skipped, which reads as a quiet week rather than\n * as an unreachable inbox.\n *\n * `createdAt` is safe to order on, checked against the writer rather than\n * assumed: `apps/tenant/app/api/forms/submit/route.ts` is the only path\n * that creates one and stamps `createdAt: serverTimestamp()` on every add,\n * the v1 API only ever reads and deletes, and `formSubmissions` is absent\n * from `IMPORTABLE_FIELDS`, so no restore path can make one without it.\n */\n const {\n rows: submissions,\n hasMore: hasMoreSubmissions,\n page: submissionPage,\n setPage: setSubmissionPage,\n pageSize: submissionPageSize,\n setPageSize: setSubmissionPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'formSubmissions'),\n // Served by the `formId ASC, createdAt DESC` composite index in\n // `cloud/firebase-firestore.indexes.json`, which must be deployed\n // before this ships — without it Firestore refuses the query\n // rather than answering it slowly.\n ...(activeForm ? [where('formId', '==', activeForm)] : []),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n )\n : orgId\n ? query(\n /*\n * EVERY SITE'S SUBMISSIONS IN ONE BOUNDED QUERY (AGL-3303), not\n * a listener per site: the same page-plus-probe window, over\n * the collection group, newest first.\n *\n * The `orgId` clause is not a filter this card could drop. The\n * rules admit a group read only when it is narrowed to an org\n * the reader spans, so an unfiltered group query is refused\n * outright. Served by the `orgId ASC, createdAt DESC`\n * COLLECTION_GROUP index. Every submission carries `orgId`: the\n * submit route stamps it, and the rows written before it did\n * were stamped once (AGL-3303). A row without one would be in no\n * organization's list, though its own site's Inbox lists it\n * either way.\n */\n collectionGroup(firestore, 'formSubmissions'),\n where('orgId', '==', orgId),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n )\n : null,\n [firestore, hostId, orgId, activeForm],\n { idField: '$id' },\n )\n\n // Mail reader (AGL-104): opening a submission shows the full message and\n // marks it read.\n const [reader, setReader] = useState<any | null>(null)\n const handleOpenReader = useCallback(\n (submission: any) => () => {\n setReader(submission)\n const site = siteOf(submission)\n if (!submission.read && site) {\n void updateDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n { read: true },\n )\n }\n },\n [firestore, siteOf],\n )\n\n const handleToggleRead = useCallback(\n (submission: any) => () => {\n const site = siteOf(submission)\n if (!site) return\n void updateDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n { read: !submission.read },\n )\n },\n [firestore, siteOf],\n )\n\n /*\n * A submission named in the URL opens in the reader on arrival (AGL-2622).\n *\n * A contact's timeline names the submission that captured the person, and\n * the address it links to is this tab with `?submission={id}`. The\n * document is read once, by id, rather than looked for in the paged\n * window: the window is the newest page of a walk that may not reach a\n * submission from months back, and one document read is what the URL\n * asked for. Opening it marks it read, as a click on the row would. A\n * submission that is gone says so, because a reader that silently stays\n * shut reads as the link having done nothing.\n *\n * Once per id: the ref keeps a re-render — and the same URL re-settling —\n * from reopening a reader the person has since closed. The landing read\n * is judged against that ref rather than an effect cleanup: a listener\n * settling while the read is in flight re-renders this card, and a\n * cleanup keyed on any dep would cancel the answer to the question the\n * URL just asked.\n */\n const searchParams = useSearchParams()\n const seededSubmissionId = searchParams?.get(INBOX_SUBMISSION_PARAM) ?? null\n const seededOpened = useRef<string | null>(null)\n useEffect(() => {\n // A submission link names one site's Inbox; the org's has no site to read\n // the id under.\n if (!hostId) return\n if (!seededSubmissionId || seededOpened.current === seededSubmissionId) return\n seededOpened.current = seededSubmissionId\n void getDoc(doc(firestore, 'hosts', hostId, 'formSubmissions', seededSubmissionId))\n .then((snapshot) => {\n if (seededOpened.current !== snapshot.id) return\n if (!snapshot.exists()) {\n enqueueSnackbar('That submission is no longer in the Inbox.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n handleOpenReader({ ...snapshot.data(), $id: snapshot.id })()\n })\n .catch(() => undefined)\n }, [seededSubmissionId, firestore, hostId, handleOpenReader, enqueueSnackbar])\n\n const handleDelete = useCallback(\n (submission: any) => async () => {\n const site = siteOf(submission)\n if (!site) return\n const confirmed = await confirm({\n title: 'Delete this submission?',\n description: 'The submission is removed permanently.',\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await deleteDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n )\n enqueueSnackbar('Submission deleted', {\n variant: 'success',\n persist: false,\n })\n },\n [confirm, firestore, siteOf, enqueueSnackbar],\n )\n\n /*\n * One row per submission, newest first: who sent it, what it said and how\n * long ago. The row opens the reader, which also marks it read.\n */\n const submissionActions = (submission: any): RowActionsMenuItem[] => {\n const senderEmail = submissionSender(submission.fields).email\n // Where the sender's contact is read, asked of whichever plugin keeps\n // people; with none loaded there is no such page, and no menu item.\n const contactsHref = routeContext\n ? pluginRecordListHref('contact', routeContext)\n : null\n return [\n ...(routeContext && contactsHref\n ? [\n {\n key: 'crm',\n label: 'Open contact in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n ...(senderEmail\n ? {\n href:\n pluginRecordByEmailHref(\n 'contact',\n routeContext,\n senderEmail,\n ) ?? contactsHref,\n }\n : {\n disabled: true,\n disabledReason:\n 'This submission carried no email address, so no contact was updated.',\n }),\n },\n ]\n : []),\n {\n key: 'read',\n label: submission.read ? 'Mark unread' : 'Mark read',\n icon: (\n <MdiIcon\n path={submission.read ? mdiEmailOutline.path : mdiEmailOpenOutline.path}\n size={0.8}\n />\n ),\n onClick: handleToggleRead(submission),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: handleDelete(submission),\n },\n ]\n }\n const submissionColumns: GridColDef[] = [\n {\n /*\n The mockup's list is people, not forms (AGL-2168): an initials avatar,\n the sender, and the form name beneath it. The unread DOT replaces the\n `New` chip — the row is already bold, and a chip that says \"New\" beside\n bold text is the same fact twice.\n */\n field: 'from',\n headerName: 'From',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, submission) => submissionSender(submission.fields).label,\n renderCell: ({ row: submission }) => {\n const sender = submissionSender(submission.fields)\n const hue = senderHue(sender.label)\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n {!submission.read ? (\n <Box\n aria-label=\"Unread\"\n sx={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: 'primary.main',\n flexShrink: 0,\n }}\n />\n ) : (\n <Box sx={{ width: 8, flexShrink: 0 }} />\n )}\n <Avatar\n sx={{\n width: 28,\n height: 28,\n fontSize: 13,\n bgcolor: `hsl(${hue} 55% 45%)`,\n }}\n >\n {sender.initials}\n </Avatar>\n <Stack sx={{ 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 </Stack>\n )\n },\n },\n /*\n Which site it was sent to, on the organization's Inbox only — under a\n site every row is that site's, and a column saying so would be noise.\n */\n ...(hostId == null\n ? [\n {\n field: 'hostId',\n headerName: 'Site',\n flex: 1,\n minWidth: 140,\n valueGetter: (_value: unknown, submission: any) =>\n orgSiteName(orgMount, submission.hostId),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'message',\n headerName: 'Message',\n flex: 2,\n minWidth: 260,\n valueGetter: (_value, submission) =>\n Object.entries(submission.fields ?? {})\n .map(([key, value]) => `${key}: ${value}`)\n .join(' · '),\n renderCell: ({ value }) => (\n <Typography variant=\"body2\" noWrap>\n {value}\n </Typography>\n ),\n },\n {\n /*\n Relative, as the mockup shows it — an inbox is scanned for recency and a\n locale timestamp makes the reader do the subtraction. The absolute time\n stays on the detail dialog, where it is the fact you actually want.\n */\n field: 'createdAt',\n headerName: 'Received',\n width: 130,\n valueGetter: (_value, submission) =>\n submission.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: submission }) => (\n <Tooltip title={submission.createdAt?.toDate?.().toLocaleString() ?? ''}>\n <span>{relativeTime(submission.createdAt?.toDate?.().getTime())}</span>\n </Tooltip>\n ),\n },\n /*\n One overflow menu, as the Members & leads rows have: two inline buttons\n had no room for a third, and the contact link is the one a reader\n reaches for after reading. Present but disabled for a submission with no\n address, with the reason — a row that simply lacked the item would read\n as the contact not existing.\n */\n listActionsColumn(\n (submission) => (\n <ListRowActions\n label={String(submissionSender(submission.fields).email ?? submission.$id)}\n items={submissionActions(submission)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n /** The site the open submission was sent to, which its reader acts as. */\n const readerSite = reader ? siteOf(reader) : null\n\n return (\n <>\n <CardDisplay\n header={scoped ? 'Submissions to this form' : 'Form Submissions'}\n help={pluginDocsHelp(\n 'forms',\n hostId == null\n ? {\n anchor: '#every-sites-inbox-at-once',\n excerpt:\n 'Every site’s form messages in one list, newest first, ' +\n 'each under the site it was sent to.',\n }\n : {\n anchor: '#the-inbox',\n excerpt:\n 'Messages your forms collected, newest first, showing who ' +\n 'sent each one and where it was routed.',\n },\n )}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {/*\n * The Inbox stays the site-wide answer to \"who is waiting for a\n * reply\" — that question does not decompose by form. This narrows\n * it on request; it does not turn the page into a per-form view.\n *\n * Rendered only when the site HAS forms, so a site that has not\n * adopted any sees the page exactly as it was.\n */}\n {/*\n * Withheld when the card is scoped: the subject is settled by the\n * surface this is rendered on, and a picker offering to widen it\n * would be a control that navigates away from the page it is on.\n */}\n {!scoped && forms.length > 0 ? (\n <TextField\n select\n size=\"small\"\n label={'Form'}\n value={formFilter ?? ''}\n onChange={(event) => setFormFilter(event.target.value || null)}\n helperText={\n formsTruncated\n ? `Showing the first ${FORMS_MAX_PER_HOST.toLocaleString()} ` +\n 'forms. Narrow by form is incomplete; All forms still ' +\n 'covers every submission.'\n : undefined\n }\n sx={{ mb: 2, minWidth: 240 }}\n >\n <MenuItem value=\"\">{'All forms'}</MenuItem>\n {forms.map((form: any) => (\n <MenuItem key={form.$id} value={form.$id}>\n {form.displayName || form.$id}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n {hostId == null ? (\n <Typography\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mb: submissions.length ? 2 : 1 }}\n >\n {'Every site’s submissions, newest first. Choose a site to ' +\n 'narrow them by form.'}\n </Typography>\n ) : null}\n {submissions.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {scoped\n ? 'No submissions carry this form’s id yet. Messages this ' +\n 'form’s design collected before it became a form entity are ' +\n 'in the Inbox, filed under the name they were sent with.'\n : activeForm\n ? 'No submissions for this form yet. Submissions sent before ' +\n 'the form was created stay under All forms.'\n : hostId == null\n ? 'No form submissions on any site yet.'\n : 'No form submissions yet. Add a Contact Form element to a ' +\n 'screen — visitor messages arrive here.'}\n </Typography>\n ) : (\n <>\n <ListTable\n aria-label={scoped ? 'Submissions to this form' : 'Form submissions'}\n rows={submissions}\n columns={submissionColumns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, submission) => handleOpenReader(submission)()}\n // An unread submission reads bold across its row, which is why\n // the list needs no `New` chip.\n getRowClassName={({ row }) => (row.read ? '' : 'submission-unread')}\n sx={{\n '& .submission-unread .MuiDataGrid-cell, & .submission-unread .MuiTypography-root':\n { fontWeight: 'fontWeightBold' },\n }}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={submissionPage}\n pageSize={submissionPageSize}\n rowCount={submissions.length}\n hasMore={hasMoreSubmissions}\n onPageChange={setSubmissionPage}\n onPageSizeChange={setSubmissionPageSize}\n />\n </>\n )}\n </CardDisplay>\n <Dialog\n open={Boolean(reader)}\n onClose={() => setReader(null)}\n maxWidth=\"sm\"\n fullWidth\n >\n <DialogTitle>{reader?.formName ?? 'Form submission'}</DialogTitle>\n <DialogContent>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Received ${reader?.createdAt?.toDate?.().toLocaleString() ?? ''}` +\n // The site it was sent to, where the list spans every site.\n (hostId == null && readerSite\n ? ` · ${orgSiteName(orgMount, readerSite)}`\n : '') +\n (reader?.screenId ? ` · screen ${reader.screenId}` : '')}\n {/*\n THE PAGE THE FORM WAS ON. Stored by the submit route since the\n form existed and rendered by nothing, which is also the field\n the marketing console's landing-page grouping joins on — a\n reader who wants to check one row against that grouping has to\n be able to see the row's own page.\n */}\n {reader?.path ? ` · ${String(reader.path)}` : ''}\n </Typography>\n <Divider sx={{ my: 1.5 }} />\n <Stack spacing={1.5}>\n {Object.entries(reader?.fields ?? {}).map(([key, value]) => (\n <Stack key={key} spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {key}\n </Typography>\n <Typography\n variant=\"body2\"\n sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}\n >\n {String(value)}\n </Typography>\n </Stack>\n ))}\n </Stack>\n {/*\n What happened to this submission (AGL-2168). The mockup puts\n these under the fields: `Saved to Inbox` and `Added to \"Leads\"\n dataset`. The second is stamped by the submit route only when a\n record was really appended — a form bound to a deleted dataset,\n or one whose record quota is full, shows no chip rather than a\n chip for a row that does not exist. Both are failures the route\n already swallows silently, and a chip that lied about them\n would be worse than the silence.\n */}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ mt: 2, flexWrap: 'wrap', rowGap: 1 }}\n >\n {routingChips(reader?.routing).map((chip) => (\n <Chip\n key={chip.label}\n size=\"small\"\n label={chip.label}\n color={chip.color}\n variant=\"outlined\"\n />\n ))}\n </Stack>\n {/*\n WHERE THIS SUBMISSION CAME FROM.\n\n One keyed document read — the attribution's id is `form:{id}` and\n the submission's id is that `{id}` — paid when a merchant opens a\n submission rather than once per row of the list. A submission that\n was credited to nobody renders the sentence saying so; it never\n renders a campaign with a zero beside it.\n */}\n {reader?.$id && readerSite ? (\n <Box sx={{ mt: 2 }}>\n <InboxRecordAttributionZone\n hostId={readerSite}\n recordKind=\"form\"\n recordId={String(reader.$id)}\n />\n </Box>\n ) : null}\n {/*\n Answering the person is the act the Inbox exists for, and it is\n inside the reader rather than on the row because a reply written\n without the message in front of you is the reply that answers the\n wrong question. Mounted with the dialog, so its reads — the site\n name and the replies already sent — are paid when a merchant opens\n a submission and not once per visit to this page.\n */}\n {reader && readerSite ? (\n <SubmissionReply hostId={readerSite} submission={reader} />\n ) : null}\n {/*\n Enrolling the sender in a marketing list — a SEPARATE act from\n answering them, and a separate card, because the person asked to\n be answered and did not ask to be marketed to. Its reads are paid\n only when a merchant presses its own button, so opening a\n submission to read it costs nothing extra.\n */}\n {reader && readerSite ? (\n <SubmissionListAssignment hostId={readerSite} submission={reader} />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button\n color=\"error\"\n onClick={async () => {\n const target = reader\n setReader(null)\n if (target) await handleDelete(target)()\n }}\n >\n {'Delete'}\n </Button>\n <Button\n onClick={() => {\n if (reader) void handleToggleRead({ ...reader, read: true })()\n setReader(null)\n }}\n >\n {'Mark unread'}\n </Button>\n <Button variant=\"contained\" onClick={() => setReader(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nSubmissionsCard.displayName = 'SubmissionsCard'\n\nexport default SubmissionsCard\n"],"names":["FORMS_MAX_PER_HOST","INBOX_SUBMISSION_PARAM","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordByEmailHref","pluginRecordListHref","mdiAccountArrowRight","mdiDeleteOutline","mdiEmailOpenOutline","mdiEmailOutline","CardDisplay","MdiIcon","useConfirmationContext","ListPagination","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","useSnackbar","useFirestore","useFirestoreCollection","usePagedCollection","Avatar","Box","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Divider","MenuItem","Stack","TextField","Tooltip","Typography","collection","collectionGroup","deleteDoc","doc","getDoc","limit","orderBy","query","updateDoc","where","useSearchParams","useCallback","useEffect","useMemo","useRef","useState","relativeTime","routingChips","senderHue","submissionSender","orgSiteName","SubmissionListAssignment","SubmissionReply","useRecordRouteContext","SubmissionsCard","hostId","formId","orgMount","reader","firestore","enqueueSnackbar","confirm","scoped","Boolean","orgId","siteOf","submission","routeContext","data","formDocs","idField","formsTruncated","length","forms","slice","sort","left","right","String","displayName","$id","localeCompare","formFilter","setFormFilter","activeForm","rows","submissions","hasMore","hasMoreSubmissions","page","submissionPage","setPage","setSubmissionPage","pageSize","submissionPageSize","setPageSize","setSubmissionPageSize","pageLimit","setReader","handleOpenReader","site","read","handleToggleRead","searchParams","seededSubmissionId","get","seededOpened","current","then","snapshot","id","exists","variant","persist","catch","undefined","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","submissionActions","senderEmail","fields","email","contactsHref","key","label","icon","path","size","href","disabled","disabledReason","onClick","destructive","submissionColumns","field","headerName","flex","minWidth","valueGetter","_value","renderCell","row","sender","hue","direction","spacing","sx","alignItems","aria-label","width","height","borderRadius","bgcolor","flexShrink","fontSize","initials","noWrap","formName","Object","entries","map","value","join","createdAt","toDate","getTime","toLocaleString","span","items","readerSite","header","help","anchor","excerpt","contentGutterX","contentGutterY","contentBordered","select","onChange","event","target","helperText","mb","form","columns","rowHeight","onOpen","_id","getRowClassName","fontWeight","hideFooter","rowCount","onPageChange","onPageSizeChange","open","onClose","maxWidth","fullWidth","screenId","my","whiteSpace","wordBreak","mt","flexWrap","rowGap","routing","chip","recordKind","recordId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/submissions-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 FORMS_MAX_PER_HOST,\n INBOX_SUBMISSION_PARAM,\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, for the reason above: the barrel\n// is the plugin's site entry point.\nimport {\n pluginRecordByEmailHref,\n pluginRecordListHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiDeleteOutline,\n mdiEmailOpenOutline,\n mdiEmailOutline,\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 {\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 type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport {\n inMemoryListField,\n type ListFilterClause,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { usePagedRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-paged-rows-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Avatar,\n Box,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Divider,\n Stack,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n collectionGroup,\n deleteDoc,\n doc,\n getDoc,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useSearchParams } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport {\n relativeTime,\n routingChips,\n senderHue,\n submissionSender,\n} from '../model/submission-presenter'\nimport { orgSiteName } from './inbox-org-sites'\nimport SubmissionListAssignment from './submission-list-assignment.component'\nimport SubmissionReply from './submission-reply.component'\nimport { useRecordRouteContext } from './use-record-route-context'\n\n/*\n * What the submissions grid's Filters panel offers (AGL-3317).\n *\n * Form is SERVED: its clause is the query's `where('formId', '==')`, so it\n * reaches every submission to that form, and the rows are not matched\n * against it again. It is offered only where a site's catalog is read — not\n * on a card scoped to one form, and not across every site. The rest match\n * over what the paged window has read, which the first filter widens\n * (`usePagedRowsFilter`): From and Message (as the columns draw them), Read\n * (`readKey`), and Site on the organization's Inbox.\n */\nconst FORM_FIELD: ListFilterField = {\n ...inMemoryListField('formId', 'select'),\n operators: ['equals'],\n}\nconst SUBMISSION_BASE_FIELDS = [\n inMemoryListField('from', 'text', 'senderLabel'),\n inMemoryListField('message', 'text', 'messageText'),\n inMemoryListField('read', 'select', 'readKey'),\n]\nconst SUBMISSION_FILTER_HEADERS: Readonly<Record<string, string>> = {\n from: 'From',\n message: 'Message',\n read: 'Read',\n formId: 'Form',\n hostId: 'Site',\n}\nconst READ_OPTIONS = [\n { value: 'unread', label: 'Unread' },\n { value: 'read', label: 'Read' },\n]\nconst SUBMISSION_SEARCH_FIELDS = ['senderLabel', 'formName', 'messageText'] as const\nconst SUBMISSION_HIDDEN_COLUMNS = { read: false, formId: false }\n/** The one clause the query serves. */\nconst servedByQuery = (clause: ListFilterClause) => clause.field === 'formId'\n\n/** A submission with the derived values its filters and search read. */\nconst withFilterValues = (submission: any) => ({\n ...submission,\n senderLabel: submissionSender(submission.fields).label,\n messageText: Object.entries(submission.fields ?? {})\n .map(([key, value]) => `${key}: ${value}`)\n .join(' · '),\n readKey: submission.read ? 'read' : 'unread',\n})\n\n/**\n * The Submissions section of the Inbox (AGL-77/104/109 → AGL-395): the form\n * messages a site collected, newest first, and the reader that opens one.\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 interface SubmissionsCardProps {\n /**\n * The site whose submissions are listed, or `null` on the organization's\n * Inbox, where the card lists every site's at once (AGL-3303) and each row\n * is acted on as the site it was sent to.\n */\n hostId: string | null\n /** The organization and its sites — present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n /**\n * Narrow this card to ONE form, permanently.\n *\n * What the forms plugin's detail surface renders instead of a second\n * submissions table. The reader has already chosen the subject by being on\n * that page, so scoping here does three things a copy would have had to\n * re-derive: the form picker is not rendered (there is nothing to pick),\n * the site's `forms` collection is not read at all (the picker was its only\n * consumer), and the empty state names the form rather than the site.\n *\n * Everything else — the paged walk, the reader dialog, read/unread,\n * delete, reply, list assignment, attribution — is the same code answering\n * a narrower query. That is the reason this is a prop rather than a second\n * component: a per-form table written separately would be a second reader\n * to keep in step, and it would have been the one to reintroduce the\n * unordered `limit()` this card's own comment exists to warn about.\n */\n formId?: string\n}\n\nexport function SubmissionsCard({\n hostId,\n formId,\n orgMount,\n}: SubmissionsCardProps) {\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n /** Scoped to one form: the subject is fixed and nothing may widen it. */\n const scoped = Boolean(formId)\n /** Every site of this organization at once, when no site is named. */\n const orgId = hostId == null ? (orgMount?.orgId ?? null) : null\n /*\n * The site a row lives on, which is where every act on it is addressed:\n * this card's own site, or — across every site — the `hostId` the submit\n * route stamps on the row. That stamp is frozen against a client update and\n * written from the row's own path, so it names the document's real parent;\n * the rules check the host role on that site either way.\n */\n const siteOf = useCallback(\n (submission: any): string | null =>\n hostId ??\n (typeof submission?.hostId === 'string' && submission.hostId\n ? submission.hostId\n : null),\n [hostId],\n )\n /*\n * Where the sender's CONTACT is (AGL-2612). A submission that carried an\n * address updated a contact in the CRM at stage Lead; the row links to\n * it by that address, and the Contacts list — which holds the id nothing\n * here does — opens the record. The same hub path the Members & leads\n * rows use to open a lead.\n */\n const routeContext = useRecordRouteContext()\n\n /*\n * The site's forms, for the Submissions filter.\n *\n * `FORMS_MAX_PER_HOST` is a read WINDOW, not a cap on the collection — how\n * many forms a site may hold is `formsPerHost`, enforced at the create in\n * `/api/hosts/resources`, and a staff-set per-org override can raise it\n * past this window. So the window can be smaller than the catalog, and one\n * more document than fits is read on purpose: a filter that quietly listed\n * the first N would report \"this form does not exist\" and \"this form is\n * past the window\" as the same empty answer.\n *\n * Ordered by `__name__`. `displayName` would be the nicer order and is the\n * wrong instrument: `orderBy` on a data field DROPS every document missing\n * it, invisibly, so a form saved without a name would vanish from its own\n * filter. The list is sorted for display below, where a missing name is\n * merely ugly.\n */\n const { data: formDocs } = useFirestoreCollection<any>(\n () =>\n // Forms are one site's catalog. Across every site there is no single\n // catalog to filter by, and picking a site is how a reader gets one.\n scoped || !hostId\n ? null\n : query(\n collection(firestore, 'hosts', hostId, 'forms'),\n orderBy('__name__'),\n limit(FORMS_MAX_PER_HOST + 1),\n ),\n [firestore, hostId, scoped],\n { idField: '$id' },\n )\n /** More forms exist than the window shows; the filter has to say so. */\n const formsTruncated = (formDocs?.length ?? 0) > FORMS_MAX_PER_HOST\n const forms = useMemo(\n () =>\n [...(formDocs ?? [])]\n .slice(0, FORMS_MAX_PER_HOST)\n .sort((left: any, right: any) =>\n String(left.displayName ?? left.$id).localeCompare(\n String(right.displayName ?? right.$id),\n ),\n ),\n [formDocs],\n )\n /*\n * The panel's clauses, held here because the Form clause is the query's.\n * No clause is \"All forms\"; a Form clause narrows to one form's\n * submissions.\n *\n * `formFilter` is a PRIMITIVE, deliberately: `usePagedCollection` reopens\n * its listener when a dep changes, and an object identity would tear down\n * and reopen on every render. It also resets the reader to page 1, which\n * is what switching subjects should do.\n */\n const [clauses, setClauses] = useState<ListFilterClause[]>([])\n const formFilter =\n clauses.find((clause) => clause.field === 'formId' && clause.op === 'equals')\n ?.value || null\n /**\n * The form the query is actually narrowed to.\n *\n * The scope wins over the panel rather than seeding it. A scoped card\n * offers no Form filter, so a `formFilter` that could outrank `formId`\n * would be a filter with no control — reachable only by a state change\n * nothing on screen can cause, and unclearable if one ever could.\n */\n const activeForm = formId ?? formFilter\n\n /*\n * The inbox WALKS its submissions instead of sampling them (AGL-2501,\n * AGL-2292).\n *\n * `limit(200)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids `add()` generates — an arbitrary two hundred\n * of the site's messages, which the client sort then arranged newest-first\n * so the result looked like a feed. A site past two hundred submissions\n * could not reach the rest, and the messages missing left no gap: the row\n * dates on screen simply skipped, which reads as a quiet week rather than\n * as an unreachable inbox.\n *\n * `createdAt` is safe to order on, checked against the writer rather than\n * assumed: `apps/tenant/app/api/forms/submit/route.ts` is the only path\n * that creates one and stamps `createdAt: serverTimestamp()` on every add,\n * the v1 API only ever reads and deletes, and `formSubmissions` is absent\n * from `IMPORTABLE_FIELDS`, so no restore path can make one without it.\n */\n const {\n data: submissionData,\n rows: submissionRows,\n hasMore: hasMoreSubmissions,\n page: submissionPage,\n setPage: setSubmissionPage,\n pageSize: submissionPageSize,\n setPageSize: setSubmissionPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'formSubmissions'),\n // Served by the `formId ASC, createdAt DESC` composite index in\n // `cloud/firebase-firestore.indexes.json`, which must be deployed\n // before this ships — without it Firestore refuses the query\n // rather than answering it slowly.\n ...(activeForm ? [where('formId', '==', activeForm)] : []),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n )\n : orgId\n ? query(\n /*\n * EVERY SITE'S SUBMISSIONS IN ONE BOUNDED QUERY (AGL-3303), not\n * a listener per site: the same page-plus-probe window, over\n * the collection group, newest first.\n *\n * The `orgId` clause is not a filter this card could drop. The\n * rules admit a group read only when it is narrowed to an org\n * the reader spans, so an unfiltered group query is refused\n * outright. Served by the `orgId ASC, createdAt DESC`\n * COLLECTION_GROUP index. Every submission carries `orgId`: the\n * submit route stamps it, and the rows written before it did\n * were stamped once (AGL-3303). A row without one would be in no\n * organization's list, though its own site's Inbox lists it\n * either way.\n */\n collectionGroup(firestore, 'formSubmissions'),\n where('orgId', '==', orgId),\n orderBy('createdAt', 'desc'),\n limit(pageLimit),\n )\n : null,\n [firestore, hostId, orgId, activeForm],\n { idField: '$id' },\n )\n /** Form is offered where the site's catalog is read. */\n const offersForm = !scoped && Boolean(hostId) && forms.length > 0\n const submissionFields = useMemo(\n () => [\n ...SUBMISSION_BASE_FIELDS,\n ...(offersForm ? [FORM_FIELD] : []),\n ...(hostId == null ? [inMemoryListField('hostId', 'select')] : []),\n ],\n [offersForm, hostId],\n )\n const submissionOptions = useMemo(\n () => ({\n read: READ_OPTIONS,\n ...(offersForm\n ? {\n formId: forms.map((form: any) => ({\n value: String(form.$id),\n label: String(form.displayName || form.$id),\n })),\n }\n : {}),\n ...(hostId == null\n ? {\n hostId: (orgMount?.hosts ?? []).map((host) => ({\n value: host.id,\n label: host.name || host.id,\n })),\n }\n : {}),\n }),\n [offersForm, forms, hostId, orgMount],\n )\n const submissionWindow = useMemo(\n () => submissionData?.map(withFilterValues),\n [submissionData],\n )\n const submissionPageRows = useMemo(\n () => submissionRows.map(withFilterValues),\n [submissionRows],\n )\n const submissionFilter = usePagedRowsFilter<any>(\n {\n data: submissionWindow,\n rows: submissionPageRows,\n hasMore: hasMoreSubmissions,\n page: submissionPage,\n setPage: setSubmissionPage,\n pageSize: submissionPageSize,\n setPageSize: setSubmissionPageSize,\n },\n {\n fields: submissionFields,\n options: submissionOptions,\n headers: SUBMISSION_FILTER_HEADERS,\n search: SUBMISSION_SEARCH_FIELDS,\n served: servedByQuery,\n clauses,\n onChange: setClauses,\n },\n )\n // The rows on screen: the page, or the page of the matches.\n const submissions = submissionFilter.rows\n /** Narrowed by nothing but the served Form clause. */\n const onlyForm =\n Boolean(formFilter) &&\n clauses.every(servedByQuery) &&\n submissionFilter.gridFilter.searchWords.length === 0\n\n // Mail reader (AGL-104): opening a submission shows the full message and\n // marks it read.\n const [reader, setReader] = useState<any | null>(null)\n const handleOpenReader = useCallback(\n (submission: any) => () => {\n setReader(submission)\n const site = siteOf(submission)\n if (!submission.read && site) {\n void updateDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n { read: true },\n )\n }\n },\n [firestore, siteOf],\n )\n\n const handleToggleRead = useCallback(\n (submission: any) => () => {\n const site = siteOf(submission)\n if (!site) return\n void updateDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n { read: !submission.read },\n )\n },\n [firestore, siteOf],\n )\n\n /*\n * A submission named in the URL opens in the reader on arrival (AGL-2622).\n *\n * A contact's timeline names the submission that captured the person, and\n * the address it links to is this tab with `?submission={id}`. The\n * document is read once, by id, rather than looked for in the paged\n * window: the window is the newest page of a walk that may not reach a\n * submission from months back, and one document read is what the URL\n * asked for. Opening it marks it read, as a click on the row would. A\n * submission that is gone says so, because a reader that silently stays\n * shut reads as the link having done nothing.\n *\n * Once per id: the ref keeps a re-render — and the same URL re-settling —\n * from reopening a reader the person has since closed. The landing read\n * is judged against that ref rather than an effect cleanup: a listener\n * settling while the read is in flight re-renders this card, and a\n * cleanup keyed on any dep would cancel the answer to the question the\n * URL just asked.\n */\n const searchParams = useSearchParams()\n const seededSubmissionId = searchParams?.get(INBOX_SUBMISSION_PARAM) ?? null\n const seededOpened = useRef<string | null>(null)\n useEffect(() => {\n // A submission link names one site's Inbox; the org's has no site to read\n // the id under.\n if (!hostId) return\n if (!seededSubmissionId || seededOpened.current === seededSubmissionId) return\n seededOpened.current = seededSubmissionId\n void getDoc(doc(firestore, 'hosts', hostId, 'formSubmissions', seededSubmissionId))\n .then((snapshot) => {\n if (seededOpened.current !== snapshot.id) return\n if (!snapshot.exists()) {\n enqueueSnackbar('That submission is no longer in the Inbox.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n handleOpenReader({ ...snapshot.data(), $id: snapshot.id })()\n })\n .catch(() => undefined)\n }, [seededSubmissionId, firestore, hostId, handleOpenReader, enqueueSnackbar])\n\n const handleDelete = useCallback(\n (submission: any) => async () => {\n const site = siteOf(submission)\n if (!site) return\n const confirmed = await confirm({\n title: 'Delete this submission?',\n description: 'The submission is removed permanently.',\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await deleteDoc(\n doc(firestore, 'hosts', site, 'formSubmissions', submission.$id),\n )\n enqueueSnackbar('Submission deleted', {\n variant: 'success',\n persist: false,\n })\n },\n [confirm, firestore, siteOf, enqueueSnackbar],\n )\n\n /*\n * One row per submission, newest first: who sent it, what it said and how\n * long ago. The row opens the reader, which also marks it read.\n */\n const submissionActions = (submission: any): RowActionsMenuItem[] => {\n const senderEmail = submissionSender(submission.fields).email\n // Where the sender's contact is read, asked of whichever plugin keeps\n // people; with none loaded there is no such page, and no menu item.\n const contactsHref = routeContext\n ? pluginRecordListHref('contact', routeContext)\n : null\n return [\n ...(routeContext && contactsHref\n ? [\n {\n key: 'crm',\n label: 'Open contact in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n ...(senderEmail\n ? {\n href:\n pluginRecordByEmailHref(\n 'contact',\n routeContext,\n senderEmail,\n ) ?? contactsHref,\n }\n : {\n disabled: true,\n disabledReason:\n 'This submission carried no email address, so no contact was updated.',\n }),\n },\n ]\n : []),\n {\n key: 'read',\n label: submission.read ? 'Mark unread' : 'Mark read',\n icon: (\n <MdiIcon\n path={submission.read ? mdiEmailOutline.path : mdiEmailOpenOutline.path}\n size={0.8}\n />\n ),\n onClick: handleToggleRead(submission),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: handleDelete(submission),\n },\n ]\n }\n const submissionColumns: GridColDef[] = [\n {\n /*\n The mockup's list is people, not forms (AGL-2168): an initials avatar,\n the sender, and the form name beneath it. The unread DOT replaces the\n `New` chip — the row is already bold, and a chip that says \"New\" beside\n bold text is the same fact twice.\n */\n field: 'from',\n headerName: 'From',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, submission) => submission.senderLabel,\n renderCell: ({ row: submission }) => {\n const sender = submissionSender(submission.fields)\n const hue = senderHue(sender.label)\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n {!submission.read ? (\n <Box\n aria-label=\"Unread\"\n sx={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: 'primary.main',\n flexShrink: 0,\n }}\n />\n ) : (\n <Box sx={{ width: 8, flexShrink: 0 }} />\n )}\n <Avatar\n sx={{\n width: 28,\n height: 28,\n fontSize: 13,\n bgcolor: `hsl(${hue} 55% 45%)`,\n }}\n >\n {sender.initials}\n </Avatar>\n <Stack sx={{ 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 </Stack>\n )\n },\n },\n /*\n Which site it was sent to, on the organization's Inbox only — under a\n site every row is that site's, and a column saying so would be noise.\n */\n ...(hostId == null\n ? [\n {\n field: 'hostId',\n headerName: 'Site',\n flex: 1,\n minWidth: 140,\n // Sorted and drawn by name; the filter matches the id.\n valueGetter: (_value: unknown, submission: any) =>\n orgSiteName(orgMount, submission.hostId),\n renderCell: ({ row: submission }: { row: any }) =>\n orgSiteName(orgMount, submission.hostId),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'message',\n headerName: 'Message',\n flex: 2,\n minWidth: 260,\n valueGetter: (_value, submission) => submission.messageText,\n renderCell: ({ value }) => (\n <Typography variant=\"body2\" noWrap>\n {value}\n </Typography>\n ),\n },\n {\n /*\n Relative, as the mockup shows it — an inbox is scanned for recency and a\n locale timestamp makes the reader do the subtraction. The absolute time\n stays on the detail dialog, where it is the fact you actually want.\n */\n field: 'createdAt',\n headerName: 'Received',\n width: 130,\n valueGetter: (_value, submission) =>\n submission.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: submission }) => (\n <Tooltip title={submission.createdAt?.toDate?.().toLocaleString() ?? ''}>\n <span>{relativeTime(submission.createdAt?.toDate?.().getTime())}</span>\n </Tooltip>\n ),\n },\n /*\n One overflow menu, as the Members & leads rows have: two inline buttons\n had no room for a third, and the contact link is the one a reader\n reaches for after reading. Present but disabled for a submission with no\n address, with the reason — a row that simply lacked the item would read\n as the contact not existing.\n */\n listActionsColumn(\n (submission) => (\n <ListRowActions\n label={String(submissionSender(submission.fields).email ?? submission.$id)}\n items={submissionActions(submission)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n /** The site the open submission was sent to, which its reader acts as. */\n const readerSite = reader ? siteOf(reader) : null\n\n return (\n <>\n <CardDisplay\n header={scoped ? 'Submissions to this form' : 'Form Submissions'}\n help={pluginDocsHelp(\n 'forms',\n hostId == null\n ? {\n anchor: '#every-sites-inbox-at-once',\n excerpt:\n 'Every site’s form messages in one list, newest first, ' +\n 'each under the site it was sent to.',\n }\n : {\n anchor: '#the-inbox',\n excerpt:\n 'Messages your forms collected, newest first, showing who ' +\n 'sent each one and where it was routed.',\n },\n )}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {/*\n * The Inbox stays the site-wide answer to \"who is waiting for a\n * reply\" — that question does not decompose by form. The panel's\n * Form filter narrows it on request; it does not turn the page into\n * a per-form view. It is offered only when the site HAS forms, and\n * never on a card already scoped to one.\n */}\n {offersForm && formsTruncated ? (\n <Typography variant=\"caption\" color=\"text.secondary\" component=\"p\" sx={{ mb: 1 }}>\n {`The Form filter offers the first ${FORMS_MAX_PER_HOST.toLocaleString()} ` +\n 'forms. Narrow by form is incomplete; with no Form filter the ' +\n 'list still covers every submission.'}\n </Typography>\n ) : null}\n {hostId == null ? (\n <Typography\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mb: submissions.length ? 2 : 1 }}\n >\n {'Every site’s submissions, newest first. Choose a site to ' +\n 'narrow them by form.'}\n </Typography>\n ) : null}\n {submissionRows.length === 0 &&\n !hasMoreSubmissions &&\n !submissionFilter.filtering ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {scoped\n ? 'No submissions carry this form’s id yet. Messages this ' +\n 'form’s design collected before it became a form entity are ' +\n 'in the Inbox, filed under the name they were sent with.'\n : hostId == null\n ? 'No form submissions on any site yet.'\n : 'No form submissions yet. Add a Contact Form element to a ' +\n 'screen — visitor messages arrive here.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips {...submissionFilter.chipsProps} servedField=\"formId\" />\n {submissionFilter.filtering && hasMoreSubmissions ? (\n <Typography variant=\"caption\" color=\"text.secondary\" component=\"p\" sx={{ mb: 1 }}>\n {`Filtering the ${submissionFilter.read} submissions read so far — the next page reads more.`}\n </Typography>\n ) : null}\n <ListTable\n aria-label={scoped ? 'Submissions to this form' : 'Form submissions'}\n rows={submissions}\n columns={submissionFilter.filterColumns(submissionColumns)}\n initialState={{ columns: { columnVisibilityModel: SUBMISSION_HIDDEN_COLUMNS } }}\n noRowsLabel={\n onlyForm\n ? 'No submissions for this form yet. Submissions sent before ' +\n 'the form was created are listed with no Form filter.'\n : 'No submissions match these filters'\n }\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, submission) => handleOpenReader(submission)()}\n // An unread submission reads bold across its row, which is why\n // the list needs no `New` chip.\n getRowClassName={({ row }) => (row.read ? '' : 'submission-unread')}\n sx={{\n '& .submission-unread .MuiDataGrid-cell, & .submission-unread .MuiTypography-root':\n { fontWeight: 'fontWeightBold' },\n }}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel and the search are the grid's: Form is the query's\n // clause, the rest are answered over what the window read.\n {...submissionFilter.gridProps}\n />\n <ListPagination {...submissionFilter.pagination} />\n </>\n )}\n </CardDisplay>\n <Dialog\n open={Boolean(reader)}\n onClose={() => setReader(null)}\n maxWidth=\"sm\"\n fullWidth\n >\n <DialogTitle>{reader?.formName ?? 'Form submission'}</DialogTitle>\n <DialogContent>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Received ${reader?.createdAt?.toDate?.().toLocaleString() ?? ''}` +\n // The site it was sent to, where the list spans every site.\n (hostId == null && readerSite\n ? ` · ${orgSiteName(orgMount, readerSite)}`\n : '') +\n (reader?.screenId ? ` · screen ${reader.screenId}` : '')}\n {/*\n THE PAGE THE FORM WAS ON. Stored by the submit route since the\n form existed and rendered by nothing, which is also the field\n the marketing console's landing-page grouping joins on — a\n reader who wants to check one row against that grouping has to\n be able to see the row's own page.\n */}\n {reader?.path ? ` · ${String(reader.path)}` : ''}\n </Typography>\n <Divider sx={{ my: 1.5 }} />\n <Stack spacing={1.5}>\n {Object.entries(reader?.fields ?? {}).map(([key, value]) => (\n <Stack key={key} spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {key}\n </Typography>\n <Typography\n variant=\"body2\"\n sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}\n >\n {String(value)}\n </Typography>\n </Stack>\n ))}\n </Stack>\n {/*\n What happened to this submission (AGL-2168). The mockup puts\n these under the fields: `Saved to Inbox` and `Added to \"Leads\"\n dataset`. The second is stamped by the submit route only when a\n record was really appended — a form bound to a deleted dataset,\n or one whose record quota is full, shows no chip rather than a\n chip for a row that does not exist. Both are failures the route\n already swallows silently, and a chip that lied about them\n would be worse than the silence.\n */}\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ mt: 2, flexWrap: 'wrap', rowGap: 1 }}\n >\n {routingChips(reader?.routing).map((chip) => (\n <Chip\n key={chip.label}\n size=\"small\"\n label={chip.label}\n color={chip.color}\n variant=\"outlined\"\n />\n ))}\n </Stack>\n {/*\n WHERE THIS SUBMISSION CAME FROM.\n\n One keyed document read — the attribution's id is `form:{id}` and\n the submission's id is that `{id}` — paid when a merchant opens a\n submission rather than once per row of the list. A submission that\n was credited to nobody renders the sentence saying so; it never\n renders a campaign with a zero beside it.\n */}\n {reader?.$id && readerSite ? (\n <Box sx={{ mt: 2 }}>\n <InboxRecordAttributionZone\n hostId={readerSite}\n recordKind=\"form\"\n recordId={String(reader.$id)}\n />\n </Box>\n ) : null}\n {/*\n Answering the person is the act the Inbox exists for, and it is\n inside the reader rather than on the row because a reply written\n without the message in front of you is the reply that answers the\n wrong question. Mounted with the dialog, so its reads — the site\n name and the replies already sent — are paid when a merchant opens\n a submission and not once per visit to this page.\n */}\n {reader && readerSite ? (\n <SubmissionReply hostId={readerSite} submission={reader} />\n ) : null}\n {/*\n Enrolling the sender in a marketing list — a SEPARATE act from\n answering them, and a separate card, because the person asked to\n be answered and did not ask to be marketed to. Its reads are paid\n only when a merchant presses its own button, so opening a\n submission to read it costs nothing extra.\n */}\n {reader && readerSite ? (\n <SubmissionListAssignment hostId={readerSite} submission={reader} />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button\n color=\"error\"\n onClick={async () => {\n const target = reader\n setReader(null)\n if (target) await handleDelete(target)()\n }}\n >\n {'Delete'}\n </Button>\n <Button\n onClick={() => {\n if (reader) void handleToggleRead({ ...reader, read: true })()\n setReader(null)\n }}\n >\n {'Mark unread'}\n </Button>\n <Button variant=\"contained\" onClick={() => setReader(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nSubmissionsCard.displayName = 'SubmissionsCard'\n\nexport default SubmissionsCard\n"],"names":["FORMS_MAX_PER_HOST","INBOX_SUBMISSION_PARAM","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordByEmailHref","pluginRecordListHref","mdiAccountArrowRight","mdiDeleteOutline","mdiEmailOpenOutline","mdiEmailOutline","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListRowActions","ListTable","listActionsColumn","inMemoryListField","TABLE_ROW_HEIGHT","usePagedRowsFilter","useSnackbar","useFirestore","useFirestoreCollection","usePagedCollection","Avatar","Box","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Divider","Stack","Tooltip","Typography","collection","collectionGroup","deleteDoc","doc","getDoc","limit","orderBy","query","updateDoc","where","useSearchParams","useCallback","useEffect","useMemo","useRef","useState","relativeTime","routingChips","senderHue","submissionSender","orgSiteName","SubmissionListAssignment","SubmissionReply","useRecordRouteContext","FORM_FIELD","operators","SUBMISSION_BASE_FIELDS","SUBMISSION_FILTER_HEADERS","from","message","read","formId","hostId","READ_OPTIONS","value","label","SUBMISSION_SEARCH_FIELDS","SUBMISSION_HIDDEN_COLUMNS","servedByQuery","clause","field","withFilterValues","submission","senderLabel","fields","messageText","Object","entries","map","key","join","readKey","SubmissionsCard","orgMount","clauses","reader","firestore","enqueueSnackbar","confirm","scoped","Boolean","orgId","siteOf","routeContext","data","formDocs","idField","formsTruncated","length","forms","slice","sort","left","right","String","displayName","$id","localeCompare","setClauses","formFilter","find","op","activeForm","submissionData","rows","submissionRows","hasMore","hasMoreSubmissions","page","submissionPage","setPage","setSubmissionPage","pageSize","submissionPageSize","setPageSize","setSubmissionPageSize","pageLimit","offersForm","submissionFields","submissionOptions","form","hosts","host","id","name","submissionWindow","submissionPageRows","submissionFilter","options","headers","search","served","onChange","submissions","onlyForm","every","gridFilter","searchWords","setReader","handleOpenReader","site","handleToggleRead","searchParams","seededSubmissionId","get","seededOpened","current","then","snapshot","exists","variant","persist","catch","undefined","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","submissionActions","senderEmail","email","contactsHref","icon","path","size","href","disabled","disabledReason","onClick","destructive","submissionColumns","headerName","flex","minWidth","valueGetter","_value","renderCell","row","sender","hue","direction","spacing","sx","alignItems","aria-label","width","height","borderRadius","bgcolor","flexShrink","fontSize","initials","noWrap","formName","createdAt","toDate","getTime","toLocaleString","span","items","readerSite","header","help","anchor","excerpt","contentGutterX","contentGutterY","contentBordered","component","mb","filtering","chipsProps","servedField","columns","filterColumns","initialState","columnVisibilityModel","noRowsLabel","rowHeight","onOpen","_id","getRowClassName","fontWeight","hideFooter","gridProps","pagination","open","onClose","maxWidth","fullWidth","screenId","my","whiteSpace","wordBreak","mt","flexWrap","rowGap","routing","chip","recordKind","recordId","target"],"mappings":"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"}
|