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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,369 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { scopedSearch } from "@aglyn/aglyn/app-utils/name-search";
18
+ /*
19
+ * WHAT EACH INBOX LIST CAN BE ASKED, ON ITS FIRESTORE QUERY (AGL-3321).
20
+ *
21
+ * Every clause the Filters panel offers and every quick-search word is a
22
+ * predicate on the list's query, planned by `planListQuery`, so each page is
23
+ * a page of the answer. A question no query can answer is not offered, and a
24
+ * combination one query cannot hold is refused by name. `list-queries.spec.ts`
25
+ * holds `cloud/firebase-firestore.indexes.json` to `listQueryIndexes` of each
26
+ * declaration.
27
+ */ /*==========================================
28
+ * SITE MEMBERS — `hosts/{hostId}/siteMembers`, newest first.
29
+ *
30
+ * The same fields and index shapes as the console's Site users list
31
+ * (`apps/console/utils/site-account-query.ts`), so the composites are one
32
+ * set. The search reads `searchTokens` — the start of any word of the
33
+ * display name or of the address — which both writers of a member stamp
34
+ * (`memberSearchTokens`, the commerce plugin's register and account routes)
35
+ * and `backfill-site-member-search.mjs` stamps on the members written
36
+ * before it. The register path lower-cases `email` before it stores it, so
37
+ * the stored address is its own normalized key.
38
+ *=========================================*/ export const SITE_MEMBER_LIST_QUERY = {
39
+ fields: [
40
+ {
41
+ column: 'email',
42
+ kind: 'text',
43
+ path: 'email',
44
+ lowerPath: 'email',
45
+ presence: 'always',
46
+ operators: [
47
+ 'equals'
48
+ ]
49
+ }
50
+ ],
51
+ sorts: [
52
+ {
53
+ path: 'createdAt',
54
+ direction: 'desc'
55
+ }
56
+ ],
57
+ search: {
58
+ tokensPath: 'searchTokens'
59
+ }
60
+ };
61
+ export const SITE_MEMBER_FILTER_HEADERS = {
62
+ email: 'Email'
63
+ };
64
+ /*==========================================
65
+ * LEADS — `orgs/{orgId}/leads`, newest first.
66
+ *
67
+ * Under a site the list is narrowed to what that site may see
68
+ * (`visibleTo array-contains-any`, {@link leadListBase}); on the
69
+ * organization's Inbox an org-wide member reads it unscoped.
70
+ *
71
+ * `searchTokens` — the lead's name, address, company, title and tags as word
72
+ * prefixes — and `scopedSearchTokens` — the same behind each `visibleTo`
73
+ * token — are the CRM's list fields, stamped on every lead write by
74
+ * `crmLeadListFields` (`@aglyn/aglyn`'s CRM app-utils) and backfilled by the
75
+ * CRM's own lane. Under a site the search folds into the scope clause
76
+ * (`scopedSearch`), which the rules prove only for an org-wide member: a
77
+ * collaborator whose reach is some sites keeps the scope clause, and their
78
+ * search is asked as the start of the address beside it
79
+ * (`leadListQueryFor`).
80
+ *
81
+ * Every current lead writer normalizes the address (`normalizeContactEmail`)
82
+ * before `addHostLead` stores it, so Email is an equality on `email`.
83
+ *=========================================*/ const LEAD_EMAIL_FIELD = {
84
+ column: 'email',
85
+ kind: 'text',
86
+ path: 'email',
87
+ lowerPath: 'email',
88
+ presence: 'always',
89
+ operators: [
90
+ 'equals'
91
+ ]
92
+ };
93
+ export const LEAD_LIST_QUERY = {
94
+ fields: [
95
+ LEAD_EMAIL_FIELD
96
+ ],
97
+ sorts: [
98
+ {
99
+ path: 'createdAt',
100
+ direction: 'desc'
101
+ }
102
+ ],
103
+ search: {
104
+ tokensPath: 'searchTokens',
105
+ scoped: scopedSearch('scopedSearchTokens')
106
+ }
107
+ };
108
+ /**
109
+ * The organization's Inbox adds what an unscoped read can ask: the surface
110
+ * that captured the lead and the sites that did. Both are ARRAYS on the
111
+ * lead (`sources`, `capturedByHostIds`, `arrayUnion`ed by `addHostLead`), so
112
+ * each is the query's one array clause — "any of" — and cannot stand beside
113
+ * the search or the other. Under a site the scope clause already holds that
114
+ * one array clause, which is why neither is offered there.
115
+ */ export const ORG_LEAD_LIST_QUERY = _extends({}, LEAD_LIST_QUERY, {
116
+ fields: [
117
+ LEAD_EMAIL_FIELD,
118
+ {
119
+ column: 'sources',
120
+ kind: 'exact',
121
+ path: 'sources',
122
+ tokensPath: 'sources',
123
+ verbatimTokens: true,
124
+ operators: [
125
+ 'isAnyOf'
126
+ ]
127
+ },
128
+ {
129
+ column: 'capturedByHostIds',
130
+ kind: 'exact',
131
+ path: 'capturedByHostIds',
132
+ tokensPath: 'capturedByHostIds',
133
+ verbatimTokens: true,
134
+ operators: [
135
+ 'isAnyOf'
136
+ ]
137
+ }
138
+ ]
139
+ });
140
+ /**
141
+ * The column a site collaborator's search is asked through: the START of the
142
+ * address, a prefix range on `email`. Not a Filters-panel field — the panel
143
+ * reads the declaration the card shows, and this one rides only on the query.
144
+ */ export const LEAD_ADDRESS_SEARCH_COLUMN = '$addressSearch';
145
+ const LEAD_ADDRESS_SEARCH_FIELD = {
146
+ column: LEAD_ADDRESS_SEARCH_COLUMN,
147
+ kind: 'text',
148
+ path: 'email',
149
+ lowerPath: 'email',
150
+ presence: 'always',
151
+ operators: [
152
+ 'startsWith'
153
+ ]
154
+ };
155
+ /** The notice a site collaborator's search carries: what it matched, and the order. */ export const LEAD_ADDRESS_SEARCH_NOTICE = 'Search on this site’s leads matches the start of the address, and lists them by address.';
156
+ /**
157
+ * The query a reader may run for the Leads list, and whether its search was
158
+ * asked as an address prefix (AGL-3321).
159
+ *
160
+ * An ORG-WIDE reader's search folds into the scope clause (`scopedSearch`):
161
+ * a query without `visibleTo` is one the rules prove only for them. A reader
162
+ * whose reach is some sites must keep `visibleTo array-contains-any` on the
163
+ * query, which spends its one array clause — so their search is asked as a
164
+ * prefix RANGE on the stored, normalized `email` beside the scope instead,
165
+ * ordered by address (the `visibleTo, email` composite the lead list already
166
+ * carries). Only the first word is asked.
167
+ */ export function leadListQueryFor(declaration, foldsScope, request) {
168
+ var _join_trim_split_, _request_search;
169
+ var _declaration_search;
170
+ if (foldsScope || !((_declaration_search = declaration.search) == null ? void 0 : _declaration_search.scoped)) return {
171
+ declaration,
172
+ request,
173
+ addressSearch: false
174
+ };
175
+ const word = (_join_trim_split_ = ((_request_search = request.search) != null ? _request_search : []).join(' ').trim().split(/\s+/)[0]) != null ? _join_trim_split_ : '';
176
+ return {
177
+ declaration: _extends({}, declaration, {
178
+ fields: [
179
+ ...declaration.fields,
180
+ LEAD_ADDRESS_SEARCH_FIELD
181
+ ],
182
+ search: undefined
183
+ }),
184
+ request: _extends({}, request, {
185
+ search: [],
186
+ clauses: word ? [
187
+ ...request.clauses,
188
+ {
189
+ field: LEAD_ADDRESS_SEARCH_COLUMN,
190
+ op: 'startsWith',
191
+ value: word
192
+ }
193
+ ] : request.clauses
194
+ }),
195
+ addressSearch: Boolean(word)
196
+ };
197
+ }
198
+ /** The scope clause a site's lead list always carries, or none on the org's. */ export function leadListBase(scopeTokens) {
199
+ return scopeTokens ? [
200
+ {
201
+ path: 'visibleTo',
202
+ op: 'array-contains-any',
203
+ value: [
204
+ ...scopeTokens
205
+ ]
206
+ }
207
+ ] : [];
208
+ }
209
+ /** The base's shape for `listQueryIndexes`: the scope clause is an array clause. */ export const LEAD_LIST_BASE_INDEX = [
210
+ {
211
+ path: 'visibleTo',
212
+ array: true
213
+ }
214
+ ];
215
+ export const LEAD_FILTER_HEADERS = {
216
+ email: 'Email',
217
+ sources: 'Source',
218
+ capturedByHostIds: 'Site'
219
+ };
220
+ /**
221
+ * The capture surfaces a Source filter can name. A lead routed from a form
222
+ * is filed as `form:{formId}` — one value per form, so no fixed choice can
223
+ * ask for "every form" and none is offered; the rest are the fixed words the
224
+ * doors write: a booking, a lead added by hand, over the API or from a file,
225
+ * and `signup`, which member sign-ups wrote before they filed a contact.
226
+ */ export const LEAD_SOURCE_OPTIONS = [
227
+ {
228
+ value: 'booking',
229
+ label: 'Booking'
230
+ },
231
+ {
232
+ value: 'manual',
233
+ label: 'Added by hand'
234
+ },
235
+ {
236
+ value: 'api',
237
+ label: 'API'
238
+ },
239
+ {
240
+ value: 'import',
241
+ label: 'Import'
242
+ },
243
+ {
244
+ value: 'signup',
245
+ label: 'Sign-up'
246
+ }
247
+ ];
248
+ /** How one capture surface reads on a row. */ export function leadSourceLabel(source) {
249
+ var _ref;
250
+ var _LEAD_SOURCE_OPTIONS_find;
251
+ if (source === 'form' || source.startsWith('form:')) return 'Form';
252
+ return (_ref = (_LEAD_SOURCE_OPTIONS_find = LEAD_SOURCE_OPTIONS.find((option)=>option.value === source)) == null ? void 0 : _LEAD_SOURCE_OPTIONS_find.label) != null ? _ref : source;
253
+ }
254
+ /*==========================================
255
+ * FORM SUBMISSIONS — `hosts/{hostId}/formSubmissions` under a site, and the
256
+ * `formSubmissions` collection group narrowed to `orgId` on the
257
+ * organization's Inbox (the rules admit that group read only so narrowed).
258
+ * Newest first.
259
+ *
260
+ * `senderTokens` and `searchTokens` are `messageSearchFields` of the
261
+ * message, stamped by the submit route — the collection's only creator —
262
+ * and by `backfill-form-submission-filters.mjs` on the rows before it; a
263
+ * message's values never change after it arrives. `read` is a boolean on
264
+ * every row: the route writes `false`, the reader and the API flip it, and
265
+ * the same backfill stamps `false` on a row without one. `hostId` and
266
+ * `orgId` are stamped by the route and frozen by the rules.
267
+ *
268
+ * ⛔ The form's NAME is not searched. The row keeps the name the form had
269
+ * when the message arrived, and a rename would leave the search finding
270
+ * rows by a name the reader no longer sees. Form is a filter by id instead,
271
+ * which survives a rename.
272
+ *=========================================*/ const SUBMISSION_FIELDS = [
273
+ {
274
+ column: 'from',
275
+ kind: 'text',
276
+ path: 'senderTokens',
277
+ tokensPath: 'senderTokens',
278
+ operators: [
279
+ 'contains'
280
+ ]
281
+ },
282
+ {
283
+ column: 'read',
284
+ kind: 'boolean',
285
+ path: 'read',
286
+ operators: [
287
+ 'equals'
288
+ ]
289
+ }
290
+ ];
291
+ /** Form: offered where a site's forms are read (not across every site). */ const FORM_FIELD = {
292
+ column: 'formId',
293
+ kind: 'exact',
294
+ path: 'formId',
295
+ operators: [
296
+ 'equals'
297
+ ]
298
+ };
299
+ /** Site: the organization's Inbox only, where a row can be any site's. */ const SITE_FIELD = {
300
+ column: 'hostId',
301
+ kind: 'exact',
302
+ path: 'hostId',
303
+ operators: [
304
+ 'equals',
305
+ 'isAnyOf'
306
+ ]
307
+ };
308
+ const SUBMISSION_SORTS = [
309
+ {
310
+ path: 'createdAt',
311
+ direction: 'desc'
312
+ }
313
+ ];
314
+ const SUBMISSION_SEARCH = {
315
+ tokensPath: 'searchTokens'
316
+ };
317
+ /** A site's Inbox. */ export const SUBMISSION_LIST_QUERY = {
318
+ fields: [
319
+ ...SUBMISSION_FIELDS,
320
+ FORM_FIELD
321
+ ],
322
+ sorts: SUBMISSION_SORTS,
323
+ search: SUBMISSION_SEARCH
324
+ };
325
+ /** A card already narrowed to one form, where Form has nothing to pick. */ export const FORM_SCOPED_SUBMISSION_LIST_QUERY = {
326
+ fields: SUBMISSION_FIELDS,
327
+ sorts: SUBMISSION_SORTS,
328
+ search: SUBMISSION_SEARCH
329
+ };
330
+ /** Every site's, on the organization's Inbox. */ export const ORG_SUBMISSION_LIST_QUERY = {
331
+ fields: [
332
+ ...SUBMISSION_FIELDS,
333
+ SITE_FIELD
334
+ ],
335
+ sorts: SUBMISSION_SORTS,
336
+ search: SUBMISSION_SEARCH
337
+ };
338
+ /** The organization's Inbox's scope, which the rules require. */ export const orgSubmissionBase = (orgId)=>[
339
+ {
340
+ path: 'orgId',
341
+ op: '==',
342
+ value: orgId
343
+ }
344
+ ];
345
+ /** A form-scoped card's scope. */ export const formSubmissionBase = (formId)=>[
346
+ {
347
+ path: 'formId',
348
+ op: '==',
349
+ value: formId
350
+ }
351
+ ];
352
+ export const SUBMISSION_FILTER_HEADERS = {
353
+ from: 'From',
354
+ read: 'Read',
355
+ formId: 'Form',
356
+ hostId: 'Site'
357
+ };
358
+ /** Read is the stored boolean, by the words a reader uses for it. */ export const SUBMISSION_READ_OPTIONS = [
359
+ {
360
+ value: 'false',
361
+ label: 'Unread'
362
+ },
363
+ {
364
+ value: 'true',
365
+ label: 'Read'
366
+ }
367
+ ];
368
+
369
+ //# sourceMappingURL=list-queries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/constants/list-queries.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 { scopedSearch } from '@aglyn/aglyn/app-utils/name-search'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterOption } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQueryRequest,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT EACH INBOX LIST CAN BE ASKED, ON ITS FIRESTORE QUERY (AGL-3321).\n *\n * Every clause the Filters panel offers and every quick-search word is a\n * predicate on the list's query, planned by `planListQuery`, so each page is\n * a page of the answer. A question no query can answer is not offered, and a\n * combination one query cannot hold is refused by name. `list-queries.spec.ts`\n * holds `cloud/firebase-firestore.indexes.json` to `listQueryIndexes` of each\n * declaration.\n */\n\n/*==========================================\n * SITE MEMBERS — `hosts/{hostId}/siteMembers`, newest first.\n *\n * The same fields and index shapes as the console's Site users list\n * (`apps/console/utils/site-account-query.ts`), so the composites are one\n * set. The search reads `searchTokens` — the start of any word of the\n * display name or of the address — which both writers of a member stamp\n * (`memberSearchTokens`, the commerce plugin's register and account routes)\n * and `backfill-site-member-search.mjs` stamps on the members written\n * before it. The register path lower-cases `email` before it stores it, so\n * the stored address is its own normalized key.\n *=========================================*/\nexport const SITE_MEMBER_LIST_QUERY: ListQueryDeclaration = {\n fields: [\n {\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n operators: ['equals'],\n },\n ],\n sorts: [{ path: 'createdAt', direction: 'desc' }],\n search: { tokensPath: 'searchTokens' },\n}\n\nexport const SITE_MEMBER_FILTER_HEADERS: Readonly<Record<string, string>> = {\n email: 'Email',\n}\n\n/*==========================================\n * LEADS — `orgs/{orgId}/leads`, newest first.\n *\n * Under a site the list is narrowed to what that site may see\n * (`visibleTo array-contains-any`, {@link leadListBase}); on the\n * organization's Inbox an org-wide member reads it unscoped.\n *\n * `searchTokens` — the lead's name, address, company, title and tags as word\n * prefixes — and `scopedSearchTokens` — the same behind each `visibleTo`\n * token — are the CRM's list fields, stamped on every lead write by\n * `crmLeadListFields` (`@aglyn/aglyn`'s CRM app-utils) and backfilled by the\n * CRM's own lane. Under a site the search folds into the scope clause\n * (`scopedSearch`), which the rules prove only for an org-wide member: a\n * collaborator whose reach is some sites keeps the scope clause, and their\n * search is asked as the start of the address beside it\n * (`leadListQueryFor`).\n *\n * Every current lead writer normalizes the address (`normalizeContactEmail`)\n * before `addHostLead` stores it, so Email is an equality on `email`.\n *=========================================*/\nconst LEAD_EMAIL_FIELD: ListFilterField = {\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n operators: ['equals'],\n}\n\nexport const LEAD_LIST_QUERY: ListQueryDeclaration = {\n fields: [LEAD_EMAIL_FIELD],\n sorts: [{ path: 'createdAt', direction: 'desc' }],\n search: { tokensPath: 'searchTokens', scoped: scopedSearch('scopedSearchTokens') },\n}\n\n/**\n * The organization's Inbox adds what an unscoped read can ask: the surface\n * that captured the lead and the sites that did. Both are ARRAYS on the\n * lead (`sources`, `capturedByHostIds`, `arrayUnion`ed by `addHostLead`), so\n * each is the query's one array clause — \"any of\" — and cannot stand beside\n * the search or the other. Under a site the scope clause already holds that\n * one array clause, which is why neither is offered there.\n */\nexport const ORG_LEAD_LIST_QUERY: ListQueryDeclaration = {\n ...LEAD_LIST_QUERY,\n fields: [\n LEAD_EMAIL_FIELD,\n {\n column: 'sources',\n kind: 'exact',\n path: 'sources',\n tokensPath: 'sources',\n verbatimTokens: true,\n operators: ['isAnyOf'],\n },\n {\n column: 'capturedByHostIds',\n kind: 'exact',\n path: 'capturedByHostIds',\n tokensPath: 'capturedByHostIds',\n verbatimTokens: true,\n operators: ['isAnyOf'],\n },\n ],\n}\n\n/**\n * The column a site collaborator's search is asked through: the START of the\n * address, a prefix range on `email`. Not a Filters-panel field — the panel\n * reads the declaration the card shows, and this one rides only on the query.\n */\nexport const LEAD_ADDRESS_SEARCH_COLUMN = '$addressSearch'\n\nconst LEAD_ADDRESS_SEARCH_FIELD: ListFilterField = {\n column: LEAD_ADDRESS_SEARCH_COLUMN,\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n operators: ['startsWith'],\n}\n\n/** The notice a site collaborator's search carries: what it matched, and the order. */\nexport const LEAD_ADDRESS_SEARCH_NOTICE =\n 'Search on this site’s leads matches the start of the address, and lists them by address.'\n\n/**\n * The query a reader may run for the Leads list, and whether its search was\n * asked as an address prefix (AGL-3321).\n *\n * An ORG-WIDE reader's search folds into the scope clause (`scopedSearch`):\n * a query without `visibleTo` is one the rules prove only for them. A reader\n * whose reach is some sites must keep `visibleTo array-contains-any` on the\n * query, which spends its one array clause — so their search is asked as a\n * prefix RANGE on the stored, normalized `email` beside the scope instead,\n * ordered by address (the `visibleTo, email` composite the lead list already\n * carries). Only the first word is asked.\n */\nexport function leadListQueryFor(\n declaration: ListQueryDeclaration,\n foldsScope: boolean,\n request: ListQueryRequest,\n): { declaration: ListQueryDeclaration; request: ListQueryRequest; addressSearch: boolean } {\n if (foldsScope || !declaration.search?.scoped) return { declaration, request, addressSearch: false }\n const word = (request.search ?? []).join(' ').trim().split(/\\s+/)[0] ?? ''\n return {\n declaration: {\n ...declaration,\n fields: [...declaration.fields, LEAD_ADDRESS_SEARCH_FIELD],\n search: undefined,\n },\n request: {\n ...request,\n search: [],\n clauses: word\n ? [...request.clauses, { field: LEAD_ADDRESS_SEARCH_COLUMN, op: 'startsWith', value: word }]\n : request.clauses,\n },\n addressSearch: Boolean(word),\n }\n}\n\n/** The scope clause a site's lead list always carries, or none on the org's. */\nexport function leadListBase(scopeTokens: readonly string[] | null): ListQueryFilter[] {\n return scopeTokens ? [{ path: 'visibleTo', op: 'array-contains-any', value: [...scopeTokens] }] : []\n}\n\n/** The base's shape for `listQueryIndexes`: the scope clause is an array clause. */\nexport const LEAD_LIST_BASE_INDEX = [{ path: 'visibleTo', array: true }] as const\n\nexport const LEAD_FILTER_HEADERS: Readonly<Record<string, string>> = {\n email: 'Email',\n sources: 'Source',\n capturedByHostIds: 'Site',\n}\n\n/**\n * The capture surfaces a Source filter can name. A lead routed from a form\n * is filed as `form:{formId}` — one value per form, so no fixed choice can\n * ask for \"every form\" and none is offered; the rest are the fixed words the\n * doors write: a booking, a lead added by hand, over the API or from a file,\n * and `signup`, which member sign-ups wrote before they filed a contact.\n */\nexport const LEAD_SOURCE_OPTIONS: readonly ListFilterOption[] = [\n { value: 'booking', label: 'Booking' },\n { value: 'manual', label: 'Added by hand' },\n { value: 'api', label: 'API' },\n { value: 'import', label: 'Import' },\n { value: 'signup', label: 'Sign-up' },\n]\n\n/** How one capture surface reads on a row. */\nexport function leadSourceLabel(source: string): string {\n if (source === 'form' || source.startsWith('form:')) return 'Form'\n return LEAD_SOURCE_OPTIONS.find((option) => option.value === source)?.label ?? source\n}\n\n/*==========================================\n * FORM SUBMISSIONS — `hosts/{hostId}/formSubmissions` under a site, and the\n * `formSubmissions` collection group narrowed to `orgId` on the\n * organization's Inbox (the rules admit that group read only so narrowed).\n * Newest first.\n *\n * `senderTokens` and `searchTokens` are `messageSearchFields` of the\n * message, stamped by the submit route — the collection's only creator —\n * and by `backfill-form-submission-filters.mjs` on the rows before it; a\n * message's values never change after it arrives. `read` is a boolean on\n * every row: the route writes `false`, the reader and the API flip it, and\n * the same backfill stamps `false` on a row without one. `hostId` and\n * `orgId` are stamped by the route and frozen by the rules.\n *\n * ⛔ The form's NAME is not searched. The row keeps the name the form had\n * when the message arrived, and a rename would leave the search finding\n * rows by a name the reader no longer sees. Form is a filter by id instead,\n * which survives a rename.\n *=========================================*/\nconst SUBMISSION_FIELDS: readonly ListFilterField[] = [\n {\n column: 'from',\n kind: 'text',\n path: 'senderTokens',\n tokensPath: 'senderTokens',\n operators: ['contains'],\n },\n { column: 'read', kind: 'boolean', path: 'read', operators: ['equals'] },\n]\n\n/** Form: offered where a site's forms are read (not across every site). */\nconst FORM_FIELD: ListFilterField = {\n column: 'formId',\n kind: 'exact',\n path: 'formId',\n operators: ['equals'],\n}\n\n/** Site: the organization's Inbox only, where a row can be any site's. */\nconst SITE_FIELD: ListFilterField = {\n column: 'hostId',\n kind: 'exact',\n path: 'hostId',\n operators: ['equals', 'isAnyOf'],\n}\n\nconst SUBMISSION_SORTS = [{ path: 'createdAt', direction: 'desc' as const }]\nconst SUBMISSION_SEARCH = { tokensPath: 'searchTokens' }\n\n/** A site's Inbox. */\nexport const SUBMISSION_LIST_QUERY: ListQueryDeclaration = {\n fields: [...SUBMISSION_FIELDS, FORM_FIELD],\n sorts: SUBMISSION_SORTS,\n search: SUBMISSION_SEARCH,\n}\n\n/** A card already narrowed to one form, where Form has nothing to pick. */\nexport const FORM_SCOPED_SUBMISSION_LIST_QUERY: ListQueryDeclaration = {\n fields: SUBMISSION_FIELDS,\n sorts: SUBMISSION_SORTS,\n search: SUBMISSION_SEARCH,\n}\n\n/** Every site's, on the organization's Inbox. */\nexport const ORG_SUBMISSION_LIST_QUERY: ListQueryDeclaration = {\n fields: [...SUBMISSION_FIELDS, SITE_FIELD],\n sorts: SUBMISSION_SORTS,\n search: SUBMISSION_SEARCH,\n}\n\n/** The organization's Inbox's scope, which the rules require. */\nexport const orgSubmissionBase = (orgId: string): ListQueryFilter[] => [\n { path: 'orgId', op: '==', value: orgId },\n]\n\n/** A form-scoped card's scope. */\nexport const formSubmissionBase = (formId: string): ListQueryFilter[] => [\n { path: 'formId', op: '==', value: formId },\n]\n\nexport const SUBMISSION_FILTER_HEADERS: Readonly<Record<string, string>> = {\n from: 'From',\n read: 'Read',\n formId: 'Form',\n hostId: 'Site',\n}\n\n/** Read is the stored boolean, by the words a reader uses for it. */\nexport const SUBMISSION_READ_OPTIONS: readonly ListFilterOption[] = [\n { value: 'false', label: 'Unread' },\n { value: 'true', label: 'Read' },\n]\n"],"names":["scopedSearch","SITE_MEMBER_LIST_QUERY","fields","column","kind","path","lowerPath","presence","operators","sorts","direction","search","tokensPath","SITE_MEMBER_FILTER_HEADERS","email","LEAD_EMAIL_FIELD","LEAD_LIST_QUERY","scoped","ORG_LEAD_LIST_QUERY","verbatimTokens","LEAD_ADDRESS_SEARCH_COLUMN","LEAD_ADDRESS_SEARCH_FIELD","LEAD_ADDRESS_SEARCH_NOTICE","leadListQueryFor","declaration","foldsScope","request","addressSearch","word","join","trim","split","undefined","clauses","field","op","value","Boolean","leadListBase","scopeTokens","LEAD_LIST_BASE_INDEX","array","LEAD_FILTER_HEADERS","sources","capturedByHostIds","LEAD_SOURCE_OPTIONS","label","leadSourceLabel","source","startsWith","find","option","SUBMISSION_FIELDS","FORM_FIELD","SITE_FIELD","SUBMISSION_SORTS","SUBMISSION_SEARCH","SUBMISSION_LIST_QUERY","FORM_SCOPED_SUBMISSION_LIST_QUERY","ORG_SUBMISSION_LIST_QUERY","orgSubmissionBase","orgId","formSubmissionBase","formId","SUBMISSION_FILTER_HEADERS","from","read","hostId","SUBMISSION_READ_OPTIONS"],"mappings":"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"}
@@ -14,6 +14,16 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ /**
18
+ * How the Inbox renders one submission (AGL-2168).
19
+ *
20
+ * `/product/forms`'s hero mockup IS the Inbox, and it shows every row as a
21
+ * person: a coloured initials avatar, `Priya Nair`, the form name, and
22
+ * `2m`. The console rendered no sender at all — the row's one content cell
23
+ * was every field concatenated as `name: Priya Nair · email: … · message:
24
+ * …`, so the name was in there somewhere, at the mercy of whatever order
25
+ * the author happened to define the fields in.
26
+ */
17
27
  export interface SubmissionSender {
18
28
  /** Best available display name — a name, else an email, else a fallback. */
19
29
  label: string;
@@ -14,44 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ /**
18
- * How the Inbox renders one submission (AGL-2168).
19
- *
20
- * `/product/forms`'s hero mockup IS the Inbox, and it shows every row as a
21
- * person: a coloured initials avatar, `Priya Nair`, the form name, and
22
- * `2m`. The console rendered no sender at all — the row's one content cell
23
- * was every field concatenated as `name: Priya Nair · email: … · message:
24
- * …`, so the name was in there somewhere, at the mercy of whatever order
25
- * the author happened to define the fields in.
26
- */ /**
27
- * Field names that mean "this is who submitted it", most specific first.
28
- *
29
- * Compared against a key REDUCED to lowercase letters and digits, because
30
- * the same field reaches us as `Full Name`, `full_name` and `fullname`
31
- * depending on who built the form — and an exact-match lookup silently
32
- * finds none of the three.
33
- */ const NAME_KEYS = [
34
- 'name',
35
- 'fullname',
36
- 'yourname',
37
- 'firstname',
38
- 'contactname'
39
- ];
40
- const EMAIL_KEYS = [
41
- 'email',
42
- 'emailaddress'
43
- ];
44
- /** Reduced key → the value the author submitted. */ function normalizeKeys(fields) {
45
- const map = new Map();
46
- for (const [key, value] of Object.entries(fields != null ? fields : {})){
47
- const text = String(value != null ? value : '').trim();
48
- if (!text) continue;
49
- const reduced = key.toLowerCase().replace(/[^a-z0-9]/g, '');
50
- // First spelling wins, so `name` beats a later `Name`.
51
- if (!map.has(reduced)) map.set(reduced, text);
52
- }
53
- return map;
54
- }
17
+ */ import { messageSender } from "@aglyn/aglyn/app-utils/message-search";
55
18
  /**
56
19
  * Who sent it.
57
20
  *
@@ -61,9 +24,10 @@ const EMAIL_KEYS = [
61
24
  * it is worse than a generic one.
62
25
  */ export function submissionSender(fields, fallback = 'Someone') {
63
26
  var _ref;
64
- const map = normalizeKeys(fields);
65
- const email = EMAIL_KEYS.map((key)=>map.get(key)).find(Boolean);
66
- const name = NAME_KEYS.map((key)=>map.get(key)).find(Boolean);
27
+ // The platform's one convention for who wrote in — the one the list's
28
+ // From filter and search are stamped from (AGL-3321), so the name a row
29
+ // draws is the name it is found by.
30
+ const { name, email } = messageSender(fields);
67
31
  const label = (_ref = name != null ? name : email) != null ? _ref : fallback;
68
32
  return _extends({
69
33
  label
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/model/submission-presenter.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\n/**\n * How the Inbox renders one submission (AGL-2168).\n *\n * `/product/forms`'s hero mockup IS the Inbox, and it shows every row as a\n * person: a coloured initials avatar, `Priya Nair`, the form name, and\n * `2m`. The console rendered no sender at all — the row's one content cell\n * was every field concatenated as `name: Priya Nair · email: … · message:\n * …`, so the name was in there somewhere, at the mercy of whatever order\n * the author happened to define the fields in.\n */\n\n/**\n * Field names that mean \"this is who submitted it\", most specific first.\n *\n * Compared against a key REDUCED to lowercase letters and digits, because\n * the same field reaches us as `Full Name`, `full_name` and `fullname`\n * depending on who built the form — and an exact-match lookup silently\n * finds none of the three.\n */\nconst NAME_KEYS = ['name', 'fullname', 'yourname', 'firstname', 'contactname']\n\nconst EMAIL_KEYS = ['email', 'emailaddress']\n\n/** Reduced key → the value the author submitted. */\nfunction normalizeKeys(\n fields: Record<string, unknown> | undefined,\n): Map<string, string> {\n const map = new Map<string, string>()\n for (const [key, value] of Object.entries(fields ?? {})) {\n const text = String(value ?? '').trim()\n if (!text) continue\n const reduced = key.toLowerCase().replace(/[^a-z0-9]/g, '')\n // First spelling wins, so `name` beats a later `Name`.\n if (!map.has(reduced)) map.set(reduced, text)\n }\n return map\n}\n\nexport interface SubmissionSender {\n /** Best available display name — a name, else an email, else a fallback. */\n label: string\n /** The email, when one was submitted. */\n email?: string\n /** One or two letters for the avatar. */\n initials: string\n}\n\n/**\n * Who sent it.\n *\n * A form is author-defined, so there is no guaranteed name field — the\n * lookup is by convention and falls back to the email, then to the form's\n * own name. It never returns an empty string: an avatar with no letters in\n * it is worse than a generic one.\n */\nexport function submissionSender(\n fields: Record<string, unknown> | undefined,\n fallback = 'Someone',\n): SubmissionSender {\n const map = normalizeKeys(fields)\n const email = EMAIL_KEYS.map((key) => map.get(key)).find(Boolean)\n const name = NAME_KEYS.map((key) => map.get(key)).find(Boolean)\n const label = name ?? email ?? fallback\n return { label, ...(email ? { email } : {}), initials: initialsOf(label) }\n}\n\n/**\n * Up to two initials. An email falls back to its local part, so\n * `priya@lumen.co` gives `P` rather than a `@`.\n */\nexport function initialsOf(label: string): string {\n const trimmed = String(label ?? '').trim()\n const source = trimmed.includes('@') ? trimmed.split('@')[0] : trimmed\n const words = source\n .split(/[\\s._-]+/)\n .map((word) => word.trim())\n .filter(Boolean)\n const letters = words\n .slice(0, 2)\n .map((word) => word[0])\n .join('')\n return (letters || source[0] || '?').toUpperCase()\n}\n\n/**\n * A deterministic hue for the avatar, so one sender keeps one colour\n * across sessions and machines. The mockup's avatars are coloured, and a\n * random palette index would make the same person a different colour on\n * every render.\n */\nexport function senderHue(label: string): number {\n let hash = 0\n for (let index = 0; index < label.length; index += 1) {\n hash = (hash * 31 + label.charCodeAt(index)) % 360\n }\n return hash\n}\n\n/**\n * `2m`, `18m`, `1h`, `3d` — the relative times the mockup's list shows.\n *\n * The console rendered `toLocaleString()`, which is the right answer to a\n * different question: an inbox is scanned for recency, and \"8/18/2026,\n * 2:04:31 PM\" makes the reader do the subtraction. The absolute time stays\n * on the detail pane, where it is the fact you actually want.\n */\nexport function relativeTime(\n atMs: number | undefined,\n nowMs: number = Date.now(),\n): string {\n if (!atMs || !Number.isFinite(atMs)) return '--'\n const seconds = Math.round((nowMs - atMs) / 1000)\n // A clock skew between the browser and the server can put a fresh\n // submission a few seconds in the future. \"in 4 seconds\" on an inbox row\n // is a bug report waiting to happen; \"now\" is simply true.\n if (seconds < 60) return 'now'\n const minutes = Math.floor(seconds / 60)\n if (minutes < 60) return `${minutes}m`\n const hours = Math.floor(minutes / 60)\n if (hours < 24) return `${hours}h`\n const days = Math.floor(hours / 24)\n if (days < 7) return `${days}d`\n const weeks = Math.floor(days / 7)\n if (weeks < 5) return `${weeks}w`\n return `${Math.floor(days / 30)}mo`\n}\n\nexport interface SubmissionRouting {\n /** The dataset a record was appended to, when one was. */\n dataset?: { id?: string; name?: string; recordId?: string }\n}\n\nexport interface RoutingChip {\n label: string\n color: 'success' | 'info' | 'default'\n}\n\n/**\n * The status chips the mockup's detail pane carries under the fields:\n * `Saved to Inbox` (green) and `Added to \"Leads\" dataset` (blue).\n *\n * The first is unconditional and free — every submission that exists is in\n * the Inbox, which is exactly why the mockup shows it: it is the\n * reassurance, not a status.\n *\n * The second reports what the submit route actually DID. It is stamped on\n * the document only when a record was really appended, so a form bound to\n * a dataset that was deleted, or whose record quota was full, shows no chip\n * rather than a chip for a row that does not exist — the two failure modes\n * the route already swallows silently.\n */\nexport function routingChips(routing: SubmissionRouting | undefined): RoutingChip[] {\n const chips: RoutingChip[] = [{ label: 'Saved to Inbox', color: 'success' }]\n const dataset = routing?.dataset\n if (dataset?.recordId) {\n chips.push({\n label: dataset.name\n ? `Added to “${dataset.name}” dataset`\n : 'Added to a dataset',\n color: 'info',\n })\n }\n return chips\n}\n"],"names":["NAME_KEYS","EMAIL_KEYS","normalizeKeys","fields","map","Map","key","value","Object","entries","text","String","trim","reduced","toLowerCase","replace","has","set","submissionSender","fallback","name","email","get","find","Boolean","label","initials","initialsOf","trimmed","source","includes","split","words","word","filter","letters","slice","join","toUpperCase","senderHue","hash","index","length","charCodeAt","relativeTime","atMs","nowMs","Date","now","Number","isFinite","seconds","Math","round","minutes","floor","hours","days","weeks","routingChips","routing","chips","color","dataset","recordId","push"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/inbox/src/lib/model/submission-presenter.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 { messageSender } from '@aglyn/aglyn/app-utils/message-search'\n\n/**\n * How the Inbox renders one submission (AGL-2168).\n *\n * `/product/forms`'s hero mockup IS the Inbox, and it shows every row as a\n * person: a coloured initials avatar, `Priya Nair`, the form name, and\n * `2m`. The console rendered no sender at all — the row's one content cell\n * was every field concatenated as `name: Priya Nair · email: … · message:\n * …`, so the name was in there somewhere, at the mercy of whatever order\n * the author happened to define the fields in.\n */\n\nexport interface SubmissionSender {\n /** Best available display name — a name, else an email, else a fallback. */\n label: string\n /** The email, when one was submitted. */\n email?: string\n /** One or two letters for the avatar. */\n initials: string\n}\n\n/**\n * Who sent it.\n *\n * A form is author-defined, so there is no guaranteed name field — the\n * lookup is by convention and falls back to the email, then to the form's\n * own name. It never returns an empty string: an avatar with no letters in\n * it is worse than a generic one.\n */\nexport function submissionSender(\n fields: Record<string, unknown> | undefined,\n fallback = 'Someone',\n): SubmissionSender {\n // The platform's one convention for who wrote in — the one the list's\n // From filter and search are stamped from (AGL-3321), so the name a row\n // draws is the name it is found by.\n const { name, email } = messageSender(fields)\n const label = name ?? email ?? fallback\n return { label, ...(email ? { email } : {}), initials: initialsOf(label) }\n}\n\n/**\n * Up to two initials. An email falls back to its local part, so\n * `priya@lumen.co` gives `P` rather than a `@`.\n */\nexport function initialsOf(label: string): string {\n const trimmed = String(label ?? '').trim()\n const source = trimmed.includes('@') ? trimmed.split('@')[0] : trimmed\n const words = source\n .split(/[\\s._-]+/)\n .map((word) => word.trim())\n .filter(Boolean)\n const letters = words\n .slice(0, 2)\n .map((word) => word[0])\n .join('')\n return (letters || source[0] || '?').toUpperCase()\n}\n\n/**\n * A deterministic hue for the avatar, so one sender keeps one colour\n * across sessions and machines. The mockup's avatars are coloured, and a\n * random palette index would make the same person a different colour on\n * every render.\n */\nexport function senderHue(label: string): number {\n let hash = 0\n for (let index = 0; index < label.length; index += 1) {\n hash = (hash * 31 + label.charCodeAt(index)) % 360\n }\n return hash\n}\n\n/**\n * `2m`, `18m`, `1h`, `3d` — the relative times the mockup's list shows.\n *\n * The console rendered `toLocaleString()`, which is the right answer to a\n * different question: an inbox is scanned for recency, and \"8/18/2026,\n * 2:04:31 PM\" makes the reader do the subtraction. The absolute time stays\n * on the detail pane, where it is the fact you actually want.\n */\nexport function relativeTime(\n atMs: number | undefined,\n nowMs: number = Date.now(),\n): string {\n if (!atMs || !Number.isFinite(atMs)) return '--'\n const seconds = Math.round((nowMs - atMs) / 1000)\n // A clock skew between the browser and the server can put a fresh\n // submission a few seconds in the future. \"in 4 seconds\" on an inbox row\n // is a bug report waiting to happen; \"now\" is simply true.\n if (seconds < 60) return 'now'\n const minutes = Math.floor(seconds / 60)\n if (minutes < 60) return `${minutes}m`\n const hours = Math.floor(minutes / 60)\n if (hours < 24) return `${hours}h`\n const days = Math.floor(hours / 24)\n if (days < 7) return `${days}d`\n const weeks = Math.floor(days / 7)\n if (weeks < 5) return `${weeks}w`\n return `${Math.floor(days / 30)}mo`\n}\n\nexport interface SubmissionRouting {\n /** The dataset a record was appended to, when one was. */\n dataset?: { id?: string; name?: string; recordId?: string }\n}\n\nexport interface RoutingChip {\n label: string\n color: 'success' | 'info' | 'default'\n}\n\n/**\n * The status chips the mockup's detail pane carries under the fields:\n * `Saved to Inbox` (green) and `Added to \"Leads\" dataset` (blue).\n *\n * The first is unconditional and free — every submission that exists is in\n * the Inbox, which is exactly why the mockup shows it: it is the\n * reassurance, not a status.\n *\n * The second reports what the submit route actually DID. It is stamped on\n * the document only when a record was really appended, so a form bound to\n * a dataset that was deleted, or whose record quota was full, shows no chip\n * rather than a chip for a row that does not exist — the two failure modes\n * the route already swallows silently.\n */\nexport function routingChips(routing: SubmissionRouting | undefined): RoutingChip[] {\n const chips: RoutingChip[] = [{ label: 'Saved to Inbox', color: 'success' }]\n const dataset = routing?.dataset\n if (dataset?.recordId) {\n chips.push({\n label: dataset.name\n ? `Added to “${dataset.name}” dataset`\n : 'Added to a dataset',\n color: 'info',\n })\n }\n return chips\n}\n"],"names":["messageSender","submissionSender","fields","fallback","name","email","label","initials","initialsOf","trimmed","String","trim","source","includes","split","words","map","word","filter","Boolean","letters","slice","join","toUpperCase","senderHue","hash","index","length","charCodeAt","relativeTime","atMs","nowMs","Date","now","Number","isFinite","seconds","Math","round","minutes","floor","hours","days","weeks","routingChips","routing","chips","color","dataset","recordId","push"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,wCAAuC;AAsBrE;;;;;;;CAOC,GACD,OAAO,SAASC,iBACdC,MAA2C,EAC3CC,WAAW,SAAS;QAMNC;IAJd,sEAAsE;IACtE,wEAAwE;IACxE,oCAAoC;IACpC,MAAM,EAAEA,IAAI,EAAEC,KAAK,EAAE,GAAGL,cAAcE;IACtC,MAAMI,SAAQF,OAAAA,eAAAA,OAAQC,iBAARD,OAAiBD;IAC/B,OAAO;QAAEG;OAAWD,QAAQ;QAAEA;IAAM,IAAI,CAAC;QAAIE,UAAUC,WAAWF;;AACpE;AAEA;;;CAGC,GACD,OAAO,SAASE,WAAWF,KAAa;IACtC,MAAMG,UAAUC,OAAOJ,gBAAAA,QAAS,IAAIK,IAAI;IACxC,MAAMC,SAASH,QAAQI,QAAQ,CAAC,OAAOJ,QAAQK,KAAK,CAAC,IAAI,CAAC,EAAE,GAAGL;IAC/D,MAAMM,QAAQH,OACXE,KAAK,CAAC,YACNE,GAAG,CAAC,CAACC,OAASA,KAAKN,IAAI,IACvBO,MAAM,CAACC;IACV,MAAMC,UAAUL,MACbM,KAAK,CAAC,GAAG,GACTL,GAAG,CAAC,CAACC,OAASA,IAAI,CAAC,EAAE,EACrBK,IAAI,CAAC;IACR,OAAO,AAACF,CAAAA,WAAWR,MAAM,CAAC,EAAE,IAAI,GAAE,EAAGW,WAAW;AAClD;AAEA;;;;;CAKC,GACD,OAAO,SAASC,UAAUlB,KAAa;IACrC,IAAImB,OAAO;IACX,IAAK,IAAIC,QAAQ,GAAGA,QAAQpB,MAAMqB,MAAM,EAAED,SAAS,EAAG;QACpDD,OAAO,AAACA,CAAAA,OAAO,KAAKnB,MAAMsB,UAAU,CAACF,MAAK,IAAK;IACjD;IACA,OAAOD;AACT;AAEA;;;;;;;CAOC,GACD,OAAO,SAASI,aACdC,IAAwB,EACxBC,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,IAAI,CAACH,QAAQ,CAACI,OAAOC,QAAQ,CAACL,OAAO,OAAO;IAC5C,MAAMM,UAAUC,KAAKC,KAAK,CAAC,AAACP,CAAAA,QAAQD,IAAG,IAAK;IAC5C,kEAAkE;IAClE,yEAAyE;IACzE,2DAA2D;IAC3D,IAAIM,UAAU,IAAI,OAAO;IACzB,MAAMG,UAAUF,KAAKG,KAAK,CAACJ,UAAU;IACrC,IAAIG,UAAU,IAAI,OAAO,GAAGA,QAAQ,CAAC,CAAC;IACtC,MAAME,QAAQJ,KAAKG,KAAK,CAACD,UAAU;IACnC,IAAIE,QAAQ,IAAI,OAAO,GAAGA,MAAM,CAAC,CAAC;IAClC,MAAMC,OAAOL,KAAKG,KAAK,CAACC,QAAQ;IAChC,IAAIC,OAAO,GAAG,OAAO,GAAGA,KAAK,CAAC,CAAC;IAC/B,MAAMC,QAAQN,KAAKG,KAAK,CAACE,OAAO;IAChC,IAAIC,QAAQ,GAAG,OAAO,GAAGA,MAAM,CAAC,CAAC;IACjC,OAAO,GAAGN,KAAKG,KAAK,CAACE,OAAO,IAAI,EAAE,CAAC;AACrC;AAYA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASE,aAAaC,OAAsC;IACjE,MAAMC,QAAuB;QAAC;YAAExC,OAAO;YAAkByC,OAAO;QAAU;KAAE;IAC5E,MAAMC,UAAUH,2BAAAA,QAASG,OAAO;IAChC,IAAIA,2BAAAA,QAASC,QAAQ,EAAE;QACrBH,MAAMI,IAAI,CAAC;YACT5C,OAAO0C,QAAQ5C,IAAI,GACf,CAAC,UAAU,EAAE4C,QAAQ5C,IAAI,CAAC,SAAS,CAAC,GACpC;YACJ2C,OAAO;QACT;IACF;IACA,OAAOD;AACT"}