@aglyn/plugins-commerce 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.
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
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { ListFilterOption } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
18
+ import type { ListQueryDeclaration, ListQueryFilter, ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan';
19
+ import type { HostCollection, ProductType } from '../model/commerce';
20
+ /** The product types a visitor can filter by, in the grid's own order. */
21
+ export declare const STOREFRONT_PRODUCT_TYPES: readonly ProductType[];
22
+ /** The grid's sort values, each the one order it puts the catalog in. */
23
+ export declare const STOREFRONT_CATALOG_SORTS: {
24
+ readonly name: {
25
+ readonly path: "nameLower";
26
+ readonly direction: "asc";
27
+ };
28
+ readonly 'price-asc': {
29
+ readonly path: "priceFromCents";
30
+ readonly direction: "asc";
31
+ };
32
+ readonly 'price-desc': {
33
+ readonly path: "priceFromCents";
34
+ readonly direction: "desc";
35
+ };
36
+ readonly newest: {
37
+ readonly path: "createdAtMs";
38
+ readonly direction: "desc";
39
+ };
40
+ };
41
+ export type StorefrontCatalogSort = keyof typeof STOREFRONT_CATALOG_SORTS;
42
+ /** A grid's `sort` value as its order; anything unknown reads as Name. */
43
+ export declare function storefrontCatalogSort(sort: string | undefined): ListQuerySort;
44
+ /**
45
+ * What the storefront catalog can be narrowed by, and how it is ordered.
46
+ *
47
+ * Columns are the catalog's own controls, so a refusal names the control the
48
+ * visitor touched. `category` and `tag` match whole members of their arrays,
49
+ * byte for byte (`verbatimTokens`): a category id is an opaque id and a tag
50
+ * is matched exactly as the merchant typed it, as the grid always matched it.
51
+ * `stock` is the In stock chip, `soldOut == false`.
52
+ *
53
+ * The four sorts are the grid's sort select — every one a visitor or an author
54
+ * can choose — with Name, the author default, first.
55
+ */
56
+ export declare const STOREFRONT_CATALOG_DECLARATION: ListQueryDeclaration;
57
+ /** Each control as a refusal names it to a visitor. */
58
+ export declare const STOREFRONT_CATALOG_HEADERS: Readonly<Record<string, string>>;
59
+ /** Picked values as a refusal names them; categories are added by the grid. */
60
+ export declare const STOREFRONT_CATALOG_OPTIONS: Readonly<Record<string, readonly ListFilterOption[]>>;
61
+ /**
62
+ * The scope every storefront read carries: products a shopper can buy — live
63
+ * (`deletedAt == null`; a delete stamps a timestamp and leaves `status`
64
+ * alone) and active. Both are equalities, merged with every other predicate.
65
+ */
66
+ export declare const STOREFRONT_CATALOG_BASE: readonly ListQueryFilter[];
67
+ /**
68
+ * {@link STOREFRONT_CATALOG_BASE} as `listQueryIndexes` takes it, with the
69
+ * scope a smart collection no query can express adds to it: the membership
70
+ * every product writer stores (`collectionIds array-contains <id>`,
71
+ * AGL-3321), one `(collectionIds, order)` composite per sort under index
72
+ * merging.
73
+ */
74
+ export declare const STOREFRONT_CATALOG_BASE_PATHS: readonly {
75
+ path: string;
76
+ array?: boolean;
77
+ }[];
78
+ /** The smart-collection rule predicates a query can hold, or why it cannot. */
79
+ export type SmartCollectionScope = {
80
+ filters: ListQueryFilter[];
81
+ } | {
82
+ unservable: string;
83
+ };
84
+ /**
85
+ * A SMART collection's rules as query predicates (AGL-3321), or the reason
86
+ * they cannot be one.
87
+ *
88
+ * Membership is `matchesCollection` in the model. What a query can ask of it:
89
+ *
90
+ * tag / category is → `array-contains` (one array clause per query)
91
+ * type is / not → `==` / `in` the other types
92
+ * price under → `priceFromCents <`, the low end being what
93
+ * `under` compares
94
+ *
95
+ * and what it cannot: a tag or category NOT held (no negated array query), a
96
+ * price `over` or `equals` (they compare the HIGH end, which nothing stores),
97
+ * a name rule (substring, which no index answers), and any-of rules (an OR the
98
+ * visitor's own filters would multiply). Those collections are read by the
99
+ * membership each product stores instead — `collectionIds array-contains
100
+ * <id>`, computed from the same rules by `productCollectionIds` — never from a
101
+ * window of the catalog.
102
+ */
103
+ export declare function smartCollectionScope(collection: Pick<HostCollection, 'rules' | 'matchAll'>): SmartCollectionScope;
@@ -0,0 +1,312 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*
17
+ * THE STOREFRONT CATALOG, ON THE QUERY (AGL-3321).
18
+ *
19
+ * The Product grid a visitor browses — its search box, category and tag
20
+ * chips, type chips, In stock chip, price slider and sort select — used to be
21
+ * answered by reading `limit(500)` products in no particular order and
22
+ * narrowing that in memory. A catalog past five hundred products listed an
23
+ * arbitrary five hundred, and a search, a category or a price range answered
24
+ * "no products match" for every product outside them: the one answer a
25
+ * storefront must never give wrongly, to the one person trying to buy the
26
+ * thing.
27
+ *
28
+ * Every predicate is on the Firestore query now, through the list query plan
29
+ * (`planListQuery`), and pages come from the query's own cursor. The catalog
30
+ * is read on the server with the Admin SDK (`server/catalog.ts`), under the
31
+ * scope below, so an anonymous shopper never queries the collection itself.
32
+ *
33
+ * ## What one query can hold, as a shopper meets it
34
+ *
35
+ * One array clause: the search word (`nameTokens`), a category (`categoryIds`)
36
+ * or a tag (`tags`). A grid pinned to a category that a visitor also searches
37
+ * serves the search and says the category is not applied — the plan's rule,
38
+ * shown by the grid rather than papered over by matching the page in memory.
39
+ *
40
+ * One range: the price slider. A range must lead the order, so while the
41
+ * slider is set the grid is ordered by price — high to low when that is the
42
+ * sort chosen, low to high otherwise — and the grid says that Name or Newest
43
+ * waits until the slider is cleared.
44
+ *
45
+ * Search is WORD-PREFIX over the product's name — "cof" finds "Acme Coffee",
46
+ * "offee" does not — which is the honest edge of searching without a search
47
+ * service. Descriptions and tags are not searched: neither has a token array,
48
+ * and a tag is a chip of its own.
49
+ *
50
+ * ## The keys, and who stamps them
51
+ *
52
+ * `nameLower` / `nameTokens` / `priceFromCents` (`productSearchFields`),
53
+ * `soldOut` (`productStockFields`), `status`, `type`, `createdAtMs` and
54
+ * `deletedAt: null` are written by every product writer, and
55
+ * `tools/scripts/backfill-products-list-fields.mjs` stamps them onto the
56
+ * products written before. A product lacking the field a query orders by is
57
+ * not in that order at all.
58
+ */ /** The product types a visitor can filter by, in the grid's own order. */ export const STOREFRONT_PRODUCT_TYPES = [
59
+ 'physical',
60
+ 'digital',
61
+ 'service'
62
+ ];
63
+ /** The grid's sort values, each the one order it puts the catalog in. */ export const STOREFRONT_CATALOG_SORTS = {
64
+ name: {
65
+ path: 'nameLower',
66
+ direction: 'asc'
67
+ },
68
+ 'price-asc': {
69
+ path: 'priceFromCents',
70
+ direction: 'asc'
71
+ },
72
+ 'price-desc': {
73
+ path: 'priceFromCents',
74
+ direction: 'desc'
75
+ },
76
+ newest: {
77
+ path: 'createdAtMs',
78
+ direction: 'desc'
79
+ }
80
+ };
81
+ /** A grid's `sort` value as its order; anything unknown reads as Name. */ export function storefrontCatalogSort(sort) {
82
+ var _STOREFRONT_CATALOG_SORTS_sort;
83
+ return (_STOREFRONT_CATALOG_SORTS_sort = STOREFRONT_CATALOG_SORTS[sort]) != null ? _STOREFRONT_CATALOG_SORTS_sort : STOREFRONT_CATALOG_SORTS.name;
84
+ }
85
+ /**
86
+ * What the storefront catalog can be narrowed by, and how it is ordered.
87
+ *
88
+ * Columns are the catalog's own controls, so a refusal names the control the
89
+ * visitor touched. `category` and `tag` match whole members of their arrays,
90
+ * byte for byte (`verbatimTokens`): a category id is an opaque id and a tag
91
+ * is matched exactly as the merchant typed it, as the grid always matched it.
92
+ * `stock` is the In stock chip, `soldOut == false`.
93
+ *
94
+ * The four sorts are the grid's sort select — every one a visitor or an author
95
+ * can choose — with Name, the author default, first.
96
+ */ export const STOREFRONT_CATALOG_DECLARATION = {
97
+ fields: [
98
+ {
99
+ column: 'category',
100
+ kind: 'exact',
101
+ path: 'categoryIds',
102
+ tokensPath: 'categoryIds',
103
+ verbatimTokens: true,
104
+ operators: [
105
+ 'contains'
106
+ ]
107
+ },
108
+ {
109
+ column: 'tag',
110
+ kind: 'exact',
111
+ path: 'tags',
112
+ tokensPath: 'tags',
113
+ verbatimTokens: true,
114
+ operators: [
115
+ 'contains'
116
+ ]
117
+ },
118
+ {
119
+ column: 'type',
120
+ kind: 'exact',
121
+ path: 'type',
122
+ presence: 'always',
123
+ operators: [
124
+ 'equals',
125
+ 'isAnyOf'
126
+ ]
127
+ },
128
+ {
129
+ column: 'price',
130
+ kind: 'number',
131
+ path: 'priceFromCents',
132
+ presence: 'always',
133
+ operators: [
134
+ '>=',
135
+ '<=',
136
+ '<'
137
+ ]
138
+ },
139
+ {
140
+ column: 'stock',
141
+ kind: 'boolean',
142
+ path: 'soldOut',
143
+ operators: [
144
+ 'is'
145
+ ]
146
+ }
147
+ ],
148
+ sorts: [
149
+ STOREFRONT_CATALOG_SORTS.name,
150
+ STOREFRONT_CATALOG_SORTS['price-asc'],
151
+ STOREFRONT_CATALOG_SORTS['price-desc'],
152
+ STOREFRONT_CATALOG_SORTS.newest
153
+ ],
154
+ search: {
155
+ tokensPath: 'nameTokens'
156
+ }
157
+ };
158
+ /** Each control as a refusal names it to a visitor. */ export const STOREFRONT_CATALOG_HEADERS = {
159
+ category: 'Category',
160
+ tag: 'Tag',
161
+ type: 'Type',
162
+ price: 'Price',
163
+ stock: 'Availability'
164
+ };
165
+ /** Picked values as a refusal names them; categories are added by the grid. */ export const STOREFRONT_CATALOG_OPTIONS = {
166
+ type: [
167
+ {
168
+ value: 'physical',
169
+ label: 'Physical'
170
+ },
171
+ {
172
+ value: 'digital',
173
+ label: 'Digital'
174
+ },
175
+ {
176
+ value: 'service',
177
+ label: 'Service'
178
+ }
179
+ ],
180
+ stock: [
181
+ {
182
+ value: 'false',
183
+ label: 'In stock'
184
+ }
185
+ ]
186
+ };
187
+ /**
188
+ * The scope every storefront read carries: products a shopper can buy — live
189
+ * (`deletedAt == null`; a delete stamps a timestamp and leaves `status`
190
+ * alone) and active. Both are equalities, merged with every other predicate.
191
+ */ export const STOREFRONT_CATALOG_BASE = [
192
+ {
193
+ path: 'deletedAt',
194
+ op: '==',
195
+ value: null
196
+ },
197
+ {
198
+ path: 'status',
199
+ op: '==',
200
+ value: 'active'
201
+ }
202
+ ];
203
+ /**
204
+ * {@link STOREFRONT_CATALOG_BASE} as `listQueryIndexes` takes it, with the
205
+ * scope a smart collection no query can express adds to it: the membership
206
+ * every product writer stores (`collectionIds array-contains <id>`,
207
+ * AGL-3321), one `(collectionIds, order)` composite per sort under index
208
+ * merging.
209
+ */ export const STOREFRONT_CATALOG_BASE_PATHS = [
210
+ {
211
+ path: 'deletedAt'
212
+ },
213
+ {
214
+ path: 'status'
215
+ },
216
+ {
217
+ path: 'collectionIds',
218
+ array: true
219
+ }
220
+ ];
221
+ /**
222
+ * A SMART collection's rules as query predicates (AGL-3321), or the reason
223
+ * they cannot be one.
224
+ *
225
+ * Membership is `matchesCollection` in the model. What a query can ask of it:
226
+ *
227
+ * tag / category is → `array-contains` (one array clause per query)
228
+ * type is / not → `==` / `in` the other types
229
+ * price under → `priceFromCents <`, the low end being what
230
+ * `under` compares
231
+ *
232
+ * and what it cannot: a tag or category NOT held (no negated array query), a
233
+ * price `over` or `equals` (they compare the HIGH end, which nothing stores),
234
+ * a name rule (substring, which no index answers), and any-of rules (an OR the
235
+ * visitor's own filters would multiply). Those collections are read by the
236
+ * membership each product stores instead — `collectionIds array-contains
237
+ * <id>`, computed from the same rules by `productCollectionIds` — never from a
238
+ * window of the catalog.
239
+ */ export function smartCollectionScope(collection) {
240
+ var _collection_rules;
241
+ const rules = (_collection_rules = collection.rules) != null ? _collection_rules : [];
242
+ if (!rules.length) return {
243
+ filters: []
244
+ };
245
+ if (collection.matchAll === false && rules.length > 1) {
246
+ return {
247
+ unservable: 'matches ANY of its rules'
248
+ };
249
+ }
250
+ const filters = [];
251
+ let arrays = 0;
252
+ for (const rule of rules){
253
+ const shape = ruleFilter(rule);
254
+ if (typeof shape === 'string') return {
255
+ unservable: shape
256
+ };
257
+ if (shape.op === 'array-contains') arrays += 1;
258
+ if (arrays > 1) return {
259
+ unservable: 'names more than one tag or category'
260
+ };
261
+ filters.push(shape);
262
+ }
263
+ return {
264
+ filters
265
+ };
266
+ }
267
+ function ruleFilter(rule) {
268
+ const value = rule.value;
269
+ switch(rule.field){
270
+ case 'tag':
271
+ case 'categoryId':
272
+ if (rule.op !== 'eq') return `excludes a ${rule.field === 'tag' ? 'tag' : 'category'}`;
273
+ return {
274
+ path: rule.field === 'tag' ? 'tags' : 'categoryIds',
275
+ op: 'array-contains',
276
+ value: String(value)
277
+ };
278
+ case 'type':
279
+ {
280
+ if (rule.op === 'eq') return {
281
+ path: 'type',
282
+ op: '==',
283
+ value: String(value)
284
+ };
285
+ if (rule.op === 'neq') {
286
+ return {
287
+ path: 'type',
288
+ op: 'in',
289
+ value: STOREFRONT_PRODUCT_TYPES.filter((type)=>type !== value)
290
+ };
291
+ }
292
+ return `compares a type by ${rule.op}`;
293
+ }
294
+ case 'priceUsd':
295
+ {
296
+ const dollars = Number(value);
297
+ if (!Number.isFinite(dollars)) return 'compares a price that is not a number';
298
+ if (rule.op === 'lt') {
299
+ return {
300
+ path: 'priceFromCents',
301
+ op: '<',
302
+ value: Math.round(dollars * 100)
303
+ };
304
+ }
305
+ return `compares the price by ${rule.op}`;
306
+ }
307
+ default:
308
+ return `matches on ${rule.field}`;
309
+ }
310
+ }
311
+
312
+ //# sourceMappingURL=storefront-catalog-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/constants/storefront-catalog-query.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ListFilterOption } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQuerySort,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport type {\n CollectionRule,\n HostCollection,\n ProductType,\n} from '../model/commerce'\n\n/*\n * THE STOREFRONT CATALOG, ON THE QUERY (AGL-3321).\n *\n * The Product grid a visitor browses — its search box, category and tag\n * chips, type chips, In stock chip, price slider and sort select — used to be\n * answered by reading `limit(500)` products in no particular order and\n * narrowing that in memory. A catalog past five hundred products listed an\n * arbitrary five hundred, and a search, a category or a price range answered\n * \"no products match\" for every product outside them: the one answer a\n * storefront must never give wrongly, to the one person trying to buy the\n * thing.\n *\n * Every predicate is on the Firestore query now, through the list query plan\n * (`planListQuery`), and pages come from the query's own cursor. The catalog\n * is read on the server with the Admin SDK (`server/catalog.ts`), under the\n * scope below, so an anonymous shopper never queries the collection itself.\n *\n * ## What one query can hold, as a shopper meets it\n *\n * One array clause: the search word (`nameTokens`), a category (`categoryIds`)\n * or a tag (`tags`). A grid pinned to a category that a visitor also searches\n * serves the search and says the category is not applied — the plan's rule,\n * shown by the grid rather than papered over by matching the page in memory.\n *\n * One range: the price slider. A range must lead the order, so while the\n * slider is set the grid is ordered by price — high to low when that is the\n * sort chosen, low to high otherwise — and the grid says that Name or Newest\n * waits until the slider is cleared.\n *\n * Search is WORD-PREFIX over the product's name — \"cof\" finds \"Acme Coffee\",\n * \"offee\" does not — which is the honest edge of searching without a search\n * service. Descriptions and tags are not searched: neither has a token array,\n * and a tag is a chip of its own.\n *\n * ## The keys, and who stamps them\n *\n * `nameLower` / `nameTokens` / `priceFromCents` (`productSearchFields`),\n * `soldOut` (`productStockFields`), `status`, `type`, `createdAtMs` and\n * `deletedAt: null` are written by every product writer, and\n * `tools/scripts/backfill-products-list-fields.mjs` stamps them onto the\n * products written before. A product lacking the field a query orders by is\n * not in that order at all.\n */\n\n/** The product types a visitor can filter by, in the grid's own order. */\nexport const STOREFRONT_PRODUCT_TYPES: readonly ProductType[] = [\n 'physical',\n 'digital',\n 'service',\n]\n\n/** The grid's sort values, each the one order it puts the catalog in. */\nexport const STOREFRONT_CATALOG_SORTS = {\n name: { path: 'nameLower', direction: 'asc' },\n 'price-asc': { path: 'priceFromCents', direction: 'asc' },\n 'price-desc': { path: 'priceFromCents', direction: 'desc' },\n newest: { path: 'createdAtMs', direction: 'desc' },\n} as const satisfies Record<string, ListQuerySort>\n\nexport type StorefrontCatalogSort = keyof typeof STOREFRONT_CATALOG_SORTS\n\n/** A grid's `sort` value as its order; anything unknown reads as Name. */\nexport function storefrontCatalogSort(sort: string | undefined): ListQuerySort {\n return (\n STOREFRONT_CATALOG_SORTS[sort as StorefrontCatalogSort] ??\n STOREFRONT_CATALOG_SORTS.name\n )\n}\n\n/**\n * What the storefront catalog can be narrowed by, and how it is ordered.\n *\n * Columns are the catalog's own controls, so a refusal names the control the\n * visitor touched. `category` and `tag` match whole members of their arrays,\n * byte for byte (`verbatimTokens`): a category id is an opaque id and a tag\n * is matched exactly as the merchant typed it, as the grid always matched it.\n * `stock` is the In stock chip, `soldOut == false`.\n *\n * The four sorts are the grid's sort select — every one a visitor or an author\n * can choose — with Name, the author default, first.\n */\nexport const STOREFRONT_CATALOG_DECLARATION: ListQueryDeclaration = {\n fields: [\n {\n column: 'category',\n kind: 'exact',\n path: 'categoryIds',\n tokensPath: 'categoryIds',\n verbatimTokens: true,\n operators: ['contains'],\n },\n {\n column: 'tag',\n kind: 'exact',\n path: 'tags',\n tokensPath: 'tags',\n verbatimTokens: true,\n operators: ['contains'],\n },\n {\n column: 'type',\n kind: 'exact',\n path: 'type',\n presence: 'always',\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: 'price',\n kind: 'number',\n path: 'priceFromCents',\n presence: 'always',\n operators: ['>=', '<=', '<'],\n },\n { column: 'stock', kind: 'boolean', path: 'soldOut', operators: ['is'] },\n ],\n sorts: [\n STOREFRONT_CATALOG_SORTS.name,\n STOREFRONT_CATALOG_SORTS['price-asc'],\n STOREFRONT_CATALOG_SORTS['price-desc'],\n STOREFRONT_CATALOG_SORTS.newest,\n ],\n search: { tokensPath: 'nameTokens' },\n}\n\n/** Each control as a refusal names it to a visitor. */\nexport const STOREFRONT_CATALOG_HEADERS: Readonly<Record<string, string>> = {\n category: 'Category',\n tag: 'Tag',\n type: 'Type',\n price: 'Price',\n stock: 'Availability',\n}\n\n/** Picked values as a refusal names them; categories are added by the grid. */\nexport const STOREFRONT_CATALOG_OPTIONS: Readonly<Record<string, readonly ListFilterOption[]>> = {\n type: [\n { value: 'physical', label: 'Physical' },\n { value: 'digital', label: 'Digital' },\n { value: 'service', label: 'Service' },\n ],\n stock: [{ value: 'false', label: 'In stock' }],\n}\n\n/**\n * The scope every storefront read carries: products a shopper can buy — live\n * (`deletedAt == null`; a delete stamps a timestamp and leaves `status`\n * alone) and active. Both are equalities, merged with every other predicate.\n */\nexport const STOREFRONT_CATALOG_BASE: readonly ListQueryFilter[] = [\n { path: 'deletedAt', op: '==', value: null },\n { path: 'status', op: '==', value: 'active' },\n]\n\n/**\n * {@link STOREFRONT_CATALOG_BASE} as `listQueryIndexes` takes it, with the\n * scope a smart collection no query can express adds to it: the membership\n * every product writer stores (`collectionIds array-contains <id>`,\n * AGL-3321), one `(collectionIds, order)` composite per sort under index\n * merging.\n */\nexport const STOREFRONT_CATALOG_BASE_PATHS: readonly { path: string; array?: boolean }[] = [\n { path: 'deletedAt' },\n { path: 'status' },\n { path: 'collectionIds', array: true },\n]\n\n/** The smart-collection rule predicates a query can hold, or why it cannot. */\nexport type SmartCollectionScope =\n | { filters: ListQueryFilter[] }\n | { unservable: string }\n\n/**\n * A SMART collection's rules as query predicates (AGL-3321), or the reason\n * they cannot be one.\n *\n * Membership is `matchesCollection` in the model. What a query can ask of it:\n *\n * tag / category is → `array-contains` (one array clause per query)\n * type is / not → `==` / `in` the other types\n * price under → `priceFromCents <`, the low end being what\n * `under` compares\n *\n * and what it cannot: a tag or category NOT held (no negated array query), a\n * price `over` or `equals` (they compare the HIGH end, which nothing stores),\n * a name rule (substring, which no index answers), and any-of rules (an OR the\n * visitor's own filters would multiply). Those collections are read by the\n * membership each product stores instead — `collectionIds array-contains\n * <id>`, computed from the same rules by `productCollectionIds` — never from a\n * window of the catalog.\n */\nexport function smartCollectionScope(\n collection: Pick<HostCollection, 'rules' | 'matchAll'>,\n): SmartCollectionScope {\n const rules = collection.rules ?? []\n if (!rules.length) return { filters: [] }\n if (collection.matchAll === false && rules.length > 1) {\n return { unservable: 'matches ANY of its rules' }\n }\n const filters: ListQueryFilter[] = []\n let arrays = 0\n for (const rule of rules) {\n const shape = ruleFilter(rule)\n if (typeof shape === 'string') return { unservable: shape }\n if (shape.op === 'array-contains') arrays += 1\n if (arrays > 1) return { unservable: 'names more than one tag or category' }\n filters.push(shape)\n }\n return { filters }\n}\n\nfunction ruleFilter(rule: CollectionRule): ListQueryFilter | string {\n const value = rule.value\n switch (rule.field) {\n case 'tag':\n case 'categoryId':\n if (rule.op !== 'eq') return `excludes a ${rule.field === 'tag' ? 'tag' : 'category'}`\n return {\n path: rule.field === 'tag' ? 'tags' : 'categoryIds',\n op: 'array-contains',\n value: String(value),\n }\n case 'type': {\n if (rule.op === 'eq') return { path: 'type', op: '==', value: String(value) }\n if (rule.op === 'neq') {\n return {\n path: 'type',\n op: 'in',\n value: STOREFRONT_PRODUCT_TYPES.filter((type) => type !== value),\n }\n }\n return `compares a type by ${rule.op}`\n }\n case 'priceUsd': {\n const dollars = Number(value)\n if (!Number.isFinite(dollars)) return 'compares a price that is not a number'\n if (rule.op === 'lt') {\n return { path: 'priceFromCents', op: '<', value: Math.round(dollars * 100) }\n }\n return `compares the price by ${rule.op}`\n }\n default:\n return `matches on ${rule.field}`\n }\n}\n"],"names":["STOREFRONT_PRODUCT_TYPES","STOREFRONT_CATALOG_SORTS","name","path","direction","newest","storefrontCatalogSort","sort","STOREFRONT_CATALOG_DECLARATION","fields","column","kind","tokensPath","verbatimTokens","operators","presence","sorts","search","STOREFRONT_CATALOG_HEADERS","category","tag","type","price","stock","STOREFRONT_CATALOG_OPTIONS","value","label","STOREFRONT_CATALOG_BASE","op","STOREFRONT_CATALOG_BASE_PATHS","array","smartCollectionScope","collection","rules","length","filters","matchAll","unservable","arrays","rule","shape","ruleFilter","push","field","String","filter","dollars","Number","isFinite","Math","round"],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** `scanAbandonedCheckouts` waits this long before it will remind a checkout. */
18
+ export declare const CHECKOUT_REMIND_AFTER_MS: number;
19
+ /** …and gives up after this, marking the checkout `expired`. */
20
+ export declare const CHECKOUT_GIVE_UP_AFTER_MS: number;
21
+ /**
22
+ * - `pending` — carries an email and has not been reminded: the scan's work.
23
+ * - `reminded` — the scan has sent (or settled) its one reminder.
24
+ * - `none` — no email, so there is nobody to remind.
25
+ */
26
+ export type CheckoutRecoveryState = 'pending' | 'reminded' | 'none';
27
+ /** The field every checkout writer stamps. */
28
+ export declare const CHECKOUT_RECOVERY_STATE_FIELD = "recoveryState";
29
+ /**
30
+ * A checkout's recovery state from what it stores, read the way
31
+ * `scanAbandonedCheckouts` reads it: a truthy `remindedAtMs` retires it, and
32
+ * without an email there is nobody to remind.
33
+ */
34
+ export declare function checkoutRecoveryState(checkout: {
35
+ email?: unknown;
36
+ remindedAtMs?: unknown;
37
+ }): CheckoutRecoveryState;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*
17
+ * WHERE A CHECKOUT STANDS IN ABANDONED-CART RECOVERY, WRITTEN ON IT (AGL-3321).
18
+ *
19
+ * `scanAbandonedCheckouts` acts on an open checkout that carries an email and
20
+ * has not been reminded yet. The console's recovery queue counts the same
21
+ * set with Firestore count queries, and a query cannot ask for a field a
22
+ * document lacks — "no email" and "not reminded yet" are both absences on a
23
+ * checkout written before this field existed. So every writer stamps the
24
+ * state explicitly:
25
+ *
26
+ * - `cart-checkout.ts` on create: `pending` with an email, `none` without.
27
+ * - `process-abandoned.ts` beside `remindedAtMs`: `reminded`.
28
+ *
29
+ * `status` still says whether the checkout is open; this says only where it
30
+ * stands in recovery. `tools/scripts/backfill-checkout-recovery-state.mjs`
31
+ * stamps the checkouts written before, from the same two fields.
32
+ */ /** `scanAbandonedCheckouts` waits this long before it will remind a checkout. */ export const CHECKOUT_REMIND_AFTER_MS = 60 * 60 * 1000;
33
+ /** …and gives up after this, marking the checkout `expired`. */ export const CHECKOUT_GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
34
+ /** The field every checkout writer stamps. */ export const CHECKOUT_RECOVERY_STATE_FIELD = 'recoveryState';
35
+ /**
36
+ * A checkout's recovery state from what it stores, read the way
37
+ * `scanAbandonedCheckouts` reads it: a truthy `remindedAtMs` retires it, and
38
+ * without an email there is nobody to remind.
39
+ */ export function checkoutRecoveryState(checkout) {
40
+ if (!checkout.email) return 'none';
41
+ return checkout.remindedAtMs ? 'reminded' : 'pending';
42
+ }
43
+
44
+ //# sourceMappingURL=checkout-recovery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/checkout-recovery.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 * WHERE A CHECKOUT STANDS IN ABANDONED-CART RECOVERY, WRITTEN ON IT (AGL-3321).\n *\n * `scanAbandonedCheckouts` acts on an open checkout that carries an email and\n * has not been reminded yet. The console's recovery queue counts the same\n * set with Firestore count queries, and a query cannot ask for a field a\n * document lacks — \"no email\" and \"not reminded yet\" are both absences on a\n * checkout written before this field existed. So every writer stamps the\n * state explicitly:\n *\n * - `cart-checkout.ts` on create: `pending` with an email, `none` without.\n * - `process-abandoned.ts` beside `remindedAtMs`: `reminded`.\n *\n * `status` still says whether the checkout is open; this says only where it\n * stands in recovery. `tools/scripts/backfill-checkout-recovery-state.mjs`\n * stamps the checkouts written before, from the same two fields.\n */\n\n/** `scanAbandonedCheckouts` waits this long before it will remind a checkout. */\nexport const CHECKOUT_REMIND_AFTER_MS = 60 * 60 * 1000\n\n/** …and gives up after this, marking the checkout `expired`. */\nexport const CHECKOUT_GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000\n\n/**\n * - `pending` — carries an email and has not been reminded: the scan's work.\n * - `reminded` — the scan has sent (or settled) its one reminder.\n * - `none` — no email, so there is nobody to remind.\n */\nexport type CheckoutRecoveryState = 'pending' | 'reminded' | 'none'\n\n/** The field every checkout writer stamps. */\nexport const CHECKOUT_RECOVERY_STATE_FIELD = 'recoveryState'\n\n/**\n * A checkout's recovery state from what it stores, read the way\n * `scanAbandonedCheckouts` reads it: a truthy `remindedAtMs` retires it, and\n * without an email there is nobody to remind.\n */\nexport function checkoutRecoveryState(checkout: {\n email?: unknown\n remindedAtMs?: unknown\n}): CheckoutRecoveryState {\n if (!checkout.email) return 'none'\n return checkout.remindedAtMs ? 'reminded' : 'pending'\n}\n"],"names":["CHECKOUT_REMIND_AFTER_MS","CHECKOUT_GIVE_UP_AFTER_MS","CHECKOUT_RECOVERY_STATE_FIELD","checkoutRecoveryState","checkout","email","remindedAtMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GAED,+EAA+E,GAC/E,OAAO,MAAMA,2BAA2B,KAAK,KAAK,KAAI;AAEtD,8DAA8D,GAC9D,OAAO,MAAMC,4BAA4B,IAAI,KAAK,KAAK,KAAK,KAAI;AAShE,4CAA4C,GAC5C,OAAO,MAAMC,gCAAgC,gBAAe;AAE5D;;;;CAIC,GACD,OAAO,SAASC,sBAAsBC,QAGrC;IACC,IAAI,CAACA,SAASC,KAAK,EAAE,OAAO;IAC5B,OAAOD,SAASE,YAAY,GAAG,aAAa;AAC9C"}
@@ -106,6 +106,11 @@ export interface HostProduct {
106
106
  /** Ordered media-library image URLs (first = primary). */
107
107
  mediaUrls?: string[];
108
108
  categoryIds?: string[];
109
+ /**
110
+ * The SMART collections this product's rules answer (AGL-3321), written by
111
+ * every writer — see `productCollectionIds`.
112
+ */
113
+ collectionIds?: string[];
109
114
  tags?: string[];
110
115
  options?: ProductOption[];
111
116
  variants: ProductVariant[];
@@ -257,6 +262,13 @@ export declare function commerceSlug(name: string): string;
257
262
  * `[]`. `isNotEmpty` is served as `!= null`, and an empty array is not null —
258
263
  * so a product with no SKUs at all would answer "has a SKU" for every row.
259
264
  *
265
+ * `priceFromCents` is the storefront's price key (AGL-3321): the price a
266
+ * product grid card shows — "From $X", the lowest variant price — in whole
267
+ * cents, so the catalog's price slider and its price sorts are a range and an
268
+ * `orderBy` on one stored number rather than a comparison over variants no
269
+ * query can reach. Written only when the variants are, because it is derived
270
+ * from them: a write that carries no variants must not stamp a price.
271
+ *
260
272
  * ⚠️ Spread this at EVERY write that sets a product's name or variants. A
261
273
  * write that sets the name without it leaves the keys describing the previous
262
274
  * name, and the product becomes findable only by what it used to be called.
@@ -271,6 +283,31 @@ export declare function productSearchFields(product: {
271
283
  nameReversed: string;
272
284
  skus?: string[];
273
285
  barcodes?: string[];
286
+ priceFromCents?: number;
287
+ };
288
+ /**
289
+ * The price a storefront card shows, in whole cents (AGL-3321): the low end
290
+ * of the variant range, which is what "From $X" prints and what the catalog's
291
+ * price filter and price sorts compare. Stored as `priceFromCents` by
292
+ * {@link productSearchFields}; `0` for a product with no priced variant yet.
293
+ */
294
+ export declare function productPriceFromCents(product: Pick<HostProduct, 'variants'>): number;
295
+ /**
296
+ * The stock keys a product write carries (AGL-3321): the flat `inventory`
297
+ * total, and `soldOut` — whether a storefront card reads "Sold out", which is
298
+ * what the product grid's In stock chip asks the catalog query for
299
+ * (`soldOut == false`). A grid cannot ask a query about stock inside
300
+ * `variants`, so the verdict is stored beside them.
301
+ *
302
+ * ⚠️ Spread this at EVERY write that changes `variants` or `oversellPolicy`:
303
+ * a sale, a cancellation, a stock adjustment and the editor alike. A write
304
+ * that moves stock without it leaves a sold-out product under In stock, or an
305
+ * in-stock one missing from it. Stored by `backfill-products-list-fields.mjs`
306
+ * on the products written before it.
307
+ */
308
+ export declare function productStockFields(product: Pick<HostProduct, 'variants' | 'oversellPolicy'>): {
309
+ inventory: number | null;
310
+ soldOut: boolean;
274
311
  };
275
312
  /**
276
313
  * Cartesian product of option values → the variant option combinations
@@ -532,11 +569,54 @@ export declare function appliedVariantInventoryDelta(product: Pick<HostProduct,
532
569
  export declare function transferVariantInventory(product: Pick<HostProduct, 'variants'>, variantId: string, fromLocationId: string, toLocationId: string, quantity: number): ProductVariant[];
533
570
  /** True when tracked stock is at/below the product's alert threshold. */
534
571
  export declare function isLowStock(product: Pick<HostProduct, 'variants' | 'lowStockThreshold'>): boolean;
572
+ /**
573
+ * Whether a product's own fields answer a SMART collection's rules — its
574
+ * tags, categories, prices, name and type, and nothing about whether it is
575
+ * on sale. That is the storefront query's scope (`deletedAt == null`,
576
+ * `status == 'active'`), so membership stored on the product never goes stale
577
+ * when a product is archived or brought back.
578
+ *
579
+ * A collection with no rules holds nothing.
580
+ */
581
+ /** What smart-collection membership reads of a product — a draft's too. */
582
+ export interface MembershipProduct {
583
+ name?: string;
584
+ type?: ProductType;
585
+ tags?: string[];
586
+ categoryIds?: string[];
587
+ variants?: ReadonlyArray<{
588
+ id?: string;
589
+ priceUsd?: number | null;
590
+ }>;
591
+ }
592
+ export declare function smartCollectionMatches(product: MembershipProduct, collection: Pick<HostCollection, 'rules' | 'matchAll'>): boolean;
535
593
  /**
536
594
  * Smart-collection membership: draft/archived/deleted products never
537
595
  * match; manual collections answer from productIds.
538
596
  */
539
597
  export declare function matchesCollection(product: HostProduct, collection: HostCollection, productId?: string): boolean;
598
+ /**
599
+ * The stored membership of a product in the host's SMART collections
600
+ * (AGL-3321): the ids of those whose rules it answers
601
+ * ({@link smartCollectionMatches}), sorted.
602
+ *
603
+ * The storefront reads a smart collection whose rules no query can hold (a
604
+ * NOT, an OR, a name rule, a price over) as `collectionIds array-contains
605
+ * <id>` — so the membership is WRITTEN, by every product writer from the
606
+ * host's smart collections as they stand, and by the collection-membership
607
+ * route for every product whenever a smart collection's rules change or it
608
+ * is deleted. `tools/scripts/backfill-product-collection-ids.mjs` stamps the
609
+ * products written before; the two answer
610
+ * `tools/scripts/lib/product-collection-ids.fixtures.json`.
611
+ */
612
+ export declare const PRODUCT_COLLECTION_IDS = "collectionIds";
613
+ /** A smart collection as membership is computed from it. */
614
+ export interface SmartCollectionRules {
615
+ id: string;
616
+ rules?: CollectionRule[];
617
+ matchAll?: boolean;
618
+ }
619
+ export declare function productCollectionIds(product: MembershipProduct, smartCollections: readonly SmartCollectionRules[]): string[];
540
620
  /**
541
621
  * Lifts a legacy Commerce Starter doc (AGL-90: flat name/priceUsd/
542
622
  * inventory/imageUrl) into the catalog shape with a single default