@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.
- package/package.json +13 -13
- package/src/lib/components/console/catalog-organization-card.component.js +48 -3
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/gift-cards-card.component.d.ts +9 -0
- package/src/lib/components/console/gift-cards-card.component.js +53 -28
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.d.ts +26 -0
- package/src/lib/components/console/host-orders-card.component.js +264 -194
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/pos-page.component.d.ts +14 -0
- package/src/lib/components/console/pos-page.component.js +86 -45
- package/src/lib/components/console/pos-page.component.js.map +1 -1
- package/src/lib/components/console/product-editor-dialog.component.js +30 -6
- package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
- package/src/lib/components/console/product-slugs.d.ts +43 -0
- package/src/lib/components/console/product-slugs.js +60 -0
- package/src/lib/components/console/product-slugs.js.map +1 -0
- package/src/lib/components/console/products-hub-card.component.d.ts +2 -2
- package/src/lib/components/console/products-hub-card.component.js +229 -282
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/components/console/products-hub-zone.component.d.ts +10 -5
- package/src/lib/components/console/products-hub-zone.component.js +32 -9
- package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
- package/src/lib/components/console/recovery-queue-card.component.d.ts +37 -5
- package/src/lib/components/console/recovery-queue-card.component.js +107 -86
- package/src/lib/components/console/recovery-queue-card.component.js.map +1 -1
- package/src/lib/components/console/reservations-card.component.js +15 -3
- package/src/lib/components/console/reservations-card.component.js.map +1 -1
- package/src/lib/components/console/smart-collections.d.ts +32 -0
- package/src/lib/components/console/smart-collections.js +57 -0
- package/src/lib/components/console/smart-collections.js.map +1 -0
- package/src/lib/components/console/stock-movements-card.component.d.ts +13 -4
- package/src/lib/components/console/stock-movements-card.component.js +142 -180
- package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
- package/src/lib/components/product-grid.d.ts +5 -0
- package/src/lib/components/product-grid.js +95 -34
- package/src/lib/components/product-grid.js.map +1 -1
- package/src/lib/constants/orders-list-query.d.ts +40 -0
- package/src/lib/constants/orders-list-query.js +205 -0
- package/src/lib/constants/orders-list-query.js.map +1 -0
- package/src/lib/constants/product-list-query.d.ts +42 -0
- package/src/lib/constants/product-list-query.js +217 -0
- package/src/lib/constants/product-list-query.js.map +1 -0
- package/src/lib/constants/stock-movement-query.d.ts +27 -0
- package/src/lib/constants/stock-movement-query.js +110 -0
- package/src/lib/constants/stock-movement-query.js.map +1 -0
- package/src/lib/constants/storefront-catalog-query.d.ts +103 -0
- package/src/lib/constants/storefront-catalog-query.js +312 -0
- package/src/lib/constants/storefront-catalog-query.js.map +1 -0
- package/src/lib/model/checkout-recovery.d.ts +37 -0
- package/src/lib/model/checkout-recovery.js +44 -0
- package/src/lib/model/checkout-recovery.js.map +1 -0
- package/src/lib/model/commerce.d.ts +80 -0
- package/src/lib/model/commerce.js +67 -6
- package/src/lib/model/commerce.js.map +1 -1
- package/src/lib/model/gift-card-search.d.ts +24 -0
- package/src/lib/model/gift-card-search.js +48 -0
- package/src/lib/model/gift-card-search.js.map +1 -0
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/order-list-fields.d.ts +80 -0
- package/src/lib/model/order-list-fields.js +112 -0
- package/src/lib/model/order-list-fields.js.map +1 -0
- package/src/lib/server/billing-webhook.js +108 -89
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cancel-order.js +6 -6
- package/src/lib/server/cancel-order.js.map +1 -1
- package/src/lib/server/cart-checkout.js +6 -0
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/catalog.d.ts +46 -30
- package/src/lib/server/catalog.js +492 -170
- package/src/lib/server/catalog.js.map +1 -1
- package/src/lib/server/collection-membership.d.ts +35 -0
- package/src/lib/server/collection-membership.js +172 -0
- package/src/lib/server/collection-membership.js.map +1 -0
- package/src/lib/server/draft-order.js +3 -2
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/gift-cards.js +3 -0
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/member-name-search.d.ts +17 -0
- package/src/lib/server/member-name-search.js +23 -1
- package/src/lib/server/member-name-search.js.map +1 -1
- package/src/lib/server/membership-account.js +7 -3
- package/src/lib/server/membership-account.js.map +1 -1
- package/src/lib/server/membership-register.js +3 -1
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/pos-order.js +6 -4
- package/src/lib/server/pos-order.js.map +1 -1
- package/src/lib/server/process-abandoned.js +14 -5
- package/src/lib/server/process-abandoned.js.map +1 -1
- package/src/lib/server/reserve-stock.js +3 -4
- package/src/lib/server/reserve-stock.js.map +1 -1
- package/src/lib/server/site-page-enricher.js +3 -3
- package/src/lib/server/site-page-enricher.js.map +1 -1
- package/src/lib/server.js +4 -0
- 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 {
|
|
18
|
+
*/ import { nameSearchNormalizers } from "@aglyn/aglyn/app-utils/name-search";
|
|
17
19
|
import * as Aglyn from "@aglyn/aglyn/server";
|
|
18
|
-
import
|
|
20
|
+
import { planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
|
|
19
21
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
110
|
-
const
|
|
111
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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,
|
|
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 :
|
|
264
|
-
|
|
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');
|