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

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 (111) 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/order-detail-dialog.component.js +12 -0
  11. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  12. package/src/lib/components/console/pos-page.component.d.ts +14 -0
  13. package/src/lib/components/console/pos-page.component.js +86 -45
  14. package/src/lib/components/console/pos-page.component.js.map +1 -1
  15. package/src/lib/components/console/product-editor-dialog.component.js +30 -6
  16. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  17. package/src/lib/components/console/product-slugs.d.ts +43 -0
  18. package/src/lib/components/console/product-slugs.js +60 -0
  19. package/src/lib/components/console/product-slugs.js.map +1 -0
  20. package/src/lib/components/console/products-hub-card.component.d.ts +2 -2
  21. package/src/lib/components/console/products-hub-card.component.js +229 -282
  22. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  23. package/src/lib/components/console/products-hub-zone.component.d.ts +10 -5
  24. package/src/lib/components/console/products-hub-zone.component.js +32 -9
  25. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  26. package/src/lib/components/console/recovery-queue-card.component.d.ts +37 -5
  27. package/src/lib/components/console/recovery-queue-card.component.js +107 -86
  28. package/src/lib/components/console/recovery-queue-card.component.js.map +1 -1
  29. package/src/lib/components/console/reservations-card.component.js +15 -3
  30. package/src/lib/components/console/reservations-card.component.js.map +1 -1
  31. package/src/lib/components/console/smart-collections.d.ts +32 -0
  32. package/src/lib/components/console/smart-collections.js +57 -0
  33. package/src/lib/components/console/smart-collections.js.map +1 -0
  34. package/src/lib/components/console/stock-movements-card.component.d.ts +13 -4
  35. package/src/lib/components/console/stock-movements-card.component.js +142 -180
  36. package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
  37. package/src/lib/components/product-grid.d.ts +5 -0
  38. package/src/lib/components/product-grid.js +95 -34
  39. package/src/lib/components/product-grid.js.map +1 -1
  40. package/src/lib/constants/orders-list-query.d.ts +40 -0
  41. package/src/lib/constants/orders-list-query.js +205 -0
  42. package/src/lib/constants/orders-list-query.js.map +1 -0
  43. package/src/lib/constants/product-list-query.d.ts +42 -0
  44. package/src/lib/constants/product-list-query.js +217 -0
  45. package/src/lib/constants/product-list-query.js.map +1 -0
  46. package/src/lib/constants/stock-movement-query.d.ts +27 -0
  47. package/src/lib/constants/stock-movement-query.js +110 -0
  48. package/src/lib/constants/stock-movement-query.js.map +1 -0
  49. package/src/lib/constants/storefront-catalog-query.d.ts +103 -0
  50. package/src/lib/constants/storefront-catalog-query.js +312 -0
  51. package/src/lib/constants/storefront-catalog-query.js.map +1 -0
  52. package/src/lib/model/checkout-recovery.d.ts +37 -0
  53. package/src/lib/model/checkout-recovery.js +44 -0
  54. package/src/lib/model/checkout-recovery.js.map +1 -0
  55. package/src/lib/model/commerce-orders.d.ts +7 -0
  56. package/src/lib/model/commerce-orders.js.map +1 -1
  57. package/src/lib/model/commerce.d.ts +80 -0
  58. package/src/lib/model/commerce.js +67 -6
  59. package/src/lib/model/commerce.js.map +1 -1
  60. package/src/lib/model/gift-card-search.d.ts +24 -0
  61. package/src/lib/model/gift-card-search.js +48 -0
  62. package/src/lib/model/gift-card-search.js.map +1 -0
  63. package/src/lib/model/index.d.ts +1 -0
  64. package/src/lib/model/index.js +1 -0
  65. package/src/lib/model/index.js.map +1 -1
  66. package/src/lib/model/order-list-fields.d.ts +80 -0
  67. package/src/lib/model/order-list-fields.js +112 -0
  68. package/src/lib/model/order-list-fields.js.map +1 -0
  69. package/src/lib/server/billing-webhook.js +180 -99
  70. package/src/lib/server/billing-webhook.js.map +1 -1
  71. package/src/lib/server/cancel-order.js +6 -6
  72. package/src/lib/server/cancel-order.js.map +1 -1
  73. package/src/lib/server/cart-checkout.js +9 -2
  74. package/src/lib/server/cart-checkout.js.map +1 -1
  75. package/src/lib/server/catalog.d.ts +46 -30
  76. package/src/lib/server/catalog.js +507 -170
  77. package/src/lib/server/catalog.js.map +1 -1
  78. package/src/lib/server/checkout.js +3 -1
  79. package/src/lib/server/checkout.js.map +1 -1
  80. package/src/lib/server/collection-membership.d.ts +35 -0
  81. package/src/lib/server/collection-membership.js +172 -0
  82. package/src/lib/server/collection-membership.js.map +1 -0
  83. package/src/lib/server/draft-order.js +5 -3
  84. package/src/lib/server/draft-order.js.map +1 -1
  85. package/src/lib/server/gift-cards.js +7 -1
  86. package/src/lib/server/gift-cards.js.map +1 -1
  87. package/src/lib/server/member-name-search.d.ts +17 -0
  88. package/src/lib/server/member-name-search.js +23 -1
  89. package/src/lib/server/member-name-search.js.map +1 -1
  90. package/src/lib/server/membership-account.js +7 -3
  91. package/src/lib/server/membership-account.js.map +1 -1
  92. package/src/lib/server/membership-admin-password.js +8 -2
  93. package/src/lib/server/membership-admin-password.js.map +1 -1
  94. package/src/lib/server/membership-recover.js +4 -1
  95. package/src/lib/server/membership-recover.js.map +1 -1
  96. package/src/lib/server/membership-register.js +3 -1
  97. package/src/lib/server/membership-register.js.map +1 -1
  98. package/src/lib/server/pos-order.js +9 -5
  99. package/src/lib/server/pos-order.js.map +1 -1
  100. package/src/lib/server/process-abandoned.js +20 -7
  101. package/src/lib/server/process-abandoned.js.map +1 -1
  102. package/src/lib/server/process-restock.js +6 -2
  103. package/src/lib/server/process-restock.js.map +1 -1
  104. package/src/lib/server/reserve-stock.js +3 -4
  105. package/src/lib/server/reserve-stock.js.map +1 -1
  106. package/src/lib/server/reserve.js +3 -1
  107. package/src/lib/server/reserve.js.map +1 -1
  108. package/src/lib/server/site-page-enricher.js +3 -3
  109. package/src/lib/server/site-page-enricher.js.map +1 -1
  110. package/src/lib/server.js +4 -0
  111. 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,258 @@ 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;
46
37
  /**
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).
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
+ /** The operators that make a field a range, which must lead the order. */ const RANGE_OPS = new Set([
199
+ '<',
200
+ '<=',
201
+ '>',
202
+ '>=',
203
+ '!='
204
+ ]);
205
+ /**
206
+ * The plan, ordered the way the visitor asked when a range forced its field.
207
+ *
208
+ * A range leads the order, so while the price slider (or a smart collection's
209
+ * "price under" rule) is set the query is ordered by price: "high to low"
210
+ * stays high to low, and any other sort — Name, Newest — becomes low to high
211
+ * and is SAID to, rather than silently replaced (AGL-3321).
212
+ *
213
+ * The notice names the cause. The slider is the visitor's to clear, so the
214
+ * sort they chose returns when they do; a collection's rule is in the scope
215
+ * (`request.base`), which no visitor can clear, so that notice says the
216
+ * collection is always ordered by price.
217
+ */ function planFor(request, sort) {
218
+ const plan = planListQuery(STOREFRONT_CATALOG_DECLARATION, request, nameSearchNormalizers);
219
+ if (plan.orderBy.path !== sort.path) {
220
+ var _request_base;
221
+ const asked = SORT_LABELS[sort.path];
222
+ if (!asked) return plan;
223
+ const byRule = ((_request_base = request.base) != null ? _request_base : []).some((filter)=>filter.path === plan.orderBy.path && RANGE_OPS.has(filter.op));
224
+ return _extends({}, plan, {
225
+ notices: [
226
+ ...plan.notices,
227
+ byRule ? `Sorted by price, low to high — this collection picks its products by price, so it cannot be sorted by ${asked}.` : `Sorted by price, low to high, while a price range is set — ${asked} applies again when it is cleared.`
228
+ ]
229
+ });
230
+ }
231
+ return plan.orderBy.direction !== sort.direction ? _extends({}, plan, {
232
+ orderBy: sort
233
+ }) : plan;
234
+ }
235
+ /** 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)}`;
236
+ /**
237
+ * Public catalog listing (AGL-291): active products for storefront blocks —
238
+ * a collection, a category or a tag, searched, filtered by type and price,
239
+ * sorted, and paged by cursor (AGL-561, AGL-564).
240
+ *
241
+ * EVERY CONTROL IS ON THE QUERY (AGL-3321). This used to read `limit(500)`
242
+ * products and narrow them in memory, so a catalog past five hundred answered
243
+ * every search, chip and slider from an arbitrary five hundred of them. Now:
244
+ *
245
+ * - the catalog, a category or a tag — ONE query built by the list query
246
+ * plan (`STOREFRONT_CATALOG_DECLARATION`), paged by its own cursor;
247
+ * - a SMART collection whose rules a query can hold — the same query, with
248
+ * the rules as its scope (`smartCollectionScope`);
249
+ * - a MANUAL collection, or an explicit id list — read whole by id, which
250
+ * is a complete set, and answered over all of it;
251
+ * - a smart collection whose rules no query can hold (a NOT, an OR, a name
252
+ * rule, a price over) — the same query, scoped by the membership every
253
+ * product writer stores (`collectionIds array-contains <id>`), which the
254
+ * collection-membership route re-stamps whenever the rules change.
255
+ *
256
+ * What one query cannot hold — two array clauses, two ranges — is refused by
257
+ * name in `refused` and not applied, never applied to some rows and not
258
+ * others. Read-only public data; prices here are display-only (charges
259
+ * always come from the docs server-side).
55
260
  *
56
261
  * Extracted from the handler (AGL-659) so the site-page enricher seeds a
57
262
  * grid's first page through THIS function rather than a reimplementation
58
263
  * of it. A seed that filters or sorts differently from the API would
59
264
  * server-render one set of products and hydrate to another.
60
265
  */ 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_;
266
+ var _reads, _results;
267
+ const { reads } = params, question = _object_without_properties_loose(params, [
268
+ "reads"
269
+ ]);
270
+ if (!reads) return runPublicCatalog(question);
271
+ const key = JSON.stringify(question);
272
+ (_results = (_reads = reads).results) != null ? _results : _reads.results = new Map();
273
+ const shared = reads.results.get(key);
274
+ if (shared) return shared;
275
+ // Stored BEFORE it is awaited, so grids running together under one
276
+ // `Promise.all` share the one in-flight read rather than racing to store it.
277
+ const pending = runPublicCatalog(question, reads);
278
+ reads.results.set(key, pending);
279
+ return pending;
280
+ }
281
+ async function runPublicCatalog(params, reads) {
282
+ var _params_collectionSlug, _params_categorySlug, _params_collectionId, _params_categoryId, _params_tag, _params_query, _params_ids, _params_limit;
283
+ var _categoriesSnapshot_docs_, _lowest_docs_, _highest_docs_;
63
284
  const { hostId } = params;
64
285
  const collectionSlug = (_params_collectionSlug = params.collectionSlug) != null ? _params_collectionSlug : '';
65
286
  const categorySlug = (_params_categorySlug = params.categorySlug) != null ? _params_categorySlug : '';
@@ -67,36 +288,38 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
67
288
  // for legacy screens and template-URL resolution.
68
289
  const collectionIdParam = (_params_collectionId = params.collectionId) != null ? _params_collectionId : '';
69
290
  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);
291
+ const tag = ((_params_tag = params.tag) != null ? _params_tag : '').trim();
292
+ const query = ((_params_query = params.query) != null ? _params_query : '').trim();
293
+ const type = STOREFRONT_PRODUCT_TYPES.includes(params.type) ? params.type : '';
294
+ const ids = ((_params_ids = params.ids) != null ? _params_ids : []).slice(0, CATALOG_PAGE_MAX);
295
+ const asked = Math.floor(Number((_params_limit = params.limit) != null ? _params_limit : CATALOG_PAGE_DEFAULT));
296
+ const max = Number.isFinite(asked) && asked > 0 ? Math.min(CATALOG_PAGE_MAX, asked) : CATALOG_PAGE_DEFAULT;
297
+ const sort = storefrontCatalogSort(params.sort);
83
298
  const wantFacets = Boolean(params.facets);
84
299
  const firestore = firebaseAdmin.app().firestore();
85
300
  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
- };
301
+ const productsRef = hostRef.collection('products');
94
302
  const sharedCategories = ()=>{
95
303
  var _reads, _categories;
96
304
  return reads ? (_categories = (_reads = reads).categories) != null ? _categories : _reads.categories = hostRef.collection('productCategories').limit(200).get() : hostRef.collection('productCategories').limit(200).get();
97
305
  };
98
- const [productsSnapshot, collectionsSnapshot, categoriesSnapshot, collectionByIdSnapshot, facetsSnapshot] = await Promise.all([
99
- sharedProducts(),
306
+ // Wishlist and related rendering (AGL-297): an explicit id list is its own
307
+ // complete set — read it by id, keep what is on sale, in the given order.
308
+ if (ids.length) {
309
+ const order = new Map(ids.map((id, index)=>[
310
+ id,
311
+ index
312
+ ]));
313
+ const rows = (await readProductsById(productsRef, ids)).filter(onSale);
314
+ rows.sort((a, b)=>{
315
+ var _order_get, _order_get1;
316
+ return ((_order_get = order.get(a.id)) != null ? _order_get : 0) - ((_order_get1 = order.get(b.id)) != null ? _order_get1 : 0);
317
+ });
318
+ return {
319
+ items: rows.slice(0, max).map(toItem)
320
+ };
321
+ }
322
+ const [collectionsSnapshot, categoriesSnapshot, collectionByIdSnapshot, facetsSnapshot] = await Promise.all([
100
323
  // limit(5) not 1: content collections share this path and a slug is
101
324
  // unique only within a kind (AGL-954) — the catalog match is picked
102
325
  // out below rather than trusting whichever doc came back first.
@@ -106,114 +329,12 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
106
329
  wantFacets ? sharedCategories() : null
107
330
  ]);
108
331
  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
- });
332
+ const collectionDoc = collectionById != null ? collectionById : collectionsSnapshot == null ? void 0 : collectionsSnapshot.docs.find((docSnapshot)=>Aglyn.hostCollectionKind(docSnapshot.data()) === 'catalog');
333
+ const collection = collectionDoc ? collectionDoc.data() : undefined;
334
+ const categoryId = categoryIdParam || (categoriesSnapshot == null ? void 0 : (_categoriesSnapshot_docs_ = categoriesSnapshot.docs[0]) == null ? void 0 : _categoriesSnapshot_docs_.id) || '';
215
335
  // Category facets (AGL-561): id/name/slug for grid filter chips,
216
- // parent-tree order preserved (order, then name).
336
+ // parent-tree order preserved (order, then name). The taxonomy is a
337
+ // bounded picker, read whole.
217
338
  const categories = facetsSnapshot ? facetsSnapshot.docs.map((docSnapshot)=>{
218
339
  var _data_name, _data_slug, _data_order;
219
340
  const data = docSnapshot.data();
@@ -228,15 +349,230 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
228
349
  name,
229
350
  slug
230
351
  })) : undefined;
231
- return _extends({
232
- items
233
- }, nextOffset != null ? {
234
- nextOffset
235
- } : {}, categories ? {
236
- categories
237
- } : {}, priceBounds ? {
238
- priceBounds
239
- } : {});
352
+ // The visitor's controls, as clauses on the one declaration. A price
353
+ // clause carries the dollars the slider showed, for a refusal's sentence.
354
+ const priceClauses = [
355
+ ...params.minPriceCents != null ? [
356
+ {
357
+ field: 'price',
358
+ op: '>=',
359
+ value: String(params.minPriceCents),
360
+ label: dollars(params.minPriceCents)
361
+ }
362
+ ] : [],
363
+ ...params.maxPriceCents != null ? [
364
+ {
365
+ field: 'price',
366
+ op: '<=',
367
+ value: String(params.maxPriceCents),
368
+ label: dollars(params.maxPriceCents)
369
+ }
370
+ ] : []
371
+ ];
372
+ const otherClauses = [
373
+ ...categoryId ? [
374
+ {
375
+ field: 'category',
376
+ op: 'contains',
377
+ value: categoryId
378
+ }
379
+ ] : [],
380
+ ...tag ? [
381
+ {
382
+ field: 'tag',
383
+ op: 'contains',
384
+ value: tag
385
+ }
386
+ ] : [],
387
+ ...type ? [
388
+ {
389
+ field: 'type',
390
+ op: 'equals',
391
+ value: type
392
+ }
393
+ ] : [],
394
+ ...params.inStock ? [
395
+ {
396
+ field: 'stock',
397
+ op: 'is',
398
+ value: 'false'
399
+ }
400
+ ] : []
401
+ ];
402
+ // The words typed, as a quick search hands them over: the plan searches by
403
+ // the first and says so when there were more.
404
+ const search = query.split(/\s+/).filter(Boolean);
405
+ const result = (rows, plan, extra = {})=>{
406
+ var _extra_notices;
407
+ const refused = refusalsOf(plan);
408
+ const notices = [
409
+ ...plan.notices,
410
+ ...(_extra_notices = extra.notices) != null ? _extra_notices : []
411
+ ];
412
+ return _extends({
413
+ items: rows.map(toItem)
414
+ }, extra.nextCursor ? {
415
+ nextCursor: extra.nextCursor
416
+ } : {}, categories ? {
417
+ categories
418
+ } : {}, extra.priceBounds ? {
419
+ priceBounds: extra.priceBounds
420
+ } : {}, refused.length ? {
421
+ refused
422
+ } : {}, notices.length ? {
423
+ notices
424
+ } : {});
425
+ };
426
+ /*
427
+ * A collection held WHOLE: a manual one's members, or a smart one whose
428
+ * rules no query can hold. The plan still decides what is applied — the
429
+ * same refusals as the query path, so a grid behaves the same whatever it
430
+ * lists — and its predicates are answered over every held row.
431
+ */ const answerHeld = (held, options)=>{
432
+ const request = {
433
+ base: STOREFRONT_CATALOG_BASE,
434
+ clauses: [
435
+ ...otherClauses,
436
+ ...priceClauses
437
+ ],
438
+ search,
439
+ sort
440
+ };
441
+ const plan = planFor(request, sort);
442
+ const unpriced = planFor(_extends({}, request, {
443
+ clauses: otherClauses
444
+ }), sort);
445
+ const matched = held.filter((row)=>plan.filters.every((filter)=>rowMatches(row, filter)));
446
+ // Bounds span every OTHER control, so the slider stays anchored while the
447
+ // visitor narrows it.
448
+ const bounding = held.filter((row)=>unpriced.filters.every((filter)=>rowMatches(row, filter)));
449
+ const priceBounds = wantFacets && bounding.length ? {
450
+ minCents: Math.min(...bounding.map((row)=>Number(rowValue(row, 'priceFromCents')))),
451
+ maxCents: Math.max(...bounding.map((row)=>Number(rowValue(row, 'priceFromCents'))))
452
+ } : undefined;
453
+ // A manual collection keeps the merchant's order unless the visitor (or
454
+ // the author) chose a price or recency sort.
455
+ const keepManual = options.manualOrder && plan.orderBy.path === STOREFRONT_CATALOG_SORTS.name.path;
456
+ const page = keepManual ? pageHeldRows(matched, (row)=>{
457
+ var _ref;
458
+ var _options_manualOrder;
459
+ return (_ref = (_options_manualOrder = options.manualOrder) == null ? void 0 : _options_manualOrder.get(row.id)) != null ? _ref : Number.MAX_SAFE_INTEGER;
460
+ }, 'asc', params.after, max) : pageHeldRows(matched, (row)=>rowValue(row, plan.orderBy.path), plan.orderBy.direction, params.after, max);
461
+ return result(page.rows, plan, _extends({}, page.nextCursor ? {
462
+ nextCursor: page.nextCursor
463
+ } : {}, priceBounds ? {
464
+ priceBounds
465
+ } : {}, options.notices ? {
466
+ notices: options.notices
467
+ } : {}));
468
+ };
469
+ let scope = [];
470
+ if (collection) {
471
+ var _collection_rules;
472
+ if (collection.mode === 'manual') {
473
+ var _collection_productIds;
474
+ const memberIds = ((_collection_productIds = collection.productIds) != null ? _collection_productIds : []).slice(0, MANUAL_COLLECTION_MAX);
475
+ const manualOrder = new Map(memberIds.map((id, index)=>[
476
+ id,
477
+ index
478
+ ]));
479
+ return answerHeld(await readProductsById(productsRef, memberIds), {
480
+ manualOrder
481
+ });
482
+ }
483
+ const smart = smartCollectionScope(collection);
484
+ if (!((_collection_rules = collection.rules) != null ? _collection_rules : []).length) {
485
+ // A smart collection with no rules holds nothing (`matchesCollection`).
486
+ return result([], planFor({
487
+ base: STOREFRONT_CATALOG_BASE,
488
+ clauses: [],
489
+ sort
490
+ }, sort));
491
+ }
492
+ if ('unservable' in smart) {
493
+ var _ref;
494
+ // Rules no query can express (a NOT, an OR, a name rule, a price over)
495
+ // are answered by the membership every product writer stores
496
+ // (`collectionIds`, AGL-3321): one array clause on the same query, so
497
+ // the grid's other array controls are refused by name beside it.
498
+ scope = [
499
+ {
500
+ path: CommerceModel.PRODUCT_COLLECTION_IDS,
501
+ op: 'array-contains',
502
+ value: String((_ref = collectionDoc == null ? void 0 : collectionDoc.id) != null ? _ref : '')
503
+ }
504
+ ];
505
+ } else {
506
+ scope = smart.filters;
507
+ // A "type is not" rule is `type in […]`; the visitor's Type chip is the
508
+ // narrower question, answered inside it or not at all.
509
+ const typeRule = scope.find((filter)=>filter.path === 'type');
510
+ if (typeRule && type) {
511
+ const allowed = Array.isArray(typeRule.value) ? typeRule.value : [
512
+ typeRule.value
513
+ ];
514
+ if (!allowed.includes(type)) {
515
+ return result([], planFor({
516
+ base: STOREFRONT_CATALOG_BASE,
517
+ clauses: [],
518
+ sort
519
+ }, sort));
520
+ }
521
+ scope = scope.filter((filter)=>filter !== typeRule);
522
+ }
523
+ }
524
+ }
525
+ // THE QUERY: every predicate, one order, a page and one row to spare.
526
+ const request = {
527
+ base: [
528
+ ...STOREFRONT_CATALOG_BASE,
529
+ ...scope
530
+ ],
531
+ clauses: [
532
+ ...otherClauses,
533
+ ...priceClauses
534
+ ],
535
+ search,
536
+ sort
537
+ };
538
+ const plan = planFor(request, sort);
539
+ const byId = firebaseAdmin.firestore.FieldPath.documentId();
540
+ const ordered = (target)=>applyListQuery(productsRef, target).orderBy(byId, target.orderBy.direction);
541
+ let pageQuery = ordered(plan);
542
+ const cursor = decodeCursor(params.after);
543
+ if (cursor) pageQuery = pageQuery.startAfter(cursor.value, cursor.id);
544
+ // Bounds span every OTHER control, so the slider stays anchored while the
545
+ // visitor narrows it: the lowest and highest price the rest allows, as two
546
+ // one-row reads ordered by price.
547
+ const byPrice = STOREFRONT_CATALOG_SORTS['price-asc'];
548
+ const unpriced = wantFacets ? planFor(_extends({}, request, {
549
+ clauses: otherClauses,
550
+ sort: byPrice
551
+ }), byPrice) : null;
552
+ const pricedBy = (target, direction)=>ordered(_extends({}, target, {
553
+ orderBy: {
554
+ path: 'priceFromCents',
555
+ direction
556
+ }
557
+ })).limit(1).get();
558
+ const [snapshot, lowest, highest] = await Promise.all([
559
+ pageQuery.limit(max + 1).get(),
560
+ unpriced ? pricedBy(unpriced, 'asc') : null,
561
+ unpriced ? pricedBy(unpriced, 'desc') : null
562
+ ]);
563
+ const docs = snapshot.docs.slice(0, max);
564
+ const rows = docs.map(toRow).filter((row)=>Boolean(row));
565
+ const last = docs[docs.length - 1];
566
+ const low = lowest == null ? void 0 : (_lowest_docs_ = lowest.docs[0]) == null ? void 0 : _lowest_docs_.get('priceFromCents');
567
+ const high = highest == null ? void 0 : (_highest_docs_ = highest.docs[0]) == null ? void 0 : _highest_docs_.get('priceFromCents');
568
+ return result(rows, plan, _extends({}, last && snapshot.docs.length > max ? {
569
+ nextCursor: encodeCursor(last.get(plan.orderBy.path), last.id)
570
+ } : {}, typeof low === 'number' && typeof high === 'number' ? {
571
+ priceBounds: {
572
+ minCents: low,
573
+ maxCents: high
574
+ }
575
+ } : {}));
240
576
  }
241
577
  /** Public catalog listing endpoint — a thin parse over queryPublicCatalog. */ export const catalogHandler = async (req, res)=>{
242
578
  var _req_query_hostId;
@@ -245,7 +581,7 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
245
581
  error: 'Missing hostId'
246
582
  });
247
583
  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;
584
+ 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
585
  const result = await queryPublicCatalog({
250
586
  hostId,
251
587
  collectionSlug: String((_req_query_collection = req.query.collection) != null ? _req_query_collection : ''),
@@ -255,13 +591,14 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
255
591
  tag: String((_req_query_tag = req.query.tag) != null ? _req_query_tag : ''),
256
592
  query: String((_req_query_q = req.query.q) != null ? _req_query_q : ''),
257
593
  type: String((_req_query_type = req.query.type) != null ? _req_query_type : ''),
594
+ inStock: String((_req_query_inStock = req.query.inStock) != null ? _req_query_inStock : '') === '1',
258
595
  ids: String((_req_query_ids = req.query.ids) != null ? _req_query_ids : '').split(',').map((id)=>id.trim()).filter(Boolean),
259
596
  // Price-range filter (AGL-564): integer cents on the displayed price.
260
597
  minPriceCents: parsePriceCents(req.query.minPriceCents),
261
598
  maxPriceCents: parsePriceCents(req.query.maxPriceCents),
262
599
  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),
600
+ limit: Number((_req_query_limit = req.query.limit) != null ? _req_query_limit : CATALOG_PAGE_DEFAULT),
601
+ after: String((_req_query_after = req.query.after) != null ? _req_query_after : ''),
265
602
  facets: String((_req_query_facets = req.query.facets) != null ? _req_query_facets : '') === '1'
266
603
  });
267
604
  res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');