@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.
- package/package.json +10 -10
- package/src/lib/components/contacts-card.component.d.ts +19 -8
- package/src/lib/components/contacts-card.component.js +291 -243
- package/src/lib/components/contacts-card.component.js.map +1 -1
- package/src/lib/components/submissions-card.component.js +123 -157
- package/src/lib/components/submissions-card.component.js.map +1 -1
- package/src/lib/constants/list-queries.d.ts +86 -0
- package/src/lib/constants/list-queries.js +369 -0
- package/src/lib/constants/list-queries.js.map +1 -0
- package/src/lib/model/submission-presenter.d.ts +10 -0
- package/src/lib/model/submission-presenter.js +5 -41
- package/src/lib/model/submission-presenter.js.map +1 -1
|
@@ -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 {
|
|
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 {
|
|
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,
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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):
|
|
83
|
-
* collected,
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
|
93
|
-
*
|
|
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
|
|
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
|
|
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
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* pre-migration rows and
|
|
138
|
-
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
204
|
+
view,
|
|
223
205
|
hostId,
|
|
224
206
|
orgMount
|
|
225
207
|
]);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
341
|
+
onClick: ()=>setLeadOrigin(lead)
|
|
341
342
|
}
|
|
342
343
|
] : []
|
|
343
344
|
];
|
|
344
|
-
const
|
|
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 =
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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:
|
|
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__*/
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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: (
|
|
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, {
|