@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.
Files changed (48) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +51 -0
  4. package/src/index.d.ts +18 -0
  5. package/src/index.js +19 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/contacts-card.component.d.ts +16 -0
  8. package/src/lib/components/contacts-card.component.js +351 -0
  9. package/src/lib/components/contacts-card.component.js.map +1 -0
  10. package/src/lib/components/inbox-attribution-zone.d.ts +52 -0
  11. package/src/lib/components/inbox-attribution-zone.js +53 -0
  12. package/src/lib/components/inbox-attribution-zone.js.map +1 -0
  13. package/src/lib/components/inbox-console-page.d.ts +28 -0
  14. package/src/lib/components/inbox-console-page.js +219 -0
  15. package/src/lib/components/inbox-console-page.js.map +1 -0
  16. package/src/lib/components/inbox-console-sections.d.ts +40 -0
  17. package/src/lib/components/inbox-console-sections.js +66 -0
  18. package/src/lib/components/inbox-console-sections.js.map +1 -0
  19. package/src/lib/components/inbox-glance-card.component.d.ts +26 -0
  20. package/src/lib/components/inbox-glance-card.component.js +215 -0
  21. package/src/lib/components/inbox-glance-card.component.js.map +1 -0
  22. package/src/lib/components/submission-list-assignment.component.d.ts +29 -0
  23. package/src/lib/components/submission-list-assignment.component.js +257 -0
  24. package/src/lib/components/submission-list-assignment.component.js.map +1 -0
  25. package/src/lib/components/submission-reply.component.d.ts +30 -0
  26. package/src/lib/components/submission-reply.component.js +249 -0
  27. package/src/lib/components/submission-reply.component.js.map +1 -0
  28. package/src/lib/components/submissions-card.component.d.ts +35 -0
  29. package/src/lib/components/submissions-card.component.js +577 -0
  30. package/src/lib/components/submissions-card.component.js.map +1 -0
  31. package/src/lib/components/use-record-route-context.d.ts +15 -0
  32. package/src/lib/components/use-record-route-context.js +41 -0
  33. package/src/lib/components/use-record-route-context.js.map +1 -0
  34. package/src/lib/constants/bundle-common.d.ts +8 -0
  35. package/src/lib/constants/bundle-common.js +9 -0
  36. package/src/lib/constants/bundle-common.js.map +1 -0
  37. package/src/lib/model/reply-policy.d.ts +134 -0
  38. package/src/lib/model/reply-policy.js +141 -0
  39. package/src/lib/model/reply-policy.js.map +1 -0
  40. package/src/lib/model/submission-presenter.d.ts +81 -0
  41. package/src/lib/model/submission-presenter.js +152 -0
  42. package/src/lib/model/submission-presenter.js.map +1 -0
  43. package/src/lib/plugin.d.ts +25 -0
  44. package/src/lib/plugin.js +102 -0
  45. package/src/lib/plugin.js.map +1 -0
  46. package/src/lib/server.d.ts +179 -0
  47. package/src/lib/server.js +666 -0
  48. package/src/lib/server.js.map +1 -0
@@ -0,0 +1,219 @@
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 { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
+ import { formSpamCaughtNotice, formSubmissionsPausedNotice, submissionMonthKey, visitorRecordRefusedCounterId, visitorRecordsPausedNotice } from "@aglyn/aglyn";
19
+ import { HubSections } from "@aglyn/shared-ui-next";
20
+ import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
21
+ import { Alert, AlertTitle, Typography } from "@mui/material";
22
+ import { doc } from "firebase/firestore";
23
+ import ContactsCard from "./contacts-card.component.js";
24
+ import { InboxCampaignsZone } from "./inbox-attribution-zone.js";
25
+ import SubmissionsCard from "./submissions-card.component.js";
26
+ /**
27
+ * The body of one inbox section, built only when that section is the one being
28
+ * read (AGL-2501).
29
+ *
30
+ * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`
31
+ * would CONSTRUCT all three every render, and each card opens its Firestore
32
+ * listens on mount — which is the entire cost this page exists to stop paying.
33
+ * Only the returned branch is ever built.
34
+ */ function sectionBody(section, hostId) {
35
+ switch(section){
36
+ case 'submissions':
37
+ return /*#__PURE__*/ _jsx(SubmissionsCard, {
38
+ hostId: hostId
39
+ });
40
+ case 'contacts':
41
+ return /*#__PURE__*/ _jsx(ContactsCard, {
42
+ hostId: hostId
43
+ });
44
+ case 'campaigns':
45
+ return /*#__PURE__*/ _jsx(InboxCampaignsZone, {
46
+ hostId: hostId
47
+ });
48
+ default:
49
+ return null;
50
+ }
51
+ }
52
+ /**
53
+ * Inbox (AGL-77/104/109 → AGL-395): form submissions reader, site members +
54
+ * leads, and campaigns — owned by the inbox plugin and rendered by the shell's
55
+ * generic plugin route. The Campaigns section is a zone this page hosts; the
56
+ * plugin that sends campaigns draws its card there.
57
+ *
58
+ * Orders are NOT here. A sale is not something that arrived in an inbox, and
59
+ * the card was nested inside the members section rather than carrying a tab of
60
+ * its own — so the rail listed three sections while the page drew a fourth
61
+ * subject. Commerce already owns it: `commerce-console-sections.ts` declares
62
+ * an `orders` section that renders the same card.
63
+ *
64
+ * Sections are ROUTES (AGL-2501), following the hubs that migrated before it.
65
+ * Here it IS a read saving as well as an addressing one: this page carried
66
+ * `HubTabs lazy`, which mounts one panel but keeps every panel it has visited,
67
+ * so a reader who looked at Members & leads and went back to Submissions held
68
+ * both sections' listens open for the rest of the visit. A URL per section
69
+ * makes the saving structural — the page builds one section's body and the
70
+ * others do not exist to subscribe. What routing adds besides is that the URL
71
+ * names the section: it is linkable, the back button walks sections, and the
72
+ * breadcrumb says where you are.
73
+ */ export function InboxConsolePage(props) {
74
+ var _ref, _ref1, _ref2, _ref3;
75
+ const { hostId, section, sections, basePath } = props;
76
+ const firestore = useFirestore();
77
+ // Submissions this site's abuse ceiling refused (AGL-1655 → AGL-1666).
78
+ //
79
+ // Until this, the refusal existed in two places a site owner cannot see: a
80
+ // counters document only Firestore's console renders, and one in-app
81
+ // notification that `system.` bucket-muting can suppress at write time —
82
+ // `notifyUsers` skips the batch entirely, so a muted owner's notification
83
+ // is never created and cannot be recovered by unmuting. This surface is
84
+ // the durable one, and it is a plain read of the same document the
85
+ // dropped-contacts alert uses (AGL-891).
86
+ const { data: refusedCounter } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'counters', 'formSubmissionsRefused'), [
87
+ firestore,
88
+ hostId
89
+ ]);
90
+ // Keyed by the month the SERVER wrote, via the shared helper — a key
91
+ // derived differently here would read zero refusals on exactly the sites
92
+ // being refused.
93
+ const pausedNotice = formSubmissionsPausedNotice({
94
+ refused: Number((_ref = refusedCounter == null ? void 0 : refusedCounter[submissionMonthKey()]) != null ? _ref : 0),
95
+ ceiling: Number(refusedCounter == null ? void 0 : refusedCounter['ceiling']) || undefined
96
+ });
97
+ // Bot submissions the honeypot caught (AGL-1831 → AGL-1836). The staff org
98
+ // page has shown this number per host since AGL-1831; this is the same
99
+ // count where the site's OWNER already looks, so "is my form being hit by
100
+ // bots?" is answered by their own inbox instead of a support ticket. Same
101
+ // client-unwritable counters document shape as the refusal counter above,
102
+ // same host-admin read the rules already grant (AGL-1367), same shared
103
+ // month key — and the shared sentence returns null below one catch, so a
104
+ // quiet month renders nothing rather than a reassuring zero.
105
+ const { data: spamCounter } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'counters', 'formSubmissionsSpam'), [
106
+ firestore,
107
+ hostId
108
+ ]);
109
+ const spamNotice = formSpamCaughtNotice({
110
+ spam: Number((_ref1 = spamCounter == null ? void 0 : spamCounter[submissionMonthKey()]) != null ? _ref1 : 0)
111
+ });
112
+ // Sign-ups and leads this site's PLATFORM ceiling refused (AGL-1529).
113
+ //
114
+ // Same instrument as the form ceiling directly above and read the same way:
115
+ // a client-unwritable counters document (AGL-1367) that host admins can
116
+ // already read, keyed by the SERVER's month through the shared helper — a
117
+ // key derived differently here would read zero refusals on exactly the
118
+ // sites being refused. The counter id comes from the shared function for
119
+ // the same reason: the writer is in `@aglyn/tenant-data-admin` and this is
120
+ // the reader, and two spellings of one document id is a surface that
121
+ // renders nothing forever.
122
+ //
123
+ // This is the surface that makes the ceiling SHIPPED rather than merely
124
+ // implemented. Without it a refusal exists in two places a site owner
125
+ // cannot see: a Firestore document only our console renders, and one
126
+ // notification that `system.` bucket-muting can suppress at write time.
127
+ const { data: membersRefusedCounter } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'counters', visitorRecordRefusedCounterId('siteMembers')), [
128
+ firestore,
129
+ hostId
130
+ ]);
131
+ const membersPausedNotice = visitorRecordsPausedNotice({
132
+ kind: 'siteMembers',
133
+ refused: Number((_ref2 = membersRefusedCounter == null ? void 0 : membersRefusedCounter[submissionMonthKey()]) != null ? _ref2 : 0),
134
+ ceiling: Number(membersRefusedCounter == null ? void 0 : membersRefusedCounter['ceiling']) || undefined
135
+ });
136
+ const { data: leadsRefusedCounter } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'counters', visitorRecordRefusedCounterId('leads')), [
137
+ firestore,
138
+ hostId
139
+ ]);
140
+ const leadsPausedNotice = visitorRecordsPausedNotice({
141
+ kind: 'leads',
142
+ refused: Number((_ref3 = leadsRefusedCounter == null ? void 0 : leadsRefusedCounter[submissionMonthKey()]) != null ? _ref3 : 0),
143
+ ceiling: Number(leadsRefusedCounter == null ? void 0 : leadsRefusedCounter['ceiling']) || undefined
144
+ });
145
+ /*
146
+ * Nothing until the URL names a section. The shell redirects a bare hub URL
147
+ * to the landing section and holds a spinner while it does, so this state is
148
+ * transient — and rendering a default section here instead would pay for its
149
+ * listens on a URL that is already being replaced.
150
+ *
151
+ * The four counter reads above run either way, and that is deliberate: they
152
+ * are four single-document listens, and hoisting them behind this guard
153
+ * would make a ceiling notice appear a frame late on the section a reader
154
+ * lands on.
155
+ */ if (!section || !(sections == null ? void 0 : sections.length) || !basePath) return null;
156
+ return /*#__PURE__*/ _jsxs(_Fragment, {
157
+ children: [
158
+ pausedNotice ? /*#__PURE__*/ _jsxs(Alert, {
159
+ severity: "warning",
160
+ sx: {
161
+ mb: 2
162
+ },
163
+ children: [
164
+ /*#__PURE__*/ _jsx(AlertTitle, {
165
+ children: pausedNotice.title
166
+ }),
167
+ /*#__PURE__*/ _jsx(Typography, {
168
+ component: "div",
169
+ variant: "body2",
170
+ children: pausedNotice.message
171
+ }),
172
+ /*#__PURE__*/ _jsx(Typography, {
173
+ component: "div",
174
+ variant: "body2",
175
+ color: "text.secondary",
176
+ sx: {
177
+ mt: 0.5
178
+ },
179
+ children: pausedNotice.until
180
+ })
181
+ ]
182
+ }) : null,
183
+ spamNotice ? /*#__PURE__*/ _jsx(Alert, {
184
+ severity: "info",
185
+ sx: {
186
+ mb: 2
187
+ },
188
+ children: spamNotice
189
+ }) : null,
190
+ [
191
+ membersPausedNotice,
192
+ leadsPausedNotice
193
+ ].map((notice)=>notice ? /*#__PURE__*/ _jsxs(Alert, {
194
+ severity: "warning",
195
+ sx: {
196
+ mb: 2
197
+ },
198
+ children: [
199
+ /*#__PURE__*/ _jsx(AlertTitle, {
200
+ children: notice.title
201
+ }),
202
+ /*#__PURE__*/ _jsx(Typography, {
203
+ component: "div",
204
+ variant: "body2",
205
+ children: notice.message
206
+ })
207
+ ]
208
+ }, notice.title) : null),
209
+ /*#__PURE__*/ _jsx(HubSections, {
210
+ sections: sections,
211
+ children: sectionBody(section, hostId)
212
+ })
213
+ ]
214
+ });
215
+ }
216
+ InboxConsolePage.displayName = 'InboxConsolePage';
217
+ export default InboxConsolePage;
218
+
219
+ //# sourceMappingURL=inbox-console-page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/inbox-console-page.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n type ConsolePluginPageProps,\n formSpamCaughtNotice,\n formSubmissionsPausedNotice,\n submissionMonthKey,\n visitorRecordRefusedCounterId,\n visitorRecordsPausedNotice,\n} from '@aglyn/aglyn'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { Alert, AlertTitle, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport type { ReactNode } from 'react'\nimport ContactsCard from './contacts-card.component'\nimport { InboxCampaignsZone } from './inbox-attribution-zone'\nimport type { InboxConsoleSectionId } from './inbox-console-sections'\nimport SubmissionsCard from './submissions-card.component'\n\n/**\n * The body of one inbox section, built only when that section is the one being\n * read (AGL-2501).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT all three every render, and each card opens its Firestore\n * listens on mount — which is the entire cost this page exists to stop paying.\n * Only the returned branch is ever built.\n */\nfunction sectionBody(\n section: InboxConsoleSectionId,\n hostId: string,\n): ReactNode {\n switch (section) {\n case 'submissions':\n return <SubmissionsCard hostId={hostId} />\n case 'contacts':\n return <ContactsCard hostId={hostId} />\n case 'campaigns':\n return <InboxCampaignsZone hostId={hostId} />\n default:\n return null\n }\n}\n\n/**\n * Inbox (AGL-77/104/109 → AGL-395): form submissions reader, site members +\n * leads, and campaigns — owned by the inbox plugin and rendered by the shell's\n * generic plugin route. The Campaigns section is a zone this page hosts; the\n * plugin that sends campaigns draws its card there.\n *\n * Orders are NOT here. A sale is not something that arrived in an inbox, and\n * the card was nested inside the members section rather than carrying a tab of\n * its own — so the rail listed three sections while the page drew a fourth\n * subject. Commerce already owns it: `commerce-console-sections.ts` declares\n * an `orders` section that renders the same card.\n *\n * Sections are ROUTES (AGL-2501), following the hubs that migrated before it.\n * Here it IS a read saving as well as an addressing one: this page carried\n * `HubTabs lazy`, which mounts one panel but keeps every panel it has visited,\n * so a reader who looked at Members & leads and went back to Submissions held\n * both sections' listens open for the rest of the visit. A URL per section\n * makes the saving structural — the page builds one section's body and the\n * others do not exist to subscribe. What routing adds besides is that the URL\n * names the section: it is linkable, the back button walks sections, and the\n * breadcrumb says where you are.\n */\nexport function InboxConsolePage(props: ConsolePluginPageProps) {\n const { hostId, section, sections, basePath } = props\n const firestore = useFirestore()\n\n // Submissions this site's abuse ceiling refused (AGL-1655 → AGL-1666).\n //\n // Until this, the refusal existed in two places a site owner cannot see: a\n // counters document only Firestore's console renders, and one in-app\n // notification that `system.` bucket-muting can suppress at write time —\n // `notifyUsers` skips the batch entirely, so a muted owner's notification\n // is never created and cannot be recovered by unmuting. This surface is\n // the durable one, and it is a plain read of the same document the\n // dropped-contacts alert uses (AGL-891).\n const { data: refusedCounter } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId, 'counters', 'formSubmissionsRefused'),\n [firestore, hostId],\n )\n // Keyed by the month the SERVER wrote, via the shared helper — a key\n // derived differently here would read zero refusals on exactly the sites\n // being refused.\n const pausedNotice = formSubmissionsPausedNotice({\n refused: Number(refusedCounter?.[submissionMonthKey()] ?? 0),\n ceiling: Number(refusedCounter?.['ceiling']) || undefined,\n })\n\n // Bot submissions the honeypot caught (AGL-1831 → AGL-1836). The staff org\n // page has shown this number per host since AGL-1831; this is the same\n // count where the site's OWNER already looks, so \"is my form being hit by\n // bots?\" is answered by their own inbox instead of a support ticket. Same\n // client-unwritable counters document shape as the refusal counter above,\n // same host-admin read the rules already grant (AGL-1367), same shared\n // month key — and the shared sentence returns null below one catch, so a\n // quiet month renders nothing rather than a reassuring zero.\n const { data: spamCounter } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId, 'counters', 'formSubmissionsSpam'),\n [firestore, hostId],\n )\n const spamNotice = formSpamCaughtNotice({\n spam: Number(spamCounter?.[submissionMonthKey()] ?? 0),\n })\n\n // Sign-ups and leads this site's PLATFORM ceiling refused (AGL-1529).\n //\n // Same instrument as the form ceiling directly above and read the same way:\n // a client-unwritable counters document (AGL-1367) that host admins can\n // already read, keyed by the SERVER's month through the shared helper — a\n // key derived differently here would read zero refusals on exactly the\n // sites being refused. The counter id comes from the shared function for\n // the same reason: the writer is in `@aglyn/tenant-data-admin` and this is\n // the reader, and two spellings of one document id is a surface that\n // renders nothing forever.\n //\n // This is the surface that makes the ceiling SHIPPED rather than merely\n // implemented. Without it a refusal exists in two places a site owner\n // cannot see: a Firestore document only our console renders, and one\n // notification that `system.` bucket-muting can suppress at write time.\n const { data: membersRefusedCounter } = useFirestoreDoc<any>(\n () =>\n doc(\n firestore,\n 'hosts',\n hostId,\n 'counters',\n visitorRecordRefusedCounterId('siteMembers'),\n ),\n [firestore, hostId],\n )\n const membersPausedNotice = visitorRecordsPausedNotice({\n kind: 'siteMembers',\n refused: Number(membersRefusedCounter?.[submissionMonthKey()] ?? 0),\n ceiling: Number(membersRefusedCounter?.['ceiling']) || undefined,\n })\n const { data: leadsRefusedCounter } = useFirestoreDoc<any>(\n () =>\n doc(\n firestore,\n 'hosts',\n hostId,\n 'counters',\n visitorRecordRefusedCounterId('leads'),\n ),\n [firestore, hostId],\n )\n const leadsPausedNotice = visitorRecordsPausedNotice({\n kind: 'leads',\n refused: Number(leadsRefusedCounter?.[submissionMonthKey()] ?? 0),\n ceiling: Number(leadsRefusedCounter?.['ceiling']) || undefined,\n })\n\n /*\n * Nothing until the URL names a section. The shell redirects a bare hub URL\n * to the landing section and holds a spinner while it does, so this state is\n * transient — and rendering a default section here instead would pay for its\n * listens on a URL that is already being replaced.\n *\n * The four counter reads above run either way, and that is deliberate: they\n * are four single-document listens, and hoisting them behind this guard\n * would make a ceiling notice appear a frame late on the section a reader\n * lands on.\n */\n if (!section || !sections?.length || !basePath) return null\n\n return (\n <>\n {/* Above the RAIL on purpose (AGL-1666). A paused form is not a fact\n about the Submissions section — it is why the whole inbox stopped\n filling — and the notification that brings an owner here links to\n the surface, not to a section. Inside one section's body it would be\n invisible to anyone who followed a link into another. */}\n {pausedNotice ? (\n <Alert severity=\"warning\" sx={{ mb: 2 }}>\n <AlertTitle>{pausedNotice.title}</AlertTitle>\n <Typography component=\"div\" variant=\"body2\">\n {pausedNotice.message}\n </Typography>\n <Typography\n component=\"div\"\n variant=\"body2\"\n color=\"text.secondary\"\n sx={{ mt: 0.5 }}\n >\n {pausedNotice.until}\n </Typography>\n </Alert>\n ) : null}\n {/* Beside the paused notice, and info rather than warning on purpose\n (AGL-1836): the honeypot count reports protection WORKING — those\n submissions were caught, dropped, and never stored or billed. An\n owner whose inbox went quiet checks here; bots absorbed silently is\n the answer that stops the support ticket. */}\n {spamNotice ? (\n <Alert severity=\"info\" sx={{ mb: 2 }}>\n {spamNotice}\n </Alert>\n ) : null}\n {/* Above the rail for the same reason the form notice is (AGL-1529):\n the Members and Leads lists are one section of several, and an owner\n who followed a link into another would never see a notice hidden\n inside that one. Two notices rather than one because the two ceilings\n are independent — a site can be refusing leads while sign-ups still\n land — and a merged sentence could only be true of both. Neither\n carries an `until` line: unlike the monthly form ceiling, these\n count LIVE DOCUMENTS and no date lifts them. */}\n {[membersPausedNotice, leadsPausedNotice].map((notice) =>\n notice ? (\n <Alert key={notice.title} severity=\"warning\" sx={{ mb: 2 }}>\n <AlertTitle>{notice.title}</AlertTitle>\n <Typography component=\"div\" variant=\"body2\">\n {notice.message}\n </Typography>\n </Alert>\n ) : null,\n )}\n <HubSections sections={sections}>\n {sectionBody(section as InboxConsoleSectionId, hostId)}\n </HubSections>\n </>\n )\n}\nInboxConsolePage.displayName = 'InboxConsolePage'\n\nexport default InboxConsolePage\n"],"names":["formSpamCaughtNotice","formSubmissionsPausedNotice","submissionMonthKey","visitorRecordRefusedCounterId","visitorRecordsPausedNotice","HubSections","useFirestore","useFirestoreDoc","Alert","AlertTitle","Typography","doc","ContactsCard","InboxCampaignsZone","SubmissionsCard","sectionBody","section","hostId","InboxConsolePage","props","sections","basePath","firestore","data","refusedCounter","pausedNotice","refused","Number","ceiling","undefined","spamCounter","spamNotice","spam","membersRefusedCounter","membersPausedNotice","kind","leadsRefusedCounter","leadsPausedNotice","length","severity","sx","mb","title","component","variant","message","color","mt","until","map","notice","displayName"],"mappings":"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"}
@@ -0,0 +1,40 @@
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
+ import type { ConsoleNavSection } from '@aglyn/aglyn';
18
+ export type InboxConsoleSectionId = 'submissions' | 'contacts' | 'campaigns';
19
+ /**
20
+ * The inbox console's sections, in rail order (AGL-2501).
21
+ *
22
+ * One list, read twice and never copied: `plugin.ts` registers it on the nav
23
+ * item so the shell can route and gate each section, and the page switches its
24
+ * body on the id the shell resolves back. A second copy is how a section comes
25
+ * to be routable under one id and drawn under another.
26
+ *
27
+ * Ids are the `?tab=` ids this page already deep-linked by, kept deliberately
28
+ * so a bookmark that named a tab names the same section as a route. `contacts`
29
+ * therefore keeps its id while its LABEL reads "Members & leads".
30
+ *
31
+ * ORDERS ARE NOT HERE, and the omission is the decision. A sale is not
32
+ * something that arrived in an inbox, and commerce owns the surface that
33
+ * lists them: `commerce-console-sections.ts` declares an `orders` section
34
+ * rendering the same card. Adding one here would give one record two
35
+ * addresses.
36
+ *
37
+ * No `navTabId` on any of them: every section ships with the surface, so they
38
+ * inherit the Inbox nav item's gate.
39
+ */
40
+ export declare const INBOX_CONSOLE_SECTIONS: readonly ConsoleNavSection[];
@@ -0,0 +1,66 @@
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
+ * The inbox console's sections, in rail order (AGL-2501).
18
+ *
19
+ * One list, read twice and never copied: `plugin.ts` registers it on the nav
20
+ * item so the shell can route and gate each section, and the page switches its
21
+ * body on the id the shell resolves back. A second copy is how a section comes
22
+ * to be routable under one id and drawn under another.
23
+ *
24
+ * Ids are the `?tab=` ids this page already deep-linked by, kept deliberately
25
+ * so a bookmark that named a tab names the same section as a route. `contacts`
26
+ * therefore keeps its id while its LABEL reads "Members & leads".
27
+ *
28
+ * ORDERS ARE NOT HERE, and the omission is the decision. A sale is not
29
+ * something that arrived in an inbox, and commerce owns the surface that
30
+ * lists them: `commerce-console-sections.ts` declares an `orders` section
31
+ * rendering the same card. Adding one here would give one record two
32
+ * addresses.
33
+ *
34
+ * No `navTabId` on any of them: every section ships with the surface, so they
35
+ * inherit the Inbox nav item's gate.
36
+ */ export const INBOX_CONSOLE_SECTIONS = [
37
+ {
38
+ id: 'submissions',
39
+ label: 'Submissions'
40
+ },
41
+ /*
42
+ * The people a site collected, members and leads in one list. Its label
43
+ * names both because the table is one list of two collections — a member
44
+ * who was a lead first appears once, and a rail reading "Contacts" beside
45
+ * the Contacts nav item would name a different surface's subject.
46
+ */ {
47
+ id: 'contacts',
48
+ label: 'Members & leads'
49
+ },
50
+ /*
51
+ * Borrowed from the marketing plugin, which owns the campaign. It is listed
52
+ * here because a campaign is the outbound half of the same conversation the
53
+ * submissions are the inbound half of, and a merchant reading one reaches
54
+ * for the other.
55
+ */ {
56
+ id: 'campaigns',
57
+ label: 'Campaigns'
58
+ }
59
+ ] /*
60
+ * Rail ORDER decides where `/inbox` lands: the shell redirects a bare hub URL
61
+ * to the first section in this list the reader may open (AGL-2501). There is
62
+ * deliberately no separate default constant — a second place to say which
63
+ * section is first is a second place for it to disagree with the rail.
64
+ */ ;
65
+
66
+ //# sourceMappingURL=inbox-console-sections.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/inbox-console-sections.ts"],"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\nimport type { ConsoleNavSection } from '@aglyn/aglyn'\n\nexport type InboxConsoleSectionId = 'submissions' | 'contacts' | 'campaigns'\n\n/**\n * The inbox console's sections, in rail order (AGL-2501).\n *\n * One list, read twice and never copied: `plugin.ts` registers it on the nav\n * item so the shell can route and gate each section, and the page switches its\n * body on the id the shell resolves back. A second copy is how a section comes\n * to be routable under one id and drawn under another.\n *\n * Ids are the `?tab=` ids this page already deep-linked by, kept deliberately\n * so a bookmark that named a tab names the same section as a route. `contacts`\n * therefore keeps its id while its LABEL reads \"Members & leads\".\n *\n * ORDERS ARE NOT HERE, and the omission is the decision. A sale is not\n * something that arrived in an inbox, and commerce owns the surface that\n * lists them: `commerce-console-sections.ts` declares an `orders` section\n * rendering the same card. Adding one here would give one record two\n * addresses.\n *\n * No `navTabId` on any of them: every section ships with the surface, so they\n * inherit the Inbox nav item's gate.\n */\nexport const INBOX_CONSOLE_SECTIONS: readonly ConsoleNavSection[] = [\n { id: 'submissions', label: 'Submissions' },\n /*\n * The people a site collected, members and leads in one list. Its label\n * names both because the table is one list of two collections — a member\n * who was a lead first appears once, and a rail reading \"Contacts\" beside\n * the Contacts nav item would name a different surface's subject.\n */\n { id: 'contacts', label: 'Members & leads' },\n /*\n * Borrowed from the marketing plugin, which owns the campaign. It is listed\n * here because a campaign is the outbound half of the same conversation the\n * submissions are the inbound half of, and a merchant reading one reaches\n * for the other.\n */\n { id: 'campaigns', label: 'Campaigns' },\n]\n\n/*\n * Rail ORDER decides where `/inbox` lands: the shell redirects a bare hub URL\n * to the first section in this list the reader may open (AGL-2501). There is\n * deliberately no separate default constant — a second place to say which\n * section is first is a second place for it to disagree with the rail.\n */\n"],"names":["INBOX_CONSOLE_SECTIONS","id","label"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAMA,yBAAuD;IAClE;QAAEC,IAAI;QAAeC,OAAO;IAAc;IAC1C;;;;;GAKC,GACD;QAAED,IAAI;QAAYC,OAAO;IAAkB;IAC3C;;;;;GAKC,GACD;QAAED,IAAI;QAAaC,OAAO;IAAY;CACvC,CAED;;;;;CAKC,IAPA"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The inbox at a glance: who wrote in, how long ago, and how many are
3
+ * unread.
4
+ *
5
+ * A dashboard is where a site owner looks first, and a form submission is
6
+ * the one thing on it that is waiting for a REPLY — until now it was two
7
+ * clicks away with nothing on the dashboard saying it had arrived.
8
+ *
9
+ * Renders nothing until the site has a submission, the same rule the other
10
+ * capability glances follow: an empty card about forms on a site with no
11
+ * form is an advertisement, not a summary.
12
+ *
13
+ * `orderBy('createdAt', 'desc')` is checked against the writer rather than
14
+ * assumed — an `orderBy` drops documents missing the field, which on a
15
+ * newest-first list is invisible. `apps/tenant/app/api/forms/submit/route.ts`
16
+ * is the only path that creates one and stamps `createdAt: serverTimestamp()`
17
+ * on every add, the v1 API only reads and deletes, and `formSubmissions` is
18
+ * absent from `IMPORTABLE_FIELDS`, so no restore can mint one without it.
19
+ */
20
+ export declare function InboxGlanceCard(props: {
21
+ hostId: string;
22
+ }): import("react").JSX.Element;
23
+ export declare namespace InboxGlanceCard {
24
+ var displayName: string;
25
+ }
26
+ export default InboxGlanceCard;
@@ -0,0 +1,215 @@
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 { buildRoute, CRM_LEAD_CLOSED_STATUSES, openLeadsFromCounts, pluginDocsHelp, Route } from "@aglyn/aglyn";
20
+ // The CRM's route builder by its leaf path, not the plugin barrel: the
21
+ // barrel is the CRM's site entry point, and a dashboard card named there
22
+ // would ship to every published page.
23
+ import { pluginRecordListHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
24
+ import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
25
+ import { Avatar, Box, Button, Stack, Typography } from "@mui/material";
26
+ import { collection, getCountFromServer, limit, orderBy, query, where } from "firebase/firestore";
27
+ import { useParams } from "next/navigation";
28
+ import { useEffect, useState } from "react";
29
+ import { useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
30
+ import { useRecordRouteContext } from "./use-record-route-context.js";
31
+ import { relativeTime, senderHue, submissionSender } from "../model/submission-presenter.js";
32
+ /**
33
+ * Rows on the card, plus one so it can say the inbox holds more.
34
+ *
35
+ * The extra document is never rendered — it is what turns "there may be
36
+ * more" into a fact, the same probe the console's paged lists use.
37
+ */ const PREVIEW_ROWS = 3;
38
+ /**
39
+ * The inbox at a glance: who wrote in, how long ago, and how many are
40
+ * unread.
41
+ *
42
+ * A dashboard is where a site owner looks first, and a form submission is
43
+ * the one thing on it that is waiting for a REPLY — until now it was two
44
+ * clicks away with nothing on the dashboard saying it had arrived.
45
+ *
46
+ * Renders nothing until the site has a submission, the same rule the other
47
+ * capability glances follow: an empty card about forms on a site with no
48
+ * form is an advertisement, not a summary.
49
+ *
50
+ * `orderBy('createdAt', 'desc')` is checked against the writer rather than
51
+ * assumed — an `orderBy` drops documents missing the field, which on a
52
+ * newest-first list is invisible. `apps/tenant/app/api/forms/submit/route.ts`
53
+ * is the only path that creates one and stamps `createdAt: serverTimestamp()`
54
+ * on every add, the v1 API only reads and deletes, and `formSubmissions` is
55
+ * absent from `IMPORTABLE_FIELDS`, so no restore can mint one without it.
56
+ */ export function InboxGlanceCard(props) {
57
+ const { hostId } = props;
58
+ const firestore = useFirestore();
59
+ const { orgSlug, host } = useParams();
60
+ const { data: submissionDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'formSubmissions'), orderBy('createdAt', 'desc'), limit(PREVIEW_ROWS + 1)), [
61
+ firestore,
62
+ hostId
63
+ ], {
64
+ idField: '$id'
65
+ });
66
+ /*
67
+ * How many leads are waiting to be worked (AGL-2622), so the dashboard
68
+ * says where the Inbox's captures went and the CRM's Leads list is one
69
+ * click from here. Two server-side counts rather than a window: a lead
70
+ * nobody has touched carries no status field at all — it is `new` by
71
+ * absence, the rule `crmLeadStatus` reads — and Firestore can neither
72
+ * count an absent field nor exclude the closed statuses without dropping
73
+ * the unstamped rows too. So the open count is every lead less the ones
74
+ * closed by a stamped status — `openLeadsFromCounts`, the subtraction the
75
+ * CRM's own glance card makes — and both counts are aggregations that cost
76
+ * one read per thousand index entries, not one per lead. `null` until
77
+ * they land, and left null on a refused read so the line is withheld
78
+ * rather than drawn as zero.
79
+ */ const routeContext = useRecordRouteContext();
80
+ const leadsHref = routeContext ? pluginRecordListHref('lead', routeContext) : null;
81
+ const [openLeads, setOpenLeads] = useState(null);
82
+ useEffect(()=>{
83
+ let active = true;
84
+ const leads = collection(firestore, 'hosts', hostId, 'leads');
85
+ void Promise.all([
86
+ getCountFromServer(query(leads)),
87
+ getCountFromServer(query(leads, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)))
88
+ ]).then(([all, closed])=>{
89
+ if (!active) return;
90
+ setOpenLeads(openLeadsFromCounts(all.data().count, closed.data().count));
91
+ }).catch(()=>undefined);
92
+ return ()=>{
93
+ active = false;
94
+ };
95
+ }, [
96
+ firestore,
97
+ hostId
98
+ ]);
99
+ const submissions = submissionDocs != null ? submissionDocs : [];
100
+ if (!submissions.length && !openLeads) return null;
101
+ const rows = submissions.slice(0, PREVIEW_ROWS);
102
+ const unread = rows.filter((submission)=>!submission.read).length;
103
+ return /*#__PURE__*/ _jsx(CardDisplay, {
104
+ header: 'Inbox',
105
+ help: pluginDocsHelp('forms', {
106
+ anchor: '#the-inbox',
107
+ excerpt: 'The newest form submissions on this site — the Inbox has the ' + 'full list, the reader, and the routing each one took.'
108
+ }),
109
+ contentGutterX: true,
110
+ contentGutterY: true,
111
+ HeaderProps: {
112
+ action: /*#__PURE__*/ _jsx(Button, _extends({
113
+ component: AppLink
114
+ }, {
115
+ componentVariant: 'naked',
116
+ nativeButton: false
117
+ }, {
118
+ href: buildRoute(Route.HOST_INBOX, {
119
+ orgSlug,
120
+ host
121
+ }),
122
+ size: "small",
123
+ color: "primary",
124
+ children: 'Open inbox'
125
+ }))
126
+ },
127
+ children: /*#__PURE__*/ _jsxs(Stack, {
128
+ spacing: 1,
129
+ children: [
130
+ rows.map((submission)=>{
131
+ var _submission_formName;
132
+ var _submission_createdAt_toDate, _submission_createdAt;
133
+ const sender = submissionSender(submission.fields);
134
+ const hue = senderHue(sender.label);
135
+ return /*#__PURE__*/ _jsxs(Stack, {
136
+ direction: "row",
137
+ spacing: 1,
138
+ sx: {
139
+ alignItems: 'center'
140
+ },
141
+ children: [
142
+ /*#__PURE__*/ _jsx(Box, {
143
+ "aria-label": submission.read ? undefined : 'Unread',
144
+ sx: {
145
+ width: 8,
146
+ height: 8,
147
+ borderRadius: '50%',
148
+ bgcolor: submission.read ? 'transparent' : 'primary.main',
149
+ flexShrink: 0
150
+ }
151
+ }),
152
+ /*#__PURE__*/ _jsx(Avatar, {
153
+ sx: {
154
+ width: 28,
155
+ height: 28,
156
+ typography: 'caption',
157
+ bgcolor: `hsl(${hue} 55% 45%)`
158
+ },
159
+ children: sender.initials
160
+ }),
161
+ /*#__PURE__*/ _jsxs(Stack, {
162
+ sx: {
163
+ flex: 1,
164
+ minWidth: 0
165
+ },
166
+ children: [
167
+ /*#__PURE__*/ _jsx(Typography, {
168
+ variant: "body2",
169
+ noWrap: true,
170
+ children: sender.label
171
+ }),
172
+ /*#__PURE__*/ _jsx(Typography, {
173
+ variant: "caption",
174
+ color: "text.secondary",
175
+ noWrap: true,
176
+ children: (_submission_formName = submission.formName) != null ? _submission_formName : 'Form'
177
+ })
178
+ ]
179
+ }),
180
+ /*#__PURE__*/ _jsx(Typography, {
181
+ variant: "caption",
182
+ color: "text.secondary",
183
+ noWrap: true,
184
+ children: relativeTime((_submission_createdAt = submission.createdAt) == null ? void 0 : (_submission_createdAt_toDate = _submission_createdAt.toDate) == null ? void 0 : _submission_createdAt_toDate.call(_submission_createdAt).getTime())
185
+ })
186
+ ]
187
+ }, submission.$id);
188
+ }),
189
+ /*#__PURE__*/ _jsx(Typography, {
190
+ variant: "caption",
191
+ color: "text.secondary",
192
+ children: [
193
+ unread ? `${unread} unread here` : null,
194
+ submissions.length > PREVIEW_ROWS ? 'more in the Inbox' : null
195
+ ].filter(Boolean).join(' · ') || 'All caught up'
196
+ }),
197
+ openLeads ? /*#__PURE__*/ _jsxs(Typography, {
198
+ variant: "caption",
199
+ color: "text.secondary",
200
+ children: [
201
+ `${openLeads.toLocaleString()} open lead${openLeads === 1 ? '' : 's'} · `,
202
+ leadsHref ? /*#__PURE__*/ _jsx(AppLink, {
203
+ href: leadsHref,
204
+ children: 'Work them in the CRM'
205
+ }) : 'in the CRM'
206
+ ]
207
+ }) : null
208
+ ]
209
+ })
210
+ });
211
+ }
212
+ InboxGlanceCard.displayName = 'InboxGlanceCard';
213
+ export default InboxGlanceCard;
214
+
215
+ //# sourceMappingURL=inbox-glance-card.component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/inbox-glance-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n buildRoute,\n CRM_LEAD_CLOSED_STATUSES,\n openLeadsFromCounts,\n pluginDocsHelp,\n Route,\n} from '@aglyn/aglyn'\n// The CRM's route builder by its leaf path, not the plugin barrel: the\n// barrel is the CRM's site entry point, and a dashboard card named there\n// would ship to every published page.\nimport { pluginRecordListHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Avatar, Box, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n getCountFromServer,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useParams } from 'next/navigation'\nimport { useEffect, useState } from 'react'\nimport { useFirestore, useFirestoreCollection } from '@aglyn/tenant-feature-instance'\nimport { useRecordRouteContext } from './use-record-route-context'\nimport {\n relativeTime,\n senderHue,\n submissionSender,\n} from '../model/submission-presenter'\n\n/**\n * Rows on the card, plus one so it can say the inbox holds more.\n *\n * The extra document is never rendered — it is what turns \"there may be\n * more\" into a fact, the same probe the console's paged lists use.\n */\nconst PREVIEW_ROWS = 3\n\n/**\n * The inbox at a glance: who wrote in, how long ago, and how many are\n * unread.\n *\n * A dashboard is where a site owner looks first, and a form submission is\n * the one thing on it that is waiting for a REPLY — until now it was two\n * clicks away with nothing on the dashboard saying it had arrived.\n *\n * Renders nothing until the site has a submission, the same rule the other\n * capability glances follow: an empty card about forms on a site with no\n * form is an advertisement, not a summary.\n *\n * `orderBy('createdAt', 'desc')` is checked against the writer rather than\n * assumed — an `orderBy` drops documents missing the field, which on a\n * newest-first list is invisible. `apps/tenant/app/api/forms/submit/route.ts`\n * is the only path that creates one and stamps `createdAt: serverTimestamp()`\n * on every add, the v1 API only reads and deletes, and `formSubmissions` is\n * absent from `IMPORTABLE_FIELDS`, so no restore can mint one without it.\n */\nexport function InboxGlanceCard(props: { hostId: string }) {\n const { hostId } = props\n const firestore = useFirestore()\n const { orgSlug, host } = useParams<{ orgSlug: string; host: string }>()\n const { data: submissionDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'formSubmissions'),\n orderBy('createdAt', 'desc'),\n limit(PREVIEW_ROWS + 1),\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n /*\n * How many leads are waiting to be worked (AGL-2622), so the dashboard\n * says where the Inbox's captures went and the CRM's Leads list is one\n * click from here. Two server-side counts rather than a window: a lead\n * nobody has touched carries no status field at all — it is `new` by\n * absence, the rule `crmLeadStatus` reads — and Firestore can neither\n * count an absent field nor exclude the closed statuses without dropping\n * the unstamped rows too. So the open count is every lead less the ones\n * closed by a stamped status — `openLeadsFromCounts`, the subtraction the\n * CRM's own glance card makes — and both counts are aggregations that cost\n * one read per thousand index entries, not one per lead. `null` until\n * they land, and left null on a refused read so the line is withheld\n * rather than drawn as zero.\n */\n const routeContext = useRecordRouteContext()\n const leadsHref = routeContext\n ? pluginRecordListHref('lead', routeContext)\n : null\n const [openLeads, setOpenLeads] = useState<number | null>(null)\n useEffect(() => {\n let active = true\n const leads = collection(firestore, 'hosts', hostId, 'leads')\n void Promise.all([\n getCountFromServer(query(leads)),\n getCountFromServer(query(leads, where('status', 'in', CRM_LEAD_CLOSED_STATUSES))),\n ])\n .then(([all, closed]) => {\n if (!active) return\n setOpenLeads(openLeadsFromCounts(all.data().count, closed.data().count))\n })\n .catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId])\n\n const submissions = submissionDocs ?? []\n if (!submissions.length && !openLeads) return null\n const rows = submissions.slice(0, PREVIEW_ROWS)\n const unread = rows.filter((submission: any) => !submission.read).length\n\n return (\n <CardDisplay\n header={'Inbox'}\n help={pluginDocsHelp('forms', {\n anchor: '#the-inbox',\n excerpt:\n 'The newest form submissions on this site — the Inbox has the ' +\n 'full list, the reader, and the routing each one took.',\n })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={buildRoute(Route.HOST_INBOX, { orgSlug, host })}\n size=\"small\"\n color=\"primary\"\n >\n {'Open inbox'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1}>\n {rows.map((submission: any) => {\n const sender = submissionSender(submission.fields)\n const hue = senderHue(sender.label)\n return (\n <Stack\n key={submission.$id}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n {/*\n The unread DOT, as the Inbox itself draws it — same fact, same\n shape, so a reader recognizes the row when they open it.\n */}\n <Box\n aria-label={submission.read ? undefined : 'Unread'}\n sx={{\n width: 8,\n height: 8,\n borderRadius: '50%',\n bgcolor: submission.read ? 'transparent' : 'primary.main',\n flexShrink: 0,\n }}\n />\n <Avatar\n sx={{\n width: 28,\n height: 28,\n typography: 'caption',\n bgcolor: `hsl(${hue} 55% 45%)`,\n }}\n >\n {sender.initials}\n </Avatar>\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {sender.label}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {submission.formName ?? 'Form'}\n </Typography>\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {relativeTime(submission.createdAt?.toDate?.().getTime())}\n </Typography>\n </Stack>\n )\n })}\n {/*\n What the card is NOT showing. `submissions` holds the probe row, so\n a fourth document is a fact rather than an inference from a full\n page — and the unread count is stated over the rows on screen, not\n over the collection, because counting the collection is a read this\n card has no reason to pay for.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {[\n unread ? `${unread} unread here` : null,\n submissions.length > PREVIEW_ROWS ? 'more in the Inbox' : null,\n ]\n .filter(Boolean)\n .join(' · ') || 'All caught up'}\n </Typography>\n {/*\n Where the captures went (AGL-2622): the leads still to be worked,\n and the Leads list where they are, which the plugin that keeps\n them publishes. Text alone until the route params settle or where\n no plugin publishes one, so the line never links to nowhere.\n */}\n {openLeads ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${openLeads.toLocaleString()} open lead${openLeads === 1 ? '' : 's'} · `}\n {leadsHref ? (\n <AppLink href={leadsHref}>\n {'Work them in the CRM'}\n </AppLink>\n ) : (\n 'in the CRM'\n )}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nInboxGlanceCard.displayName = 'InboxGlanceCard'\n\nexport default InboxGlanceCard\n"],"names":["buildRoute","CRM_LEAD_CLOSED_STATUSES","openLeadsFromCounts","pluginDocsHelp","Route","pluginRecordListHref","AppLink","CardDisplay","Avatar","Box","Button","Stack","Typography","collection","getCountFromServer","limit","orderBy","query","where","useParams","useEffect","useState","useFirestore","useFirestoreCollection","useRecordRouteContext","relativeTime","senderHue","submissionSender","PREVIEW_ROWS","InboxGlanceCard","props","hostId","firestore","orgSlug","host","data","submissionDocs","idField","routeContext","leadsHref","openLeads","setOpenLeads","active","leads","Promise","all","then","closed","count","catch","undefined","submissions","length","rows","slice","unread","filter","submission","read","header","help","anchor","excerpt","contentGutterX","contentGutterY","HeaderProps","action","component","componentVariant","nativeButton","href","HOST_INBOX","size","color","spacing","map","sender","fields","hue","label","direction","sx","alignItems","aria-label","width","height","borderRadius","bgcolor","flexShrink","typography","initials","flex","minWidth","variant","noWrap","formName","createdAt","toDate","getTime","$id","Boolean","join","toLocaleString","displayName"],"mappings":"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"}