@aglyn/plugins-inbox 1.0.0-beta.221 → 1.0.0-beta.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-inbox",
3
- "version": "1.0.0-beta.221",
3
+ "version": "1.0.0-beta.222",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,15 +25,15 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.221",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.221",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.221",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.221",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.221",
33
- "@aglyn/shared-util-email": "1.0.0-beta.221",
34
- "@aglyn/shared-util-http": "1.0.0-beta.221",
35
- "@aglyn/tenant-data-admin": "1.0.0-beta.221",
36
- "@aglyn/tenant-feature-instance": "1.0.0-beta.221",
28
+ "@aglyn/aglyn": "1.0.0-beta.222",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.222",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.222",
31
+ "@aglyn/shared-ui-next": "1.0.0-beta.222",
32
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.222",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.222",
34
+ "@aglyn/shared-util-http": "1.0.0-beta.222",
35
+ "@aglyn/tenant-data-admin": "1.0.0-beta.222",
36
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.222",
37
37
  "@swc/helpers": "0.5.23"
38
38
  },
39
39
  "peerDependencies": {
@@ -15,7 +15,8 @@
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
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";
18
+ import { visitorRecordRefusedCounterId, visitorRecordsPausedNotice } from "@aglyn/aglyn";
19
+ import { utcMonthKey } from "@aglyn/aglyn/app-utils/utc-month";
19
20
  import { HubSections } from "@aglyn/shared-ui-next";
20
21
  import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
21
22
  import { Alert, AlertTitle, Box, CircularProgress, MenuItem, TextField, Typography } from "@mui/material";
@@ -24,6 +25,7 @@ import ContactsCard from "./contacts-card.component.js";
24
25
  import { InboxCampaignsZone } from "./inbox-attribution-zone.js";
25
26
  import SubmissionsCard from "./submissions-card.component.js";
26
27
  import { useInboxSitePick } from "./use-inbox-site-pick.js";
28
+ import { useVisitorDoorNotices, VisitorDoorNotices } from "./visitor-door-notices.component.js";
27
29
  /**
28
30
  * The body of one inbox section, built only when that section is the one being
29
31
  * read (AGL-2501).
@@ -108,7 +110,7 @@ import { useInboxSitePick } from "./use-inbox-site-pick.js";
108
110
  * sections over every site at once, and a site filter above the rail that
109
111
  * narrows the whole page to one site's own view.
110
112
  */ export function InboxConsolePage(props) {
111
- var _ref, _ref1, _ref2, _ref3;
113
+ var _ref, _ref1;
112
114
  const { hostId, orgMount, section, sections, basePath } = props;
113
115
  const firestore = useFirestore();
114
116
  /*
@@ -122,46 +124,15 @@ import { useInboxSitePick } from "./use-inbox-site-pick.js";
122
124
  * would be a listener per site on a page that promises a bounded read, so
123
125
  * the notices wait for a pick.
124
126
  */ const noticeHostId = hostId != null ? hostId : sitePick.hostId;
125
- // Submissions this site's abuse ceiling refused (AGL-1655 → AGL-1666).
126
- //
127
- // Until this, the refusal existed in two places a site owner cannot see: a
128
- // counters document only Firestore's console renders, and one in-app
129
- // notification that `system.` bucket-muting can suppress at write time —
130
- // `notifyUsers` skips the batch entirely, so a muted owner's notification
131
- // is never created and cannot be recovered by unmuting. This surface is
132
- // the durable one, and it is a plain read of the same document the
133
- // dropped-contacts alert uses (AGL-891).
134
- const { data: refusedCounter } = useFirestoreDoc(()=>noticeHostId ? doc(firestore, 'hosts', noticeHostId, 'counters', 'formSubmissionsRefused') : null, [
135
- firestore,
136
- noticeHostId
137
- ]);
138
- // Keyed by the month the SERVER wrote, via the shared helper — a key
139
- // derived differently here would read zero refusals on exactly the sites
140
- // being refused.
141
- const pausedNotice = formSubmissionsPausedNotice({
142
- refused: Number((_ref = refusedCounter == null ? void 0 : refusedCounter[submissionMonthKey()]) != null ? _ref : 0),
143
- ceiling: Number(refusedCounter == null ? void 0 : refusedCounter['ceiling']) || undefined
144
- });
145
- // Bot submissions the honeypot caught (AGL-1831 → AGL-1836). The staff org
146
- // page has shown this number per host since AGL-1831; this is the same
147
- // count where the site's OWNER already looks, so "is my form being hit by
148
- // bots?" is answered by their own inbox instead of a support ticket. Same
149
- // client-unwritable counters document shape as the refusal counter above,
150
- // same host-admin read the rules already grant (AGL-1367), same shared
151
- // month key — and the shared sentence returns null below one catch, so a
152
- // quiet month renders nothing rather than a reassuring zero.
153
- const { data: spamCounter } = useFirestoreDoc(()=>noticeHostId ? doc(firestore, 'hosts', noticeHostId, 'counters', 'formSubmissionsSpam') : null, [
154
- firestore,
155
- noticeHostId
156
- ]);
157
- const spamNotice = formSpamCaughtNotice({
158
- spam: Number((_ref1 = spamCounter == null ? void 0 : spamCounter[submissionMonthKey()]) != null ? _ref1 : 0)
159
- });
127
+ // Every public door's ceiling refusals and honeypot catches (AGL-1666 →
128
+ // AGL-1836), from the counters the plugin that keeps each door declares.
129
+ const doorNotices = useVisitorDoorNotices(noticeHostId);
160
130
  // Sign-ups and leads this site's PLATFORM ceiling refused (AGL-1529).
161
131
  //
162
- // Same instrument as the form ceiling directly above and read the same way:
163
- // a client-unwritable counters document (AGL-1367) that host admins can
164
- // already read, keyed by the SERVER's month through the shared helper — a
132
+ // Same instrument as a public door's ceiling (`VisitorDoorNotices`) and
133
+ // read the same way: a client-unwritable counters document (AGL-1367) that
134
+ // host admins can already read, keyed by the SERVER's month through the
135
+ // shared helper — a
165
136
  // key derived differently here would read zero refusals on exactly the
166
137
  // sites being refused. The counter id comes from the shared function for
167
138
  // the same reason: the writer is in `@aglyn/tenant-data-admin` and this is
@@ -178,7 +149,7 @@ import { useInboxSitePick } from "./use-inbox-site-pick.js";
178
149
  ]);
179
150
  const membersPausedNotice = visitorRecordsPausedNotice({
180
151
  kind: 'siteMembers',
181
- refused: Number((_ref2 = membersRefusedCounter == null ? void 0 : membersRefusedCounter[submissionMonthKey()]) != null ? _ref2 : 0),
152
+ refused: Number((_ref = membersRefusedCounter == null ? void 0 : membersRefusedCounter[utcMonthKey()]) != null ? _ref : 0),
182
153
  ceiling: Number(membersRefusedCounter == null ? void 0 : membersRefusedCounter['ceiling']) || undefined
183
154
  });
184
155
  const { data: leadsRefusedCounter } = useFirestoreDoc(()=>noticeHostId ? doc(firestore, 'hosts', noticeHostId, 'counters', visitorRecordRefusedCounterId('leads')) : null, [
@@ -187,7 +158,7 @@ import { useInboxSitePick } from "./use-inbox-site-pick.js";
187
158
  ]);
188
159
  const leadsPausedNotice = visitorRecordsPausedNotice({
189
160
  kind: 'leads',
190
- refused: Number((_ref3 = leadsRefusedCounter == null ? void 0 : leadsRefusedCounter[submissionMonthKey()]) != null ? _ref3 : 0),
161
+ refused: Number((_ref1 = leadsRefusedCounter == null ? void 0 : leadsRefusedCounter[utcMonthKey()]) != null ? _ref1 : 0),
191
162
  ceiling: Number(leadsRefusedCounter == null ? void 0 : leadsRefusedCounter['ceiling']) || undefined
192
163
  });
193
164
  /*
@@ -196,45 +167,16 @@ import { useInboxSitePick } from "./use-inbox-site-pick.js";
196
167
  * transient — and rendering a default section here instead would pay for its
197
168
  * listens on a URL that is already being replaced.
198
169
  *
199
- * The four counter reads above run either way, and that is deliberate: they
200
- * are four single-document listens, and hoisting them behind this guard
170
+ * The ceiling counter reads above run either way, and that is deliberate:
171
+ * they are single-document listens, and hoisting them behind this guard
201
172
  * would make a ceiling notice appear a frame late on the section a reader
202
173
  * lands on. (On the organization's Inbox they run once a site is picked.)
203
174
  */ if (!section || !(sections == null ? void 0 : sections.length) || !basePath) return null;
204
175
  const notices = /*#__PURE__*/ _jsxs(_Fragment, {
205
176
  children: [
206
- pausedNotice ? /*#__PURE__*/ _jsxs(Alert, {
207
- severity: "warning",
208
- sx: {
209
- mb: 2
210
- },
211
- children: [
212
- /*#__PURE__*/ _jsx(AlertTitle, {
213
- children: pausedNotice.title
214
- }),
215
- /*#__PURE__*/ _jsx(Typography, {
216
- component: "div",
217
- variant: "body2",
218
- children: pausedNotice.message
219
- }),
220
- /*#__PURE__*/ _jsx(Typography, {
221
- component: "div",
222
- variant: "body2",
223
- color: "text.secondary",
224
- sx: {
225
- mt: 0.5
226
- },
227
- children: pausedNotice.until
228
- })
229
- ]
230
- }) : null,
231
- spamNotice ? /*#__PURE__*/ _jsx(Alert, {
232
- severity: "info",
233
- sx: {
234
- mb: 2
235
- },
236
- children: spamNotice
237
- }) : null,
177
+ /*#__PURE__*/ _jsx(VisitorDoorNotices, {
178
+ notices: doorNotices
179
+ }),
238
180
  [
239
181
  membersPausedNotice,
240
182
  leadsPausedNotice
@@ -1 +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 ConsolePluginOrgMount,\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 {\n Alert,\n AlertTitle,\n Box,\n CircularProgress,\n MenuItem,\n TextField,\n Typography,\n} 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'\nimport { useInboxSitePick } from './use-inbox-site-pick'\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 * The body of one section on the ORGANIZATION's Inbox (AGL-3303): every\n * site's, with no site picked — the submissions in one collection-group\n * list, the organization's leads, every campaign — and exactly the site\n * view, through the same cards, once one is.\n *\n * Handing a picked site to the site body is the point rather than a\n * shortcut: that site's form filter, its members and its campaigns are\n * facts only a site has, and the cards that already read them are the ones\n * that read them correctly.\n */\nfunction orgSectionBody(\n section: InboxConsoleSectionId,\n mount: ConsolePluginOrgMount,\n siteHostId: string | null,\n): ReactNode {\n if (siteHostId) return sectionBody(section, siteHostId)\n switch (section) {\n case 'submissions':\n return <SubmissionsCard hostId={null} orgMount={mount} />\n case 'contacts':\n return <ContactsCard hostId={null} orgMount={mount} />\n case 'campaigns':\n return <InboxCampaignsZone hostId={null} orgMount={mount} />\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 *\n * The ORGANIZATION's Inbox is this page handed no site (AGL-3303): the same\n * sections over every site at once, and a site filter above the rail that\n * narrows the whole page to one site's own view.\n */\nexport function InboxConsolePage(props: ConsolePluginPageProps) {\n const { hostId, orgMount, section, sections, basePath } = props\n const firestore = useFirestore()\n /*\n * On the organization's Inbox, the site the page is narrowed to — `null`\n * for every site (AGL-3303). Under a site there is no mount and no pick.\n */\n const sitePick = useInboxSitePick(hostId == null ? orgMount : undefined)\n /*\n * The site whose ceilings the notices below read: this page's own, or the\n * one picked on the organization's Inbox. With every site listed there is\n * no one site to read them for, and reading every site's four counters\n * would be a listener per site on a page that promises a bounded read, so\n * the notices wait for a pick.\n */\n const noticeHostId = hostId ?? sitePick.hostId\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 () =>\n noticeHostId\n ? doc(\n firestore,\n 'hosts',\n noticeHostId,\n 'counters',\n 'formSubmissionsRefused',\n )\n : null,\n [firestore, noticeHostId],\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 () =>\n noticeHostId\n ? doc(\n firestore,\n 'hosts',\n noticeHostId,\n 'counters',\n 'formSubmissionsSpam',\n )\n : null,\n [firestore, noticeHostId],\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 noticeHostId\n ? doc(\n firestore,\n 'hosts',\n noticeHostId,\n 'counters',\n visitorRecordRefusedCounterId('siteMembers'),\n )\n : null,\n [firestore, noticeHostId],\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 noticeHostId\n ? doc(\n firestore,\n 'hosts',\n noticeHostId,\n 'counters',\n visitorRecordRefusedCounterId('leads'),\n )\n : null,\n [firestore, noticeHostId],\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. (On the organization's Inbox they run once a site is picked.)\n */\n if (!section || !sections?.length || !basePath) return null\n\n const notices = (\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 </>\n )\n\n if (hostId == null) {\n // No site and no org to stand in for it: nothing this page can scope.\n if (!orgMount) return null\n return (\n <>\n {/*\n The site filter sits above the rail with the notices, because it\n is a fact about the whole page: every section follows it, and the\n notices it brings in are the picked site's. An organization with\n one site has nothing to choose, and gets that site's page.\n */}\n {sitePick.options.length ? (\n <TextField\n select\n size=\"small\"\n label={'Site'}\n value={sitePick.hostId ?? ''}\n onChange={(event) => sitePick.setHostId(event.target.value || null)}\n // \"All sites\" is the empty value, which a select would otherwise\n // draw as a blank rather than as the choice it is — and the label\n // is shrunk with it, or it paints over that choice.\n slotProps={{\n select: { displayEmpty: true },\n inputLabel: { shrink: true },\n }}\n sx={{ mb: 2, minWidth: 240 }}\n >\n <MenuItem value=\"\">{'All sites'}</MenuItem>\n {sitePick.options.map((option) => (\n <MenuItem key={option.value} value={option.value}>\n {option.label}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n {notices}\n <HubSections sections={sections}>\n {sitePick.ready ? (\n orgSectionBody(\n section as InboxConsoleSectionId,\n orgMount,\n sitePick.hostId,\n )\n ) : (\n <Box sx={{ p: 2 }}>\n <CircularProgress size={24} />\n </Box>\n )}\n </HubSections>\n </>\n )\n }\n\n return (\n <>\n {notices}\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","Box","CircularProgress","MenuItem","TextField","Typography","doc","ContactsCard","InboxCampaignsZone","SubmissionsCard","useInboxSitePick","sectionBody","section","hostId","orgSectionBody","mount","siteHostId","orgMount","InboxConsolePage","props","sections","basePath","firestore","sitePick","undefined","noticeHostId","data","refusedCounter","pausedNotice","refused","Number","ceiling","spamCounter","spamNotice","spam","membersRefusedCounter","membersPausedNotice","kind","leadsRefusedCounter","leadsPausedNotice","length","notices","severity","sx","mb","title","component","variant","message","color","mt","until","map","notice","options","select","size","label","value","onChange","event","setHostId","target","slotProps","displayEmpty","inputLabel","shrink","minWidth","option","ready","p","displayName"],"mappings":"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"}
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 ConsolePluginOrgMount,\n type ConsolePluginPageProps,\n visitorRecordRefusedCounterId,\n visitorRecordsPausedNotice,\n} from '@aglyn/aglyn'\nimport { utcMonthKey } from '@aglyn/aglyn/app-utils/utc-month'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n AlertTitle,\n Box,\n CircularProgress,\n MenuItem,\n TextField,\n Typography,\n} 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'\nimport { useInboxSitePick } from './use-inbox-site-pick'\nimport { useVisitorDoorNotices, VisitorDoorNotices } from './visitor-door-notices.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 * The body of one section on the ORGANIZATION's Inbox (AGL-3303): every\n * site's, with no site picked — the submissions in one collection-group\n * list, the organization's leads, every campaign — and exactly the site\n * view, through the same cards, once one is.\n *\n * Handing a picked site to the site body is the point rather than a\n * shortcut: that site's form filter, its members and its campaigns are\n * facts only a site has, and the cards that already read them are the ones\n * that read them correctly.\n */\nfunction orgSectionBody(\n section: InboxConsoleSectionId,\n mount: ConsolePluginOrgMount,\n siteHostId: string | null,\n): ReactNode {\n if (siteHostId) return sectionBody(section, siteHostId)\n switch (section) {\n case 'submissions':\n return <SubmissionsCard hostId={null} orgMount={mount} />\n case 'contacts':\n return <ContactsCard hostId={null} orgMount={mount} />\n case 'campaigns':\n return <InboxCampaignsZone hostId={null} orgMount={mount} />\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 *\n * The ORGANIZATION's Inbox is this page handed no site (AGL-3303): the same\n * sections over every site at once, and a site filter above the rail that\n * narrows the whole page to one site's own view.\n */\nexport function InboxConsolePage(props: ConsolePluginPageProps) {\n const { hostId, orgMount, section, sections, basePath } = props\n const firestore = useFirestore()\n /*\n * On the organization's Inbox, the site the page is narrowed to — `null`\n * for every site (AGL-3303). Under a site there is no mount and no pick.\n */\n const sitePick = useInboxSitePick(hostId == null ? orgMount : undefined)\n /*\n * The site whose ceilings the notices below read: this page's own, or the\n * one picked on the organization's Inbox. With every site listed there is\n * no one site to read them for, and reading every site's four counters\n * would be a listener per site on a page that promises a bounded read, so\n * the notices wait for a pick.\n */\n const noticeHostId = hostId ?? sitePick.hostId\n\n // Every public door's ceiling refusals and honeypot catches (AGL-1666 →\n // AGL-1836), from the counters the plugin that keeps each door declares.\n const doorNotices = useVisitorDoorNotices(noticeHostId)\n\n // Sign-ups and leads this site's PLATFORM ceiling refused (AGL-1529).\n //\n // Same instrument as a public door's ceiling (`VisitorDoorNotices`) and\n // read the same way: a client-unwritable counters document (AGL-1367) that\n // host admins can already read, keyed by the SERVER's month through the\n // 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 noticeHostId\n ? doc(\n firestore,\n 'hosts',\n noticeHostId,\n 'counters',\n visitorRecordRefusedCounterId('siteMembers'),\n )\n : null,\n [firestore, noticeHostId],\n )\n const membersPausedNotice = visitorRecordsPausedNotice({\n kind: 'siteMembers',\n refused: Number(membersRefusedCounter?.[utcMonthKey()] ?? 0),\n ceiling: Number(membersRefusedCounter?.['ceiling']) || undefined,\n })\n const { data: leadsRefusedCounter } = useFirestoreDoc<any>(\n () =>\n noticeHostId\n ? doc(\n firestore,\n 'hosts',\n noticeHostId,\n 'counters',\n visitorRecordRefusedCounterId('leads'),\n )\n : null,\n [firestore, noticeHostId],\n )\n const leadsPausedNotice = visitorRecordsPausedNotice({\n kind: 'leads',\n refused: Number(leadsRefusedCounter?.[utcMonthKey()] ?? 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 ceiling counter reads above run either way, and that is deliberate:\n * they are 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. (On the organization's Inbox they run once a site is picked.)\n */\n if (!section || !sections?.length || !basePath) return null\n\n const notices = (\n <>\n {/* Above the RAIL on purpose (AGL-1666). A paused door is not a fact\n about one section — it is why the whole inbox stopped filling — and\n the notification that brings an owner here links to the surface,\n not to a section. Inside one section's body it would be invisible to\n anyone who followed a link into another. One per door a plugin\n declares (`visitorDoors`), each beside its own catch count. */}\n <VisitorDoorNotices notices={doorNotices} />\n {/* Above the rail for the same reason a door's 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 </>\n )\n\n if (hostId == null) {\n // No site and no org to stand in for it: nothing this page can scope.\n if (!orgMount) return null\n return (\n <>\n {/*\n The site filter sits above the rail with the notices, because it\n is a fact about the whole page: every section follows it, and the\n notices it brings in are the picked site's. An organization with\n one site has nothing to choose, and gets that site's page.\n */}\n {sitePick.options.length ? (\n <TextField\n select\n size=\"small\"\n label={'Site'}\n value={sitePick.hostId ?? ''}\n onChange={(event) => sitePick.setHostId(event.target.value || null)}\n // \"All sites\" is the empty value, which a select would otherwise\n // draw as a blank rather than as the choice it is — and the label\n // is shrunk with it, or it paints over that choice.\n slotProps={{\n select: { displayEmpty: true },\n inputLabel: { shrink: true },\n }}\n sx={{ mb: 2, minWidth: 240 }}\n >\n <MenuItem value=\"\">{'All sites'}</MenuItem>\n {sitePick.options.map((option) => (\n <MenuItem key={option.value} value={option.value}>\n {option.label}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n {notices}\n <HubSections sections={sections}>\n {sitePick.ready ? (\n orgSectionBody(\n section as InboxConsoleSectionId,\n orgMount,\n sitePick.hostId,\n )\n ) : (\n <Box sx={{ p: 2 }}>\n <CircularProgress size={24} />\n </Box>\n )}\n </HubSections>\n </>\n )\n }\n\n return (\n <>\n {notices}\n <HubSections sections={sections}>\n {sectionBody(section as InboxConsoleSectionId, hostId)}\n </HubSections>\n </>\n )\n}\nInboxConsolePage.displayName = 'InboxConsolePage'\n\nexport default InboxConsolePage\n"],"names":["visitorRecordRefusedCounterId","visitorRecordsPausedNotice","utcMonthKey","HubSections","useFirestore","useFirestoreDoc","Alert","AlertTitle","Box","CircularProgress","MenuItem","TextField","Typography","doc","ContactsCard","InboxCampaignsZone","SubmissionsCard","useInboxSitePick","useVisitorDoorNotices","VisitorDoorNotices","sectionBody","section","hostId","orgSectionBody","mount","siteHostId","orgMount","InboxConsolePage","props","sections","basePath","firestore","sitePick","undefined","noticeHostId","doorNotices","data","membersRefusedCounter","membersPausedNotice","kind","refused","Number","ceiling","leadsRefusedCounter","leadsPausedNotice","length","notices","map","notice","severity","sx","mb","title","component","variant","message","options","select","size","label","value","onChange","event","setHostId","target","slotProps","displayEmpty","inputLabel","shrink","minWidth","option","ready","p","displayName"],"mappings":"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"}
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * `orderBy('createdAt', 'desc')` is checked against the writer rather than
14
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`
15
+ * newest-first list is invisible. `libs/plugins/forms/src/lib/server/form-submit.ts`
16
16
  * is the only path that creates one and stamps `createdAt: serverTimestamp()`
17
17
  * on every add, the v1 API only reads and deletes, and `formSubmissions` is
18
18
  * absent from `IMPORTABLE_FIELDS`, so no restore can mint one without it.
@@ -21,9 +21,10 @@ import { buildRoute, CRM_LEAD_CLOSED_STATUSES, openLeadsFromCounts, pluginDocsHe
21
21
  // barrel is the CRM's site entry point, and a dashboard card named there
22
22
  // would ship to every published page.
23
23
  import { pluginRecordListHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
24
+ import { pluginRecordListQuery } from "@aglyn/aglyn/plugin-manager/plugin-record-lists";
24
25
  import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
25
26
  import { Avatar, Box, Button, Stack, Typography } from "@mui/material";
26
- import { collection, getCountFromServer, limit, orderBy, query, where } from "firebase/firestore";
27
+ import { collection, getCountFromServer, query, where } from "firebase/firestore";
27
28
  import { useParams } from "next/navigation";
28
29
  import { useEffect, useState } from "react";
29
30
  import { useOrgDataScope, useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
@@ -50,7 +51,7 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
50
51
  *
51
52
  * `orderBy('createdAt', 'desc')` is checked against the writer rather than
52
53
  * assumed — an `orderBy` drops documents missing the field, which on a
53
- * newest-first list is invisible. `apps/tenant/app/api/forms/submit/route.ts`
54
+ * newest-first list is invisible. `libs/plugins/forms/src/lib/server/form-submit.ts`
54
55
  * is the only path that creates one and stamps `createdAt: serverTimestamp()`
55
56
  * on every add, the v1 API only reads and deletes, and `formSubmissions` is
56
57
  * absent from `IMPORTABLE_FIELDS`, so no restore can mint one without it.
@@ -58,7 +59,12 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
58
59
  const { hostId } = props;
59
60
  const firestore = useFirestore();
60
61
  const { orgSlug, host } = useParams();
61
- const { data: submissionDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'formSubmissions'), orderBy('createdAt', 'desc'), limit(PREVIEW_ROWS + 1)), [
62
+ const { data: submissionDocs } = useFirestoreCollection(()=>// The newest of the site's, one past the preview, through the list
63
+ // source the forms plugin publishes for its records (AGL-3080).
64
+ pluginRecordListQuery('formSubmission', firestore, {
65
+ hostId,
66
+ limit: PREVIEW_ROWS + 1
67
+ }), [
62
68
  firestore,
63
69
  hostId
64
70
  ], {
@@ -1 +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 { useOrgDataScope, 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'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\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 /*\n * The lead silo is the org's (AGL-3275), narrowed to this site by\n * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its\n * pre-migration rows and nothing captured since.\n *\n * `orgId` settles AFTER the first render — it is a `hostIndex` lookup —\n * so it is a dependency of the counts below: an effect keyed on the site\n * alone ran once with no org, returned, and never asked again.\n */\n const { orgId } = useOrgDataScope({ hostId })\n const [openLeads, setOpenLeads] = useState<number | null>(null)\n useEffect(() => {\n let active = true\n if (!orgId) return undefined\n const leads = collection(firestore, 'orgs', orgId, 'leads')\n const scoped = where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))\n void Promise.all([\n getCountFromServer(query(leads, scoped)),\n getCountFromServer(\n query(leads, scoped, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)),\n ),\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, orgId])\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","useOrgDataScope","useFirestore","useFirestoreCollection","useRecordRouteContext","relativeTime","senderHue","submissionSender","scopeTokensForHost","PREVIEW_ROWS","InboxGlanceCard","props","hostId","firestore","orgSlug","host","data","submissionDocs","idField","routeContext","leadsHref","orgId","openLeads","setOpenLeads","active","undefined","leads","scoped","Promise","all","then","closed","count","catch","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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"}
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 { pluginRecordListQuery } from '@aglyn/aglyn/plugin-manager/plugin-record-lists'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Avatar, Box, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n getCountFromServer,\n query,\n where,\n} from 'firebase/firestore'\nimport { useParams } from 'next/navigation'\nimport { useEffect, useState } from 'react'\nimport { useOrgDataScope, 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'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\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. `libs/plugins/forms/src/lib/server/form-submit.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 // The newest of the site's, one past the preview, through the list\n // source the forms plugin publishes for its records (AGL-3080).\n pluginRecordListQuery('formSubmission', firestore, {\n hostId,\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 /*\n * The lead silo is the org's (AGL-3275), narrowed to this site by\n * `visibleTo`. Reading `hosts/{hostId}/leads` showed a site its\n * pre-migration rows and nothing captured since.\n *\n * `orgId` settles AFTER the first render — it is a `hostIndex` lookup —\n * so it is a dependency of the counts below: an effect keyed on the site\n * alone ran once with no org, returned, and never asked again.\n */\n const { orgId } = useOrgDataScope({ hostId })\n const [openLeads, setOpenLeads] = useState<number | null>(null)\n useEffect(() => {\n let active = true\n if (!orgId) return undefined\n const leads = collection(firestore, 'orgs', orgId, 'leads')\n const scoped = where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))\n void Promise.all([\n getCountFromServer(query(leads, scoped)),\n getCountFromServer(\n query(leads, scoped, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)),\n ),\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, orgId])\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","pluginRecordListQuery","AppLink","CardDisplay","Avatar","Box","Button","Stack","Typography","collection","getCountFromServer","query","where","useParams","useEffect","useState","useOrgDataScope","useFirestore","useFirestoreCollection","useRecordRouteContext","relativeTime","senderHue","submissionSender","scopeTokensForHost","PREVIEW_ROWS","InboxGlanceCard","props","hostId","firestore","orgSlug","host","data","submissionDocs","limit","idField","routeContext","leadsHref","orgId","openLeads","setOpenLeads","active","undefined","leads","scoped","Promise","all","then","closed","count","catch","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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"}
@@ -36,6 +36,7 @@ import { useFirestore, useFirestoreCollection, useFirestoreDoc, useUser } from "
36
36
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
37
37
  import { Alert, Box, Button, Stack, TextField, Typography } from "@mui/material";
38
38
  import { collection, doc, limit, orderBy, query } from "firebase/firestore";
39
+ import { pluginRecordListDoc } from "@aglyn/aglyn/plugin-manager/plugin-record-lists";
39
40
  import { useCallback, useEffect, useMemo, useState } from "react";
40
41
  import { REPLY_BODY_MAX, REPLY_SUBJECT_MAX, defaultReplySubject, replyRecipient } from "../model/reply-policy.js";
41
42
  /** How many past replies the panel lists. A submission answered more than a
@@ -75,10 +76,18 @@ export function SubmissionReply(props) {
75
76
  submission == null ? void 0 : submission.$id,
76
77
  submission == null ? void 0 : submission.formName
77
78
  ]);
78
- const { data: sentReplies } = useFirestoreCollection(()=>(submission == null ? void 0 : submission.$id) ? query(collection(firestore, 'hosts', hostId, 'formSubmissions', submission.$id, 'replies'), orderBy('sentAtMs', 'desc'), // One past the window, so a thread longer than the cap can say
79
+ const { data: sentReplies } = useFirestoreCollection(()=>{
80
+ // Kept under the submission they answer: the forms plugin's document,
81
+ // which the list source it publishes hands this plugin (AGL-3080).
82
+ const submissionDoc = (submission == null ? void 0 : submission.$id) ? pluginRecordListDoc('formSubmission', firestore, {
83
+ hostId,
84
+ id: submission.$id
85
+ }) : null;
86
+ return submissionDoc ? query(collection(submissionDoc, 'replies'), orderBy('sentAtMs', 'desc'), // One past the window, so a thread longer than the cap can say
79
87
  // so rather than silently dropping its oldest replies. The probe row
80
88
  // is never rendered.
81
- limit(SENT_REPLIES_LIMIT + 1)) : null, [
89
+ limit(SENT_REPLIES_LIMIT + 1)) : null;
90
+ }, [
82
91
  firestore,
83
92
  hostId,
84
93
  submission == null ? void 0 : submission.$id
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/submission-reply.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\n'use client'\n\n/**\n * The reply composer, inside the submission reader.\n *\n * ## The one sentence this component exists to say\n *\n * \"Replies come back to your email, not here.\" Nothing in this platform\n * receives mail, so a customer who answers reaches the merchant's own\n * mailbox and their answer never appears in the Inbox. A composer that\n * looked like a mail client without saying so would teach a merchant to wait\n * here for a reply that is already sitting somewhere else — which is a worse\n * outcome than not shipping the button.\n *\n * That is also why the sent replies below are labelled as what was sent\n * rather than as a conversation: this list is one-sided by construction.\n */\n\nimport { pluginDocsHelp } from '@aglyn/aglyn/app-utils/docs-help'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material'\nimport { collection, doc, limit, orderBy, query } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport {\n REPLY_BODY_MAX,\n REPLY_SUBJECT_MAX,\n defaultReplySubject,\n replyRecipient,\n} from '../model/reply-policy'\n\n/** How many past replies the panel lists. A submission answered more than a\n * handful of times is a conversation that belongs in a real mailbox. */\nconst SENT_REPLIES_LIMIT = 10\n\nexport interface SubmissionReplyProps {\n hostId: string\n /** The submission being read, with `$id` and `fields`. */\n submission: { $id: string; fields?: Record<string, unknown>; formName?: string }\n}\n\nexport function SubmissionReply(props: SubmissionReplyProps) {\n const { hostId, submission } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n\n /*\n * The site's name leads the default subject, and reading it here rather\n * than on the Inbox page is what keeps it off the mount path: this\n * component exists only while a submission is open, so the read is paid\n * when a merchant asks to reply and not once per visit to the page.\n */\n const { data: host } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId),\n [firestore, hostId],\n )\n const siteName = String(host?.displayName ?? host?.subdomain ?? '')\n\n const recipient = useMemo(\n () => replyRecipient(submission?.fields),\n [submission?.fields],\n )\n const canReply = 'email' in recipient\n\n const [subject, setSubject] = useState('')\n const [message, setMessage] = useState('')\n const [busy, setBusy] = useState(false)\n\n // Re-seeded per submission rather than once: the reader dialog keeps this\n // component mounted while the merchant moves between rows, so a subject\n // computed on first mount would carry the previous sender's site into the\n // next reply.\n useEffect(() => {\n setSubject(defaultReplySubject(siteName, submission?.formName))\n setMessage('')\n }, [siteName, submission?.$id, submission?.formName])\n\n const { data: sentReplies } = useFirestoreCollection<any>(\n () =>\n submission?.$id\n ? query(\n collection(\n firestore,\n 'hosts',\n hostId,\n 'formSubmissions',\n submission.$id,\n 'replies',\n ),\n orderBy('sentAtMs', 'desc'),\n // One past the window, so a thread longer than the cap can say\n // so rather than silently dropping its oldest replies. The probe row\n // is never rendered.\n limit(SENT_REPLIES_LIMIT + 1),\n )\n : null,\n [firestore, hostId, submission?.$id],\n { idField: '$id' },\n )\n\n const handleSend = useCallback(async () => {\n if (busy) return\n setBusy(true)\n try {\n const response = await authorizedFetch(user, '/api/inbox/reply', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n submissionId: submission.$id,\n subject: subject.trim(),\n message: message.trim(),\n }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The reply was not sent', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n setMessage('')\n enqueueSnackbar(`Reply sent to ${payload?.to ?? 'the sender'}`, {\n variant: 'success',\n persist: false,\n })\n } catch {\n enqueueSnackbar('The reply was not sent', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, user, hostId, submission?.$id, subject, message, enqueueSnackbar])\n\n if (!canReply) {\n return (\n <Alert severity=\"info\" sx={{ mt: 2 }}>\n {'This submission has no email field, so there is nobody to reply to.'}\n </Alert>\n )\n }\n\n return (\n /*\n * `header`, NOT `title` — the bug class AGL-1140 named and\n * `card-display-header.spec` guards, which this file escaped because that\n * sweep read `apps/console` only. `title` is a valid DOM attribute, so it\n * rides in on `CardProps`, compiles clean and renders a browser tooltip:\n * the card drew with no heading at all, and its content sat against the\n * card's edges because the gutters are opt-in, so inside the submission\n * reader it read as an unexplained lighter slab rather than a section.\n *\n * Outlined for the same reason: this card is nested inside a Dialog, whose\n * own surface is already raised, and a filled card on top of it is the\n * paper-on-paper that made the slab look like a rendering fault. A border\n * says \"section\" without a second background.\n */\n <CardDisplay\n header={'Reply'}\n // A headered card carries help (AGL-2213), and the guard that says so\n // reads `libs/plugins` — which is how a card with the wrong prop, and\n // therefore no header at all, went un-flagged here for so long.\n help={pluginDocsHelp('forms', {\n anchor: '#replying-to-a-submission',\n excerpt:\n 'Answer a submission by email. Replies are transactional — they ' +\n 'add nobody to a list and never touch your campaign allowance.',\n })}\n variant=\"outlined\"\n contentGutterX\n contentGutterY\n contentBordered=\"top\"\n sx={{ mt: 2 }}\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`To ${(recipient as { email: string }).email}`}\n </Typography>\n <TextField\n label=\"Subject\"\n value={subject}\n onChange={(event) => setSubject(event.target.value.slice(0, REPLY_SUBJECT_MAX))}\n fullWidth\n size=\"small\"\n />\n <TextField\n label=\"Message\"\n value={message}\n onChange={(event) => setMessage(event.target.value.slice(0, REPLY_BODY_MAX))}\n fullWidth\n multiline\n minRows={4}\n placeholder=\"Write your reply. The original message is quoted underneath it.\"\n />\n {/*\n The boundary, stated where the decision is made rather than in a\n docs page nobody opens mid-task. Both halves matter: the address the\n message LEAVES from is the platform's, because per-org sending\n domains do not exist yet, and the address an answer COMES BACK to is\n the merchant's own, which is the only reason the first half is\n tolerable.\n */}\n <Alert severity=\"info\">\n {`Sent from your site's name at this platform's address, with replies ` +\n `directed to ${user?.email ?? 'your account email'}. Answers arrive ` +\n 'in your email, not in this Inbox.'}\n </Alert>\n <Box>\n <Button\n variant=\"contained\"\n disabled={busy || !subject.trim() || !message.trim()}\n onClick={handleSend}\n >\n {busy ? 'Sending…' : 'Send reply'}\n </Button>\n </Box>\n {sentReplies?.length ? (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Replies sent'}\n </Typography>\n {sentReplies.length > SENT_REPLIES_LIMIT ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Showing the ${SENT_REPLIES_LIMIT} most recent. This thread has more.`}\n </Typography>\n ) : null}\n {sentReplies.slice(0, SENT_REPLIES_LIMIT).map((reply: any) => (\n <Stack key={reply.$id} spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${new Date(reply.sentAtMs).toLocaleString()} · to ${reply.to}`}\n </Typography>\n <Typography\n variant=\"body2\"\n sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}\n >\n {reply.message}\n </Typography>\n </Stack>\n ))}\n </Stack>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nSubmissionReply.displayName = 'SubmissionReply'\n\nexport default SubmissionReply\n"],"names":["pluginDocsHelp","CardDisplay","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useUser","authorizedFetch","Alert","Box","Button","Stack","TextField","Typography","collection","doc","limit","orderBy","query","useCallback","useEffect","useMemo","useState","REPLY_BODY_MAX","REPLY_SUBJECT_MAX","defaultReplySubject","replyRecipient","SENT_REPLIES_LIMIT","SubmissionReply","props","host","hostId","submission","firestore","data","user","enqueueSnackbar","siteName","String","displayName","subdomain","recipient","fields","canReply","subject","setSubject","message","setMessage","busy","setBusy","formName","$id","sentReplies","idField","handleSend","response","method","headers","body","JSON","stringify","submissionId","trim","payload","json","catch","ok","error","variant","allowDuplicate","to","persist","severity","sx","mt","header","help","anchor","excerpt","contentGutterX","contentGutterY","contentBordered","spacing","color","email","label","value","onChange","event","target","slice","fullWidth","size","multiline","minRows","placeholder","disabled","onClick","length","map","reply","Date","sentAtMs","toLocaleString","whiteSpace","wordBreak"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/components/submission-reply.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\n'use client'\n\n/**\n * The reply composer, inside the submission reader.\n *\n * ## The one sentence this component exists to say\n *\n * \"Replies come back to your email, not here.\" Nothing in this platform\n * receives mail, so a customer who answers reaches the merchant's own\n * mailbox and their answer never appears in the Inbox. A composer that\n * looked like a mail client without saying so would teach a merchant to wait\n * here for a reply that is already sitting somewhere else — which is a worse\n * outcome than not shipping the button.\n *\n * That is also why the sent replies below are labelled as what was sent\n * rather than as a conversation: this list is one-sided by construction.\n */\n\nimport { pluginDocsHelp } from '@aglyn/aglyn/app-utils/docs-help'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material'\nimport { collection, doc, limit, orderBy, query } from 'firebase/firestore'\nimport { pluginRecordListDoc } from '@aglyn/aglyn/plugin-manager/plugin-record-lists'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport {\n REPLY_BODY_MAX,\n REPLY_SUBJECT_MAX,\n defaultReplySubject,\n replyRecipient,\n} from '../model/reply-policy'\n\n/** How many past replies the panel lists. A submission answered more than a\n * handful of times is a conversation that belongs in a real mailbox. */\nconst SENT_REPLIES_LIMIT = 10\n\nexport interface SubmissionReplyProps {\n hostId: string\n /** The submission being read, with `$id` and `fields`. */\n submission: { $id: string; fields?: Record<string, unknown>; formName?: string }\n}\n\nexport function SubmissionReply(props: SubmissionReplyProps) {\n const { hostId, submission } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n\n /*\n * The site's name leads the default subject, and reading it here rather\n * than on the Inbox page is what keeps it off the mount path: this\n * component exists only while a submission is open, so the read is paid\n * when a merchant asks to reply and not once per visit to the page.\n */\n const { data: host } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId),\n [firestore, hostId],\n )\n const siteName = String(host?.displayName ?? host?.subdomain ?? '')\n\n const recipient = useMemo(\n () => replyRecipient(submission?.fields),\n [submission?.fields],\n )\n const canReply = 'email' in recipient\n\n const [subject, setSubject] = useState('')\n const [message, setMessage] = useState('')\n const [busy, setBusy] = useState(false)\n\n // Re-seeded per submission rather than once: the reader dialog keeps this\n // component mounted while the merchant moves between rows, so a subject\n // computed on first mount would carry the previous sender's site into the\n // next reply.\n useEffect(() => {\n setSubject(defaultReplySubject(siteName, submission?.formName))\n setMessage('')\n }, [siteName, submission?.$id, submission?.formName])\n\n const { data: sentReplies } = useFirestoreCollection<any>(\n () => {\n // Kept under the submission they answer: the forms plugin's document,\n // which the list source it publishes hands this plugin (AGL-3080).\n const submissionDoc = submission?.$id\n ? pluginRecordListDoc('formSubmission', firestore, { hostId, id: submission.$id })\n : null\n return submissionDoc\n ? query(\n collection(submissionDoc, 'replies'),\n orderBy('sentAtMs', 'desc'),\n // One past the window, so a thread longer than the cap can say\n // so rather than silently dropping its oldest replies. The probe row\n // is never rendered.\n limit(SENT_REPLIES_LIMIT + 1),\n )\n : null\n },\n [firestore, hostId, submission?.$id],\n { idField: '$id' },\n )\n\n const handleSend = useCallback(async () => {\n if (busy) return\n setBusy(true)\n try {\n const response = await authorizedFetch(user, '/api/inbox/reply', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n submissionId: submission.$id,\n subject: subject.trim(),\n message: message.trim(),\n }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The reply was not sent', {\n variant: 'warning',\n allowDuplicate: true,\n })\n }\n setMessage('')\n enqueueSnackbar(`Reply sent to ${payload?.to ?? 'the sender'}`, {\n variant: 'success',\n persist: false,\n })\n } catch {\n enqueueSnackbar('The reply was not sent', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, user, hostId, submission?.$id, subject, message, enqueueSnackbar])\n\n if (!canReply) {\n return (\n <Alert severity=\"info\" sx={{ mt: 2 }}>\n {'This submission has no email field, so there is nobody to reply to.'}\n </Alert>\n )\n }\n\n return (\n /*\n * `header`, NOT `title` — the bug class AGL-1140 named and\n * `card-display-header.spec` guards, which this file escaped because that\n * sweep read `apps/console` only. `title` is a valid DOM attribute, so it\n * rides in on `CardProps`, compiles clean and renders a browser tooltip:\n * the card drew with no heading at all, and its content sat against the\n * card's edges because the gutters are opt-in, so inside the submission\n * reader it read as an unexplained lighter slab rather than a section.\n *\n * Outlined for the same reason: this card is nested inside a Dialog, whose\n * own surface is already raised, and a filled card on top of it is the\n * paper-on-paper that made the slab look like a rendering fault. A border\n * says \"section\" without a second background.\n */\n <CardDisplay\n header={'Reply'}\n // A headered card carries help (AGL-2213), and the guard that says so\n // reads `libs/plugins` — which is how a card with the wrong prop, and\n // therefore no header at all, went un-flagged here for so long.\n help={pluginDocsHelp('forms', {\n anchor: '#replying-to-a-submission',\n excerpt:\n 'Answer a submission by email. Replies are transactional — they ' +\n 'add nobody to a list and never touch your campaign allowance.',\n })}\n variant=\"outlined\"\n contentGutterX\n contentGutterY\n contentBordered=\"top\"\n sx={{ mt: 2 }}\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`To ${(recipient as { email: string }).email}`}\n </Typography>\n <TextField\n label=\"Subject\"\n value={subject}\n onChange={(event) => setSubject(event.target.value.slice(0, REPLY_SUBJECT_MAX))}\n fullWidth\n size=\"small\"\n />\n <TextField\n label=\"Message\"\n value={message}\n onChange={(event) => setMessage(event.target.value.slice(0, REPLY_BODY_MAX))}\n fullWidth\n multiline\n minRows={4}\n placeholder=\"Write your reply. The original message is quoted underneath it.\"\n />\n {/*\n The boundary, stated where the decision is made rather than in a\n docs page nobody opens mid-task. Both halves matter: the address the\n message LEAVES from is the platform's, because per-org sending\n domains do not exist yet, and the address an answer COMES BACK to is\n the merchant's own, which is the only reason the first half is\n tolerable.\n */}\n <Alert severity=\"info\">\n {`Sent from your site's name at this platform's address, with replies ` +\n `directed to ${user?.email ?? 'your account email'}. Answers arrive ` +\n 'in your email, not in this Inbox.'}\n </Alert>\n <Box>\n <Button\n variant=\"contained\"\n disabled={busy || !subject.trim() || !message.trim()}\n onClick={handleSend}\n >\n {busy ? 'Sending…' : 'Send reply'}\n </Button>\n </Box>\n {sentReplies?.length ? (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Replies sent'}\n </Typography>\n {sentReplies.length > SENT_REPLIES_LIMIT ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Showing the ${SENT_REPLIES_LIMIT} most recent. This thread has more.`}\n </Typography>\n ) : null}\n {sentReplies.slice(0, SENT_REPLIES_LIMIT).map((reply: any) => (\n <Stack key={reply.$id} spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${new Date(reply.sentAtMs).toLocaleString()} · to ${reply.to}`}\n </Typography>\n <Typography\n variant=\"body2\"\n sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}\n >\n {reply.message}\n </Typography>\n </Stack>\n ))}\n </Stack>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nSubmissionReply.displayName = 'SubmissionReply'\n\nexport default SubmissionReply\n"],"names":["pluginDocsHelp","CardDisplay","useSnackbar","useFirestore","useFirestoreCollection","useFirestoreDoc","useUser","authorizedFetch","Alert","Box","Button","Stack","TextField","Typography","collection","doc","limit","orderBy","query","pluginRecordListDoc","useCallback","useEffect","useMemo","useState","REPLY_BODY_MAX","REPLY_SUBJECT_MAX","defaultReplySubject","replyRecipient","SENT_REPLIES_LIMIT","SubmissionReply","props","host","hostId","submission","firestore","data","user","enqueueSnackbar","siteName","String","displayName","subdomain","recipient","fields","canReply","subject","setSubject","message","setMessage","busy","setBusy","formName","$id","sentReplies","submissionDoc","id","idField","handleSend","response","method","headers","body","JSON","stringify","submissionId","trim","payload","json","catch","ok","error","variant","allowDuplicate","to","persist","severity","sx","mt","header","help","anchor","excerpt","contentGutterX","contentGutterY","contentBordered","spacing","color","email","label","value","onChange","event","target","slice","fullWidth","size","multiline","minRows","placeholder","disabled","onClick","length","map","reply","Date","sentAtMs","toLocaleString","whiteSpace","wordBreak"],"mappings":"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"}