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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
- import { normalizeContactEmail, pluginDocsHelp } from "@aglyn/aglyn";
19
+ import { pluginDocsHelp } from "@aglyn/aglyn";
20
20
  // A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry
21
21
  // point the tenant's loader dynamically imports to activate the marketing
22
22
  // plugin's SITE half, so a console card named there ships to every published
@@ -25,74 +25,61 @@ import { InboxRecordAttributionZone } from "./inbox-attribution-zone.js";
25
25
  // The CRM's route builder by its leaf path, not the plugin barrel: the barrel
26
26
  // carries the plugin registration, and a link needs only the address grammar.
27
27
  import { pluginRecordHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
28
+ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
28
29
  import { mdiAccountArrowRight, mdiAccountRemoveOutline, mdiBullhornOutline } from "@aglyn/shared-data-mdi";
29
30
  import { CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
30
31
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
32
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
33
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
31
34
  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";
35
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
34
36
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
37
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
35
38
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
36
39
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
37
- import { useFirestore, useFirestoreCollection, useOrgDataScope, useUser } from "@aglyn/tenant-feature-instance";
38
- import { Alert, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Typography } from "@mui/material";
39
- import { collection, limit, orderBy, query, where } from "firebase/firestore";
40
+ import { useFirestore, useOrgDataScope, useScopeTokens, useUser } from "@aglyn/tenant-feature-instance";
41
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
42
+ import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Stack, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
43
+ import { collection } from "firebase/firestore";
40
44
  import { useCallback, useMemo, useState } from "react";
41
- import { useRecordRouteContext } from "./use-record-route-context.js";
42
- import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
45
+ import { LEAD_FILTER_HEADERS, LEAD_LIST_QUERY, LEAD_SOURCE_OPTIONS, ORG_LEAD_LIST_QUERY, SITE_MEMBER_FILTER_HEADERS, SITE_MEMBER_LIST_QUERY, leadListBase, LEAD_ADDRESS_SEARCH_COLUMN, LEAD_ADDRESS_SEARCH_NOTICE, leadListQueryFor, leadSourceLabel } from "../constants/list-queries.js";
43
46
  import { orgSiteNames } from "./inbox-org-sites.js";
44
- /**
45
- * How many members and how many leads the contacts table reads.
46
- *
47
- * A ceiling rather than a page size — see the two queries, which explain why
48
- * this one table cannot be paged by the server without breaking the dedupe
49
- * between them.
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'
47
+ import { useRecordRouteContext } from "./use-record-route-context.js";
48
+ /** The organization's Inbox's Source and Site are picked, so the panel shows selects. */ const ORG_LEAD_SELECT_FIELDS = [
49
+ 'sources',
50
+ 'capturedByHostIds'
79
51
  ];
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';
52
+ const NO_SELECT_FIELDS = [];
53
+ /**
54
+ * Every surface a lead was captured by: `sources`, which every capture
55
+ * `arrayUnion`s, or the single `source` a lead written before it carries.
56
+ */ const leadSources = (lead)=>Array.isArray(lead == null ? void 0 : lead.sources) ? lead.sources.filter((source)=>typeof source === 'string') : typeof (lead == null ? void 0 : lead.source) === 'string' && lead.source ? [
57
+ lead.source
58
+ ] : [];
81
59
  /**
82
- * The Members & leads section of the Inbox (AGL-109): everybody a site
83
- * collected, in one list.
60
+ * The Members & leads section of the Inbox (AGL-109): the people a site
61
+ * collected — its members, who signed up to it, and its leads, whom a form,
62
+ * a booking or a teammate captured.
84
63
  *
85
- * Its own component since AGL-2501, when the Inbox's tabs became routes. The
86
- * split is what makes "mount only the section being read" structural: hooks
87
- * cannot be conditional, so a page holding every section's reads pays for all
88
- * of them whichever one the URL names.
64
+ * ## Two lists, one table (AGL-3321)
65
+ *
66
+ * Members and leads are two collections, and the table shows one at a time,
67
+ * chosen by the toggle above it. Each is its own Firestore query, every
68
+ * filter and search word on it, paged by the server — so a page is a page of
69
+ * the answer, and a site with thousands of either reaches all of them.
70
+ *
71
+ * It used to read the two hundred newest of each and interleave them, hiding
72
+ * a lead whose address matched a member. That dedupe is only correct while
73
+ * both reads are whole, which is exactly what a paged query is not, so it is
74
+ * gone: a person who left their address and later signed up is a lead in one
75
+ * list and a member in the other, which is what they are in each collection.
89
76
  *
90
77
  * On the organization's Inbox (`hostId: null`, AGL-3303) it lists the
91
78
  * organization's leads with the sites each was captured by. A member signs up
92
- * to ONE site and lives under it, so members are listed once a site is
93
- * picked, which is the page handing this card that site.
79
+ * to ONE site and lives under it, so Members waits for a site to be picked,
80
+ * which is the page handing this card that site.
94
81
  */ export function ContactsCard({ hostId, orgMount }) {
95
- var _ref, _ref1, _ref2;
82
+ var _ref;
96
83
  const firestore = useFirestore();
97
84
  const { data: user } = useUser();
98
85
  const { enqueueSnackbar } = useSnackbar();
@@ -101,135 +88,148 @@ const CONTACT_SEARCH_FIELDS = [
101
88
  // Leads section gives each one a status, an owner and a conversion.
102
89
  const routeContext = useRecordRouteContext();
103
90
  const leadHref = (contact)=>routeContext ? pluginRecordHref('lead', routeContext, String(contact.$id)) : null;
91
+ /*
92
+ * The list shown. Members exist only under a site, so the organization's
93
+ * Inbox shows leads whatever was chosen, and the toggle says why.
94
+ */ const [chosen, setChosen] = useState('members');
95
+ const view = hostId ? chosen : 'leads';
96
+ const [searchWords, setSearchWords] = useState([]);
97
+ const gridFilter = useListGridFilter({
98
+ selectFields: view === 'leads' && hostId == null ? ORG_LEAD_SELECT_FIELDS : NO_SELECT_FIELDS,
99
+ search: {
100
+ words: searchWords,
101
+ onChange: setSearchWords
102
+ }
103
+ });
104
+ /*
105
+ * A switch of list starts it clean: the two lists filter by different
106
+ * fields, and a clause carried across would be one the other list cannot
107
+ * ask — refused on arrival, for a question the reader asked of the list
108
+ * they just left.
109
+ */ const switchView = (next)=>{
110
+ if (!next || next === view) return;
111
+ gridFilter.setClauses([]);
112
+ setSearchWords([]);
113
+ setChosen(next);
114
+ };
115
+ const request = {
116
+ clauses: gridFilter.clauses,
117
+ search: gridFilter.searchWords
118
+ };
104
119
  /*==========================================
105
- * SITE MEMBERS + LEADS (AGL-109): ORDERED AND CEILINGED, NOT PAGED BY QUERY.
106
- *
107
- * Both reads were `limit(200)` with no `orderBy` and a client sort on top —
108
- * the same document-id sample the submissions list used to take, and both
109
- * now name the order the rows are rendered in. `createdAt` is safe on both:
120
+ * SITE MEMBERS — `hosts/{hostId}/siteMembers`, newest first
121
+ * (`SITE_MEMBER_LIST_QUERY`). `createdAt` is safe to order on:
110
122
  * `membership-register.ts` is the only writer that CREATES a site member
111
- * and stamps it inside its transaction (every other membership path
112
- * updates an existing document), `recordVisitorLead` is the only writer
113
- * that creates a lead and stamps it on every `tx.create`, and neither
114
- * collection is in `IMPORTABLE_FIELDS`.
115
- *
116
- * What they must NOT do is page the query, and the reason is the dedupe
117
- * below: a lead is hidden when a MEMBER already exists on the same address.
118
- * That test is only correct while both windows are whole. On a ten-row
119
- * server page it would compare a page of leads against a page of members, so
120
- * somebody who signed up after leaving their address would render as a
121
- * Member on one page and again as a Lead on another — one person counted
122
- * twice, in a list a site owner uses to count people.
123
- *
124
- * So the CEILING stays and the page is a slice of the assembled rows. The
125
- * probe row makes "there are more contacts than these" a fact rather than a
126
- * guess from `length === CONTACT_CEILING`, which is wrong at exactly the
127
- * count that equals the ceiling.
128
- *=========================================*/ const { data: memberDocs } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'siteMembers'), orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)) : null, [
129
- firestore,
130
- hostId
131
- ], {
123
+ * and stamps it inside its transaction, and the collection is not in
124
+ * `IMPORTABLE_FIELDS`. No query opens while the other list is shown.
125
+ *=========================================*/ const members = useListQuery({
126
+ collection: hostId && view === 'members' ? collection(firestore, 'hosts', hostId, 'siteMembers') : null,
127
+ declaration: SITE_MEMBER_LIST_QUERY,
128
+ request: view === 'members' ? request : {
129
+ clauses: []
130
+ },
131
+ deps: [
132
+ firestore,
133
+ hostId,
134
+ view
135
+ ],
132
136
  idField: '$id'
133
137
  });
134
- /*
135
- * The lead silo is the org's (AGL-3275), narrowed to this site by
136
- * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its
137
- * pre-migration rows and nothing captured since.
138
- */ const { orgId } = useOrgDataScope({
138
+ /*==========================================
139
+ * LEADS — `orgs/{orgId}/leads`, newest first (`LEAD_LIST_QUERY`). The
140
+ * silo is the org's (AGL-3275), narrowed to this site by `visibleTo`;
141
+ * reading `hosts/{hostId}/leads` showed a site its pre-migration rows and
142
+ * nothing captured since. `addHostLead`, every lead door's one writer,
143
+ * stamps `createdAt` on every create.
144
+ *
145
+ * Every site's, on the organization's Inbox: an org-wide member reads the
146
+ * collection unscoped — the rules short-circuit on their reach, as for the
147
+ * CRM's own org list — so a clause there would only narrow what they may
148
+ * already read.
149
+ *=========================================*/ const { orgId } = useOrgDataScope({
139
150
  hostId: hostId != null ? hostId : undefined,
140
151
  orgId: hostId == null ? orgMount == null ? void 0 : orgMount.orgId : undefined
141
152
  });
142
- const siteMembers = (memberDocs != null ? memberDocs : []).slice(0, CONTACT_CEILING);
143
- const { data: leadDocs } = useFirestoreCollection(()=>orgId ? query(collection(firestore, 'orgs', orgId, 'leads'), /*
144
- * Every site's, on the organization's Inbox: an org-wide member
145
- * reads the collection unscoped — the rules short-circuit on
146
- * their reach, as for the CRM's own org list — so a clause here
147
- * would only narrow what they may already read.
148
- */ ...hostId ? [
149
- where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))
150
- ] : [], orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)) : null, [
151
- firestore,
152
- orgId,
153
- hostId
154
- ], {
155
- idField: '$id'
156
- });
157
- const leads = (leadDocs != null ? leadDocs : []).slice(0, CONTACT_CEILING);
158
- const contactsTruncated = ((_ref = memberDocs == null ? void 0 : memberDocs.length) != null ? _ref : 0) > CONTACT_CEILING || ((_ref1 = leadDocs == null ? void 0 : leadDocs.length) != null ? _ref1 : 0) > CONTACT_CEILING;
159
153
  /*
160
- * One person renders once, whichever way they came in.
161
- *
162
- * Raw `===` made `Bob@x.com` and `bob@x.com` two different people, so
163
- * somebody could appear as a Member on one row and a Lead on another. The
164
- * comparison is now the same NORMALIZATION a lead document is keyed by —
165
- * `personKey` is `sha256(normalizeContactEmail(email))`, and hashing an
166
- * already-normalized address cannot merge or split anything the normalizer
167
- * did not, so the two agree by construction.
168
- *
169
- * `personKey` itself cannot run here: it needs `node:crypto` and this is a
170
- * client component. That is why the shared half is the normalizer rather
171
- * than the digest.
172
- */ const dedupedLeads = useMemo(()=>{
173
- const memberKeys = new Set(siteMembers.map((member)=>normalizeContactEmail(member.email)).filter(Boolean));
174
- return leads.filter((lead)=>!memberKeys.has(normalizeContactEmail(lead.email)));
175
- }, [
176
- leads,
177
- siteMembers
154
+ * Whether the search may fold into the scope clause (`scopedSearch`): a
155
+ * query without `visibleTo` is one the rules prove only for an ORG-WIDE
156
+ * member. Anyone else keeps the clause, and the plan refuses their search
157
+ * by name rather than sending a query the rules deny. Until the reach has
158
+ * answered, the answer is no. Asked only while Leads is shown: the member
159
+ * document is a read, and Members has no scope to fold into.
160
+ */ const reach = useScopeTokens(hostId && orgId && view === 'leads' ? orgId : undefined);
161
+ const foldsScope = hostId == null || reach.loaded && reach.orgWide;
162
+ const leadDeclaration = hostId ? LEAD_LIST_QUERY : ORG_LEAD_LIST_QUERY;
163
+ const leadBase = useMemo(()=>leadListBase(hostId ? scopeTokensForHost(hostId) : null), [
164
+ hostId
178
165
  ]);
179
- /*
180
- * ONE list of two collections — members first, then the leads that are not
181
- * already members — so the grid pages the concatenation rather than either
182
- * read, and a page stays whole across the seam between them. Both reads are
183
- * ceilinged and complete below the ceiling, so the grid's count is the whole
184
- * deduped list.
185
- */ const contacts = useMemo(()=>[
186
- ...siteMembers.map((member)=>_extends({}, member, {
187
- contactKind: 'member',
188
- typeKey: 'member'
189
- })),
190
- ...dedupedLeads.map((lead)=>_extends({}, lead, {
191
- contactKind: 'lead',
192
- typeKey: lead.source ? `lead:${lead.source}` : 'lead'
193
- }))
194
- ], [
195
- siteMembers,
196
- dedupedLeads
166
+ const leadQuery = useMemo(()=>leadListQueryFor(leadDeclaration, foldsScope, view === 'leads' ? _extends({}, request, {
167
+ base: leadBase
168
+ }) : {
169
+ clauses: []
170
+ }), // The request is data; its JSON is its identity.
171
+ // eslint-disable-next-line react-hooks/exhaustive-deps
172
+ [
173
+ leadDeclaration,
174
+ foldsScope,
175
+ view,
176
+ leadBase,
177
+ JSON.stringify(request)
197
178
  ]);
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(()=>{
179
+ const leads = useListQuery({
180
+ collection: orgId && view === 'leads' ? collection(firestore, 'orgs', orgId, 'leads') : null,
181
+ declaration: leadQuery.declaration,
182
+ request: leadQuery.request,
183
+ deps: [
184
+ firestore,
185
+ orgId,
186
+ hostId,
187
+ view
188
+ ],
189
+ idField: '$id'
190
+ });
191
+ const shown = view === 'members' ? members : leads;
192
+ const declaration = view === 'members' ? SITE_MEMBER_LIST_QUERY : leadDeclaration;
193
+ const headers = view === 'members' ? SITE_MEMBER_FILTER_HEADERS : LEAD_FILTER_HEADERS;
194
+ const options = useMemo(()=>{
202
195
  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 ? {
196
+ return view === 'leads' && hostId == null ? {
197
+ sources: LEAD_SOURCE_OPTIONS,
216
198
  capturedByHostIds: ((_ref = orgMount == null ? void 0 : orgMount.hosts) != null ? _ref : []).map((host)=>({
217
199
  value: host.id,
218
200
  label: host.name || host.id
219
201
  }))
220
- } : {});
202
+ } : {};
221
203
  }, [
222
- contacts,
204
+ view,
223
205
  hostId,
224
206
  orgMount
225
207
  ]);
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
- });
208
+ /*
209
+ * A site collaborator's search rides the query as an address-prefix clause
210
+ * (`leadListQueryFor`); a refusal of it is the search's, and when it is
211
+ * served the list says what it matched and how it is now ordered.
212
+ */ const refusals = useMemo(()=>listQueryRefusals(shown.plan.refused.map((entry)=>entry.clause !== 'search' && entry.clause.field === LEAD_ADDRESS_SEARCH_COLUMN ? _extends({}, entry, {
213
+ clause: 'search'
214
+ }) : entry), {
215
+ fields: declaration.fields,
216
+ headers,
217
+ options
218
+ }), [
219
+ shown.plan.refused,
220
+ declaration,
221
+ headers,
222
+ options
223
+ ]);
224
+ const addressSearched = view === 'leads' && leadQuery.addressSearch && leads.plan.served.some((clause)=>clause.field === LEAD_ADDRESS_SEARCH_COLUMN);
225
+ const notices = useMemo(()=>addressSearched ? [
226
+ ...shown.plan.notices,
227
+ LEAD_ADDRESS_SEARCH_NOTICE
228
+ ] : shown.plan.notices, [
229
+ addressSearched,
230
+ shown.plan.notices
231
+ ]);
232
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
233
233
  /*
234
234
  * REMOVED BY THE ROUTE THAT OWNS MEMBER ACCOUNTS (AGL-3308), not by a
235
235
  * client delete. A member's password hash lives in a document no client can
@@ -303,7 +303,7 @@ const CONTACT_SEARCH_FIELDS = [
303
303
  return typeof first === 'string' && first ? first : null;
304
304
  };
305
305
  const leadOriginSite = leadOrigin ? originSite(leadOrigin) : null;
306
- const contactActions = (contact)=>contact.contactKind === 'member' ? [
306
+ const memberActions = (member)=>[
307
307
  {
308
308
  key: 'remove',
309
309
  label: 'Remove member',
@@ -312,12 +312,13 @@ const CONTACT_SEARCH_FIELDS = [
312
312
  size: 0.8
313
313
  }),
314
314
  destructive: true,
315
- onClick: ()=>void handleDeleteMember(contact)()
315
+ onClick: ()=>void handleDeleteMember(member)()
316
316
  }
317
- ] : [
317
+ ];
318
+ const leadActions = (lead)=>[
318
319
  // Offered only where a plugin publishes a lead's address: text-less
319
320
  // rather than a link to a page this workspace cannot open.
320
- ...leadHref(contact) ? [
321
+ ...leadHref(lead) ? [
321
322
  {
322
323
  key: 'crm',
323
324
  label: 'Open in CRM',
@@ -325,11 +326,11 @@ const CONTACT_SEARCH_FIELDS = [
325
326
  path: mdiAccountArrowRight.path,
326
327
  size: 0.8
327
328
  }),
328
- href: leadHref(contact)
329
+ href: leadHref(lead)
329
330
  }
330
331
  ] : [],
331
332
  // A lead no site captured has no site to ask for its campaign.
332
- ...originSite(contact) ? [
333
+ ...originSite(lead) ? [
333
334
  {
334
335
  key: 'origin',
335
336
  label: 'Where this came from',
@@ -337,11 +338,11 @@ const CONTACT_SEARCH_FIELDS = [
337
338
  path: mdiBullhornOutline.path,
338
339
  size: 0.8
339
340
  }),
340
- onClick: ()=>setLeadOrigin(contact)
341
+ onClick: ()=>setLeadOrigin(lead)
341
342
  }
342
343
  ] : []
343
344
  ];
344
- const contactColumns = [
345
+ const columns = [
345
346
  {
346
347
  field: 'email',
347
348
  headerName: 'Email',
@@ -352,7 +353,7 @@ const CONTACT_SEARCH_FIELDS = [
352
353
  the lead writer stores (AGL-2303) — because a list of bare addresses is
353
354
  a list nobody recognizes anyone in.
354
355
  */ renderCell: ({ row: contact })=>{
355
- const name = contact.contactKind === 'member' ? contact.displayName : contact.name;
356
+ const name = view === 'members' ? contact.displayName : contact.name;
356
357
  return /*#__PURE__*/ _jsxs(Stack, {
357
358
  direction: "row",
358
359
  spacing: 1,
@@ -376,52 +377,53 @@ const CONTACT_SEARCH_FIELDS = [
376
377
  });
377
378
  }
378
379
  },
379
- {
380
- /*
381
- WHERE A LEAD CAME FROM (AGL-2338). `source` has been written by both
382
- lead writers — `'signup'` and `'booking'` — since AGL-109, so a site
383
- owner can tell a membership sign-up from a booking. Falls back to the
384
- bare label for a row written before the field, or by a future writer
385
- that omits it.
386
- */ field: 'contactKind',
387
- headerName: 'Type',
388
- width: 170,
389
- valueGetter: (_value, contact)=>contact.typeKey,
390
- renderCell: ({ row: contact })=>contact.contactKind === 'member' ? /*#__PURE__*/ _jsx(Chip, {
391
- label: contactTypeLabel(contact.typeKey),
392
- color: "primary",
393
- size: "small"
394
- }) : /*#__PURE__*/ _jsx(Chip, {
395
- label: contactTypeLabel(contact.typeKey),
396
- size: "small",
397
- variant: "outlined"
398
- })
399
- },
380
+ /*
381
+ WHERE A LEAD CAME FROM (AGL-2338): every surface that captured the
382
+ person — a form, a booking, a lead added by hand — one chip each. A lead
383
+ written before `sources` carries the one `source` it was captured by,
384
+ and a row with neither simply shows none.
385
+ */ ...view === 'leads' ? [
386
+ {
387
+ field: 'sources',
388
+ headerName: 'Source',
389
+ width: 200,
390
+ sortable: false,
391
+ valueGetter: (_value, lead)=>leadSources(lead).map(leadSourceLabel).join(', '),
392
+ renderCell: ({ row: lead })=>/*#__PURE__*/ _jsx(Stack, {
393
+ direction: "row",
394
+ spacing: 0.5,
395
+ sx: {
396
+ alignItems: 'center',
397
+ height: '100%'
398
+ },
399
+ children: [
400
+ ...new Set(leadSources(lead).map(leadSourceLabel))
401
+ ].map((label)=>/*#__PURE__*/ _jsx(Chip, {
402
+ label: label,
403
+ size: "small",
404
+ variant: "outlined"
405
+ }, label))
406
+ })
407
+ }
408
+ ] : [],
400
409
  /*
401
410
  KNOWN BY, on the organization's Inbox: every site that captured the
402
411
  person, as the CRM's organization Leads list names them. A lead is one
403
412
  org row, so "which site" is a set, never one answer.
404
- */ ...hostId == null ? [
413
+ */ ...view === 'leads' && hostId == null ? [
405
414
  {
406
415
  field: 'capturedByHostIds',
407
416
  headerName: 'Site',
408
417
  flex: 1,
409
418
  minWidth: 160,
410
- // Sorted and drawn by name; the filter matches the ids.
411
- valueGetter: (_value, contact)=>orgSiteNames(orgMount, contact.capturedByHostIds),
419
+ sortable: false,
412
420
  renderCell: ({ row: contact })=>orgSiteNames(orgMount, contact.capturedByHostIds)
413
421
  }
414
422
  ] : [],
415
423
  {
416
424
  field: 'createdAt',
417
- headerName: 'Joined',
425
+ headerName: view === 'members' ? 'Joined' : 'Captured',
418
426
  width: 200,
419
- // Sorted on the instant, drawn as a local string.
420
- valueGetter: (_value, contact)=>{
421
- var _ref;
422
- var _contact_createdAt_toDate_getTime, _contact_createdAt_toDate, _contact_createdAt_toDate1, _contact_createdAt;
423
- return (_ref = (_contact_createdAt = contact.createdAt) == null ? void 0 : (_contact_createdAt_toDate1 = _contact_createdAt.toDate) == null ? void 0 : (_contact_createdAt_toDate = _contact_createdAt_toDate1.call(_contact_createdAt)) == null ? void 0 : (_contact_createdAt_toDate_getTime = _contact_createdAt_toDate.getTime) == null ? void 0 : _contact_createdAt_toDate_getTime.call(_contact_createdAt_toDate)) != null ? _ref : 0;
424
- },
425
427
  renderCell: ({ row: contact })=>{
426
428
  var _ref;
427
429
  var _contact_createdAt_toDate, _contact_createdAt;
@@ -432,15 +434,17 @@ const CONTACT_SEARCH_FIELDS = [
432
434
  var _contact_email;
433
435
  return /*#__PURE__*/ _jsx(ListRowActions, {
434
436
  label: String((_contact_email = contact.email) != null ? _contact_email : contact.$id),
435
- items: contactActions(contact)
437
+ items: view === 'members' ? memberActions(contact) : leadActions(contact)
436
438
  });
437
439
  }, {
438
440
  width: 72
439
441
  })
440
442
  ];
443
+ const empty = shown.rows.length === 0 && !shown.hasMore && !filtering;
444
+ const emptyLabel = view === 'members' ? 'No members yet — visitors can join at /signup on your site.' : hostId == null ? 'No leads on any site yet.' : 'No leads yet — the forms and bookings that file leads add them here.';
441
445
  return /*#__PURE__*/ _jsxs(_Fragment, {
442
446
  children: [
443
- /*#__PURE__*/ _jsxs(CardDisplay, {
447
+ /*#__PURE__*/ _jsx(CardDisplay, {
444
448
  header: hostId == null ? 'Leads' : 'Site Members & Leads',
445
449
  help: pluginDocsHelp('forms', {
446
450
  anchor: '#filter-the-inbox'
@@ -448,43 +452,87 @@ const CONTACT_SEARCH_FIELDS = [
448
452
  contentGutterX: true,
449
453
  contentGutterY: true,
450
454
  contentBordered: "all",
451
- children: [
452
- hostId == null ? /*#__PURE__*/ _jsx(Typography, {
453
- variant: "body2",
454
- color: "text.secondary",
455
- sx: {
456
- mb: leads.length ? 2 : 1
457
- },
458
- children: 'Every site’s leads, newest first. Members sign up to one site ' + 'each — choose a site to list its members beside its leads.'
459
- }) : null,
460
- siteMembers.length === 0 && leads.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
461
- variant: "body2",
462
- color: "text.secondary",
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.'
464
- }) : /*#__PURE__*/ _jsxs(_Fragment, {
465
- children: [
466
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, contactFilter.chipsProps)),
467
- /*#__PURE__*/ _jsx(ListTable, _extends({
468
- "aria-label": "Site members and leads",
469
- rows: contactFilter.rows,
470
- columns: contactFilter.filterColumns(contactColumns)
471
- }, contactFilter.gridProps, {
472
- noRowsLabel: "No contacts match these filters",
473
- // A member and a lead are two collections, so an id alone could
474
- // name one of each.
475
- getRowId: (contact)=>`${contact.contactKind}:${contact.$id}`,
476
- rowHeight: TABLE_ROW_HEIGHT
477
- })),
478
- contactsTruncated ? /*#__PURE__*/ _jsx(Alert, {
479
- severity: "info",
480
- sx: {
481
- mt: 1
482
- },
483
- 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.'
484
- }) : null
485
- ]
486
- })
487
- ]
455
+ children: /*#__PURE__*/ _jsxs(Stack, {
456
+ spacing: 1.5,
457
+ children: [
458
+ /*#__PURE__*/ _jsxs(Stack, {
459
+ direction: "row",
460
+ spacing: 1.5,
461
+ sx: {
462
+ alignItems: 'center',
463
+ flexWrap: 'wrap'
464
+ },
465
+ children: [
466
+ /*#__PURE__*/ _jsxs(ToggleButtonGroup, {
467
+ exclusive: true,
468
+ size: "small",
469
+ value: view,
470
+ onChange: (_event, next)=>switchView(next),
471
+ "aria-label": "Which people to list",
472
+ children: [
473
+ /*#__PURE__*/ _jsx(ToggleButton, {
474
+ value: "members",
475
+ disabled: hostId == null,
476
+ children: 'Members'
477
+ }),
478
+ /*#__PURE__*/ _jsx(ToggleButton, {
479
+ value: "leads",
480
+ children: 'Leads'
481
+ })
482
+ ]
483
+ }),
484
+ hostId == null ? /*#__PURE__*/ _jsx(Typography, {
485
+ variant: "body2",
486
+ color: "text.secondary",
487
+ children: 'Every site’s leads, newest first. Members sign up to one ' + 'site each — choose a site to list its members.'
488
+ }) : null
489
+ ]
490
+ }),
491
+ empty ? /*#__PURE__*/ _jsx(Typography, {
492
+ variant: "body2",
493
+ color: "text.secondary",
494
+ children: emptyLabel
495
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
496
+ children: [
497
+ /*#__PURE__*/ _jsx(ListFilterChips, {
498
+ fields: declaration.fields,
499
+ headers: headers,
500
+ clauses: gridFilter.clauses,
501
+ onChange: gridFilter.setClauses,
502
+ options: options
503
+ }),
504
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
505
+ refused: refusals,
506
+ notices: notices
507
+ }),
508
+ /*#__PURE__*/ _jsx(ListTable, {
509
+ "aria-label": view === 'members' ? 'Site members' : 'Leads',
510
+ rows: shown.rows,
511
+ columns: listFilterGridColumns(columns, declaration.fields, options, headers),
512
+ rowHeight: TABLE_ROW_HEIGHT,
513
+ // Paged by the footer below, so the grid must not also slice.
514
+ hideFooter: true,
515
+ // The panel and the search go to the query; the grid neither
516
+ // filters nor sorts the page it holds.
517
+ filterMode: "server",
518
+ filterModel: gridFilter.filterModel,
519
+ onFilterModelChange: gridFilter.onFilterModelChange,
520
+ quickFilter: true,
521
+ disableColumnSorting: true,
522
+ noRowsLabel: view === 'members' ? 'No members match these filters' : 'No leads match these filters'
523
+ }, view),
524
+ /*#__PURE__*/ _jsx(ListPagination, {
525
+ page: shown.page,
526
+ pageSize: shown.pageSize,
527
+ rowCount: shown.rows.length,
528
+ hasMore: shown.hasMore,
529
+ onPageChange: shown.setPage,
530
+ onPageSizeChange: shown.setPageSize
531
+ })
532
+ ]
533
+ })
534
+ ]
535
+ })
488
536
  }),
489
537
  /*#__PURE__*/ _jsxs(Dialog, {
490
538
  open: Boolean(leadOrigin),
@@ -493,7 +541,7 @@ const CONTACT_SEARCH_FIELDS = [
493
541
  fullWidth: true,
494
542
  children: [
495
543
  /*#__PURE__*/ _jsx(DialogTitle, {
496
- children: (_ref2 = leadOrigin == null ? void 0 : leadOrigin.email) != null ? _ref2 : 'Lead'
544
+ children: (_ref = leadOrigin == null ? void 0 : leadOrigin.email) != null ? _ref : 'Lead'
497
545
  }),
498
546
  /*#__PURE__*/ _jsx(DialogContent, {
499
547
  children: (leadOrigin == null ? void 0 : leadOrigin.$id) && leadOriginSite ? /*#__PURE__*/ _jsx(InboxRecordAttributionZone, {