@aglyn/plugins-inbox 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +51 -0
- package/src/index.d.ts +18 -0
- package/src/index.js +19 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/contacts-card.component.d.ts +16 -0
- package/src/lib/components/contacts-card.component.js +351 -0
- package/src/lib/components/contacts-card.component.js.map +1 -0
- package/src/lib/components/inbox-attribution-zone.d.ts +52 -0
- package/src/lib/components/inbox-attribution-zone.js +53 -0
- package/src/lib/components/inbox-attribution-zone.js.map +1 -0
- package/src/lib/components/inbox-console-page.d.ts +28 -0
- package/src/lib/components/inbox-console-page.js +219 -0
- package/src/lib/components/inbox-console-page.js.map +1 -0
- package/src/lib/components/inbox-console-sections.d.ts +40 -0
- package/src/lib/components/inbox-console-sections.js +66 -0
- package/src/lib/components/inbox-console-sections.js.map +1 -0
- package/src/lib/components/inbox-glance-card.component.d.ts +26 -0
- package/src/lib/components/inbox-glance-card.component.js +215 -0
- package/src/lib/components/inbox-glance-card.component.js.map +1 -0
- package/src/lib/components/submission-list-assignment.component.d.ts +29 -0
- package/src/lib/components/submission-list-assignment.component.js +257 -0
- package/src/lib/components/submission-list-assignment.component.js.map +1 -0
- package/src/lib/components/submission-reply.component.d.ts +30 -0
- package/src/lib/components/submission-reply.component.js +249 -0
- package/src/lib/components/submission-reply.component.js.map +1 -0
- package/src/lib/components/submissions-card.component.d.ts +35 -0
- package/src/lib/components/submissions-card.component.js +577 -0
- package/src/lib/components/submissions-card.component.js.map +1 -0
- package/src/lib/components/use-record-route-context.d.ts +15 -0
- package/src/lib/components/use-record-route-context.js +41 -0
- package/src/lib/components/use-record-route-context.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/reply-policy.d.ts +134 -0
- package/src/lib/model/reply-policy.js +141 -0
- package/src/lib/model/reply-policy.js.map +1 -0
- package/src/lib/model/submission-presenter.d.ts +81 -0
- package/src/lib/model/submission-presenter.js +152 -0
- package/src/lib/model/submission-presenter.js.map +1 -0
- package/src/lib/plugin.d.ts +25 -0
- package/src/lib/plugin.js +102 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server.d.ts +179 -0
- package/src/lib/server.js +666 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
19
|
+
import { normalizeContactEmail, pluginDocsHelp } from "@aglyn/aglyn";
|
|
20
|
+
// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry
|
|
21
|
+
// point the tenant's loader dynamically imports to activate the marketing
|
|
22
|
+
// plugin's SITE half, so a console card named there ships to every published
|
|
23
|
+
// page. The component path reaches the same module without crossing it.
|
|
24
|
+
import { InboxRecordAttributionZone } from "./inbox-attribution-zone.js";
|
|
25
|
+
// The CRM's route builder by its leaf path, not the plugin barrel: the barrel
|
|
26
|
+
// carries the plugin registration, and a link needs only the address grammar.
|
|
27
|
+
import { pluginRecordHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
|
|
28
|
+
import { mdiAccountArrowRight, mdiAccountRemoveOutline, mdiBullhornOutline } from "@aglyn/shared-data-mdi";
|
|
29
|
+
import { CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
30
|
+
import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
|
|
31
|
+
import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
32
|
+
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
33
|
+
import { useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
|
|
34
|
+
import { Alert, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Typography } from "@mui/material";
|
|
35
|
+
import { collection, deleteDoc, doc, limit, orderBy, query } from "firebase/firestore";
|
|
36
|
+
import { useCallback, useMemo, useState } from "react";
|
|
37
|
+
import { useRecordRouteContext } from "./use-record-route-context.js";
|
|
38
|
+
/**
|
|
39
|
+
* How many members and how many leads the contacts table reads.
|
|
40
|
+
*
|
|
41
|
+
* A ceiling rather than a page size — see the two queries, which explain why
|
|
42
|
+
* this one table cannot be paged by the server without breaking the dedupe
|
|
43
|
+
* between them.
|
|
44
|
+
*/ const CONTACT_CEILING = 200;
|
|
45
|
+
/**
|
|
46
|
+
* The Members & leads section of the Inbox (AGL-109): everybody a site
|
|
47
|
+
* collected, in one list.
|
|
48
|
+
*
|
|
49
|
+
* Its own component since AGL-2501, when the Inbox's tabs became routes. The
|
|
50
|
+
* split is what makes "mount only the section being read" structural: hooks
|
|
51
|
+
* cannot be conditional, so a page holding every section's reads pays for all
|
|
52
|
+
* of them whichever one the URL names.
|
|
53
|
+
*/ export function ContactsCard({ hostId }) {
|
|
54
|
+
var _ref, _ref1, _ref2;
|
|
55
|
+
const firestore = useFirestore();
|
|
56
|
+
const { enqueueSnackbar } = useSnackbar();
|
|
57
|
+
const { confirm } = useConfirmationContext();
|
|
58
|
+
// Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's
|
|
59
|
+
// Leads section gives each one a status, an owner and a conversion.
|
|
60
|
+
const routeContext = useRecordRouteContext();
|
|
61
|
+
const leadHref = (contact)=>routeContext ? pluginRecordHref('lead', routeContext, String(contact.$id)) : null;
|
|
62
|
+
/*==========================================
|
|
63
|
+
* SITE MEMBERS + LEADS (AGL-109): ORDERED AND CEILINGED, NOT PAGED BY QUERY.
|
|
64
|
+
*
|
|
65
|
+
* Both reads were `limit(200)` with no `orderBy` and a client sort on top —
|
|
66
|
+
* the same document-id sample the submissions list used to take, and both
|
|
67
|
+
* now name the order the rows are rendered in. `createdAt` is safe on both:
|
|
68
|
+
* `membership-register.ts` is the only writer that CREATES a site member
|
|
69
|
+
* and stamps it inside its transaction (every other membership path
|
|
70
|
+
* updates an existing document), `recordVisitorLead` is the only writer
|
|
71
|
+
* that creates a lead and stamps it on every `tx.create`, and neither
|
|
72
|
+
* collection is in `IMPORTABLE_FIELDS`.
|
|
73
|
+
*
|
|
74
|
+
* What they must NOT do is page the query, and the reason is the dedupe
|
|
75
|
+
* below: a lead is hidden when a MEMBER already exists on the same address.
|
|
76
|
+
* That test is only correct while both windows are whole. On a ten-row
|
|
77
|
+
* server page it would compare a page of leads against a page of members, so
|
|
78
|
+
* somebody who signed up after leaving their address would render as a
|
|
79
|
+
* Member on one page and again as a Lead on another — one person counted
|
|
80
|
+
* twice, in a list a site owner uses to count people.
|
|
81
|
+
*
|
|
82
|
+
* So the CEILING stays and the page is a slice of the assembled rows. The
|
|
83
|
+
* probe row makes "there are more contacts than these" a fact rather than a
|
|
84
|
+
* guess from `length === CONTACT_CEILING`, which is wrong at exactly the
|
|
85
|
+
* count that equals the ceiling.
|
|
86
|
+
*=========================================*/ const { data: memberDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'siteMembers'), orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)), [
|
|
87
|
+
firestore,
|
|
88
|
+
hostId
|
|
89
|
+
], {
|
|
90
|
+
idField: '$id'
|
|
91
|
+
});
|
|
92
|
+
const siteMembers = (memberDocs != null ? memberDocs : []).slice(0, CONTACT_CEILING);
|
|
93
|
+
const { data: leadDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'leads'), orderBy('createdAt', 'desc'), limit(CONTACT_CEILING + 1)), [
|
|
94
|
+
firestore,
|
|
95
|
+
hostId
|
|
96
|
+
], {
|
|
97
|
+
idField: '$id'
|
|
98
|
+
});
|
|
99
|
+
const leads = (leadDocs != null ? leadDocs : []).slice(0, CONTACT_CEILING);
|
|
100
|
+
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;
|
|
101
|
+
/*
|
|
102
|
+
* One person renders once, whichever way they came in.
|
|
103
|
+
*
|
|
104
|
+
* Raw `===` made `Bob@x.com` and `bob@x.com` two different people, so
|
|
105
|
+
* somebody could appear as a Member on one row and a Lead on another. The
|
|
106
|
+
* comparison is now the same NORMALIZATION a lead document is keyed by —
|
|
107
|
+
* `personKey` is `sha256(normalizeContactEmail(email))`, and hashing an
|
|
108
|
+
* already-normalized address cannot merge or split anything the normalizer
|
|
109
|
+
* did not, so the two agree by construction.
|
|
110
|
+
*
|
|
111
|
+
* `personKey` itself cannot run here: it needs `node:crypto` and this is a
|
|
112
|
+
* client component. That is why the shared half is the normalizer rather
|
|
113
|
+
* than the digest.
|
|
114
|
+
*/ const dedupedLeads = useMemo(()=>{
|
|
115
|
+
const memberKeys = new Set(siteMembers.map((member)=>normalizeContactEmail(member.email)).filter(Boolean));
|
|
116
|
+
return leads.filter((lead)=>!memberKeys.has(normalizeContactEmail(lead.email)));
|
|
117
|
+
}, [
|
|
118
|
+
leads,
|
|
119
|
+
siteMembers
|
|
120
|
+
]);
|
|
121
|
+
/*
|
|
122
|
+
* ONE list of two collections — members first, then the leads that are not
|
|
123
|
+
* already members — so the grid pages the concatenation rather than either
|
|
124
|
+
* read, and a page stays whole across the seam between them. Both reads are
|
|
125
|
+
* ceilinged and complete below the ceiling, so the grid's count is the whole
|
|
126
|
+
* deduped list.
|
|
127
|
+
*/ const contacts = useMemo(()=>[
|
|
128
|
+
...siteMembers.map((member)=>_extends({}, member, {
|
|
129
|
+
contactKind: 'member'
|
|
130
|
+
})),
|
|
131
|
+
...dedupedLeads.map((lead)=>_extends({}, lead, {
|
|
132
|
+
contactKind: 'lead'
|
|
133
|
+
}))
|
|
134
|
+
], [
|
|
135
|
+
siteMembers,
|
|
136
|
+
dedupedLeads
|
|
137
|
+
]);
|
|
138
|
+
const handleDeleteMember = useCallback((member)=>async ()=>{
|
|
139
|
+
const confirmed = await confirm({
|
|
140
|
+
title: 'Remove this member?',
|
|
141
|
+
description: `"${member.email}" can no longer sign in to your site.`,
|
|
142
|
+
confirmationText: 'Remove',
|
|
143
|
+
confirmationButtonProps: {
|
|
144
|
+
color: 'error'
|
|
145
|
+
}
|
|
146
|
+
}).then(()=>true).catch(()=>false);
|
|
147
|
+
if (!confirmed) return;
|
|
148
|
+
await deleteDoc(doc(firestore, 'hosts', hostId, 'siteMembers', member.$id));
|
|
149
|
+
enqueueSnackbar('Member removed', {
|
|
150
|
+
variant: 'success',
|
|
151
|
+
persist: false
|
|
152
|
+
});
|
|
153
|
+
}, [
|
|
154
|
+
confirm,
|
|
155
|
+
firestore,
|
|
156
|
+
hostId,
|
|
157
|
+
enqueueSnackbar
|
|
158
|
+
]);
|
|
159
|
+
/*
|
|
160
|
+
* WHERE A LEAD CAME FROM, on request.
|
|
161
|
+
*
|
|
162
|
+
* A lead is a table row with no page of its own, and the attribution is one
|
|
163
|
+
* keyed document read — cheap on its own, and the page size times cheap in
|
|
164
|
+
* a column. So it is an overflow action that opens a dialog: the reader who
|
|
165
|
+
* wants the answer pays for it, and the reader who came to scan the list
|
|
166
|
+
* does not.
|
|
167
|
+
*/ const [leadOrigin, setLeadOrigin] = useState(null);
|
|
168
|
+
const contactActions = (contact)=>contact.contactKind === 'member' ? [
|
|
169
|
+
{
|
|
170
|
+
key: 'remove',
|
|
171
|
+
label: 'Remove member',
|
|
172
|
+
icon: /*#__PURE__*/ _jsx(MdiIcon, {
|
|
173
|
+
path: mdiAccountRemoveOutline.path,
|
|
174
|
+
size: 0.8
|
|
175
|
+
}),
|
|
176
|
+
destructive: true,
|
|
177
|
+
onClick: ()=>void handleDeleteMember(contact)()
|
|
178
|
+
}
|
|
179
|
+
] : [
|
|
180
|
+
// Offered only where a plugin publishes a lead's address: text-less
|
|
181
|
+
// rather than a link to a page this workspace cannot open.
|
|
182
|
+
...leadHref(contact) ? [
|
|
183
|
+
{
|
|
184
|
+
key: 'crm',
|
|
185
|
+
label: 'Open in CRM',
|
|
186
|
+
icon: /*#__PURE__*/ _jsx(MdiIcon, {
|
|
187
|
+
path: mdiAccountArrowRight.path,
|
|
188
|
+
size: 0.8
|
|
189
|
+
}),
|
|
190
|
+
href: leadHref(contact)
|
|
191
|
+
}
|
|
192
|
+
] : [],
|
|
193
|
+
{
|
|
194
|
+
key: 'origin',
|
|
195
|
+
label: 'Where this came from',
|
|
196
|
+
icon: /*#__PURE__*/ _jsx(MdiIcon, {
|
|
197
|
+
path: mdiBullhornOutline.path,
|
|
198
|
+
size: 0.8
|
|
199
|
+
}),
|
|
200
|
+
onClick: ()=>setLeadOrigin(contact)
|
|
201
|
+
}
|
|
202
|
+
];
|
|
203
|
+
const contactColumns = [
|
|
204
|
+
{
|
|
205
|
+
field: 'email',
|
|
206
|
+
headerName: 'Email',
|
|
207
|
+
flex: 1,
|
|
208
|
+
minWidth: 240,
|
|
209
|
+
/*
|
|
210
|
+
The name beside the address — a member's `displayName`, and the name
|
|
211
|
+
the lead writer stores (AGL-2303) — because a list of bare addresses is
|
|
212
|
+
a list nobody recognizes anyone in.
|
|
213
|
+
*/ renderCell: ({ row: contact })=>{
|
|
214
|
+
const name = contact.contactKind === 'member' ? contact.displayName : contact.name;
|
|
215
|
+
return /*#__PURE__*/ _jsxs(Stack, {
|
|
216
|
+
direction: "row",
|
|
217
|
+
spacing: 1,
|
|
218
|
+
sx: {
|
|
219
|
+
alignItems: 'baseline',
|
|
220
|
+
minWidth: 0
|
|
221
|
+
},
|
|
222
|
+
children: [
|
|
223
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
224
|
+
variant: "body2",
|
|
225
|
+
noWrap: true,
|
|
226
|
+
children: contact.email
|
|
227
|
+
}),
|
|
228
|
+
name ? /*#__PURE__*/ _jsx(Typography, {
|
|
229
|
+
variant: "caption",
|
|
230
|
+
color: "text.secondary",
|
|
231
|
+
noWrap: true,
|
|
232
|
+
children: name
|
|
233
|
+
}) : null
|
|
234
|
+
]
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
/*
|
|
240
|
+
WHERE A LEAD CAME FROM (AGL-2338). `source` has been written by both
|
|
241
|
+
lead writers — `'signup'` and `'booking'` — since AGL-109, so a site
|
|
242
|
+
owner can tell a membership sign-up from a booking. Falls back to the
|
|
243
|
+
bare label for a row written before the field, or by a future writer
|
|
244
|
+
that omits it.
|
|
245
|
+
*/ field: 'contactKind',
|
|
246
|
+
headerName: 'Type',
|
|
247
|
+
width: 170,
|
|
248
|
+
valueGetter: (_value, contact)=>contact.contactKind === 'member' ? 'Member' : contact.source ? `Lead · ${contact.source}` : 'Lead',
|
|
249
|
+
renderCell: ({ row: contact, value })=>contact.contactKind === 'member' ? /*#__PURE__*/ _jsx(Chip, {
|
|
250
|
+
label: value,
|
|
251
|
+
color: "primary",
|
|
252
|
+
size: "small"
|
|
253
|
+
}) : /*#__PURE__*/ _jsx(Chip, {
|
|
254
|
+
label: value,
|
|
255
|
+
size: "small",
|
|
256
|
+
variant: "outlined"
|
|
257
|
+
})
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
field: 'createdAt',
|
|
261
|
+
headerName: 'Joined',
|
|
262
|
+
width: 200,
|
|
263
|
+
// Sorted on the instant, drawn as a local string.
|
|
264
|
+
valueGetter: (_value, contact)=>{
|
|
265
|
+
var _ref;
|
|
266
|
+
var _contact_createdAt_toDate_getTime, _contact_createdAt_toDate, _contact_createdAt_toDate1, _contact_createdAt;
|
|
267
|
+
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;
|
|
268
|
+
},
|
|
269
|
+
renderCell: ({ row: contact })=>{
|
|
270
|
+
var _ref;
|
|
271
|
+
var _contact_createdAt_toDate, _contact_createdAt;
|
|
272
|
+
return (_ref = (_contact_createdAt = contact.createdAt) == null ? void 0 : (_contact_createdAt_toDate = _contact_createdAt.toDate) == null ? void 0 : _contact_createdAt_toDate.call(_contact_createdAt).toLocaleString()) != null ? _ref : '--';
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
listActionsColumn((contact)=>{
|
|
276
|
+
var _contact_email;
|
|
277
|
+
return /*#__PURE__*/ _jsx(ListRowActions, {
|
|
278
|
+
label: String((_contact_email = contact.email) != null ? _contact_email : contact.$id),
|
|
279
|
+
items: contactActions(contact)
|
|
280
|
+
});
|
|
281
|
+
}, {
|
|
282
|
+
width: 72
|
|
283
|
+
})
|
|
284
|
+
];
|
|
285
|
+
return /*#__PURE__*/ _jsxs(_Fragment, {
|
|
286
|
+
children: [
|
|
287
|
+
/*#__PURE__*/ _jsx(CardDisplay, {
|
|
288
|
+
header: 'Site Members & Leads',
|
|
289
|
+
help: pluginDocsHelp('membersOnly', {
|
|
290
|
+
anchor: '#manage-your-members'
|
|
291
|
+
}),
|
|
292
|
+
contentGutterX: true,
|
|
293
|
+
contentGutterY: true,
|
|
294
|
+
contentBordered: "all",
|
|
295
|
+
children: siteMembers.length === 0 && leads.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
|
|
296
|
+
variant: "body2",
|
|
297
|
+
color: "text.secondary",
|
|
298
|
+
children: 'No members yet — visitors can join at /signup on your ' + 'site; sign-ups also appear here as leads.'
|
|
299
|
+
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
300
|
+
children: [
|
|
301
|
+
/*#__PURE__*/ _jsx(ListTable, {
|
|
302
|
+
"aria-label": "Site members and leads",
|
|
303
|
+
rows: contacts,
|
|
304
|
+
columns: contactColumns,
|
|
305
|
+
// A member and a lead are two collections, so an id alone could
|
|
306
|
+
// name one of each.
|
|
307
|
+
getRowId: (contact)=>`${contact.contactKind}:${contact.$id}`,
|
|
308
|
+
rowHeight: TABLE_ROW_HEIGHT
|
|
309
|
+
}),
|
|
310
|
+
contactsTruncated ? /*#__PURE__*/ _jsx(Alert, {
|
|
311
|
+
severity: "info",
|
|
312
|
+
sx: {
|
|
313
|
+
mt: 1
|
|
314
|
+
},
|
|
315
|
+
children: `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.'
|
|
316
|
+
}) : null
|
|
317
|
+
]
|
|
318
|
+
})
|
|
319
|
+
}),
|
|
320
|
+
/*#__PURE__*/ _jsxs(Dialog, {
|
|
321
|
+
open: Boolean(leadOrigin),
|
|
322
|
+
onClose: ()=>setLeadOrigin(null),
|
|
323
|
+
maxWidth: "xs",
|
|
324
|
+
fullWidth: true,
|
|
325
|
+
children: [
|
|
326
|
+
/*#__PURE__*/ _jsx(DialogTitle, {
|
|
327
|
+
children: (_ref2 = leadOrigin == null ? void 0 : leadOrigin.email) != null ? _ref2 : 'Lead'
|
|
328
|
+
}),
|
|
329
|
+
/*#__PURE__*/ _jsx(DialogContent, {
|
|
330
|
+
children: (leadOrigin == null ? void 0 : leadOrigin.$id) ? /*#__PURE__*/ _jsx(InboxRecordAttributionZone, {
|
|
331
|
+
hostId: hostId,
|
|
332
|
+
recordKind: "lead",
|
|
333
|
+
recordId: String(leadOrigin.$id)
|
|
334
|
+
}) : null
|
|
335
|
+
}),
|
|
336
|
+
/*#__PURE__*/ _jsx(DialogActions, {
|
|
337
|
+
children: /*#__PURE__*/ _jsx(Button, {
|
|
338
|
+
variant: "contained",
|
|
339
|
+
onClick: ()=>setLeadOrigin(null),
|
|
340
|
+
children: 'Close'
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
]
|
|
344
|
+
})
|
|
345
|
+
]
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
ContactsCard.displayName = 'ContactsCard';
|
|
349
|
+
export default ContactsCard;
|
|
350
|
+
|
|
351
|
+
//# sourceMappingURL=contacts-card.component.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/contacts-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { normalizeContactEmail, pluginDocsHelp } from '@aglyn/aglyn'\n// A deep import, NOT the plugin barrel (AGL-1151): the barrel is the entry\n// point the tenant's loader dynamically imports to activate the marketing\n// plugin's SITE half, so a console card named there ships to every published\n// page. The component path reaches the same module without crossing it.\nimport { InboxRecordAttributionZone } from './inbox-attribution-zone'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// carries the plugin registration, and a link needs only the address grammar.\nimport { pluginRecordHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport {\n mdiAccountArrowRight,\n mdiAccountRemoveOutline,\n mdiBullhornOutline,\n} from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n deleteDoc,\n doc,\n limit,\n orderBy,\n query,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useRecordRouteContext } from './use-record-route-context'\n\n/**\n * How many members and how many leads the contacts table reads.\n *\n * A ceiling rather than a page size — see the two queries, which explain why\n * this one table cannot be paged by the server without breaking the dedupe\n * between them.\n */\nconst CONTACT_CEILING = 200\n\n/**\n * The Members & leads section of the Inbox (AGL-109): everybody a site\n * collected, in one list.\n *\n * Its own component since AGL-2501, when the Inbox's tabs became routes. The\n * split is what makes \"mount only the section being read\" structural: hooks\n * cannot be conditional, so a page holding every section's reads pays for all\n * of them whichever one the URL names.\n */\nexport function ContactsCard({ hostId }: { hostId: string }) {\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // Where a lead is WORKED (AGL-2608). This card lists leads; the CRM's\n // Leads section gives each one a status, an owner and a conversion.\n const routeContext = useRecordRouteContext()\n const leadHref = (contact: { $id?: unknown }): string | null =>\n routeContext\n ? pluginRecordHref('lead', routeContext, String(contact.$id))\n : null\n\n /*==========================================\n * SITE MEMBERS + LEADS (AGL-109): ORDERED AND CEILINGED, NOT PAGED BY QUERY.\n *\n * Both reads were `limit(200)` with no `orderBy` and a client sort on top —\n * the same document-id sample the submissions list used to take, and both\n * now name the order the rows are rendered in. `createdAt` is safe on both:\n * `membership-register.ts` is the only writer that CREATES a site member\n * and stamps it inside its transaction (every other membership path\n * updates an existing document), `recordVisitorLead` is the only writer\n * that creates a lead and stamps it on every `tx.create`, and neither\n * collection is in `IMPORTABLE_FIELDS`.\n *\n * What they must NOT do is page the query, and the reason is the dedupe\n * below: a lead is hidden when a MEMBER already exists on the same address.\n * That test is only correct while both windows are whole. On a ten-row\n * server page it would compare a page of leads against a page of members, so\n * somebody who signed up after leaving their address would render as a\n * Member on one page and again as a Lead on another — one person counted\n * twice, in a list a site owner uses to count people.\n *\n * So the CEILING stays and the page is a slice of the assembled rows. The\n * probe row makes \"there are more contacts than these\" a fact rather than a\n * guess from `length === CONTACT_CEILING`, which is wrong at exactly the\n * count that equals the ceiling.\n *=========================================*/\n const { data: memberDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'siteMembers'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const siteMembers = (memberDocs ?? []).slice(0, CONTACT_CEILING)\n const { data: leadDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'leads'),\n orderBy('createdAt', 'desc'),\n limit(CONTACT_CEILING + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const leads = (leadDocs ?? []).slice(0, CONTACT_CEILING)\n const contactsTruncated =\n (memberDocs?.length ?? 0) > CONTACT_CEILING ||\n (leadDocs?.length ?? 0) > CONTACT_CEILING\n /*\n * One person renders once, whichever way they came in.\n *\n * Raw `===` made `Bob@x.com` and `bob@x.com` two different people, so\n * somebody could appear as a Member on one row and a Lead on another. The\n * comparison is now the same NORMALIZATION a lead document is keyed by —\n * `personKey` is `sha256(normalizeContactEmail(email))`, and hashing an\n * already-normalized address cannot merge or split anything the normalizer\n * did not, so the two agree by construction.\n *\n * `personKey` itself cannot run here: it needs `node:crypto` and this is a\n * client component. That is why the shared half is the normalizer rather\n * than the digest.\n */\n const dedupedLeads = useMemo(() => {\n const memberKeys = new Set(\n siteMembers\n .map((member: any) => normalizeContactEmail(member.email))\n .filter(Boolean),\n )\n return leads.filter(\n (lead: any) => !memberKeys.has(normalizeContactEmail(lead.email)),\n )\n }, [leads, siteMembers])\n /*\n * ONE list of two collections — members first, then the leads that are not\n * already members — so the grid pages the concatenation rather than either\n * read, and a page stays whole across the seam between them. Both reads are\n * ceilinged and complete below the ceiling, so the grid's count is the whole\n * deduped list.\n */\n const contacts = useMemo(\n () => [\n ...siteMembers.map((member: any) => ({ ...member, contactKind: 'member' })),\n ...dedupedLeads.map((lead: any) => ({ ...lead, contactKind: 'lead' })),\n ],\n [siteMembers, dedupedLeads],\n )\n const handleDeleteMember = useCallback(\n (member: any) => async () => {\n const confirmed = await confirm({\n title: 'Remove this member?',\n description: `\"${member.email}\" can no longer sign in to your site.`,\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await deleteDoc(doc(firestore, 'hosts', hostId, 'siteMembers', member.$id))\n enqueueSnackbar('Member removed', { variant: 'success', persist: false })\n },\n [confirm, firestore, hostId, enqueueSnackbar],\n )\n\n /*\n * WHERE A LEAD CAME FROM, on request.\n *\n * A lead is a table row with no page of its own, and the attribution is one\n * keyed document read — cheap on its own, and the page size times cheap in\n * a column. So it is an overflow action that opens a dialog: the reader who\n * wants the answer pays for it, and the reader who came to scan the list\n * does not.\n */\n const [leadOrigin, setLeadOrigin] = useState<any | null>(null)\n\n const contactActions = (contact: any): RowActionsMenuItem[] =>\n contact.contactKind === 'member'\n ? [\n {\n key: 'remove',\n label: 'Remove member',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDeleteMember(contact)(),\n },\n ]\n : [\n // Offered only where a plugin publishes a lead's address: text-less\n // rather than a link to a page this workspace cannot open.\n ...(leadHref(contact)\n ? [\n {\n key: 'crm',\n label: 'Open in CRM',\n icon: <MdiIcon path={mdiAccountArrowRight.path} size={0.8} />,\n href: leadHref(contact) as string,\n },\n ]\n : []),\n {\n key: 'origin',\n label: 'Where this came from',\n icon: <MdiIcon path={mdiBullhornOutline.path} size={0.8} />,\n onClick: () => setLeadOrigin(contact),\n },\n ]\n const contactColumns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Email',\n flex: 1,\n minWidth: 240,\n /*\n The name beside the address — a member's `displayName`, and the name\n the lead writer stores (AGL-2303) — because a list of bare addresses is\n a list nobody recognizes anyone in.\n */\n renderCell: ({ row: contact }) => {\n const name =\n contact.contactKind === 'member' ? contact.displayName : contact.name\n return (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline', minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {contact.email}\n </Typography>\n {name ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {name}\n </Typography>\n ) : null}\n </Stack>\n )\n },\n },\n {\n /*\n WHERE A LEAD CAME FROM (AGL-2338). `source` has been written by both\n lead writers — `'signup'` and `'booking'` — since AGL-109, so a site\n owner can tell a membership sign-up from a booking. Falls back to the\n bare label for a row written before the field, or by a future writer\n that omits it.\n */\n field: 'contactKind',\n headerName: 'Type',\n width: 170,\n valueGetter: (_value, contact) =>\n contact.contactKind === 'member'\n ? 'Member'\n : contact.source\n ? `Lead · ${contact.source}`\n : 'Lead',\n renderCell: ({ row: contact, value }) =>\n contact.contactKind === 'member' ? (\n <Chip label={value} color=\"primary\" size=\"small\" />\n ) : (\n <Chip label={value} size=\"small\" variant=\"outlined\" />\n ),\n },\n {\n field: 'createdAt',\n headerName: 'Joined',\n width: 200,\n // Sorted on the instant, drawn as a local string.\n valueGetter: (_value, contact) => contact.createdAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row: contact }) =>\n contact.createdAt?.toDate?.().toLocaleString() ?? '--',\n },\n listActionsColumn(\n (contact) => (\n <ListRowActions\n label={String(contact.email ?? contact.$id)}\n items={contactActions(contact)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <>\n <CardDisplay\n header={'Site Members & Leads'}\n help={pluginDocsHelp('membersOnly', {\n anchor: '#manage-your-members',\n })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n >\n {siteMembers.length === 0 && leads.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No members yet — visitors can join at /signup on your ' +\n 'site; sign-ups also appear here as leads.'}\n </Typography>\n ) : (\n <>\n <ListTable\n aria-label=\"Site members and leads\"\n rows={contacts}\n columns={contactColumns}\n // A member and a lead are two collections, so an id alone could\n // name one of each.\n getRowId={(contact: any) => `${contact.contactKind}:${contact.$id}`}\n rowHeight={TABLE_ROW_HEIGHT}\n />\n {contactsTruncated ? (\n <Alert severity=\"info\" sx={{ mt: 1 }}>\n {`Paging the ${CONTACT_CEILING} newest members and the ` +\n `${CONTACT_CEILING} newest leads. This site has more ` +\n 'than that — the campaign audiences still reach ' +\n 'everyone, whether or not they are listed here.'}\n </Alert>\n ) : null}\n </>\n )}\n </CardDisplay>\n {/*\n WHERE A LEAD CAME FROM.\n\n Its own dialog rather than a column, for the reason the state above\n gives: one keyed read, paid by the reader who asked the question. A\n lead has no page of its own to put this on, and giving it one to carry\n a single line would be a new record surface rather than attribution.\n */}\n <Dialog\n open={Boolean(leadOrigin)}\n onClose={() => setLeadOrigin(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{leadOrigin?.email ?? 'Lead'}</DialogTitle>\n <DialogContent>\n {leadOrigin?.$id ? (\n <InboxRecordAttributionZone\n hostId={hostId}\n recordKind=\"lead\"\n recordId={String(leadOrigin.$id)}\n />\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button variant=\"contained\" onClick={() => setLeadOrigin(null)}>\n {'Close'}\n </Button>\n </DialogActions>\n </Dialog>\n </>\n )\n}\nContactsCard.displayName = 'ContactsCard'\n\nexport default ContactsCard\n"],"names":["normalizeContactEmail","pluginDocsHelp","InboxRecordAttributionZone","pluginRecordHref","mdiAccountArrowRight","mdiAccountRemoveOutline","mdiBullhornOutline","CardDisplay","MdiIcon","useConfirmationContext","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","useSnackbar","useFirestore","useFirestoreCollection","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","Stack","Typography","collection","deleteDoc","doc","limit","orderBy","query","useCallback","useMemo","useState","useRecordRouteContext","CONTACT_CEILING","ContactsCard","hostId","firestore","enqueueSnackbar","confirm","routeContext","leadHref","contact","String","$id","data","memberDocs","idField","siteMembers","slice","leadDocs","leads","contactsTruncated","length","dedupedLeads","memberKeys","Set","map","member","email","filter","Boolean","lead","has","contacts","contactKind","handleDeleteMember","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","variant","persist","leadOrigin","setLeadOrigin","contactActions","key","label","icon","path","size","destructive","onClick","href","contactColumns","field","headerName","flex","minWidth","renderCell","row","name","displayName","direction","spacing","sx","alignItems","noWrap","width","valueGetter","_value","source","value","createdAt","toDate","getTime","toLocaleString","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","aria-label","rows","columns","getRowId","rowHeight","severity","mt","open","onClose","maxWidth","fullWidth","recordKind","recordId"],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Where a lead or a submission came from, drawn by whichever plugin can say.
|
|
19
|
+
*
|
|
20
|
+
* The Inbox lists what arrived; which campaign or link brought it is a
|
|
21
|
+
* question for a plugin that credits conversions. The Inbox used to import
|
|
22
|
+
* the marketing plugin's component to answer it. It hosts this zone instead,
|
|
23
|
+
* in a lead's "Where this came from" dialog and under an open submission, and
|
|
24
|
+
* hands it the record's identity.
|
|
25
|
+
*/
|
|
26
|
+
export interface InboxRecordAttributionZoneProps {
|
|
27
|
+
hostId: string;
|
|
28
|
+
/** What the record is, in the Inbox's own words. */
|
|
29
|
+
recordKind: 'lead' | 'form';
|
|
30
|
+
recordId: string;
|
|
31
|
+
}
|
|
32
|
+
export declare const INBOX_RECORD_ATTRIBUTION_ZONE: import("@aglyn/aglyn/plugin-manager/plugin-zones").PluginZone<InboxRecordAttributionZoneProps>;
|
|
33
|
+
export declare function InboxRecordAttributionZone(props: InboxRecordAttributionZoneProps): import("react").JSX.Element;
|
|
34
|
+
export declare namespace InboxRecordAttributionZone {
|
|
35
|
+
var displayName: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The Inbox's Campaigns section: a place, not a table.
|
|
39
|
+
*
|
|
40
|
+
* What arrived and who it came from sit beside what was sent to them, so the
|
|
41
|
+
* Inbox keeps a Campaigns tab. The campaigns themselves belong to a plugin
|
|
42
|
+
* that sends them, which draws its own card here; the Inbox hands it the site
|
|
43
|
+
* and nothing else.
|
|
44
|
+
*/
|
|
45
|
+
export interface InboxCampaignsZoneProps {
|
|
46
|
+
hostId: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const INBOX_CAMPAIGNS_ZONE: import("@aglyn/aglyn/plugin-manager/plugin-zones").PluginZone<InboxCampaignsZoneProps>;
|
|
49
|
+
export declare function InboxCampaignsZone(props: InboxCampaignsZoneProps): import("react").JSX.Element;
|
|
50
|
+
export declare namespace InboxCampaignsZone {
|
|
51
|
+
var displayName: string;
|
|
52
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
19
|
+
import { listConsoleWidgets } from "@aglyn/aglyn";
|
|
20
|
+
import { useConsoleWidgetSlot } from "@aglyn/aglyn/app-utils/console-widget-slot-context";
|
|
21
|
+
import { Alert, AlertTitle } from "@mui/material";
|
|
22
|
+
import { definePluginZone } from "@aglyn/aglyn/plugin-manager/plugin-zones";
|
|
23
|
+
export const INBOX_RECORD_ATTRIBUTION_ZONE = definePluginZone('inboxRecordAttribution');
|
|
24
|
+
export function InboxRecordAttributionZone(props) {
|
|
25
|
+
const Slot = useConsoleWidgetSlot();
|
|
26
|
+
if (!Slot) return null;
|
|
27
|
+
return /*#__PURE__*/ _jsx(Slot, _extends({
|
|
28
|
+
slot: INBOX_RECORD_ATTRIBUTION_ZONE.id
|
|
29
|
+
}, props));
|
|
30
|
+
}
|
|
31
|
+
InboxRecordAttributionZone.displayName = 'InboxRecordAttributionZone';
|
|
32
|
+
export const INBOX_CAMPAIGNS_ZONE = definePluginZone('inboxCampaigns');
|
|
33
|
+
export function InboxCampaignsZone(props) {
|
|
34
|
+
const Slot = useConsoleWidgetSlot();
|
|
35
|
+
// Read at render, after the shell has loaded its plugins.
|
|
36
|
+
if (!Slot || listConsoleWidgets(INBOX_CAMPAIGNS_ZONE.id).length === 0) {
|
|
37
|
+
return /*#__PURE__*/ _jsxs(Alert, {
|
|
38
|
+
severity: "info",
|
|
39
|
+
children: [
|
|
40
|
+
/*#__PURE__*/ _jsx(AlertTitle, {
|
|
41
|
+
children: 'Campaigns are part of Marketing'
|
|
42
|
+
}),
|
|
43
|
+
'Marketing is switched off for this workspace or this site, so there ' + 'are no campaigns to show here.'
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return /*#__PURE__*/ _jsx(Slot, _extends({
|
|
48
|
+
slot: INBOX_CAMPAIGNS_ZONE.id
|
|
49
|
+
}, props));
|
|
50
|
+
}
|
|
51
|
+
InboxCampaignsZone.displayName = 'InboxCampaignsZone';
|
|
52
|
+
|
|
53
|
+
//# sourceMappingURL=inbox-attribution-zone.js.map
|
|
@@ -0,0 +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 there ' +\n '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"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;AAEA,SAASA,kBAAkB,QAAQ,eAAc;AACjD,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;AAczC,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;;gBACZ,yEACC;;;IAGR;IACA,qBAAO,KAACK;QAAKC,MAAMG,qBAAqBF,EAAE;OAAMH;AAClD;AACAM,mBAAmBF,WAAW,GAAG"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ConsolePluginPageProps } from '@aglyn/aglyn';
|
|
2
|
+
/**
|
|
3
|
+
* Inbox (AGL-77/104/109 → AGL-395): form submissions reader, site members +
|
|
4
|
+
* leads, and campaigns — owned by the inbox plugin and rendered by the shell's
|
|
5
|
+
* generic plugin route. The Campaigns section is a zone this page hosts; the
|
|
6
|
+
* plugin that sends campaigns draws its card there.
|
|
7
|
+
*
|
|
8
|
+
* Orders are NOT here. A sale is not something that arrived in an inbox, and
|
|
9
|
+
* the card was nested inside the members section rather than carrying a tab of
|
|
10
|
+
* its own — so the rail listed three sections while the page drew a fourth
|
|
11
|
+
* subject. Commerce already owns it: `commerce-console-sections.ts` declares
|
|
12
|
+
* an `orders` section that renders the same card.
|
|
13
|
+
*
|
|
14
|
+
* Sections are ROUTES (AGL-2501), following the hubs that migrated before it.
|
|
15
|
+
* Here it IS a read saving as well as an addressing one: this page carried
|
|
16
|
+
* `HubTabs lazy`, which mounts one panel but keeps every panel it has visited,
|
|
17
|
+
* so a reader who looked at Members & leads and went back to Submissions held
|
|
18
|
+
* both sections' listens open for the rest of the visit. A URL per section
|
|
19
|
+
* makes the saving structural — the page builds one section's body and the
|
|
20
|
+
* others do not exist to subscribe. What routing adds besides is that the URL
|
|
21
|
+
* names the section: it is linkable, the back button walks sections, and the
|
|
22
|
+
* breadcrumb says where you are.
|
|
23
|
+
*/
|
|
24
|
+
export declare function InboxConsolePage(props: ConsolePluginPageProps): import("react").JSX.Element;
|
|
25
|
+
export declare namespace InboxConsolePage {
|
|
26
|
+
var displayName: string;
|
|
27
|
+
}
|
|
28
|
+
export default InboxConsolePage;
|