@aglyn/plugins-inbox 1.0.0-beta.179 → 1.0.0-beta.181
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 +10 -10
- package/src/lib/components/contacts-card.component.d.ts +10 -2
- package/src/lib/components/contacts-card.component.js +94 -41
- package/src/lib/components/contacts-card.component.js.map +1 -1
- package/src/lib/components/inbox-attribution-zone.d.ts +9 -1
- package/src/lib/components/inbox-attribution-zone.js +1 -1
- package/src/lib/components/inbox-attribution-zone.js.map +1 -1
- package/src/lib/components/inbox-console-page.d.ts +4 -0
- package/src/lib/components/inbox-console-page.js +119 -13
- package/src/lib/components/inbox-console-page.js.map +1 -1
- package/src/lib/components/inbox-console-sections.d.ts +13 -0
- package/src/lib/components/inbox-console-sections.js +18 -3
- package/src/lib/components/inbox-console-sections.js.map +1 -1
- package/src/lib/components/inbox-glance-card.component.js +6 -1
- package/src/lib/components/inbox-glance-card.component.js.map +1 -1
- package/src/lib/components/inbox-org-sites.d.ts +27 -0
- package/src/lib/components/inbox-org-sites.js +31 -0
- package/src/lib/components/inbox-org-sites.js.map +1 -0
- package/src/lib/components/submissions-card.component.d.ts +10 -2
- package/src/lib/components/submissions-card.component.js +89 -29
- package/src/lib/components/submissions-card.component.js.map +1 -1
- package/src/lib/components/use-inbox-site-pick.d.ts +38 -0
- package/src/lib/components/use-inbox-site-pick.js +99 -0
- package/src/lib/components/use-inbox-site-pick.js.map +1 -0
- package/src/lib/components/use-record-route-context.d.ts +8 -3
- package/src/lib/components/use-record-route-context.js +10 -6
- package/src/lib/components/use-record-route-context.js.map +1 -1
- package/src/lib/plugin.d.ts +4 -3
- package/src/lib/plugin.js +35 -5
- package/src/lib/plugin.js.map +1 -1
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.181",
|
|
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.181",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.181",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.181",
|
|
31
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.181",
|
|
32
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.181",
|
|
33
|
+
"@aglyn/shared-util-email": "1.0.0-beta.181",
|
|
34
|
+
"@aglyn/shared-util-http": "1.0.0-beta.181",
|
|
35
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.181",
|
|
36
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.181",
|
|
37
37
|
"@swc/helpers": "0.5.23"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ConsolePluginOrgMount } from '@aglyn/aglyn';
|
|
1
2
|
/**
|
|
2
3
|
* The Members & leads section of the Inbox (AGL-109): everybody a site
|
|
3
4
|
* collected, in one list.
|
|
@@ -6,9 +7,16 @@
|
|
|
6
7
|
* split is what makes "mount only the section being read" structural: hooks
|
|
7
8
|
* cannot be conditional, so a page holding every section's reads pays for all
|
|
8
9
|
* of them whichever one the URL names.
|
|
10
|
+
*
|
|
11
|
+
* On the organization's Inbox (`hostId: null`, AGL-3303) it lists the
|
|
12
|
+
* organization's leads with the sites each was captured by. A member signs up
|
|
13
|
+
* to ONE site and lives under it, so members are listed once a site is
|
|
14
|
+
* picked, which is the page handing this card that site.
|
|
9
15
|
*/
|
|
10
|
-
export declare function ContactsCard({ hostId }: {
|
|
11
|
-
hostId: string;
|
|
16
|
+
export declare function ContactsCard({ hostId, orgMount, }: {
|
|
17
|
+
hostId: string | null;
|
|
18
|
+
/** The organization and its sites — present exactly when `hostId` is `null`. */
|
|
19
|
+
orgMount?: ConsolePluginOrgMount;
|
|
12
20
|
}): import("react").JSX.Element;
|
|
13
21
|
export declare namespace ContactsCard {
|
|
14
22
|
var displayName: string;
|
|
@@ -36,6 +36,7 @@ import { collection, deleteDoc, doc, limit, orderBy, query, where } from "fireba
|
|
|
36
36
|
import { useCallback, useMemo, useState } from "react";
|
|
37
37
|
import { useRecordRouteContext } from "./use-record-route-context.js";
|
|
38
38
|
import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
39
|
+
import { orgSiteNames } from "./inbox-org-sites.js";
|
|
39
40
|
/**
|
|
40
41
|
* How many members and how many leads the contacts table reads.
|
|
41
42
|
*
|
|
@@ -51,7 +52,12 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
51
52
|
* split is what makes "mount only the section being read" structural: hooks
|
|
52
53
|
* cannot be conditional, so a page holding every section's reads pays for all
|
|
53
54
|
* of them whichever one the URL names.
|
|
54
|
-
|
|
55
|
+
*
|
|
56
|
+
* On the organization's Inbox (`hostId: null`, AGL-3303) it lists the
|
|
57
|
+
* organization's leads with the sites each was captured by. A member signs up
|
|
58
|
+
* to ONE site and lives under it, so members are listed once a site is
|
|
59
|
+
* picked, which is the page handing this card that site.
|
|
60
|
+
*/ export function ContactsCard({ hostId, orgMount }) {
|
|
55
61
|
var _ref, _ref1, _ref2;
|
|
56
62
|
const firestore = useFirestore();
|
|
57
63
|
const { enqueueSnackbar } = useSnackbar();
|
|
@@ -84,7 +90,7 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
84
90
|
* probe row makes "there are more contacts than these" a fact rather than a
|
|
85
91
|
* guess from `length === CONTACT_CEILING`, which is wrong at exactly the
|
|
86
92
|
* count that equals the ceiling.
|
|
87
|
-
*=========================================*/ const { data: memberDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'siteMembers'), orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)), [
|
|
93
|
+
*=========================================*/ const { data: memberDocs } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'siteMembers'), orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)) : null, [
|
|
88
94
|
firestore,
|
|
89
95
|
hostId
|
|
90
96
|
], {
|
|
@@ -95,10 +101,18 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
95
101
|
* `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its
|
|
96
102
|
* pre-migration rows and nothing captured since.
|
|
97
103
|
*/ const { orgId } = useOrgDataScope({
|
|
98
|
-
hostId
|
|
104
|
+
hostId: hostId != null ? hostId : undefined,
|
|
105
|
+
orgId: hostId == null ? orgMount == null ? void 0 : orgMount.orgId : undefined
|
|
99
106
|
});
|
|
100
107
|
const siteMembers = (memberDocs != null ? memberDocs : []).slice(0, CONTACT_CEILING);
|
|
101
|
-
const { data: leadDocs } = useFirestoreCollection(()=>orgId ? query(collection(firestore, 'orgs', orgId, 'leads'),
|
|
108
|
+
const { data: leadDocs } = useFirestoreCollection(()=>orgId ? query(collection(firestore, 'orgs', orgId, 'leads'), /*
|
|
109
|
+
* Every site's, on the organization's Inbox: an org-wide member
|
|
110
|
+
* reads the collection unscoped — the rules short-circuit on
|
|
111
|
+
* their reach, as for the CRM's own org list — so a clause here
|
|
112
|
+
* would only narrow what they may already read.
|
|
113
|
+
*/ ...hostId ? [
|
|
114
|
+
where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))
|
|
115
|
+
] : [], orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)) : null, [
|
|
102
116
|
firestore,
|
|
103
117
|
orgId,
|
|
104
118
|
hostId
|
|
@@ -145,6 +159,8 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
145
159
|
dedupedLeads
|
|
146
160
|
]);
|
|
147
161
|
const handleDeleteMember = useCallback((member)=>async ()=>{
|
|
162
|
+
// Members are listed only under a site, so there is one to delete from.
|
|
163
|
+
if (!hostId) return;
|
|
148
164
|
const confirmed = await confirm({
|
|
149
165
|
title: 'Remove this member?',
|
|
150
166
|
description: `"${member.email}" can no longer sign in to your site.`,
|
|
@@ -174,6 +190,17 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
174
190
|
* wants the answer pays for it, and the reader who came to scan the list
|
|
175
191
|
* does not.
|
|
176
192
|
*/ const [leadOrigin, setLeadOrigin] = useState(null);
|
|
193
|
+
/*
|
|
194
|
+
* The site a lead's attribution is asked of. Under a site it is that site;
|
|
195
|
+
* across every site a lead is one org row that several sites may have met,
|
|
196
|
+
* and the credit is filed by the site whose capture made it — the first in
|
|
197
|
+
* `capturedByHostIds`, the site the CRM's own lead page asks as well.
|
|
198
|
+
*/ const originSite = (contact)=>{
|
|
199
|
+
if (hostId) return hostId;
|
|
200
|
+
const first = Array.isArray(contact == null ? void 0 : contact.capturedByHostIds) ? contact.capturedByHostIds[0] : null;
|
|
201
|
+
return typeof first === 'string' && first ? first : null;
|
|
202
|
+
};
|
|
203
|
+
const leadOriginSite = leadOrigin ? originSite(leadOrigin) : null;
|
|
177
204
|
const contactActions = (contact)=>contact.contactKind === 'member' ? [
|
|
178
205
|
{
|
|
179
206
|
key: 'remove',
|
|
@@ -199,15 +226,18 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
199
226
|
href: leadHref(contact)
|
|
200
227
|
}
|
|
201
228
|
] : [],
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
229
|
+
// A lead no site captured has no site to ask for its campaign.
|
|
230
|
+
...originSite(contact) ? [
|
|
231
|
+
{
|
|
232
|
+
key: 'origin',
|
|
233
|
+
label: 'Where this came from',
|
|
234
|
+
icon: /*#__PURE__*/ _jsx(MdiIcon, {
|
|
235
|
+
path: mdiBullhornOutline.path,
|
|
236
|
+
size: 0.8
|
|
237
|
+
}),
|
|
238
|
+
onClick: ()=>setLeadOrigin(contact)
|
|
239
|
+
}
|
|
240
|
+
] : []
|
|
211
241
|
];
|
|
212
242
|
const contactColumns = [
|
|
213
243
|
{
|
|
@@ -265,6 +295,19 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
265
295
|
variant: "outlined"
|
|
266
296
|
})
|
|
267
297
|
},
|
|
298
|
+
/*
|
|
299
|
+
KNOWN BY, on the organization's Inbox: every site that captured the
|
|
300
|
+
person, as the CRM's organization Leads list names them. A lead is one
|
|
301
|
+
org row, so "which site" is a set, never one answer.
|
|
302
|
+
*/ ...hostId == null ? [
|
|
303
|
+
{
|
|
304
|
+
field: 'capturedByHostIds',
|
|
305
|
+
headerName: 'Site',
|
|
306
|
+
flex: 1,
|
|
307
|
+
minWidth: 160,
|
|
308
|
+
valueGetter: (_value, contact)=>orgSiteNames(orgMount, contact.capturedByHostIds)
|
|
309
|
+
}
|
|
310
|
+
] : [],
|
|
268
311
|
{
|
|
269
312
|
field: 'createdAt',
|
|
270
313
|
headerName: 'Joined',
|
|
@@ -293,38 +336,48 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
293
336
|
];
|
|
294
337
|
return /*#__PURE__*/ _jsxs(_Fragment, {
|
|
295
338
|
children: [
|
|
296
|
-
/*#__PURE__*/
|
|
297
|
-
header: 'Site Members & Leads',
|
|
339
|
+
/*#__PURE__*/ _jsxs(CardDisplay, {
|
|
340
|
+
header: hostId == null ? 'Leads' : 'Site Members & Leads',
|
|
298
341
|
help: pluginDocsHelp('membersOnly', {
|
|
299
342
|
anchor: '#manage-your-members'
|
|
300
343
|
}),
|
|
301
344
|
contentGutterX: true,
|
|
302
345
|
contentGutterY: true,
|
|
303
346
|
contentBordered: "all",
|
|
304
|
-
children:
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
347
|
+
children: [
|
|
348
|
+
hostId == null ? /*#__PURE__*/ _jsx(Typography, {
|
|
349
|
+
variant: "body2",
|
|
350
|
+
color: "text.secondary",
|
|
351
|
+
sx: {
|
|
352
|
+
mb: leads.length ? 2 : 1
|
|
353
|
+
},
|
|
354
|
+
children: 'Every site’s leads, newest first. Members sign up to one site ' + 'each — choose a site to list its members beside its leads.'
|
|
355
|
+
}) : null,
|
|
356
|
+
siteMembers.length === 0 && leads.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
|
|
357
|
+
variant: "body2",
|
|
358
|
+
color: "text.secondary",
|
|
359
|
+
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.'
|
|
360
|
+
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
361
|
+
children: [
|
|
362
|
+
/*#__PURE__*/ _jsx(ListTable, {
|
|
363
|
+
"aria-label": "Site members and leads",
|
|
364
|
+
rows: contacts,
|
|
365
|
+
columns: contactColumns,
|
|
366
|
+
// A member and a lead are two collections, so an id alone could
|
|
367
|
+
// name one of each.
|
|
368
|
+
getRowId: (contact)=>`${contact.contactKind}:${contact.$id}`,
|
|
369
|
+
rowHeight: TABLE_ROW_HEIGHT
|
|
370
|
+
}),
|
|
371
|
+
contactsTruncated ? /*#__PURE__*/ _jsx(Alert, {
|
|
372
|
+
severity: "info",
|
|
373
|
+
sx: {
|
|
374
|
+
mt: 1
|
|
375
|
+
},
|
|
376
|
+
children: hostId == null ? `Paging the ${CONTACT_CEILING} newest leads. Your ` + 'sites hold more than that — the CRM’s Leads list ' + 'reaches every one of them.' : `Paging the ${CONTACT_CEILING} newest members and the ` + `${CONTACT_CEILING} newest leads. This site has more ` + 'than that — the campaign audiences still reach ' + 'everyone, whether or not they are listed here.'
|
|
377
|
+
}) : null
|
|
378
|
+
]
|
|
379
|
+
})
|
|
380
|
+
]
|
|
328
381
|
}),
|
|
329
382
|
/*#__PURE__*/ _jsxs(Dialog, {
|
|
330
383
|
open: Boolean(leadOrigin),
|
|
@@ -336,8 +389,8 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
336
389
|
children: (_ref2 = leadOrigin == null ? void 0 : leadOrigin.email) != null ? _ref2 : 'Lead'
|
|
337
390
|
}),
|
|
338
391
|
/*#__PURE__*/ _jsx(DialogContent, {
|
|
339
|
-
children: (leadOrigin == null ? void 0 : leadOrigin.$id) ? /*#__PURE__*/ _jsx(InboxRecordAttributionZone, {
|
|
340
|
-
hostId:
|
|
392
|
+
children: (leadOrigin == null ? void 0 : leadOrigin.$id) && leadOriginSite ? /*#__PURE__*/ _jsx(InboxRecordAttributionZone, {
|
|
393
|
+
hostId: leadOriginSite,
|
|
341
394
|
recordKind: "lead",
|
|
342
395
|
recordId: String(leadOrigin.$id)
|
|
343
396
|
}) : null
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/contacts-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { normalizeContactEmail, pluginDocsHelp } from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// carries the plugin registration, and a link needs only the address grammar.\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiAccountRemoveOutline,\n mdiBullhornOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n\n/**\n * How many members and how many leads the contacts table reads.\n *\n * A ceiling rather than a page size — see the two queries, which explain why\n * this one table cannot be paged by the server without breaking the dedupe\n * between them.\n */\nconst CONTACT_CEILING = 200\n\n/**\n * The Members & leads section of the Inbox (AGL-109): everybody a site\n * collected, in one list.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n */\nexport function ContactsCard({ hostId }: { hostId: string }) {\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's\n // Leads section gives each one a status, an owner and a conversion.\n const routeContext = useRecordRouteContext()\n const leadHref = (contact: { $id?: unknown }): string | null =>\n routeContext\n ? pluginRecordHref('lead', routeContext, String(contact.$id))\n : null\n\n /*==========================================\n * SITE MEMBERS + LEADS (AGL-109): ORDERED AND CEILINGED, NOT PAGED BY QUERY.\n *\n * Both reads were `limit(200)` with no `orderBy` and a client sort on top —\n * the same document-id sample the submissions list used to take, and both\n * now name the order the rows are rendered in. `createdAt` is safe on both:\n * `membership-register.ts` is the only writer that CREATES a site member\n * and stamps it inside its transaction (every other membership path\n * updates an existing document), `recordVisitorLead` is the only writer\n * that creates a lead and stamps it on every `tx.create`, and neither\n * collection is in `IMPORTABLE_FIELDS`.\n *\n * What they must NOT do is page the query, and the reason is the dedupe\n * below: a lead is hidden when a MEMBER already exists on the same address.\n * That test is only correct while both windows are whole. On a ten-row\n * server page it would compare a page of leads against a page of members, so\n * somebody who signed up after leaving their address would render as a\n * Member on one page and again as a Lead on another — one person counted\n * twice, in a list a site owner uses to count people.\n *\n * So the CEILING stays and the page is a slice of the assembled rows. The\n * probe row makes \"there are more contacts than these\" a fact rather than a\n * guess from `length === CONTACT_CEILING`, which is wrong at exactly the\n * count that equals the ceiling.\n *=========================================*/\n const { data: memberDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'siteMembers'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n /*\n * The lead silo is the org's (AGL-3275), narrowed to this site by\n * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its\n * pre-migration rows and nothing captured since.\n */\n const { orgId } = useOrgDataScope({ hostId })\n const siteMembers = (memberDocs ?? []).slice(0, CONTACT_CEILING)\n const { data: leadDocs } = useFirestoreCollection<any>(\n () =>\n orgId\n ? query(\n collection(firestore, 'orgs', orgId, 'leads'),\n where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n )\n : null,\n [firestore, orgId, hostId],\n { idField: '$id' },\n )\n const leads = (leadDocs ?? []).slice(0, CONTACT_CEILING)\n const contactsTruncated =\n (memberDocs?.length ?? 0) > CONTACT_CEILING ||\n (leadDocs?.length ?? 0) > CONTACT_CEILING\n /*\n * One person renders once, whichever way they came in.\n *\n * Raw `===` made `Bob@x.com` and `bob@x.com` two different people, so\n * somebody could appear as a Member on one row and a Lead on another. The\n * comparison is now the same NORMALIZATION a lead document is keyed by —\n * `personKey` is `sha256(normalizeContactEmail(email))`, and hashing an\n * already-normalized address cannot merge or split anything the normalizer\n * did not, so the two agree by construction.\n *\n * `personKey` itself cannot run here: it needs `node:crypto` and this is a\n * client component. That is why the shared half is the normalizer rather\n * than the digest.\n */\n const dedupedLeads = useMemo(() => {\n const memberKeys = new Set(\n siteMembers\n .map((member: any) => normalizeContactEmail(member.email))\n .filter(Boolean),\n )\n return leads.filter(\n (lead: any) => !memberKeys.has(normalizeContactEmail(lead.email)),\n )\n }, [leads, siteMembers])\n /*\n * ONE list of two collections — members first, then the leads that are not\n * already members — so the grid pages the concatenation rather than either\n * read, and a page stays whole across the seam between them. Both reads are\n * ceilinged and complete below the ceiling, so the grid's count is the whole\n * deduped list.\n */\n const contacts = useMemo(\n () => [\n ...siteMembers.map((member: any) => ({ ...member, contactKind: 'member' })),\n ...dedupedLeads.map((lead: any) => ({ ...lead, contactKind: 'lead' })),\n ],\n [siteMembers, dedupedLeads],\n )\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\n const confirmed = await confirm({\n title: 'Remove this member?',\n description: `\"${member.email}\" can no longer sign in to your site.`,\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await deleteDoc(doc(firestore, 'hosts', hostId, 'siteMembers', member.$id))\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n },\n [confirm, firestore, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n\n const contactActions = (contact: any): RowActionsMenuItem[] =>\n contact.contactKind === 'member'\n ? [\n {\n key: 'remove',\n label: 'Remove member',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDeleteMember(contact)(),\n },\n ]\n : [\n // Offered only where a plugin publishes a lead's address: text-less\n // rather than a link to a page this workspace cannot open.\n ...(leadHref(contact)\n ? [\n {\n key: 'crm',\n label: 'Open in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: leadHref(contact) as string,\n },\n ]\n : []),\n {\n key: 'origin',\n label: 'Where this came from',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n onClick: () => setLeadOrigin(contact),\n },\n ]\n const contactColumns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Email',\n flex: 1,\n minWidth: 240,\n /*\n The name beside the address — a member's `displayName`, and the name\n the lead writer stores (AGL-2303) — because a list of bare addresses is\n a list nobody recognizes anyone in.\n */\n renderCell: ({ row: contact }) => {\n const name =\n contact.contactKind === 'member' ? contact.displayName : contact.name\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline', minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {contact.email}\n </Typography>\n {name ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {name}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n /*\n WHERE A LEAD CAME FROM (AGL-2338). `source` has been written by both\n lead writers — `'signup'` and `'booking'` — since AGL-109, so a site\n owner can tell a membership sign-up from a booking. Falls back to the\n bare label for a row written before the field, or by a future writer\n that omits it.\n */\n field: 'contactKind',\n headerName: 'Type',\n width: 170,\n valueGetter: (_value, contact) =>\n contact.contactKind === 'member'\n ? 'Member'\n : contact.source\n ? `Lead · ${contact.source}`\n : 'Lead',\n renderCell: ({ row: contact, value }) =>\n contact.contactKind === 'member' ? (\n <Chip label={value} color=\"primary\" size=\"small\" />\n ) : (\n <Chip label={value} size=\"small\" variant=\"outlined\" />\n ),\n },\n {\n field: 'createdAt',\n headerName: 'Joined',\n width: 200,\n // Sorted on the instant, drawn as a local string.\n valueGetter: (_value, contact) => contact.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: contact }) =>\n contact.createdAt?.toDate?.().toLocaleString() ?? '--',\n },\n listActionsColumn(\n (contact) => (\n <ListRowActions\n label={String(contact.email ?? contact.$id)}\n items={contactActions(contact)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <>\n <CardDisplay\n header={'Site Members & Leads'}\n help={pluginDocsHelp('membersOnly', {\n anchor: '#manage-your-members',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {siteMembers.length === 0 && leads.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No members yet — visitors can join at /signup on your ' +\n 'site; sign-ups also appear here as leads.'}\n </Typography>\n ) : (\n <>\n <ListTable\n aria-label=\"Site members and leads\"\n rows={contacts}\n columns={contactColumns}\n // A member and a lead are two collections, so an id alone could\n // name one of each.\n getRowId={(contact: any) => `${contact.contactKind}:${contact.$id}`}\n rowHeight={TABLE_ROW_HEIGHT}\n />\n {contactsTruncated ? (\n <Alert severity=\"info\" sx={{ mt: 1 }}>\n {`Paging the ${CONTACT_CEILING} newest members and the ` +\n `${CONTACT_CEILING} newest leads. This site has more ` +\n 'than that — the campaign audiences still reach ' +\n 'everyone, whether or not they are listed here.'}\n </Alert>\n ) : null}\n </>\n )}\n </CardDisplay>\n {/*\n WHERE A LEAD CAME FROM.\n\n Its own dialog rather than a column, for the reason the state above\n gives: one keyed read, paid by the reader who asked the question. A\n lead has no page of its own to put this on, and giving it one to carry\n a single line would be a new record surface rather than attribution.\n */}\n <Dialog\n open={Boolean(leadOrigin)}\n onClose={() => setLeadOrigin(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{leadOrigin?.email ?? 'Lead'}</DialogTitle>\n <DialogContent>\n {leadOrigin?.$id ? (\n <InboxRecordAttributionZone\n hostId={hostId}\n recordKind=\"lead\"\n recordId={String(leadOrigin.$id)}\n />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button variant=\"contained\" onClick={() => setLeadOrigin(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nContactsCard.displayName = 'ContactsCard'\n\nexport default ContactsCard\n"],"names":["normalizeContactEmail","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordHref","mdiAccountArrowRight","mdiAccountRemoveOutline","mdiBullhornOutline","CardDisplay","MdiIcon","useConfirmationContext","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","useSnackbar","useFirestore","useFirestoreCollection","useOrgDataScope","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","deleteDoc","doc","limit","orderBy","query","where","useCallback","useMemo","useState","useRecordRouteContext","scopeTokensForHost","CONTACT_CEILING","ContactsCard","hostId","firestore","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","data","memberDocs","idField","orgId","siteMembers","slice","leadDocs","leads","contactsTruncated","length","dedupedLeads","memberKeys","Set","map","member","email","filter","Boolean","lead","has","contacts","contactKind","handleDeleteMember","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","variant","persist","leadOrigin","setLeadOrigin","contactActions","key","label","icon","path","size","destructive","onClick","href","contactColumns","field","headerName","flex","minWidth","renderCell","row","name","displayName","direction","spacing","sx","alignItems","noWrap","width","valueGetter","_value","source","value","createdAt","toDate","getTime","toLocaleString","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","aria-label","rows","columns","getRowId","rowHeight","severity","mt","open","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"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"}
|
|
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 {\n useFirestore,\n useFirestoreCollection,\n useOrgDataScope,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\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 { 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 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 await deleteDoc(doc(firestore, 'hosts', hostId, 'siteMembers', member.$id))\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n },\n [confirm, firestore, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n /*\n * 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","useFirestore","useFirestoreCollection","useOrgDataScope","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","deleteDoc","doc","limit","orderBy","query","where","useCallback","useMemo","useState","useRecordRouteContext","scopeTokensForHost","orgSiteNames","CONTACT_CEILING","ContactsCard","hostId","orgMount","firestore","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","data","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","variant","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":"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"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import { type ConsolePluginOrgMount } from '@aglyn/aglyn';
|
|
17
18
|
/**
|
|
18
19
|
* Where a lead or a submission came from, drawn by whichever plugin can say.
|
|
19
20
|
*
|
|
@@ -41,9 +42,16 @@ export declare namespace InboxRecordAttributionZone {
|
|
|
41
42
|
* Inbox keeps a Campaigns tab. The campaigns themselves belong to a plugin
|
|
42
43
|
* that sends them, which draws its own card here; the Inbox hands it the site
|
|
43
44
|
* and nothing else.
|
|
45
|
+
*
|
|
46
|
+
* On the organization's Inbox there is no site (AGL-3303): `hostId` is
|
|
47
|
+
* `null` and `orgMount` names the organization and its sites, which is the
|
|
48
|
+
* shell's own shape for a surface mounted with no site. A widget lists every
|
|
49
|
+
* site's campaigns then, the way the organization's Marketing page does.
|
|
44
50
|
*/
|
|
45
51
|
export interface InboxCampaignsZoneProps {
|
|
46
|
-
hostId: string;
|
|
52
|
+
hostId: string | null;
|
|
53
|
+
/** Present exactly when `hostId` is `null`. */
|
|
54
|
+
orgMount?: ConsolePluginOrgMount;
|
|
47
55
|
}
|
|
48
56
|
export declare const INBOX_CAMPAIGNS_ZONE: import("@aglyn/aglyn/plugin-manager/plugin-zones").PluginZone<InboxCampaignsZoneProps>;
|
|
49
57
|
export declare function InboxCampaignsZone(props: InboxCampaignsZoneProps): import("react").JSX.Element;
|
|
@@ -40,7 +40,7 @@ export function InboxCampaignsZone(props) {
|
|
|
40
40
|
/*#__PURE__*/ _jsx(AlertTitle, {
|
|
41
41
|
children: 'Campaigns are part of Marketing'
|
|
42
42
|
}),
|
|
43
|
-
'Marketing is switched off for this workspace or this site, so
|
|
43
|
+
props.hostId == null ? 'Marketing is switched off for this workspace, so there are no ' + 'campaigns to show here.' : 'Marketing is switched off for this workspace or this site, so ' + 'there are no campaigns to show here.'
|
|
44
44
|
]
|
|
45
45
|
});
|
|
46
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/inbox-attribution-zone.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\n'use client'\n\nimport { listConsoleWidgets } from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { Alert, AlertTitle } from '@mui/material'\nimport { definePluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\n\n/**\n * Where a lead or a submission came from, drawn by whichever plugin can say.\n *\n * The Inbox lists what arrived; which campaign or link brought it is a\n * question for a plugin that credits conversions. The Inbox used to import\n * the marketing plugin's component to answer it. It hosts this zone instead,\n * in a lead's \"Where this came from\" dialog and under an open submission, and\n * hands it the record's identity.\n */\nexport interface InboxRecordAttributionZoneProps {\n hostId: string\n /** What the record is, in the Inbox's own words. */\n recordKind: 'lead' | 'form'\n recordId: string\n}\n\nexport const INBOX_RECORD_ATTRIBUTION_ZONE =\n definePluginZone<InboxRecordAttributionZoneProps>('inboxRecordAttribution')\n\nexport function InboxRecordAttributionZone(\n props: InboxRecordAttributionZoneProps,\n) {\n const Slot = useConsoleWidgetSlot()\n if (!Slot) return null\n return <Slot slot={INBOX_RECORD_ATTRIBUTION_ZONE.id} {...props} />\n}\nInboxRecordAttributionZone.displayName = 'InboxRecordAttributionZone'\n\n/**\n * The Inbox's Campaigns section: a place, not a table.\n *\n * What arrived and who it came from sit beside what was sent to them, so the\n * Inbox keeps a Campaigns tab. The campaigns themselves belong to a plugin\n * that sends them, which draws its own card here; the Inbox hands it the site\n * and nothing else.\n */\nexport interface InboxCampaignsZoneProps {\n hostId: string\n}\n\nexport const INBOX_CAMPAIGNS_ZONE =\n definePluginZone<InboxCampaignsZoneProps>('inboxCampaigns')\n\nexport function InboxCampaignsZone(props: InboxCampaignsZoneProps) {\n const Slot = useConsoleWidgetSlot()\n // Read at render, after the shell has loaded its plugins.\n if (!Slot || listConsoleWidgets(INBOX_CAMPAIGNS_ZONE.id).length === 0) {\n return (\n <Alert severity=\"info\">\n <AlertTitle>{'Campaigns are part of Marketing'}</AlertTitle>\n {'Marketing is switched off for this workspace or this site, so
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/inbox-attribution-zone.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\n'use client'\n\nimport { listConsoleWidgets, type ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { Alert, AlertTitle } from '@mui/material'\nimport { definePluginZone } from '@aglyn/aglyn/plugin-manager/plugin-zones'\n\n/**\n * Where a lead or a submission came from, drawn by whichever plugin can say.\n *\n * The Inbox lists what arrived; which campaign or link brought it is a\n * question for a plugin that credits conversions. The Inbox used to import\n * the marketing plugin's component to answer it. It hosts this zone instead,\n * in a lead's \"Where this came from\" dialog and under an open submission, and\n * hands it the record's identity.\n */\nexport interface InboxRecordAttributionZoneProps {\n hostId: string\n /** What the record is, in the Inbox's own words. */\n recordKind: 'lead' | 'form'\n recordId: string\n}\n\nexport const INBOX_RECORD_ATTRIBUTION_ZONE =\n definePluginZone<InboxRecordAttributionZoneProps>('inboxRecordAttribution')\n\nexport function InboxRecordAttributionZone(\n props: InboxRecordAttributionZoneProps,\n) {\n const Slot = useConsoleWidgetSlot()\n if (!Slot) return null\n return <Slot slot={INBOX_RECORD_ATTRIBUTION_ZONE.id} {...props} />\n}\nInboxRecordAttributionZone.displayName = 'InboxRecordAttributionZone'\n\n/**\n * The Inbox's Campaigns section: a place, not a table.\n *\n * What arrived and who it came from sit beside what was sent to them, so the\n * Inbox keeps a Campaigns tab. The campaigns themselves belong to a plugin\n * that sends them, which draws its own card here; the Inbox hands it the site\n * and nothing else.\n *\n * On the organization's Inbox there is no site (AGL-3303): `hostId` is\n * `null` and `orgMount` names the organization and its sites, which is the\n * shell's own shape for a surface mounted with no site. A widget lists every\n * site's campaigns then, the way the organization's Marketing page does.\n */\nexport interface InboxCampaignsZoneProps {\n hostId: string | null\n /** Present exactly when `hostId` is `null`. */\n orgMount?: ConsolePluginOrgMount\n}\n\nexport const INBOX_CAMPAIGNS_ZONE =\n definePluginZone<InboxCampaignsZoneProps>('inboxCampaigns')\n\nexport function InboxCampaignsZone(props: InboxCampaignsZoneProps) {\n const Slot = useConsoleWidgetSlot()\n // Read at render, after the shell has loaded its plugins.\n if (!Slot || listConsoleWidgets(INBOX_CAMPAIGNS_ZONE.id).length === 0) {\n return (\n <Alert severity=\"info\">\n <AlertTitle>{'Campaigns are part of Marketing'}</AlertTitle>\n {props.hostId == null\n ? 'Marketing is switched off for this workspace, so there are no ' +\n 'campaigns to show here.'\n : 'Marketing is switched off for this workspace or this site, so ' +\n 'there are no campaigns to show here.'}\n </Alert>\n )\n }\n return <Slot slot={INBOX_CAMPAIGNS_ZONE.id} {...props} />\n}\nInboxCampaignsZone.displayName = 'InboxCampaignsZone'\n"],"names":["listConsoleWidgets","useConsoleWidgetSlot","Alert","AlertTitle","definePluginZone","INBOX_RECORD_ATTRIBUTION_ZONE","InboxRecordAttributionZone","props","Slot","slot","id","displayName","INBOX_CAMPAIGNS_ZONE","InboxCampaignsZone","length","severity","hostId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;AAEA,SAASA,kBAAkB,QAAoC,eAAc;AAC7E,SAASC,oBAAoB,QAAQ,qDAAoD;AACzF,SAASC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACjD,SAASC,gBAAgB,QAAQ,2CAA0C;AAkB3E,OAAO,MAAMC,gCACXD,iBAAkD,0BAAyB;AAE7E,OAAO,SAASE,2BACdC,KAAsC;IAEtC,MAAMC,OAAOP;IACb,IAAI,CAACO,MAAM,OAAO;IAClB,qBAAO,KAACA;QAAKC,MAAMJ,8BAA8BK,EAAE;OAAMH;AAC3D;AACAD,2BAA2BK,WAAW,GAAG;AAqBzC,OAAO,MAAMC,uBACXR,iBAA0C,kBAAiB;AAE7D,OAAO,SAASS,mBAAmBN,KAA8B;IAC/D,MAAMC,OAAOP;IACb,0DAA0D;IAC1D,IAAI,CAACO,QAAQR,mBAAmBY,qBAAqBF,EAAE,EAAEI,MAAM,KAAK,GAAG;QACrE,qBACE,MAACZ;YAAMa,UAAS;;8BACd,KAACZ;8BAAY;;gBACZI,MAAMS,MAAM,IAAI,OACb,mEACA,4BACA,mEACA;;;IAGV;IACA,qBAAO,KAACR;QAAKC,MAAMG,qBAAqBF,EAAE;OAAMH;AAClD;AACAM,mBAAmBF,WAAW,GAAG"}
|
|
@@ -20,6 +20,10 @@ import { type ConsolePluginPageProps } from '@aglyn/aglyn';
|
|
|
20
20
|
* others do not exist to subscribe. What routing adds besides is that the URL
|
|
21
21
|
* names the section: it is linkable, the back button walks sections, and the
|
|
22
22
|
* breadcrumb says where you are.
|
|
23
|
+
*
|
|
24
|
+
* The ORGANIZATION's Inbox is this page handed no site (AGL-3303): the same
|
|
25
|
+
* sections over every site at once, and a site filter above the rail that
|
|
26
|
+
* narrows the whole page to one site's own view.
|
|
23
27
|
*/
|
|
24
28
|
export declare function InboxConsolePage(props: ConsolePluginPageProps): import("react").JSX.Element;
|
|
25
29
|
export declare namespace InboxConsolePage {
|