@aglyn/plugins-commerce 1.0.0-beta.199 → 1.0.0-beta.200

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/console/catalog-organization-card.component.js +48 -3
  3. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  4. package/src/lib/components/console/gift-cards-card.component.d.ts +9 -0
  5. package/src/lib/components/console/gift-cards-card.component.js +53 -28
  6. package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
  7. package/src/lib/components/console/host-orders-card.component.d.ts +26 -0
  8. package/src/lib/components/console/host-orders-card.component.js +264 -194
  9. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  10. package/src/lib/components/console/pos-page.component.d.ts +14 -0
  11. package/src/lib/components/console/pos-page.component.js +86 -45
  12. package/src/lib/components/console/pos-page.component.js.map +1 -1
  13. package/src/lib/components/console/product-editor-dialog.component.js +30 -6
  14. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  15. package/src/lib/components/console/product-slugs.d.ts +43 -0
  16. package/src/lib/components/console/product-slugs.js +60 -0
  17. package/src/lib/components/console/product-slugs.js.map +1 -0
  18. package/src/lib/components/console/products-hub-card.component.d.ts +2 -2
  19. package/src/lib/components/console/products-hub-card.component.js +229 -282
  20. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  21. package/src/lib/components/console/products-hub-zone.component.d.ts +10 -5
  22. package/src/lib/components/console/products-hub-zone.component.js +32 -9
  23. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  24. package/src/lib/components/console/recovery-queue-card.component.d.ts +37 -5
  25. package/src/lib/components/console/recovery-queue-card.component.js +107 -86
  26. package/src/lib/components/console/recovery-queue-card.component.js.map +1 -1
  27. package/src/lib/components/console/reservations-card.component.js +15 -3
  28. package/src/lib/components/console/reservations-card.component.js.map +1 -1
  29. package/src/lib/components/console/smart-collections.d.ts +32 -0
  30. package/src/lib/components/console/smart-collections.js +57 -0
  31. package/src/lib/components/console/smart-collections.js.map +1 -0
  32. package/src/lib/components/console/stock-movements-card.component.d.ts +13 -4
  33. package/src/lib/components/console/stock-movements-card.component.js +142 -180
  34. package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
  35. package/src/lib/components/product-grid.d.ts +5 -0
  36. package/src/lib/components/product-grid.js +95 -34
  37. package/src/lib/components/product-grid.js.map +1 -1
  38. package/src/lib/constants/orders-list-query.d.ts +40 -0
  39. package/src/lib/constants/orders-list-query.js +205 -0
  40. package/src/lib/constants/orders-list-query.js.map +1 -0
  41. package/src/lib/constants/product-list-query.d.ts +42 -0
  42. package/src/lib/constants/product-list-query.js +217 -0
  43. package/src/lib/constants/product-list-query.js.map +1 -0
  44. package/src/lib/constants/stock-movement-query.d.ts +27 -0
  45. package/src/lib/constants/stock-movement-query.js +110 -0
  46. package/src/lib/constants/stock-movement-query.js.map +1 -0
  47. package/src/lib/constants/storefront-catalog-query.d.ts +103 -0
  48. package/src/lib/constants/storefront-catalog-query.js +312 -0
  49. package/src/lib/constants/storefront-catalog-query.js.map +1 -0
  50. package/src/lib/model/checkout-recovery.d.ts +37 -0
  51. package/src/lib/model/checkout-recovery.js +44 -0
  52. package/src/lib/model/checkout-recovery.js.map +1 -0
  53. package/src/lib/model/commerce.d.ts +80 -0
  54. package/src/lib/model/commerce.js +67 -6
  55. package/src/lib/model/commerce.js.map +1 -1
  56. package/src/lib/model/gift-card-search.d.ts +24 -0
  57. package/src/lib/model/gift-card-search.js +48 -0
  58. package/src/lib/model/gift-card-search.js.map +1 -0
  59. package/src/lib/model/index.d.ts +1 -0
  60. package/src/lib/model/index.js +1 -0
  61. package/src/lib/model/index.js.map +1 -1
  62. package/src/lib/model/order-list-fields.d.ts +80 -0
  63. package/src/lib/model/order-list-fields.js +112 -0
  64. package/src/lib/model/order-list-fields.js.map +1 -0
  65. package/src/lib/server/billing-webhook.js +108 -89
  66. package/src/lib/server/billing-webhook.js.map +1 -1
  67. package/src/lib/server/cancel-order.js +6 -6
  68. package/src/lib/server/cancel-order.js.map +1 -1
  69. package/src/lib/server/cart-checkout.js +6 -0
  70. package/src/lib/server/cart-checkout.js.map +1 -1
  71. package/src/lib/server/catalog.d.ts +46 -30
  72. package/src/lib/server/catalog.js +492 -170
  73. package/src/lib/server/catalog.js.map +1 -1
  74. package/src/lib/server/collection-membership.d.ts +35 -0
  75. package/src/lib/server/collection-membership.js +172 -0
  76. package/src/lib/server/collection-membership.js.map +1 -0
  77. package/src/lib/server/draft-order.js +3 -2
  78. package/src/lib/server/draft-order.js.map +1 -1
  79. package/src/lib/server/gift-cards.js +3 -0
  80. package/src/lib/server/gift-cards.js.map +1 -1
  81. package/src/lib/server/member-name-search.d.ts +17 -0
  82. package/src/lib/server/member-name-search.js +23 -1
  83. package/src/lib/server/member-name-search.js.map +1 -1
  84. package/src/lib/server/membership-account.js +7 -3
  85. package/src/lib/server/membership-account.js.map +1 -1
  86. package/src/lib/server/membership-register.js +3 -1
  87. package/src/lib/server/membership-register.js.map +1 -1
  88. package/src/lib/server/pos-order.js +6 -4
  89. package/src/lib/server/pos-order.js.map +1 -1
  90. package/src/lib/server/process-abandoned.js +14 -5
  91. package/src/lib/server/process-abandoned.js.map +1 -1
  92. package/src/lib/server/reserve-stock.js +3 -4
  93. package/src/lib/server/reserve-stock.js.map +1 -1
  94. package/src/lib/server/site-page-enricher.js +3 -3
  95. package/src/lib/server/site-page-enricher.js.map +1 -1
  96. package/src/lib/server.js +4 -0
  97. package/src/lib/server.js.map +1 -1
@@ -1,3 +1,5 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
1
3
  /**
2
4
  * @license
3
5
  * Copyright 2026 Aglyn LLC
@@ -13,17 +15,13 @@
13
15
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
16
  * See the License for the specific language governing permissions and
15
17
  * limitations under the License.
16
- */ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ */ import { nameSearchNormalizers } from "@aglyn/aglyn/app-utils/name-search";
17
19
  import * as Aglyn from "@aglyn/aglyn/server";
18
- import * as CommerceModel from "../model/index.js";
20
+ import { planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
19
21
  import { firebaseAdmin } from "@aglyn/tenant-data-admin";
20
- /**
21
- * Displayed price in cents (AGL-564): the low end of the variant range —
22
- * the number grid cards render (`$X` / `From $X`) — so price filtering
23
- * matches what visitors see.
24
- */ export function displayedPriceCents(product) {
25
- return Math.round(CommerceModel.productPriceRange(product)[0] * 100);
26
- }
22
+ import { applyListQuery } from "@aglyn/tenant-data-admin/server/list-query";
23
+ import { smartCollectionScope, STOREFRONT_CATALOG_BASE, STOREFRONT_CATALOG_DECLARATION, STOREFRONT_CATALOG_SORTS, STOREFRONT_PRODUCT_TYPES, storefrontCatalogSort } from "../constants/storefront-catalog-query.js";
24
+ import * as CommerceModel from "../model/index.js";
27
25
  /** Non-negative integer cents from a query param; undefined otherwise. */ function parsePriceCents(value) {
28
26
  const raw = String(value != null ? value : '').trim();
29
27
  if (!raw) return undefined;
@@ -31,35 +29,243 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
31
29
  if (!Number.isFinite(cents) || cents < 0) return undefined;
32
30
  return Math.round(cents);
33
31
  }
34
- /**
35
- * Case-insensitive catalog search (AGL-561): matches the product name,
36
- * description, or any tag. An empty/blank query matches everything.
37
- */ export function matchesCatalogQuery(product, query) {
38
- var _product_description, _product_tags;
39
- const needle = query.trim().toLowerCase();
40
- if (!needle) return true;
41
- return product.name.toLowerCase().includes(needle) || ((_product_description = product.description) != null ? _product_description : '').toLowerCase().includes(needle) || ((_product_tags = product.tags) != null ? _product_tags : []).some((tag)=>tag.toLowerCase().includes(needle));
42
- }
43
32
  /** A fresh read scope. One per render, never reused across hosts. */ export function createCatalogReadScope() {
44
33
  return {};
45
34
  }
35
+ /** Most products one page returns. */ const CATALOG_PAGE_MAX = 100;
36
+ /** Page size when a grid names none. */ const CATALOG_PAGE_DEFAULT = 24;
37
+ /**
38
+ * Members of a MANUAL collection read by id. A hand-curated list is its own
39
+ * complete set, so it is read whole and every visitor control is answered over
40
+ * all of it rather than over a window of the catalog.
41
+ */ const MANUAL_COLLECTION_MAX = 1000;
42
+ /** Ids per `getAll`, well inside the Admin SDK's comfortable batch. */ const GET_ALL_CHUNK = 100;
43
+ function toRow(docSnapshot) {
44
+ var _raw_deletedAt, _product_name;
45
+ const raw = docSnapshot.data();
46
+ if (!raw) return null;
47
+ const product = CommerceModel.liftLegacyProduct(raw);
48
+ return {
49
+ id: docSnapshot.id,
50
+ product,
51
+ values: _extends({}, raw, {
52
+ status: product.status,
53
+ type: product.type,
54
+ deletedAt: (_raw_deletedAt = raw['deletedAt']) != null ? _raw_deletedAt : null
55
+ }, CommerceModel.productSearchFields({
56
+ name: String((_product_name = product.name) != null ? _product_name : ''),
57
+ variants: product.variants
58
+ }), CommerceModel.productStockFields(product))
59
+ };
60
+ }
61
+ /** A row's value at a queried path, as the query sees it. */ function rowValue(row, path) {
62
+ return row.values[path];
63
+ }
64
+ /** A held row a shopper may be shown: live and active, as the query's scope. */ const onSale = (row)=>STOREFRONT_CATALOG_BASE.every((filter)=>rowMatches(row, filter));
65
+ /** One plan predicate over one row, with Firestore's meaning of each operator. */ function rowMatches(row, filter) {
66
+ const actual = rowValue(row, filter.path);
67
+ if (actual === undefined) return false;
68
+ const value = filter.value;
69
+ const list = Array.isArray(value) ? value : [
70
+ value
71
+ ];
72
+ switch(filter.op){
73
+ case '==':
74
+ return actual === value;
75
+ case '!=':
76
+ return actual !== null && actual !== value;
77
+ case 'in':
78
+ return list.includes(actual);
79
+ case 'array-contains':
80
+ return Array.isArray(actual) && actual.includes(value);
81
+ case 'array-contains-any':
82
+ return Array.isArray(actual) && actual.some((member)=>list.includes(member));
83
+ case '<':
84
+ return typeof actual === typeof value && actual < value;
85
+ case '<=':
86
+ return typeof actual === typeof value && actual <= value;
87
+ case '>':
88
+ return typeof actual === typeof value && actual > value;
89
+ case '>=':
90
+ return typeof actual === typeof value && actual >= value;
91
+ default:
92
+ return false;
93
+ }
94
+ }
95
+ /** A cursor: the last row's order value and id, opaque to the grid. */ function encodeCursor(value, id) {
96
+ return Buffer.from(JSON.stringify([
97
+ value != null ? value : null,
98
+ id
99
+ ])).toString('base64url');
100
+ }
101
+ function decodeCursor(raw) {
102
+ if (!raw) return null;
103
+ try {
104
+ const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
105
+ if (!Array.isArray(parsed) || parsed.length !== 2) return null;
106
+ const [value, id] = parsed;
107
+ if (typeof id !== 'string' || !id) return null;
108
+ if (value !== null && typeof value !== 'string' && typeof value !== 'number') return null;
109
+ return {
110
+ value,
111
+ id
112
+ };
113
+ } catch (unused) {
114
+ return null;
115
+ }
116
+ }
117
+ const compareValues = (a, b)=>{
118
+ if (a === b) return 0;
119
+ // A row without the ordered value sorts after every row with one.
120
+ if (a === undefined || a === null) return 1;
121
+ if (b === undefined || b === null) return -1;
122
+ return a < b ? -1 : a > b ? 1 : 0;
123
+ };
124
+ /**
125
+ * One page of rows already held WHOLE — a manual collection, an id list, a
126
+ * smart collection's scan — ordered by `key` then id, after the cursor.
127
+ */ function pageHeldRows(rows, key, direction, after, max) {
128
+ const signed = (value)=>direction === 'desc' ? -value : value;
129
+ const ordered = [
130
+ ...rows
131
+ ].sort((a, b)=>signed(compareValues(key(a), key(b))) || signed(compareValues(a.id, b.id)));
132
+ const cursor = decodeCursor(after);
133
+ const start = cursor ? ordered.findIndex((row)=>signed(compareValues(key(row), cursor.value)) > 0 || compareValues(key(row), cursor.value) === 0 && signed(compareValues(row.id, cursor.id)) > 0) : 0;
134
+ if (start === -1) return {
135
+ rows: []
136
+ };
137
+ const page = ordered.slice(start, start + max);
138
+ const last = page[page.length - 1];
139
+ return _extends({
140
+ rows: page
141
+ }, last && start + max < ordered.length ? {
142
+ nextCursor: encodeCursor(key(last), last.id)
143
+ } : {});
144
+ }
145
+ /** Documents by id, in chunks, dropping the ones that do not exist. */ async function readProductsById(productsRef, ids) {
146
+ const firestore = firebaseAdmin.app().firestore();
147
+ const rows = [];
148
+ for(let start = 0; start < ids.length; start += GET_ALL_CHUNK){
149
+ const refs = ids.slice(start, start + GET_ALL_CHUNK).map((id)=>productsRef.doc(id));
150
+ if (!refs.length) continue;
151
+ const snapshots = await firestore.getAll(...refs);
152
+ for (const snapshot of snapshots){
153
+ if (!snapshot.exists) continue;
154
+ const row = toRow(snapshot);
155
+ if (row) rows.push(row);
156
+ }
157
+ }
158
+ return rows;
159
+ }
160
+ /** The card a grid renders for one product. */ function toItem(row) {
161
+ var _ref;
162
+ var _product_mediaUrls, _product_mediaUrls1, _product_tags;
163
+ const { product } = row;
164
+ const [minPrice, maxPrice] = CommerceModel.productPriceRange(product);
165
+ const primary = product.variants[0];
166
+ return _extends({
167
+ id: row.id,
168
+ name: product.name,
169
+ slug: product.slug,
170
+ type: product.type,
171
+ priceUsd: minPrice,
172
+ maxPriceUsd: maxPrice
173
+ }, (primary == null ? void 0 : primary.compareAtPriceUsd) ? {
174
+ compareAtPriceUsd: primary.compareAtPriceUsd
175
+ } : {}, ((_product_mediaUrls = product.mediaUrls) == null ? void 0 : _product_mediaUrls[0]) || product.imageUrl ? {
176
+ imageUrl: (_ref = (_product_mediaUrls1 = product.mediaUrls) == null ? void 0 : _product_mediaUrls1[0]) != null ? _ref : product.imageUrl
177
+ } : {}, {
178
+ // The same verdict the In stock chip queries (`soldOut`), from the one
179
+ // function every stock writer stores it through.
180
+ soldOut: CommerceModel.productStockFields(product).soldOut
181
+ }, ((_product_tags = product.tags) == null ? void 0 : _product_tags.length) ? {
182
+ tags: product.tags
183
+ } : {});
184
+ }
185
+ /**
186
+ * The plan's refusals, as the plan made them; the grid words each one. A
187
+ * search refused because a smart collection already holds the query's one
188
+ * array clause gets a reason a shopper can read.
189
+ */ function refusalsOf(plan) {
190
+ return plan.refused.map((refusal)=>refusal.clause === 'search' && refusal.reason.startsWith('this list is already narrowed') ? _extends({}, refusal, {
191
+ reason: 'this collection is already narrowed by a tag or category, which search cannot combine with'
192
+ }) : refusal);
193
+ }
194
+ /** How a sort reads in the grid's select, for the notice below. */ const SORT_LABELS = {
195
+ nameLower: 'Name',
196
+ createdAtMs: 'Newest'
197
+ };
198
+ /**
199
+ * The plan, ordered the way the visitor asked when a range forced its field.
200
+ *
201
+ * A range leads the order, so while the price slider (or a smart collection's
202
+ * "price under" rule) is set the query is ordered by price: "high to low"
203
+ * stays high to low, and any other sort — Name, Newest — becomes low to high
204
+ * and is SAID to, rather than silently replaced (AGL-3321).
205
+ */ function planFor(request, sort) {
206
+ const plan = planListQuery(STOREFRONT_CATALOG_DECLARATION, request, nameSearchNormalizers);
207
+ if (plan.orderBy.path !== sort.path) {
208
+ const asked = SORT_LABELS[sort.path];
209
+ return asked ? _extends({}, plan, {
210
+ notices: [
211
+ ...plan.notices,
212
+ `Sorted by price, low to high, while a price range is set — ${asked} applies again when it is cleared.`
213
+ ]
214
+ }) : plan;
215
+ }
216
+ return plan.orderBy.direction !== sort.direction ? _extends({}, plan, {
217
+ orderBy: sort
218
+ }) : plan;
219
+ }
220
+ /** A price bound as the grid's slider shows it, for a refusal's sentence. */ const dollars = (cents)=>`$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
46
221
  /**
47
- * Public catalog listing (AGL-291): active products for storefront
48
- * blocks — filterable by collection/category slug or tag, sortable,
49
- * capped at 100 items per request. Storefront catalog UX (AGL-561)
50
- * adds text search, a product-type filter, offset paging (for Load
51
- * more), and facets for filter chips. The price-range filter (AGL-564)
52
- * bounds on the displayed price and reports `priceBounds`.
53
- * Read-only public data; prices here are display-only (charges always
54
- * come from the docs server-side).
222
+ * Public catalog listing (AGL-291): active products for storefront blocks —
223
+ * a collection, a category or a tag, searched, filtered by type and price,
224
+ * sorted, and paged by cursor (AGL-561, AGL-564).
225
+ *
226
+ * EVERY CONTROL IS ON THE QUERY (AGL-3321). This used to read `limit(500)`
227
+ * products and narrow them in memory, so a catalog past five hundred answered
228
+ * every search, chip and slider from an arbitrary five hundred of them. Now:
229
+ *
230
+ * - the catalog, a category or a tag — ONE query built by the list query
231
+ * plan (`STOREFRONT_CATALOG_DECLARATION`), paged by its own cursor;
232
+ * - a SMART collection whose rules a query can hold — the same query, with
233
+ * the rules as its scope (`smartCollectionScope`);
234
+ * - a MANUAL collection, or an explicit id list — read whole by id, which
235
+ * is a complete set, and answered over all of it;
236
+ * - a smart collection whose rules no query can hold (a NOT, an OR, a name
237
+ * rule, a price over) — the same query, scoped by the membership every
238
+ * product writer stores (`collectionIds array-contains <id>`), which the
239
+ * collection-membership route re-stamps whenever the rules change.
240
+ *
241
+ * What one query cannot hold — two array clauses, two ranges — is refused by
242
+ * name in `refused` and not applied, never applied to some rows and not
243
+ * others. Read-only public data; prices here are display-only (charges
244
+ * always come from the docs server-side).
55
245
  *
56
246
  * Extracted from the handler (AGL-659) so the site-page enricher seeds a
57
247
  * grid's first page through THIS function rather than a reimplementation
58
248
  * of it. A seed that filters or sorts differently from the API would
59
249
  * server-render one set of products and hydrate to another.
60
250
  */ export async function queryPublicCatalog(params) {
61
- var _params_collectionSlug, _params_categorySlug, _params_collectionId, _params_categoryId, _params_tag, _params_query, _params_type, _params_ids, _params_limit, _params_offset;
62
- var _categoriesSnapshot_docs_;
251
+ var _reads, _results;
252
+ const { reads } = params, question = _object_without_properties_loose(params, [
253
+ "reads"
254
+ ]);
255
+ if (!reads) return runPublicCatalog(question);
256
+ const key = JSON.stringify(question);
257
+ (_results = (_reads = reads).results) != null ? _results : _reads.results = new Map();
258
+ const shared = reads.results.get(key);
259
+ if (shared) return shared;
260
+ // Stored BEFORE it is awaited, so grids running together under one
261
+ // `Promise.all` share the one in-flight read rather than racing to store it.
262
+ const pending = runPublicCatalog(question, reads);
263
+ reads.results.set(key, pending);
264
+ return pending;
265
+ }
266
+ async function runPublicCatalog(params, reads) {
267
+ var _params_collectionSlug, _params_categorySlug, _params_collectionId, _params_categoryId, _params_tag, _params_query, _params_ids, _params_limit;
268
+ var _categoriesSnapshot_docs_, _lowest_docs_, _highest_docs_;
63
269
  const { hostId } = params;
64
270
  const collectionSlug = (_params_collectionSlug = params.collectionSlug) != null ? _params_collectionSlug : '';
65
271
  const categorySlug = (_params_categorySlug = params.categorySlug) != null ? _params_categorySlug : '';
@@ -67,36 +273,38 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
67
273
  // for legacy screens and template-URL resolution.
68
274
  const collectionIdParam = (_params_collectionId = params.collectionId) != null ? _params_collectionId : '';
69
275
  const categoryIdParam = (_params_categoryId = params.categoryId) != null ? _params_categoryId : '';
70
- const tag = (_params_tag = params.tag) != null ? _params_tag : '';
71
- const query = (_params_query = params.query) != null ? _params_query : '';
72
- const type = [
73
- 'physical',
74
- 'digital',
75
- 'service'
76
- ].includes((_params_type = params.type) != null ? _params_type : '') ? params.type : '';
77
- const ids = ((_params_ids = params.ids) != null ? _params_ids : []).slice(0, 100);
78
- const minPriceCents = params.minPriceCents;
79
- const maxPriceCents = params.maxPriceCents;
80
- const sort = params.sort || 'name';
81
- const max = Math.min(100, Math.max(1, Number((_params_limit = params.limit) != null ? _params_limit : 24)));
82
- const offset = Math.max(0, Math.round(Number((_params_offset = params.offset) != null ? _params_offset : 0)) || 0);
276
+ const tag = ((_params_tag = params.tag) != null ? _params_tag : '').trim();
277
+ const query = ((_params_query = params.query) != null ? _params_query : '').trim();
278
+ const type = STOREFRONT_PRODUCT_TYPES.includes(params.type) ? params.type : '';
279
+ const ids = ((_params_ids = params.ids) != null ? _params_ids : []).slice(0, CATALOG_PAGE_MAX);
280
+ const asked = Math.floor(Number((_params_limit = params.limit) != null ? _params_limit : CATALOG_PAGE_DEFAULT));
281
+ const max = Number.isFinite(asked) && asked > 0 ? Math.min(CATALOG_PAGE_MAX, asked) : CATALOG_PAGE_DEFAULT;
282
+ const sort = storefrontCatalogSort(params.sort);
83
283
  const wantFacets = Boolean(params.facets);
84
284
  const firestore = firebaseAdmin.app().firestore();
85
285
  const hostRef = firestore.collection('hosts').doc(hostId);
86
- const reads = params.reads;
87
- // Assigned into the scope BEFORE it is awaited, so grids running together
88
- // under one `Promise.all` share the single in-flight read rather than each
89
- // starting their own and racing to store it.
90
- const sharedProducts = ()=>{
91
- var _reads, _products;
92
- return reads ? (_products = (_reads = reads).products) != null ? _products : _reads.products = hostRef.collection('products').limit(500).get() : hostRef.collection('products').limit(500).get();
93
- };
286
+ const productsRef = hostRef.collection('products');
94
287
  const sharedCategories = ()=>{
95
288
  var _reads, _categories;
96
289
  return reads ? (_categories = (_reads = reads).categories) != null ? _categories : _reads.categories = hostRef.collection('productCategories').limit(200).get() : hostRef.collection('productCategories').limit(200).get();
97
290
  };
98
- const [productsSnapshot, collectionsSnapshot, categoriesSnapshot, collectionByIdSnapshot, facetsSnapshot] = await Promise.all([
99
- sharedProducts(),
291
+ // Wishlist and related rendering (AGL-297): an explicit id list is its own
292
+ // complete set — read it by id, keep what is on sale, in the given order.
293
+ if (ids.length) {
294
+ const order = new Map(ids.map((id, index)=>[
295
+ id,
296
+ index
297
+ ]));
298
+ const rows = (await readProductsById(productsRef, ids)).filter(onSale);
299
+ rows.sort((a, b)=>{
300
+ var _order_get, _order_get1;
301
+ return ((_order_get = order.get(a.id)) != null ? _order_get : 0) - ((_order_get1 = order.get(b.id)) != null ? _order_get1 : 0);
302
+ });
303
+ return {
304
+ items: rows.slice(0, max).map(toItem)
305
+ };
306
+ }
307
+ const [collectionsSnapshot, categoriesSnapshot, collectionByIdSnapshot, facetsSnapshot] = await Promise.all([
100
308
  // limit(5) not 1: content collections share this path and a slug is
101
309
  // unique only within a kind (AGL-954) — the catalog match is picked
102
310
  // out below rather than trusting whichever doc came back first.
@@ -106,114 +314,12 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
106
314
  wantFacets ? sharedCategories() : null
107
315
  ]);
108
316
  const collectionById = (collectionByIdSnapshot == null ? void 0 : collectionByIdSnapshot.exists) && Aglyn.hostCollectionKind(collectionByIdSnapshot.data()) === 'catalog' ? collectionByIdSnapshot : undefined;
109
- const collection = collectionById != null ? collectionById : collectionsSnapshot == null ? void 0 : collectionsSnapshot.docs.find((docSnapshot)=>Aglyn.hostCollectionKind(docSnapshot.data()) === 'catalog');
110
- const categoryId = categoryIdParam || (categoriesSnapshot == null ? void 0 : (_categoriesSnapshot_docs_ = categoriesSnapshot.docs[0]) == null ? void 0 : _categoriesSnapshot_docs_.id);
111
- let products = productsSnapshot.docs.map((docSnapshot)=>_extends({}, CommerceModel.liftLegacyProduct(docSnapshot.data()), {
112
- $id: docSnapshot.id
113
- })).filter((product)=>!product.deletedAt && product.status === 'active');
114
- if (collection) {
115
- const shaped = collection.data();
116
- products = products.filter((product)=>CommerceModel.matchesCollection(product, shaped, product.$id));
117
- if (shaped.mode === 'manual') {
118
- var _shaped_productIds;
119
- const order = new Map(((_shaped_productIds = shaped.productIds) != null ? _shaped_productIds : []).map((id, index)=>[
120
- id,
121
- index
122
- ]));
123
- products.sort((a, b)=>{
124
- var _order_get, _order_get1;
125
- return ((_order_get = order.get(a.$id)) != null ? _order_get : 999) - ((_order_get1 = order.get(b.$id)) != null ? _order_get1 : 999);
126
- });
127
- }
128
- }
129
- if (categoryId) {
130
- products = products.filter((product)=>{
131
- var _product_categoryIds;
132
- return ((_product_categoryIds = product.categoryIds) != null ? _product_categoryIds : []).includes(categoryId);
133
- });
134
- }
135
- if (tag) {
136
- products = products.filter((product)=>{
137
- var _product_tags;
138
- return ((_product_tags = product.tags) != null ? _product_tags : []).includes(tag);
139
- });
140
- }
141
- // Storefront catalog UX (AGL-561): type filter + text search.
142
- if (type) {
143
- products = products.filter((product)=>product.type === type);
144
- }
145
- if (query.trim()) {
146
- products = products.filter((product)=>matchesCatalogQuery(product, query));
147
- }
148
- // Wishlist rendering (AGL-297): explicit id list, given order.
149
- if (ids.length) {
150
- const order = new Map(ids.map((id, index)=>[
151
- id,
152
- index
153
- ]));
154
- products = products.filter((product)=>order.has(product.$id)).sort((a, b)=>{
155
- var _order_get, _order_get1;
156
- return ((_order_get = order.get(a.$id)) != null ? _order_get : 0) - ((_order_get1 = order.get(b.$id)) != null ? _order_get1 : 0);
157
- });
158
- }
159
- // Price facets + filter (AGL-564): bounds are computed before the
160
- // price filter (with everything else applied) so the grid's slider
161
- // stays anchored while the visitor narrows it. Both use the
162
- // displayed price — the low end of the variant range grid cards
163
- // show — so filtering matches what visitors see.
164
- const priceBounds = wantFacets && products.length ? {
165
- minCents: Math.min(...products.map(displayedPriceCents)),
166
- maxCents: Math.max(...products.map(displayedPriceCents))
167
- } : undefined;
168
- if (minPriceCents != null || maxPriceCents != null) {
169
- products = products.filter((product)=>{
170
- const cents = displayedPriceCents(product);
171
- return (minPriceCents == null || cents >= minPriceCents) && (maxPriceCents == null || cents <= maxPriceCents);
172
- });
173
- }
174
- if (sort === 'price-asc' || sort === 'price-desc') {
175
- products.sort((a, b)=>{
176
- const [minA] = CommerceModel.productPriceRange(a);
177
- const [minB] = CommerceModel.productPriceRange(b);
178
- return sort === 'price-asc' ? minA - minB : minB - minA;
179
- });
180
- } else if (sort === 'newest') {
181
- products.sort((a, b)=>{
182
- var _b_createdAtMs, _a_createdAtMs;
183
- return ((_b_createdAtMs = b.createdAtMs) != null ? _b_createdAtMs : 0) - ((_a_createdAtMs = a.createdAtMs) != null ? _a_createdAtMs : 0);
184
- });
185
- } else if (!ids.length && (!collection || collection.data().mode !== 'manual')) {
186
- products.sort((a, b)=>a.name.localeCompare(b.name));
187
- }
188
- // Offset paging (AGL-561): grids pass `offset` for Load more;
189
- // `nextOffset` comes back only while more filtered items remain.
190
- const page = products.slice(offset, offset + max);
191
- const nextOffset = offset + max < products.length ? offset + max : undefined;
192
- const items = page.map((product)=>{
193
- var _ref;
194
- var _product_mediaUrls, _product_mediaUrls1, _product_tags;
195
- const [minPrice, maxPrice] = CommerceModel.productPriceRange(product);
196
- const primary = product.variants[0];
197
- const inventory = CommerceModel.productInventory(product);
198
- return _extends({
199
- id: product.$id,
200
- name: product.name,
201
- slug: product.slug,
202
- type: product.type,
203
- priceUsd: minPrice,
204
- maxPriceUsd: maxPrice
205
- }, (primary == null ? void 0 : primary.compareAtPriceUsd) ? {
206
- compareAtPriceUsd: primary.compareAtPriceUsd
207
- } : {}, ((_product_mediaUrls = product.mediaUrls) == null ? void 0 : _product_mediaUrls[0]) || product.imageUrl ? {
208
- imageUrl: (_ref = (_product_mediaUrls1 = product.mediaUrls) == null ? void 0 : _product_mediaUrls1[0]) != null ? _ref : product.imageUrl
209
- } : {}, {
210
- soldOut: inventory != null && inventory <= 0 && product.oversellPolicy !== 'backorder'
211
- }, ((_product_tags = product.tags) == null ? void 0 : _product_tags.length) ? {
212
- tags: product.tags
213
- } : {});
214
- });
317
+ const collectionDoc = collectionById != null ? collectionById : collectionsSnapshot == null ? void 0 : collectionsSnapshot.docs.find((docSnapshot)=>Aglyn.hostCollectionKind(docSnapshot.data()) === 'catalog');
318
+ const collection = collectionDoc ? collectionDoc.data() : undefined;
319
+ const categoryId = categoryIdParam || (categoriesSnapshot == null ? void 0 : (_categoriesSnapshot_docs_ = categoriesSnapshot.docs[0]) == null ? void 0 : _categoriesSnapshot_docs_.id) || '';
215
320
  // Category facets (AGL-561): id/name/slug for grid filter chips,
216
- // parent-tree order preserved (order, then name).
321
+ // parent-tree order preserved (order, then name). The taxonomy is a
322
+ // bounded picker, read whole.
217
323
  const categories = facetsSnapshot ? facetsSnapshot.docs.map((docSnapshot)=>{
218
324
  var _data_name, _data_slug, _data_order;
219
325
  const data = docSnapshot.data();
@@ -228,15 +334,230 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
228
334
  name,
229
335
  slug
230
336
  })) : undefined;
231
- return _extends({
232
- items
233
- }, nextOffset != null ? {
234
- nextOffset
235
- } : {}, categories ? {
236
- categories
237
- } : {}, priceBounds ? {
238
- priceBounds
239
- } : {});
337
+ // The visitor's controls, as clauses on the one declaration. A price
338
+ // clause carries the dollars the slider showed, for a refusal's sentence.
339
+ const priceClauses = [
340
+ ...params.minPriceCents != null ? [
341
+ {
342
+ field: 'price',
343
+ op: '>=',
344
+ value: String(params.minPriceCents),
345
+ label: dollars(params.minPriceCents)
346
+ }
347
+ ] : [],
348
+ ...params.maxPriceCents != null ? [
349
+ {
350
+ field: 'price',
351
+ op: '<=',
352
+ value: String(params.maxPriceCents),
353
+ label: dollars(params.maxPriceCents)
354
+ }
355
+ ] : []
356
+ ];
357
+ const otherClauses = [
358
+ ...categoryId ? [
359
+ {
360
+ field: 'category',
361
+ op: 'contains',
362
+ value: categoryId
363
+ }
364
+ ] : [],
365
+ ...tag ? [
366
+ {
367
+ field: 'tag',
368
+ op: 'contains',
369
+ value: tag
370
+ }
371
+ ] : [],
372
+ ...type ? [
373
+ {
374
+ field: 'type',
375
+ op: 'equals',
376
+ value: type
377
+ }
378
+ ] : [],
379
+ ...params.inStock ? [
380
+ {
381
+ field: 'stock',
382
+ op: 'is',
383
+ value: 'false'
384
+ }
385
+ ] : []
386
+ ];
387
+ // The words typed, as a quick search hands them over: the plan searches by
388
+ // the first and says so when there were more.
389
+ const search = query.split(/\s+/).filter(Boolean);
390
+ const result = (rows, plan, extra = {})=>{
391
+ var _extra_notices;
392
+ const refused = refusalsOf(plan);
393
+ const notices = [
394
+ ...plan.notices,
395
+ ...(_extra_notices = extra.notices) != null ? _extra_notices : []
396
+ ];
397
+ return _extends({
398
+ items: rows.map(toItem)
399
+ }, extra.nextCursor ? {
400
+ nextCursor: extra.nextCursor
401
+ } : {}, categories ? {
402
+ categories
403
+ } : {}, extra.priceBounds ? {
404
+ priceBounds: extra.priceBounds
405
+ } : {}, refused.length ? {
406
+ refused
407
+ } : {}, notices.length ? {
408
+ notices
409
+ } : {});
410
+ };
411
+ /*
412
+ * A collection held WHOLE: a manual one's members, or a smart one whose
413
+ * rules no query can hold. The plan still decides what is applied — the
414
+ * same refusals as the query path, so a grid behaves the same whatever it
415
+ * lists — and its predicates are answered over every held row.
416
+ */ const answerHeld = (held, options)=>{
417
+ const request = {
418
+ base: STOREFRONT_CATALOG_BASE,
419
+ clauses: [
420
+ ...otherClauses,
421
+ ...priceClauses
422
+ ],
423
+ search,
424
+ sort
425
+ };
426
+ const plan = planFor(request, sort);
427
+ const unpriced = planFor(_extends({}, request, {
428
+ clauses: otherClauses
429
+ }), sort);
430
+ const matched = held.filter((row)=>plan.filters.every((filter)=>rowMatches(row, filter)));
431
+ // Bounds span every OTHER control, so the slider stays anchored while the
432
+ // visitor narrows it.
433
+ const bounding = held.filter((row)=>unpriced.filters.every((filter)=>rowMatches(row, filter)));
434
+ const priceBounds = wantFacets && bounding.length ? {
435
+ minCents: Math.min(...bounding.map((row)=>Number(rowValue(row, 'priceFromCents')))),
436
+ maxCents: Math.max(...bounding.map((row)=>Number(rowValue(row, 'priceFromCents'))))
437
+ } : undefined;
438
+ // A manual collection keeps the merchant's order unless the visitor (or
439
+ // the author) chose a price or recency sort.
440
+ const keepManual = options.manualOrder && plan.orderBy.path === STOREFRONT_CATALOG_SORTS.name.path;
441
+ const page = keepManual ? pageHeldRows(matched, (row)=>{
442
+ var _ref;
443
+ var _options_manualOrder;
444
+ return (_ref = (_options_manualOrder = options.manualOrder) == null ? void 0 : _options_manualOrder.get(row.id)) != null ? _ref : Number.MAX_SAFE_INTEGER;
445
+ }, 'asc', params.after, max) : pageHeldRows(matched, (row)=>rowValue(row, plan.orderBy.path), plan.orderBy.direction, params.after, max);
446
+ return result(page.rows, plan, _extends({}, page.nextCursor ? {
447
+ nextCursor: page.nextCursor
448
+ } : {}, priceBounds ? {
449
+ priceBounds
450
+ } : {}, options.notices ? {
451
+ notices: options.notices
452
+ } : {}));
453
+ };
454
+ let scope = [];
455
+ if (collection) {
456
+ var _collection_rules;
457
+ if (collection.mode === 'manual') {
458
+ var _collection_productIds;
459
+ const memberIds = ((_collection_productIds = collection.productIds) != null ? _collection_productIds : []).slice(0, MANUAL_COLLECTION_MAX);
460
+ const manualOrder = new Map(memberIds.map((id, index)=>[
461
+ id,
462
+ index
463
+ ]));
464
+ return answerHeld(await readProductsById(productsRef, memberIds), {
465
+ manualOrder
466
+ });
467
+ }
468
+ const smart = smartCollectionScope(collection);
469
+ if (!((_collection_rules = collection.rules) != null ? _collection_rules : []).length) {
470
+ // A smart collection with no rules holds nothing (`matchesCollection`).
471
+ return result([], planFor({
472
+ base: STOREFRONT_CATALOG_BASE,
473
+ clauses: [],
474
+ sort
475
+ }, sort));
476
+ }
477
+ if ('unservable' in smart) {
478
+ var _ref;
479
+ // Rules no query can express (a NOT, an OR, a name rule, a price over)
480
+ // are answered by the membership every product writer stores
481
+ // (`collectionIds`, AGL-3321): one array clause on the same query, so
482
+ // the grid's other array controls are refused by name beside it.
483
+ scope = [
484
+ {
485
+ path: CommerceModel.PRODUCT_COLLECTION_IDS,
486
+ op: 'array-contains',
487
+ value: String((_ref = collectionDoc == null ? void 0 : collectionDoc.id) != null ? _ref : '')
488
+ }
489
+ ];
490
+ } else {
491
+ scope = smart.filters;
492
+ // A "type is not" rule is `type in […]`; the visitor's Type chip is the
493
+ // narrower question, answered inside it or not at all.
494
+ const typeRule = scope.find((filter)=>filter.path === 'type');
495
+ if (typeRule && type) {
496
+ const allowed = Array.isArray(typeRule.value) ? typeRule.value : [
497
+ typeRule.value
498
+ ];
499
+ if (!allowed.includes(type)) {
500
+ return result([], planFor({
501
+ base: STOREFRONT_CATALOG_BASE,
502
+ clauses: [],
503
+ sort
504
+ }, sort));
505
+ }
506
+ scope = scope.filter((filter)=>filter !== typeRule);
507
+ }
508
+ }
509
+ }
510
+ // THE QUERY: every predicate, one order, a page and one row to spare.
511
+ const request = {
512
+ base: [
513
+ ...STOREFRONT_CATALOG_BASE,
514
+ ...scope
515
+ ],
516
+ clauses: [
517
+ ...otherClauses,
518
+ ...priceClauses
519
+ ],
520
+ search,
521
+ sort
522
+ };
523
+ const plan = planFor(request, sort);
524
+ const byId = firebaseAdmin.firestore.FieldPath.documentId();
525
+ const ordered = (target)=>applyListQuery(productsRef, target).orderBy(byId, target.orderBy.direction);
526
+ let pageQuery = ordered(plan);
527
+ const cursor = decodeCursor(params.after);
528
+ if (cursor) pageQuery = pageQuery.startAfter(cursor.value, cursor.id);
529
+ // Bounds span every OTHER control, so the slider stays anchored while the
530
+ // visitor narrows it: the lowest and highest price the rest allows, as two
531
+ // one-row reads ordered by price.
532
+ const byPrice = STOREFRONT_CATALOG_SORTS['price-asc'];
533
+ const unpriced = wantFacets ? planFor(_extends({}, request, {
534
+ clauses: otherClauses,
535
+ sort: byPrice
536
+ }), byPrice) : null;
537
+ const pricedBy = (target, direction)=>ordered(_extends({}, target, {
538
+ orderBy: {
539
+ path: 'priceFromCents',
540
+ direction
541
+ }
542
+ })).limit(1).get();
543
+ const [snapshot, lowest, highest] = await Promise.all([
544
+ pageQuery.limit(max + 1).get(),
545
+ unpriced ? pricedBy(unpriced, 'asc') : null,
546
+ unpriced ? pricedBy(unpriced, 'desc') : null
547
+ ]);
548
+ const docs = snapshot.docs.slice(0, max);
549
+ const rows = docs.map(toRow).filter((row)=>Boolean(row));
550
+ const last = docs[docs.length - 1];
551
+ const low = lowest == null ? void 0 : (_lowest_docs_ = lowest.docs[0]) == null ? void 0 : _lowest_docs_.get('priceFromCents');
552
+ const high = highest == null ? void 0 : (_highest_docs_ = highest.docs[0]) == null ? void 0 : _highest_docs_.get('priceFromCents');
553
+ return result(rows, plan, _extends({}, last && snapshot.docs.length > max ? {
554
+ nextCursor: encodeCursor(last.get(plan.orderBy.path), last.id)
555
+ } : {}, typeof low === 'number' && typeof high === 'number' ? {
556
+ priceBounds: {
557
+ minCents: low,
558
+ maxCents: high
559
+ }
560
+ } : {}));
240
561
  }
241
562
  /** Public catalog listing endpoint — a thin parse over queryPublicCatalog. */ export const catalogHandler = async (req, res)=>{
242
563
  var _req_query_hostId;
@@ -245,7 +566,7 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
245
566
  error: 'Missing hostId'
246
567
  });
247
568
  try {
248
- var _req_query_collection, _req_query_category, _req_query_collectionId, _req_query_categoryId, _req_query_tag, _req_query_q, _req_query_type, _req_query_ids, _req_query_sort, _req_query_limit, _req_query_offset, _req_query_facets;
569
+ var _req_query_collection, _req_query_category, _req_query_collectionId, _req_query_categoryId, _req_query_tag, _req_query_q, _req_query_type, _req_query_inStock, _req_query_ids, _req_query_sort, _req_query_limit, _req_query_after, _req_query_facets;
249
570
  const result = await queryPublicCatalog({
250
571
  hostId,
251
572
  collectionSlug: String((_req_query_collection = req.query.collection) != null ? _req_query_collection : ''),
@@ -255,13 +576,14 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
255
576
  tag: String((_req_query_tag = req.query.tag) != null ? _req_query_tag : ''),
256
577
  query: String((_req_query_q = req.query.q) != null ? _req_query_q : ''),
257
578
  type: String((_req_query_type = req.query.type) != null ? _req_query_type : ''),
579
+ inStock: String((_req_query_inStock = req.query.inStock) != null ? _req_query_inStock : '') === '1',
258
580
  ids: String((_req_query_ids = req.query.ids) != null ? _req_query_ids : '').split(',').map((id)=>id.trim()).filter(Boolean),
259
581
  // Price-range filter (AGL-564): integer cents on the displayed price.
260
582
  minPriceCents: parsePriceCents(req.query.minPriceCents),
261
583
  maxPriceCents: parsePriceCents(req.query.maxPriceCents),
262
584
  sort: String((_req_query_sort = req.query.sort) != null ? _req_query_sort : 'name'),
263
- limit: Number((_req_query_limit = req.query.limit) != null ? _req_query_limit : 24),
264
- offset: Number((_req_query_offset = req.query.offset) != null ? _req_query_offset : 0),
585
+ limit: Number((_req_query_limit = req.query.limit) != null ? _req_query_limit : CATALOG_PAGE_DEFAULT),
586
+ after: String((_req_query_after = req.query.after) != null ? _req_query_after : ''),
265
587
  facets: String((_req_query_facets = req.query.facets) != null ? _req_query_facets : '') === '1'
266
588
  });
267
589
  res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');