@blamejs/blamejs-shop 0.3.45 → 0.3.46
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/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +508 -9
- package/lib/product-qa.js +40 -0
- package/lib/storefront.js +250 -244
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.46 (2026-06-01) — **Storefront pages render in far fewer database round trips, and the search collection filter is consistent across cache tiers.** The dynamically rendered storefront (the path that serves returning, signed-in, or non-default-currency shoppers) re-derived product data with one database query per item — a home or search page could issue dozens to hundreds of round trips, where the cached edge already did the same work in a single batched query. The home grid, search results, product page, related-products rail, and cart now read product, variant, price, inventory, and media data in batched queries that mirror the edge exactly, so those pages render with far fewer round trips and identical output. As part of this, the search 'collection' filter now reflects manual collection membership consistently whether a page is served from the cache or rendered dynamically. **Changed:** *Batched product reads on storefront pages* — The home grid, search results, product detail, related-products rail, and cart now load product/variant/price/inventory/media data in batched database queries instead of one query per item. On a cache miss, a signed-in session, or a non-default currency the dynamic render path previously issued a query per product (and per variant); these pages now match the single-query shape the cached edge already used. Rendered pages are unchanged. · *Consistent search collection filter across cache tiers* — The 'collection' filter on the search page now reflects manual collection membership on both the cached and the dynamically rendered paths. Previously the dynamic path could additionally surface smart-collection rule matches in that filter, so a shopper could see different collection filter options depending on which path served the page. If you relied on smart (rule-based) collections appearing as search filters, they now appear only where a product is an explicit member; this aligns the two render paths.
|
|
12
|
+
|
|
11
13
|
- v0.3.45 (2026-06-01) — **Tax, VAT, bulk price changes, and customer emails now compute money in exact integer arithmetic.** Several money calculations were performed with floating-point intermediates. On a large order subtotal the sales-tax math could lose a minor unit, and the customer-facing wishlist emails formatted prices by dividing by 100 — which is wrong for zero-decimal currencies like JPY (¥1,050 rendered as 10.5) and three-decimal currencies like BHD, and dropped trailing zeros ($10.00 shown as 10). All of these now run through the framework's exact BigInt money primitive: sales-tax and VAT/GST extraction, the per-rate owed figure on a tax filing, the admin bulk price-adjust, the wishlist price-drop and digest emails, and subscription monthly-revenue normalization. Prices in customer emails are now rendered with the correct currency exponent and symbol for every currency. **Changed:** *Bulk price-adjust rounds to even on exact half-unit ties* — The admin bulk price-adjust now rounds a price that lands exactly halfway between two minor units to the nearest even unit, matching the rounding used everywhere else in the money surface, instead of always rounding the half up. This only changes the result on an exact tie and by at most one minor unit (for example a price computed as 200.5 now becomes 200 rather than 201). If a pricing regime requires rounding halves up, that is a one-line option change. **Fixed:** *Exact sales-tax and VAT computation* — Sales tax, VAT-inclusive extraction, and VAT-exclusive addition are computed in exact integer arithmetic rather than with a floating-point intermediate, so the tax on a large order total is no longer off by a minor unit. The per-rate owed figure on a tax filing is likewise computed exactly, which matters when a filing window aggregates a very large taxable total. · *Currency-correct prices in wishlist emails* — Price-drop alert emails and the wishlist digest now format prices with each currency's own decimal places and symbol. Zero-decimal currencies (such as JPY) and three-decimal currencies (such as BHD) render correctly, and whole amounts keep their trailing zeros — for example $10.00 instead of 10. · *Exact subscription monthly-revenue normalization* — Normalizing a weekly or annual plan to a monthly figure for revenue reporting now uses exact rational arithmetic instead of a floating-point cadence factor, so the dashboard total is stable and does not drift by a cent between refreshes.
|
|
12
14
|
|
|
13
15
|
- v0.3.44 (2026-05-31) — **Customers can rate a delivered order, and the admin console moderates and replies.** There was no way for a customer to give feedback on how an order was fulfilled, and no way for an operator to act on it. Customers can now rate one of their own delivered orders on shipping, packaging, and how likely they are to recommend you, with an optional comment. The admin console gets a Ratings screen with a moderation queue of every flagged comment plus the most recent ratings, where an operator can flag a comment or post one public reply that the customer then sees on their order page. A customer can only rate an order they own, and every comment and reply is HTML-escaped wherever it is shown, so feedback text can't inject markup into another shopper's or an operator's page. **Added:** *Order ratings* — A customer viewing one of their own orders can rate it on three axes — shipping, packaging, and likelihood to recommend (1 to 5) — and optionally leave a comment. Each order can be rated once. The rating, and any public reply from the store, appear on the customer's order page. A customer can only rate an order tied to their own account; the rating is always recorded against the signed-in customer, never a value taken from the request. · *Ratings moderation in the admin console* — A new Ratings screen lists recent ratings with their scores and comments, alongside a moderation queue that surfaces every flagged comment regardless of its score, so a flagged comment on an otherwise high rating is never hidden. An operator can flag a comment for moderation and post a single public reply to a rating, which is then shown to the customer. Comments and replies are HTML-escaped everywhere they are rendered.
|
package/lib/asset-manifest.json
CHANGED
package/lib/catalog.js
CHANGED
|
@@ -171,6 +171,23 @@ function _contentType(s) {
|
|
|
171
171
|
function _genId() { return b.uuid.v7(); }
|
|
172
172
|
function _now() { return Date.now(); }
|
|
173
173
|
|
|
174
|
+
// Build the LIKE pattern for a single term — lowercased + the
|
|
175
|
+
// `\`-first metacharacter neutralisation so a term containing `%` /
|
|
176
|
+
// `_` matches only the literal character (otherwise `100%` would
|
|
177
|
+
// match every row). The `\\` substitution MUST run first, before the
|
|
178
|
+
// `%` / `_` passes, so the `\` we insert in front of a wildcard
|
|
179
|
+
// isn't itself re-escaped on the next pass and the wildcard survives
|
|
180
|
+
// un-neutralised. `products.search` and `batch.searchDecorate` both
|
|
181
|
+
// compose this so the escape can't drift between the two surfaces;
|
|
182
|
+
// it mirrors the edge `_likePattern` (worker/data/catalog.js).
|
|
183
|
+
function _likePattern(term) {
|
|
184
|
+
var escaped = String(term).toLowerCase()
|
|
185
|
+
.replace(/\\/g, "\\\\")
|
|
186
|
+
.replace(/%/g, "\\%")
|
|
187
|
+
.replace(/_/g, "\\_");
|
|
188
|
+
return "%" + escaped + "%";
|
|
189
|
+
}
|
|
190
|
+
|
|
174
191
|
// ---- factory -------------------------------------------------------------
|
|
175
192
|
|
|
176
193
|
function create(opts) {
|
|
@@ -206,6 +223,13 @@ function create(opts) {
|
|
|
206
223
|
// failure never rolls back the stock op that triggered it.
|
|
207
224
|
var inventory = _inventoryModule(query, { onStockChange: opts.onStockChange || null });
|
|
208
225
|
var media = _mediaModule(query);
|
|
226
|
+
// Batched read helpers — one or a few `IN (...)` / window-function
|
|
227
|
+
// queries that replace the per-item loops the container storefront
|
|
228
|
+
// ran (N+1 over the D1 bridge). Each is ported from the named edge
|
|
229
|
+
// function in worker/data/catalog.js with the SAME SQL shape +
|
|
230
|
+
// output field names, so the dual-rendered HTML stays byte-identical
|
|
231
|
+
// across the edge + container substrates.
|
|
232
|
+
var batch = _batchModule(query);
|
|
209
233
|
|
|
210
234
|
return {
|
|
211
235
|
products: products,
|
|
@@ -213,6 +237,7 @@ function create(opts) {
|
|
|
213
237
|
prices: prices,
|
|
214
238
|
inventory: inventory,
|
|
215
239
|
media: media,
|
|
240
|
+
batch: batch,
|
|
216
241
|
};
|
|
217
242
|
}
|
|
218
243
|
|
|
@@ -381,17 +406,12 @@ function _productsModule(query, cursorSecret) {
|
|
|
381
406
|
if (qTrim.length === 0) {
|
|
382
407
|
return { rows: [], next_cursor: null };
|
|
383
408
|
}
|
|
384
|
-
var qLower = qTrim.toLowerCase();
|
|
385
409
|
// Neutralize LIKE metacharacters so a search containing `%`
|
|
386
410
|
// or `_` matches only the literal character (otherwise `100%`
|
|
387
|
-
// would match every row).
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
var
|
|
391
|
-
.replace(/\\/g, "\\\\")
|
|
392
|
-
.replace(/%/g, "\\%")
|
|
393
|
-
.replace(/_/g, "\\_");
|
|
394
|
-
var pattern = "%" + qEscaped + "%";
|
|
411
|
+
// would match every row). The shared `_likePattern` lowercases
|
|
412
|
+
// and escapes `\` first so the wildcard neutralisation can't be
|
|
413
|
+
// re-escaped — the same helper `batch.searchDecorate` composes.
|
|
414
|
+
var pattern = _likePattern(qTrim);
|
|
395
415
|
var cursorVals = null;
|
|
396
416
|
if (opts.cursor != null) {
|
|
397
417
|
if (typeof opts.cursor !== "string") {
|
|
@@ -851,6 +871,485 @@ function _mediaModule(query) {
|
|
|
851
871
|
};
|
|
852
872
|
}
|
|
853
873
|
|
|
874
|
+
// ---- batch (N+1-collapsing read helpers) ---------------------------------
|
|
875
|
+
//
|
|
876
|
+
// These mirror worker/data/catalog.js — the edge worker already
|
|
877
|
+
// produces, in one or a few `IN (...)` / window-function queries, what
|
|
878
|
+
// the container storefront re-derived with per-item loops (each a
|
|
879
|
+
// full D1-bridge hop). Porting the edge SQL verbatim keeps the two
|
|
880
|
+
// substrates in lockstep: the field names + row shapes below are the
|
|
881
|
+
// ones the storefront renderers already accept, so the rendered HTML /
|
|
882
|
+
// JSON-LD is unchanged.
|
|
883
|
+
//
|
|
884
|
+
// Every helper composes the module's `query(sql, params)` closure +
|
|
885
|
+
// the existing validators. Each is missing-table-resilient (catch
|
|
886
|
+
// `/no such table/i` → empty) to match the edge + the never-500-the-
|
|
887
|
+
// page stance, EXCEPT entry-point validation (a malformed id throws
|
|
888
|
+
// TypeError, which the route catches → 404/empty). The IN(...) lists
|
|
889
|
+
// use positional placeholders generated from array length, values
|
|
890
|
+
// bound positionally — never interpolated.
|
|
891
|
+
|
|
892
|
+
// First-variant / first-media decorated SELECT — ported verbatim from
|
|
893
|
+
// the edge `DECORATED_SELECT` (worker/data/catalog.js). Window
|
|
894
|
+
// functions pick the first variant (position ASC, created_at ASC) +
|
|
895
|
+
// first media per product in one round trip; the price is joined off
|
|
896
|
+
// the hero variant for the requested currency. `?1` is the currency.
|
|
897
|
+
var _DECORATED_SELECT =
|
|
898
|
+
"SELECT p.*, " +
|
|
899
|
+
" v.id AS hero_variant_id, " +
|
|
900
|
+
" pr.amount_minor AS starting_price_minor, " +
|
|
901
|
+
" pr.currency AS starting_price_currency, " +
|
|
902
|
+
" m.r2_key AS hero_r2_key, " +
|
|
903
|
+
" m.alt_text AS hero_alt_text " +
|
|
904
|
+
"FROM products p " +
|
|
905
|
+
"LEFT JOIN ( " +
|
|
906
|
+
" SELECT iv.*, " +
|
|
907
|
+
" ROW_NUMBER() OVER (PARTITION BY iv.product_id ORDER BY iv.position ASC, iv.created_at ASC) AS rn " +
|
|
908
|
+
" FROM variants iv " +
|
|
909
|
+
") v ON v.product_id = p.id AND v.rn = 1 " +
|
|
910
|
+
"LEFT JOIN prices pr ON pr.variant_id = v.id AND pr.currency = ?1 AND pr.effective_until IS NULL " +
|
|
911
|
+
"LEFT JOIN ( " +
|
|
912
|
+
" SELECT im.*, " +
|
|
913
|
+
" ROW_NUMBER() OVER (PARTITION BY im.product_id ORDER BY im.position ASC, im.created_at ASC) AS rn " +
|
|
914
|
+
" FROM media im " +
|
|
915
|
+
" WHERE im.product_id IS NOT NULL " +
|
|
916
|
+
") m ON m.product_id = p.id AND m.rn = 1 ";
|
|
917
|
+
|
|
918
|
+
// Shape a decorated row to the exact output `_shapeDecoratedRow`
|
|
919
|
+
// produces at the edge (worker/data/catalog.js): the product columns
|
|
920
|
+
// plus `starting_price_minor` / `starting_price_currency` /
|
|
921
|
+
// `hero_media`. No arithmetic — `starting_price_minor` is the raw
|
|
922
|
+
// integer minor-unit amount passed through.
|
|
923
|
+
function _shapeDecoratedRow(r) {
|
|
924
|
+
var hero = (r.hero_r2_key != null)
|
|
925
|
+
? { r2_key: r.hero_r2_key, alt_text: r.hero_alt_text || "" }
|
|
926
|
+
: null;
|
|
927
|
+
return {
|
|
928
|
+
id: r.id,
|
|
929
|
+
slug: r.slug,
|
|
930
|
+
title: r.title,
|
|
931
|
+
description: r.description,
|
|
932
|
+
status: r.status,
|
|
933
|
+
created_at: r.created_at,
|
|
934
|
+
updated_at: r.updated_at,
|
|
935
|
+
starting_price_minor: r.starting_price_minor != null ? r.starting_price_minor : null,
|
|
936
|
+
starting_price_currency: r.starting_price_currency || "USD",
|
|
937
|
+
hero_media: hero,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function _batchModule(query) {
|
|
942
|
+
// Default to USD when an unset / wrong-shape currency reaches a
|
|
943
|
+
// helper, matching the edge's `(len === 3) ? cur : "USD"` guard.
|
|
944
|
+
function _ccy(c) {
|
|
945
|
+
return (typeof c === "string" && c.length === 3) ? c : "USD";
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return {
|
|
949
|
+
// Decorate a set of product ids in one query (backs PERF-5 home
|
|
950
|
+
// grid + PERF-1 related). Returns `{ rows, byId }`: `rows` carries
|
|
951
|
+
// one decorated row per matching id in `created_at`-stable order
|
|
952
|
+
// and `byId` maps id → row so the caller can re-impose its own
|
|
953
|
+
// order. Mirrors the edge `DECORATED_SELECT` + `_shapeDecoratedRow`.
|
|
954
|
+
decoratedProducts: async function (productIds, currency) {
|
|
955
|
+
if (!Array.isArray(productIds) || productIds.length === 0) return { rows: [], byId: {} };
|
|
956
|
+
var ids = productIds.map(function (id) { return _id(id, "product id"); });
|
|
957
|
+
var cur = _ccy(currency);
|
|
958
|
+
var placeholders = ids.map(function (_v, n) { return "?" + (n + 2); }).join(", ");
|
|
959
|
+
var params = [cur].concat(ids);
|
|
960
|
+
var r = await query(
|
|
961
|
+
_DECORATED_SELECT +
|
|
962
|
+
"WHERE p.id IN (" + placeholders + ") " +
|
|
963
|
+
"ORDER BY p.created_at ASC, p.id ASC",
|
|
964
|
+
params,
|
|
965
|
+
);
|
|
966
|
+
var rows = [];
|
|
967
|
+
var byId = {};
|
|
968
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
969
|
+
var shaped = _shapeDecoratedRow(r.rows[i]);
|
|
970
|
+
rows.push(shaped);
|
|
971
|
+
byId[shaped.id] = shaped;
|
|
972
|
+
}
|
|
973
|
+
return { rows: rows, byId: byId };
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
// Active products decorated in one query (backs PERF-5 / UX-8 home
|
|
977
|
+
// grid). Mirrors the edge `listActiveProducts`: status='active',
|
|
978
|
+
// ORDER BY updated_at DESC, id DESC, LIMIT/OFFSET. Returns
|
|
979
|
+
// `{ rows }`.
|
|
980
|
+
decoratedActive: async function (opts) {
|
|
981
|
+
opts = opts || {};
|
|
982
|
+
var cur = _ccy(opts.currency);
|
|
983
|
+
var limit = opts.limit == null ? 24 : opts.limit;
|
|
984
|
+
var offset = opts.offset == null ? 0 : opts.offset;
|
|
985
|
+
_positiveInt(limit, "limit");
|
|
986
|
+
_nonNegInt(offset, "offset");
|
|
987
|
+
var r = await query(
|
|
988
|
+
_DECORATED_SELECT +
|
|
989
|
+
"WHERE p.status = 'active' " +
|
|
990
|
+
"ORDER BY p.updated_at DESC, p.id DESC " +
|
|
991
|
+
"LIMIT ?2 OFFSET ?3",
|
|
992
|
+
[cur, limit, offset],
|
|
993
|
+
);
|
|
994
|
+
var rows = [];
|
|
995
|
+
for (var i = 0; i < r.rows.length; i += 1) rows.push(_shapeDecoratedRow(r.rows[i]));
|
|
996
|
+
return { rows: rows };
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
// Variants + their current price for a product in one query (backs
|
|
1000
|
+
// PERF-3 PDP). Mirrors the edge `listVariantsWithPrices`: LEFT JOIN
|
|
1001
|
+
// the active price (effective_until IS NULL) for `currency`,
|
|
1002
|
+
// ordered position ASC, created_at ASC. Returns `{ rows, prices }`:
|
|
1003
|
+
// - `rows` — the variant rows shaped like `variants.listForProduct`
|
|
1004
|
+
// (options_json parsed to `options`), price columns dropped
|
|
1005
|
+
// so the shape is byte-identical to the per-item path.
|
|
1006
|
+
// - `prices` — { variantId: priceRow } for variants that HAVE a
|
|
1007
|
+
// price in `currency`, each priceRow shaped like
|
|
1008
|
+
// `prices.current` output.
|
|
1009
|
+
variantsWithPrices: async function (productId, currency) {
|
|
1010
|
+
_id(productId, "product_id");
|
|
1011
|
+
var cur = _ccy(currency);
|
|
1012
|
+
var r = await query(
|
|
1013
|
+
"SELECT v.*, " +
|
|
1014
|
+
" pr.id AS price_id, " +
|
|
1015
|
+
" pr.amount_minor AS price_amount_minor, " +
|
|
1016
|
+
" pr.currency AS price_currency, " +
|
|
1017
|
+
" pr.effective_from AS price_effective_from, " +
|
|
1018
|
+
" pr.effective_until AS price_effective_until, " +
|
|
1019
|
+
" pr.created_at AS price_created_at " +
|
|
1020
|
+
"FROM variants v " +
|
|
1021
|
+
"LEFT JOIN prices pr ON pr.variant_id = v.id AND pr.currency = ?1 AND pr.effective_until IS NULL " +
|
|
1022
|
+
"WHERE v.product_id = ?2 " +
|
|
1023
|
+
"ORDER BY v.position ASC, v.created_at ASC",
|
|
1024
|
+
[cur, productId],
|
|
1025
|
+
);
|
|
1026
|
+
var rows = [];
|
|
1027
|
+
var prices = {};
|
|
1028
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1029
|
+
var raw = r.rows[i];
|
|
1030
|
+
// Lift the joined price into the per-variant prices map, shaped
|
|
1031
|
+
// exactly like `prices.current` output (only when a price for
|
|
1032
|
+
// `currency` exists), then strip the joined price columns off
|
|
1033
|
+
// the variant row so the row shape is byte-identical to
|
|
1034
|
+
// `variants.listForProduct` (the variant columns + parsed
|
|
1035
|
+
// `options`). Deleting the extra keys in place preserves the
|
|
1036
|
+
// row's identity/prototype so the two paths deep-equal.
|
|
1037
|
+
if (raw.price_amount_minor != null) {
|
|
1038
|
+
prices[raw.id] = {
|
|
1039
|
+
id: raw.price_id,
|
|
1040
|
+
variant_id: raw.id,
|
|
1041
|
+
currency: raw.price_currency,
|
|
1042
|
+
amount_minor: raw.price_amount_minor,
|
|
1043
|
+
effective_from: raw.price_effective_from,
|
|
1044
|
+
effective_until: raw.price_effective_until,
|
|
1045
|
+
created_at: raw.price_created_at,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
delete raw.price_id;
|
|
1049
|
+
delete raw.price_amount_minor;
|
|
1050
|
+
delete raw.price_currency;
|
|
1051
|
+
delete raw.price_effective_from;
|
|
1052
|
+
delete raw.price_effective_until;
|
|
1053
|
+
delete raw.price_created_at;
|
|
1054
|
+
raw.options = JSON.parse(raw.options_json);
|
|
1055
|
+
rows.push(raw);
|
|
1056
|
+
}
|
|
1057
|
+
return { rows: rows, prices: prices };
|
|
1058
|
+
},
|
|
1059
|
+
|
|
1060
|
+
// Inventory rows for a set of SKUs in one query, keyed by SKU
|
|
1061
|
+
// (backs PERF-6 cart-line stock). Mirrors the edge
|
|
1062
|
+
// `listInventoryForSkus`: the trimmed `{ stock_on_hand, stock_held }`
|
|
1063
|
+
// shape — sufficient for the cart, which derives its low-stock band
|
|
1064
|
+
// from an operator config value, not the per-SKU column. A SKU with
|
|
1065
|
+
// no inventory row is absent from the map (the renderer treats
|
|
1066
|
+
// absence as available). Missing-table-resilient → {}.
|
|
1067
|
+
inventoryForSkus: async function (skus) {
|
|
1068
|
+
if (!Array.isArray(skus) || skus.length === 0) return {};
|
|
1069
|
+
var out = {};
|
|
1070
|
+
try {
|
|
1071
|
+
var placeholders = skus.map(function (_s, i) { return "?" + (i + 1); }).join(", ");
|
|
1072
|
+
var r = await query(
|
|
1073
|
+
"SELECT sku, stock_on_hand, stock_held FROM inventory WHERE sku IN (" + placeholders + ")",
|
|
1074
|
+
skus,
|
|
1075
|
+
);
|
|
1076
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1077
|
+
out[r.rows[i].sku] = { stock_on_hand: r.rows[i].stock_on_hand, stock_held: r.rows[i].stock_held };
|
|
1078
|
+
}
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
if (e && /no such table/i.test(e.message || "")) return {};
|
|
1081
|
+
throw e;
|
|
1082
|
+
}
|
|
1083
|
+
return out;
|
|
1084
|
+
},
|
|
1085
|
+
|
|
1086
|
+
// Full inventory rows for a set of SKUs in one query, keyed by SKU
|
|
1087
|
+
// (backs PERF-3 PDP availability). Returns the SAME full-row shape
|
|
1088
|
+
// `inventory.get` does (`SELECT *` → sku, stock_on_hand, stock_held,
|
|
1089
|
+
// low_stock_threshold, updated_at), keeping the PDP's per-SKU
|
|
1090
|
+
// inventory map byte-identical to the prior per-variant
|
|
1091
|
+
// `inventory.get` loop. The PDP's availability resolver reads
|
|
1092
|
+
// `low_stock_threshold` to surface the "Only N left" nudge, so the
|
|
1093
|
+
// PDP needs the full row — the trimmed `inventoryForSkus` above is
|
|
1094
|
+
// for the cart, which doesn't. A SKU with no inventory row is absent
|
|
1095
|
+
// (renderer treats absence as available). Missing-table-resilient
|
|
1096
|
+
// → {}.
|
|
1097
|
+
inventoryRowsForSkus: async function (skus) {
|
|
1098
|
+
if (!Array.isArray(skus) || skus.length === 0) return {};
|
|
1099
|
+
var out = {};
|
|
1100
|
+
try {
|
|
1101
|
+
var placeholders = skus.map(function (_s, i) { return "?" + (i + 1); }).join(", ");
|
|
1102
|
+
var r = await query(
|
|
1103
|
+
"SELECT * FROM inventory WHERE sku IN (" + placeholders + ")",
|
|
1104
|
+
skus,
|
|
1105
|
+
);
|
|
1106
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1107
|
+
out[r.rows[i].sku] = r.rows[i];
|
|
1108
|
+
}
|
|
1109
|
+
} catch (e) {
|
|
1110
|
+
if (e && /no such table/i.test(e.message || "")) return {};
|
|
1111
|
+
throw e;
|
|
1112
|
+
}
|
|
1113
|
+
return out;
|
|
1114
|
+
},
|
|
1115
|
+
|
|
1116
|
+
// The facetable product universe for a set of search terms (backs
|
|
1117
|
+
// PERF-2). Ported from the edge `searchFacetableProducts`: one
|
|
1118
|
+
// decorated SELECT with an OR-of-LIKE term clause, then a
|
|
1119
|
+
// collection_members `IN` (MANUAL membership only — see PD-1) +
|
|
1120
|
+
// a grouped-inventory `IN`. Terms are deduped case-insensitively.
|
|
1121
|
+
// Returns `{ rows }` where each row is the decorated shape PLUS
|
|
1122
|
+
// `collection` (array of slugs), `price_minor` (int|null), and
|
|
1123
|
+
// `in_stock` (bool) — the shape the faceting walker + search-card
|
|
1124
|
+
// renderer consume. The collection + inventory reads are
|
|
1125
|
+
// missing-table-resilient (a partially-migrated deploy degrades to
|
|
1126
|
+
// the fields it has rather than 500ing search).
|
|
1127
|
+
searchDecorate: async function (opts) {
|
|
1128
|
+
opts = opts || {};
|
|
1129
|
+
var terms = Array.isArray(opts.terms) ? opts.terms : [];
|
|
1130
|
+
var dedup = {};
|
|
1131
|
+
var cleaned = [];
|
|
1132
|
+
for (var ti = 0; ti < terms.length; ti += 1) {
|
|
1133
|
+
var term = typeof terms[ti] === "string" ? terms[ti].trim() : "";
|
|
1134
|
+
if (!term.length || dedup[term.toLowerCase()]) continue;
|
|
1135
|
+
dedup[term.toLowerCase()] = true;
|
|
1136
|
+
cleaned.push(term);
|
|
1137
|
+
}
|
|
1138
|
+
if (!cleaned.length) return { rows: [] };
|
|
1139
|
+
var cur = _ccy(opts.currency);
|
|
1140
|
+
// Cap the candidate universe wider than the page so facet counts
|
|
1141
|
+
// reflect the full match set. MAX_LIMIT keeps the in-memory facet
|
|
1142
|
+
// walk bounded — matches the edge's MAX_LIMIT (100) cap.
|
|
1143
|
+
var universeLimit = 100;
|
|
1144
|
+
|
|
1145
|
+
// One OR-of-LIKE clause per term. Positional params: ?1 is the
|
|
1146
|
+
// price currency, ?2 the universe limit; term patterns start at ?3.
|
|
1147
|
+
var likeClauses = [];
|
|
1148
|
+
var params = [cur, universeLimit];
|
|
1149
|
+
var ph = 3;
|
|
1150
|
+
for (var c = 0; c < cleaned.length; c += 1) {
|
|
1151
|
+
var pat = _likePattern(cleaned[c]);
|
|
1152
|
+
likeClauses.push("lower(p.title) LIKE ?" + ph + " ESCAPE '\\'");
|
|
1153
|
+
params.push(pat);
|
|
1154
|
+
ph += 1;
|
|
1155
|
+
likeClauses.push("lower(p.description) LIKE ?" + ph + " ESCAPE '\\'");
|
|
1156
|
+
params.push(pat);
|
|
1157
|
+
ph += 1;
|
|
1158
|
+
}
|
|
1159
|
+
var r = await query(
|
|
1160
|
+
_DECORATED_SELECT +
|
|
1161
|
+
"WHERE p.status = 'active' AND (" + likeClauses.join(" OR ") + ") " +
|
|
1162
|
+
"ORDER BY p.updated_at DESC, p.id DESC LIMIT ?2",
|
|
1163
|
+
params,
|
|
1164
|
+
);
|
|
1165
|
+
var raw = r.rows;
|
|
1166
|
+
if (!raw.length) return { rows: [] };
|
|
1167
|
+
|
|
1168
|
+
var ids = raw.map(function (row) { return row.id; });
|
|
1169
|
+
var byId = {};
|
|
1170
|
+
var rows = [];
|
|
1171
|
+
for (var i = 0; i < raw.length; i += 1) {
|
|
1172
|
+
var shaped = _shapeDecoratedRow(raw[i]);
|
|
1173
|
+
shaped.collection = [];
|
|
1174
|
+
shaped.price_minor = shaped.starting_price_minor != null ? shaped.starting_price_minor : null;
|
|
1175
|
+
shaped.in_stock = false;
|
|
1176
|
+
byId[shaped.id] = shaped;
|
|
1177
|
+
rows.push(shaped);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
var placeholders = ids.map(function (_v, n) { return "?" + (n + 1); }).join(", ");
|
|
1181
|
+
|
|
1182
|
+
// Collection memberships — categorical facet field `collection`.
|
|
1183
|
+
// MANUAL membership only (collection_members joined to a live
|
|
1184
|
+
// collection), matching the edge. Smart-collection rule matches
|
|
1185
|
+
// are intentionally NOT included (PD-1 convergence).
|
|
1186
|
+
try {
|
|
1187
|
+
var cmRes = await query(
|
|
1188
|
+
"SELECT cm.product_id AS pid, cm.collection_slug AS slug " +
|
|
1189
|
+
"FROM collection_members cm " +
|
|
1190
|
+
"JOIN collections col ON col.slug = cm.collection_slug AND col.archived_at IS NULL " +
|
|
1191
|
+
"WHERE cm.product_id IN (" + placeholders + ")",
|
|
1192
|
+
ids,
|
|
1193
|
+
);
|
|
1194
|
+
for (var cm = 0; cm < cmRes.rows.length; cm += 1) {
|
|
1195
|
+
var pr1 = byId[cmRes.rows[cm].pid];
|
|
1196
|
+
if (pr1 && cmRes.rows[cm].slug) pr1.collection.push(cmRes.rows[cm].slug);
|
|
1197
|
+
}
|
|
1198
|
+
} catch (e) {
|
|
1199
|
+
if (!(e && /no such table/i.test(e.message || ""))) throw e;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// In-stock — true when any variant SKU has available stock
|
|
1203
|
+
// (stock_on_hand > stock_held). Boolean facet field `in_stock`.
|
|
1204
|
+
try {
|
|
1205
|
+
var invRes = await query(
|
|
1206
|
+
"SELECT v.product_id AS pid, " +
|
|
1207
|
+
" MAX(CASE WHEN (inv.stock_on_hand - inv.stock_held) > 0 THEN 1 ELSE 0 END) AS any_stock " +
|
|
1208
|
+
"FROM variants v " +
|
|
1209
|
+
"JOIN inventory inv ON inv.sku = v.sku " +
|
|
1210
|
+
"WHERE v.product_id IN (" + placeholders + ") " +
|
|
1211
|
+
"GROUP BY v.product_id",
|
|
1212
|
+
ids,
|
|
1213
|
+
);
|
|
1214
|
+
for (var iv = 0; iv < invRes.rows.length; iv += 1) {
|
|
1215
|
+
var pr2 = byId[invRes.rows[iv].pid];
|
|
1216
|
+
if (pr2) pr2.in_stock = Number(invRes.rows[iv].any_stock) === 1;
|
|
1217
|
+
}
|
|
1218
|
+
} catch (e2) {
|
|
1219
|
+
if (!(e2 && /no such table/i.test(e2.message || ""))) throw e2;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return { rows: rows };
|
|
1223
|
+
},
|
|
1224
|
+
|
|
1225
|
+
// "You may also like" siblings for a product, decorated in one
|
|
1226
|
+
// query (backs PERF-1). Ported from the edge `listRelatedProducts`
|
|
1227
|
+
// sibling SELECT: the other active members of `collectionSlug`
|
|
1228
|
+
// (self excluded), ordered membership position ASC then product id
|
|
1229
|
+
// ASC, capped at `limit`, each carrying its first variant's current
|
|
1230
|
+
// price + first media row. Returns the exact
|
|
1231
|
+
// `{ slug, title, hero_r2_key, hero_alt_text, price_minor,
|
|
1232
|
+
// price_currency }` shape the rail renderer consumes. NOTE: this is
|
|
1233
|
+
// ONLY the sibling decoration — the primary-collection lookup stays
|
|
1234
|
+
// in the caller (it already has it).
|
|
1235
|
+
relatedSiblings: async function (collectionSlug, excludeProductId, currency, limit) {
|
|
1236
|
+
_slug(collectionSlug);
|
|
1237
|
+
_id(excludeProductId, "product_id");
|
|
1238
|
+
var cur = _ccy(currency);
|
|
1239
|
+
var n = (Number.isInteger(limit) && limit > 0) ? limit : 4;
|
|
1240
|
+
var r = await query(
|
|
1241
|
+
"SELECT p.id AS id, p.slug AS slug, p.title AS title, " +
|
|
1242
|
+
" pr.amount_minor AS price_amount_minor, " +
|
|
1243
|
+
" pr.currency AS price_currency, " +
|
|
1244
|
+
" m.r2_key AS hero_r2_key, " +
|
|
1245
|
+
" m.alt_text AS hero_alt_text " +
|
|
1246
|
+
"FROM collection_members cm " +
|
|
1247
|
+
"JOIN products p ON p.id = cm.product_id " +
|
|
1248
|
+
"LEFT JOIN ( " +
|
|
1249
|
+
" SELECT iv.*, " +
|
|
1250
|
+
" ROW_NUMBER() OVER (PARTITION BY iv.product_id ORDER BY iv.position ASC, iv.created_at ASC) AS rn " +
|
|
1251
|
+
" FROM variants iv " +
|
|
1252
|
+
") v ON v.product_id = p.id AND v.rn = 1 " +
|
|
1253
|
+
"LEFT JOIN prices pr ON pr.variant_id = v.id AND pr.currency = ?1 AND pr.effective_until IS NULL " +
|
|
1254
|
+
"LEFT JOIN ( " +
|
|
1255
|
+
" SELECT im.*, " +
|
|
1256
|
+
" ROW_NUMBER() OVER (PARTITION BY im.product_id ORDER BY im.position ASC, im.created_at ASC) AS rn " +
|
|
1257
|
+
" FROM media im " +
|
|
1258
|
+
" WHERE im.product_id IS NOT NULL " +
|
|
1259
|
+
") m ON m.product_id = p.id AND m.rn = 1 " +
|
|
1260
|
+
"WHERE cm.collection_slug = ?2 AND cm.product_id != ?3 AND p.status = 'active' " +
|
|
1261
|
+
"ORDER BY cm.position ASC, cm.product_id ASC LIMIT ?4",
|
|
1262
|
+
[cur, collectionSlug, excludeProductId, n],
|
|
1263
|
+
);
|
|
1264
|
+
return r.rows.map(function (row) {
|
|
1265
|
+
return {
|
|
1266
|
+
slug: row.slug,
|
|
1267
|
+
title: row.title,
|
|
1268
|
+
hero_r2_key: row.hero_r2_key != null ? row.hero_r2_key : null,
|
|
1269
|
+
hero_alt_text: row.hero_r2_key != null ? (row.hero_alt_text || row.title) : null,
|
|
1270
|
+
price_minor: row.price_amount_minor != null ? row.price_amount_minor : null,
|
|
1271
|
+
price_currency: row.price_currency || "USD",
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1274
|
+
},
|
|
1275
|
+
|
|
1276
|
+
// Variants for a set of SKUs in one query, keyed by SKU (backs
|
|
1277
|
+
// PERF-9 bundle member-title batching). Returns a map sku → variant
|
|
1278
|
+
// row shaped like `variants.bySku` output (options_json parsed to
|
|
1279
|
+
// `options`). Used to collect every bundle member's owning product
|
|
1280
|
+
// id in one hop instead of a per-member `variants.bySku`.
|
|
1281
|
+
variantsBySkus: async function (skus) {
|
|
1282
|
+
if (!Array.isArray(skus) || skus.length === 0) return {};
|
|
1283
|
+
var unique = [];
|
|
1284
|
+
var seen = Object.create(null);
|
|
1285
|
+
for (var s = 0; s < skus.length; s += 1) {
|
|
1286
|
+
var sk = skus[s];
|
|
1287
|
+
if (typeof sk !== "string" || seen[sk]) continue;
|
|
1288
|
+
seen[sk] = true;
|
|
1289
|
+
unique.push(sk);
|
|
1290
|
+
}
|
|
1291
|
+
if (!unique.length) return {};
|
|
1292
|
+
var placeholders = unique.map(function (_v, n) { return "?" + (n + 1); }).join(", ");
|
|
1293
|
+
var r = await query(
|
|
1294
|
+
"SELECT * FROM variants WHERE sku IN (" + placeholders + ")",
|
|
1295
|
+
unique,
|
|
1296
|
+
);
|
|
1297
|
+
var out = {};
|
|
1298
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1299
|
+
var row = r.rows[i];
|
|
1300
|
+
row.options = JSON.parse(row.options_json);
|
|
1301
|
+
out[row.sku] = row;
|
|
1302
|
+
}
|
|
1303
|
+
return out;
|
|
1304
|
+
},
|
|
1305
|
+
|
|
1306
|
+
// Variants for a set of variant ids in one query, keyed by id
|
|
1307
|
+
// (backs PERF-6 cart reprice). Returns a map id → variant row
|
|
1308
|
+
// shaped like `variants.get` output (options_json parsed to
|
|
1309
|
+
// `options`) — the same value the prior per-line `variants.get`
|
|
1310
|
+
// returned, keyed by the stable variant_id the cart line carries.
|
|
1311
|
+
variantsByIds: async function (variantIds) {
|
|
1312
|
+
if (!Array.isArray(variantIds) || variantIds.length === 0) return {};
|
|
1313
|
+
var unique = [];
|
|
1314
|
+
var seen = Object.create(null);
|
|
1315
|
+
for (var n = 0; n < variantIds.length; n += 1) {
|
|
1316
|
+
var vid = variantIds[n];
|
|
1317
|
+
if (typeof vid !== "string" || !vid.length || seen[vid]) continue;
|
|
1318
|
+
seen[vid] = true;
|
|
1319
|
+
unique.push(_id(vid, "variant id"));
|
|
1320
|
+
}
|
|
1321
|
+
if (!unique.length) return {};
|
|
1322
|
+
var placeholders = unique.map(function (_v, i) { return "?" + (i + 1); }).join(", ");
|
|
1323
|
+
var r = await query(
|
|
1324
|
+
"SELECT * FROM variants WHERE id IN (" + placeholders + ")",
|
|
1325
|
+
unique,
|
|
1326
|
+
);
|
|
1327
|
+
var out = {};
|
|
1328
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1329
|
+
var row = r.rows[i];
|
|
1330
|
+
row.options = JSON.parse(row.options_json);
|
|
1331
|
+
out[row.id] = row;
|
|
1332
|
+
}
|
|
1333
|
+
return out;
|
|
1334
|
+
},
|
|
1335
|
+
|
|
1336
|
+
// Products for a set of ids in one query, keyed by id (backs PERF-9
|
|
1337
|
+
// bundle member-title batching). Returns a map id → product row.
|
|
1338
|
+
productsByIds: async function (productIds) {
|
|
1339
|
+
if (!Array.isArray(productIds) || productIds.length === 0) return {};
|
|
1340
|
+
var ids = productIds.map(function (id) { return _id(id, "product id"); });
|
|
1341
|
+
var placeholders = ids.map(function (_v, n) { return "?" + (n + 1); }).join(", ");
|
|
1342
|
+
var r = await query(
|
|
1343
|
+
"SELECT * FROM products WHERE id IN (" + placeholders + ")",
|
|
1344
|
+
ids,
|
|
1345
|
+
);
|
|
1346
|
+
var out = {};
|
|
1347
|
+
for (var i = 0; i < r.rows.length; i += 1) out[r.rows[i].id] = r.rows[i];
|
|
1348
|
+
return out;
|
|
1349
|
+
},
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
854
1353
|
module.exports = {
|
|
855
1354
|
create: create,
|
|
856
1355
|
};
|
package/lib/product-qa.js
CHANGED
|
@@ -652,6 +652,45 @@ function create(opts) {
|
|
|
652
652
|
return rows;
|
|
653
653
|
}
|
|
654
654
|
|
|
655
|
+
// Batched answersForQuestion — every approved answer under EACH of
|
|
656
|
+
// `questionIds`, grouped into a map keyed by question_id. One `IN`
|
|
657
|
+
// query replaces the per-question `answersForQuestion` loop the PDP
|
|
658
|
+
// ran (one D1-bridge hop per question). The approved-only filter and
|
|
659
|
+
// the (pinned DESC, vote_count DESC, occurred_at ASC, id ASC) order
|
|
660
|
+
// are byte-for-byte the single-question method's, so the stitched
|
|
661
|
+
// thread renders identically. `opts.limit` caps the answers PER
|
|
662
|
+
// question (validated like the single method) — applied
|
|
663
|
+
// application-side after the grouped read so a high-fan-out question
|
|
664
|
+
// can't starve another. Each id is validated at entry (a malformed
|
|
665
|
+
// id throws TypeError, which the route catches). An empty input or a
|
|
666
|
+
// question with no approved answers maps to an empty array.
|
|
667
|
+
async function answersForQuestions(questionIds, listOpts) {
|
|
668
|
+
if (!Array.isArray(questionIds) || questionIds.length === 0) return {};
|
|
669
|
+
listOpts = listOpts || {};
|
|
670
|
+
var limit = _limit(listOpts.limit);
|
|
671
|
+
var ids = [];
|
|
672
|
+
var byId = {};
|
|
673
|
+
for (var qi = 0; qi < questionIds.length; qi += 1) {
|
|
674
|
+
var id = _uuid(questionIds[qi], "question_id");
|
|
675
|
+
if (byId[id]) continue;
|
|
676
|
+
byId[id] = [];
|
|
677
|
+
ids.push(id);
|
|
678
|
+
}
|
|
679
|
+
var placeholders = ids.map(function (_v, n) { return "?" + (n + 1); }).join(", ");
|
|
680
|
+
var r = await query(
|
|
681
|
+
"SELECT * FROM product_qa_answers " +
|
|
682
|
+
"WHERE question_id IN (" + placeholders + ") AND status = 'approved' " +
|
|
683
|
+
"ORDER BY pinned DESC, vote_count DESC, occurred_at ASC, id ASC",
|
|
684
|
+
ids,
|
|
685
|
+
);
|
|
686
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
687
|
+
var ans = _decodeAnswer(r.rows[i]);
|
|
688
|
+
var bucket = byId[ans.question_id];
|
|
689
|
+
if (bucket && bucket.length < limit) bucket.push(ans);
|
|
690
|
+
}
|
|
691
|
+
return byId;
|
|
692
|
+
}
|
|
693
|
+
|
|
655
694
|
// Every answer under a question regardless of status — the operator
|
|
656
695
|
// moderation detail needs the pending / rejected answers too, which
|
|
657
696
|
// answersForQuestion (approved-only, storefront-facing) hides. Pinned
|
|
@@ -808,6 +847,7 @@ function create(opts) {
|
|
|
808
847
|
getQuestion: getQuestion,
|
|
809
848
|
topAnswerForQuestion: topAnswerForQuestion,
|
|
810
849
|
answersForQuestion: answersForQuestion,
|
|
850
|
+
answersForQuestions: answersForQuestions,
|
|
811
851
|
listAnswersForQuestion: listAnswersForQuestion,
|
|
812
852
|
listQuestionsByStatus: listQuestionsByStatus,
|
|
813
853
|
listAnswersByStatus: listAnswersByStatus,
|
package/lib/storefront.js
CHANGED
|
@@ -8555,23 +8555,33 @@ function mount(router, deps) {
|
|
|
8555
8555
|
currency_redirect_to: path,
|
|
8556
8556
|
};
|
|
8557
8557
|
var chosen = _readCurrencyCookie(req);
|
|
8558
|
-
|
|
8559
|
-
|
|
8560
|
-
|
|
8561
|
-
|
|
8562
|
-
|
|
8563
|
-
|
|
8564
|
-
|
|
8565
|
-
|
|
8566
|
-
|
|
8567
|
-
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
8571
|
-
|
|
8558
|
+
// Short-circuit the FX presenter hop for visitors who never picked a
|
|
8559
|
+
// currency, or chose the live base: `loadPresenter` returns an
|
|
8560
|
+
// INACTIVE base presenter in exactly those cases (currency-display:
|
|
8561
|
+
// requested unset / === baseCurrency → base presenter), so the bundle
|
|
8562
|
+
// is the base bundle above unchanged. Skipping the FX rate read +
|
|
8563
|
+
// presenter build on every base-currency render is the common-case
|
|
8564
|
+
// win — only resolve the presenter when the visitor chose a non-base
|
|
8565
|
+
// currency that could actually convert.
|
|
8566
|
+
if (chosen != null && chosen !== base) {
|
|
8567
|
+
try {
|
|
8568
|
+
var presenter = await currencyDisplayModule.loadPresenter({
|
|
8569
|
+
fx: deps.currencyDisplay,
|
|
8570
|
+
rounding: deps.currencyRounding || null,
|
|
8571
|
+
baseCurrency: base,
|
|
8572
|
+
displayCurrency: chosen,
|
|
8573
|
+
});
|
|
8574
|
+
// The presenter only activates when the chosen currency is in the
|
|
8575
|
+
// live allow-list AND has a usable rate. A cookie naming a currency
|
|
8576
|
+
// the operator since removed from the list resolves to base.
|
|
8577
|
+
if (presenter && presenter.active && options.indexOf(presenter.displayCurrency) !== -1) {
|
|
8578
|
+
bundle.format_price = presenter.format;
|
|
8579
|
+
bundle.currency_selected = presenter.displayCurrency;
|
|
8580
|
+
if (presenter.note) bundle.currency_note = presenter.note;
|
|
8581
|
+
}
|
|
8582
|
+
} catch (_e) {
|
|
8583
|
+
// FX / rounding backend unavailable — fall back to base display.
|
|
8572
8584
|
}
|
|
8573
|
-
} catch (_e) {
|
|
8574
|
-
// FX / rounding backend unavailable — fall back to base display.
|
|
8575
8585
|
}
|
|
8576
8586
|
return bundle;
|
|
8577
8587
|
}
|
|
@@ -9229,37 +9239,16 @@ function mount(router, deps) {
|
|
|
9229
9239
|
[productId],
|
|
9230
9240
|
)).rows[0];
|
|
9231
9241
|
if (!primary) return [];
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
9237
|
-
|
|
9238
|
-
)
|
|
9239
|
-
|
|
9240
|
-
|
|
9241
|
-
|
|
9242
|
-
var prod = await deps.catalog.products.get(pid);
|
|
9243
|
-
if (!prod || prod.status !== "active") continue;
|
|
9244
|
-
var priceMinor = null;
|
|
9245
|
-
var priceCurrency = "USD";
|
|
9246
|
-
var variants = await deps.catalog.variants.listForProduct(pid);
|
|
9247
|
-
if (variants.length) {
|
|
9248
|
-
var pr = await deps.catalog.prices.current(variants[0].id, "USD");
|
|
9249
|
-
if (pr) { priceMinor = pr.amount_minor; priceCurrency = pr.currency; }
|
|
9250
|
-
}
|
|
9251
|
-
var media = await deps.catalog.media.listForProduct(pid);
|
|
9252
|
-
var hero = media.length ? media[0] : null;
|
|
9253
|
-
out.push({
|
|
9254
|
-
slug: prod.slug,
|
|
9255
|
-
title: prod.title,
|
|
9256
|
-
hero_r2_key: hero ? hero.r2_key : null,
|
|
9257
|
-
hero_alt_text: hero ? (hero.alt_text || prod.title) : null,
|
|
9258
|
-
price_minor: priceMinor,
|
|
9259
|
-
price_currency: priceCurrency,
|
|
9260
|
-
});
|
|
9261
|
-
}
|
|
9262
|
-
return out;
|
|
9242
|
+
// The sibling decoration (price + hero media per pick) now lands
|
|
9243
|
+
// in one batched query instead of per-sibling `products.get` +
|
|
9244
|
+
// `variants.listForProduct` + `prices.current` +
|
|
9245
|
+
// `media.listForProduct`. The helper returns the exact
|
|
9246
|
+
// { slug, title, hero_r2_key, hero_alt_text, price_minor,
|
|
9247
|
+
// price_currency } card shape the rail renderer consumes, in the
|
|
9248
|
+
// same (membership position ASC, product_id ASC) order, self +
|
|
9249
|
+
// inactive excluded, capped at limit — mirroring the edge
|
|
9250
|
+
// listRelatedProducts.
|
|
9251
|
+
return await deps.catalog.batch.relatedSiblings(primary.collection_slug, productId, "USD", limit);
|
|
9263
9252
|
} catch (_e) {
|
|
9264
9253
|
return [];
|
|
9265
9254
|
}
|
|
@@ -9341,58 +9330,102 @@ function mount(router, deps) {
|
|
|
9341
9330
|
var seen = Object.create(null);
|
|
9342
9331
|
var offers = [];
|
|
9343
9332
|
try {
|
|
9333
|
+
// First pass: collect every distinct bundle reachable from the
|
|
9334
|
+
// product's variant SKUs (deduped), preserving discovery order.
|
|
9335
|
+
var bundles = [];
|
|
9344
9336
|
for (var s = 0; s < variantSkus.length; s += 1) {
|
|
9345
9337
|
var bundleList = await deps.bundles.bundlesForComponent(variantSkus[s]);
|
|
9346
9338
|
for (var i = 0; i < bundleList.length; i += 1) {
|
|
9347
|
-
|
|
9348
|
-
|
|
9349
|
-
|
|
9350
|
-
|
|
9351
|
-
|
|
9352
|
-
|
|
9353
|
-
|
|
9354
|
-
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9359
|
-
|
|
9360
|
-
|
|
9361
|
-
|
|
9362
|
-
|
|
9363
|
-
|
|
9364
|
-
|
|
9365
|
-
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
|
|
9369
|
-
|
|
9370
|
-
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9339
|
+
if (seen[bundleList[i].bundle_sku]) continue;
|
|
9340
|
+
seen[bundleList[i].bundle_sku] = true;
|
|
9341
|
+
bundles.push(bundleList[i]);
|
|
9342
|
+
}
|
|
9343
|
+
}
|
|
9344
|
+
if (!bundles.length) return [];
|
|
9345
|
+
|
|
9346
|
+
// Member-title batching: resolve every member SKU's variant + its
|
|
9347
|
+
// owning product in two batched reads instead of a per-member
|
|
9348
|
+
// `variants.bySku` + `products.get`. (The full single-query bundle
|
|
9349
|
+
// resolver the edge has — getBundlesForProduct — is deferred;
|
|
9350
|
+
// member-title batching is the cheap win without touching the
|
|
9351
|
+
// server-authoritative pricing path.)
|
|
9352
|
+
var memberSkus = [];
|
|
9353
|
+
for (var bi = 0; bi < bundles.length; bi += 1) {
|
|
9354
|
+
var comps = bundles[bi].components || [];
|
|
9355
|
+
for (var ci = 0; ci < comps.length; ci += 1) memberSkus.push(comps[ci].sku);
|
|
9356
|
+
}
|
|
9357
|
+
var memberVariantBySku = {};
|
|
9358
|
+
var memberProductById = {};
|
|
9359
|
+
if (deps.catalog.batch && typeof deps.catalog.batch.variantsBySkus === "function") {
|
|
9360
|
+
memberVariantBySku = await deps.catalog.batch.variantsBySkus(memberSkus);
|
|
9361
|
+
var memberProductIds = [];
|
|
9362
|
+
var pidSeen = Object.create(null);
|
|
9363
|
+
for (var ms in memberVariantBySku) {
|
|
9364
|
+
if (!Object.prototype.hasOwnProperty.call(memberVariantBySku, ms)) continue;
|
|
9365
|
+
var pid = memberVariantBySku[ms].product_id;
|
|
9366
|
+
if (pid && !pidSeen[pid]) { pidSeen[pid] = true; memberProductIds.push(pid); }
|
|
9367
|
+
}
|
|
9368
|
+
if (memberProductIds.length && typeof deps.catalog.batch.productsByIds === "function") {
|
|
9369
|
+
memberProductById = await deps.catalog.batch.productsByIds(memberProductIds);
|
|
9370
|
+
}
|
|
9371
|
+
}
|
|
9372
|
+
|
|
9373
|
+
for (var b2 = 0; b2 < bundles.length; b2 += 1) {
|
|
9374
|
+
var bundle = bundles[b2];
|
|
9375
|
+
// Decorate each member with a display title; flag unbuyable.
|
|
9376
|
+
var componentsOut = [];
|
|
9377
|
+
var allBuyable = true;
|
|
9378
|
+
for (var j = 0; j < bundle.components.length; j += 1) {
|
|
9379
|
+
var comp = bundle.components[j];
|
|
9380
|
+
var buyable = await _skuBuyable(comp.sku);
|
|
9381
|
+
if (!buyable) allBuyable = false;
|
|
9382
|
+
// The prior path always read the member variant via bySku
|
|
9383
|
+
// (regardless of buyability) for the title; the batched map is
|
|
9384
|
+
// that same variant. Falls back to a per-SKU bySku only if the
|
|
9385
|
+
// batch surface is absent.
|
|
9386
|
+
var memberVariant = Object.prototype.hasOwnProperty.call(memberVariantBySku, comp.sku)
|
|
9387
|
+
? memberVariantBySku[comp.sku]
|
|
9388
|
+
: (buyable || (await deps.catalog.variants.bySku(comp.sku)));
|
|
9389
|
+
var memberTitle = comp.sku;
|
|
9390
|
+
if (memberVariant) {
|
|
9391
|
+
var memberProduct = Object.prototype.hasOwnProperty.call(memberProductById, memberVariant.product_id)
|
|
9392
|
+
? memberProductById[memberVariant.product_id]
|
|
9393
|
+
: (deps.catalog.batch && typeof deps.catalog.batch.variantsBySkus === "function"
|
|
9394
|
+
? null
|
|
9395
|
+
: await deps.catalog.products.get(memberVariant.product_id));
|
|
9396
|
+
memberTitle = (memberProduct && memberProduct.title) || memberVariant.title || comp.sku;
|
|
9374
9397
|
}
|
|
9375
|
-
|
|
9376
|
-
var available = allBuyable && priced != null;
|
|
9377
|
-
var listMinor = priced ? priced.list_total_minor : 0;
|
|
9378
|
-
var amountMinor = priced ? priced.amount_minor : 0;
|
|
9379
|
-
var discountMinor = priced ? priced.discount_minor : 0;
|
|
9380
|
-
var cur = (priced && priced.currency) || currency;
|
|
9381
|
-
offers.push({
|
|
9382
|
-
bundle_sku: bundle.bundle_sku,
|
|
9383
|
-
title: bundle.title,
|
|
9384
|
-
components: componentsOut,
|
|
9385
|
-
list_total_str: fmt(listMinor, cur),
|
|
9386
|
-
amount_str: fmt(amountMinor, cur),
|
|
9387
|
-
discount_str: discountMinor > 0 ? fmt(discountMinor, cur) : null,
|
|
9388
|
-
available: available,
|
|
9389
|
-
unavailable_reason: available
|
|
9390
|
-
? null
|
|
9391
|
-
: (priced == null
|
|
9392
|
-
? "Pricing for this bundle isn't available right now."
|
|
9393
|
-
: "One or more items in this bundle are out of stock."),
|
|
9394
|
-
});
|
|
9398
|
+
componentsOut.push({ sku: comp.sku, quantity: comp.quantity, title: memberTitle });
|
|
9395
9399
|
}
|
|
9400
|
+
|
|
9401
|
+
var priced = null;
|
|
9402
|
+
try {
|
|
9403
|
+
priced = await deps.bundles.priceBundle({ bundle_sku: bundle.bundle_sku, pricing: _skuPricer(currency) });
|
|
9404
|
+
} catch (_e) {
|
|
9405
|
+
// A missing member price / mixed currency makes the bundle
|
|
9406
|
+
// unpriceable — surface it as unavailable rather than 500.
|
|
9407
|
+
priced = null;
|
|
9408
|
+
}
|
|
9409
|
+
|
|
9410
|
+
var available = allBuyable && priced != null;
|
|
9411
|
+
var listMinor = priced ? priced.list_total_minor : 0;
|
|
9412
|
+
var amountMinor = priced ? priced.amount_minor : 0;
|
|
9413
|
+
var discountMinor = priced ? priced.discount_minor : 0;
|
|
9414
|
+
var cur = (priced && priced.currency) || currency;
|
|
9415
|
+
offers.push({
|
|
9416
|
+
bundle_sku: bundle.bundle_sku,
|
|
9417
|
+
title: bundle.title,
|
|
9418
|
+
components: componentsOut,
|
|
9419
|
+
list_total_str: fmt(listMinor, cur),
|
|
9420
|
+
amount_str: fmt(amountMinor, cur),
|
|
9421
|
+
discount_str: discountMinor > 0 ? fmt(discountMinor, cur) : null,
|
|
9422
|
+
available: available,
|
|
9423
|
+
unavailable_reason: available
|
|
9424
|
+
? null
|
|
9425
|
+
: (priced == null
|
|
9426
|
+
? "Pricing for this bundle isn't available right now."
|
|
9427
|
+
: "One or more items in this bundle are out of stock."),
|
|
9428
|
+
});
|
|
9396
9429
|
}
|
|
9397
9430
|
} catch (_e) {
|
|
9398
9431
|
// Bundles table not migrated / read failure — degrade to no rail.
|
|
@@ -9457,12 +9490,27 @@ function mount(router, deps) {
|
|
|
9457
9490
|
// the cart total.
|
|
9458
9491
|
async function _repriceCartLines(lines) {
|
|
9459
9492
|
if (!deps.quantityDiscounts) return lines;
|
|
9493
|
+
// The owning product_id per line drives the quantity-discount
|
|
9494
|
+
// product-scope match. Resolve every line's variant in one batched
|
|
9495
|
+
// id lookup instead of a per-line `variants.get`, keyed by the same
|
|
9496
|
+
// stable variant_id the line carries (so the resolved product_id is
|
|
9497
|
+
// byte-identical to the per-line read). Best-effort — a read failure
|
|
9498
|
+
// leaves the map empty and each line falls back to no product scope,
|
|
9499
|
+
// exactly as a per-line failure did before.
|
|
9500
|
+
var variantById = {};
|
|
9501
|
+
if (deps.catalog.batch && typeof deps.catalog.batch.variantsByIds === "function") {
|
|
9502
|
+
try {
|
|
9503
|
+
variantById = await deps.catalog.batch.variantsByIds(
|
|
9504
|
+
lines.map(function (l) { return l.variant_id; }),
|
|
9505
|
+
);
|
|
9506
|
+
} catch (_e) { variantById = {}; }
|
|
9507
|
+
}
|
|
9460
9508
|
var out = [];
|
|
9461
9509
|
for (var i = 0; i < lines.length; i += 1) {
|
|
9462
9510
|
var l = lines[i];
|
|
9463
9511
|
var unit = l.unit_amount_minor;
|
|
9464
9512
|
try {
|
|
9465
|
-
var variant =
|
|
9513
|
+
var variant = variantById[l.variant_id] || null;
|
|
9466
9514
|
var applied = await deps.quantityDiscounts.applyToLine({
|
|
9467
9515
|
line: {
|
|
9468
9516
|
sku: l.sku,
|
|
@@ -9625,7 +9673,7 @@ function mount(router, deps) {
|
|
|
9625
9673
|
// applied to available-on-hand (stock_on_hand − stock_held).
|
|
9626
9674
|
async function _cartLineStock(lines) {
|
|
9627
9675
|
var out = {};
|
|
9628
|
-
if (!deps.catalog || !deps.catalog.
|
|
9676
|
+
if (!deps.catalog || !deps.catalog.batch || typeof deps.catalog.batch.inventoryForSkus !== "function") {
|
|
9629
9677
|
return out;
|
|
9630
9678
|
}
|
|
9631
9679
|
var threshold = 5;
|
|
@@ -9635,19 +9683,24 @@ function mount(router, deps) {
|
|
|
9635
9683
|
if (Number.isInteger(t) && t >= 0) threshold = t;
|
|
9636
9684
|
} catch (_e) { /* keep the default */ }
|
|
9637
9685
|
}
|
|
9686
|
+
// One batched inventory read keyed by SKU instead of a per-line
|
|
9687
|
+
// `inventory.get`. Drop-silent on a read failure — the empty map
|
|
9688
|
+
// means every line reads "ok", matching the prior per-line stance.
|
|
9689
|
+
var invBySku = {};
|
|
9690
|
+
try {
|
|
9691
|
+
invBySku = await deps.catalog.batch.inventoryForSkus(
|
|
9692
|
+
lines.map(function (l) { return l.sku; }).filter(function (s) { return !!s; }),
|
|
9693
|
+
);
|
|
9694
|
+
} catch (_e) { invBySku = {}; /* a read failure never blocks the cart */ }
|
|
9638
9695
|
for (var i = 0; i < lines.length; i += 1) {
|
|
9639
9696
|
var vId = lines[i].variant_id;
|
|
9640
9697
|
if (Object.prototype.hasOwnProperty.call(out, vId)) continue;
|
|
9641
9698
|
var sku = lines[i].sku;
|
|
9642
9699
|
if (!sku) { out[vId] = "ok"; continue; }
|
|
9643
|
-
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
out[vId] = avail <= 0 ? "out" : (avail <= threshold ? "low" : "ok");
|
|
9648
|
-
} catch (_e) {
|
|
9649
|
-
out[vId] = "ok"; // drop-silent — a read failure never blocks the cart
|
|
9650
|
-
}
|
|
9700
|
+
var inv = Object.prototype.hasOwnProperty.call(invBySku, sku) ? invBySku[sku] : null;
|
|
9701
|
+
if (!inv) { out[vId] = "ok"; continue; }
|
|
9702
|
+
var avail = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
|
|
9703
|
+
out[vId] = avail <= 0 ? "out" : (avail <= threshold ? "low" : "ok");
|
|
9651
9704
|
}
|
|
9652
9705
|
return out;
|
|
9653
9706
|
}
|
|
@@ -9681,95 +9734,37 @@ function mount(router, deps) {
|
|
|
9681
9734
|
}
|
|
9682
9735
|
|
|
9683
9736
|
router.get("/", async function (req, res) {
|
|
9684
|
-
|
|
9685
|
-
//
|
|
9686
|
-
//
|
|
9687
|
-
//
|
|
9688
|
-
//
|
|
9689
|
-
//
|
|
9690
|
-
//
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
var variants = await deps.catalog.variants.listForProduct(p.id);
|
|
9695
|
-
var startingPrice = null;
|
|
9696
|
-
if (variants.length) {
|
|
9697
|
-
var price = await deps.catalog.prices.current(variants[0].id, "USD");
|
|
9698
|
-
if (price) startingPrice = price;
|
|
9699
|
-
}
|
|
9700
|
-
var media = await deps.catalog.media.listForProduct(p.id);
|
|
9701
|
-
var heroMedia = media.length ? media[0] : null;
|
|
9702
|
-
products.push(Object.assign({}, p, {
|
|
9703
|
-
starting_price_minor: startingPrice ? startingPrice.amount_minor : null,
|
|
9704
|
-
starting_price_currency: startingPrice ? startingPrice.currency : "USD",
|
|
9705
|
-
hero_media: heroMedia,
|
|
9706
|
-
}));
|
|
9707
|
-
}
|
|
9737
|
+
// One decorated query for the whole home grid: each active product
|
|
9738
|
+
// carries its first variant's USD price (when one exists) and its
|
|
9739
|
+
// first attached media row (when one exists), pre-joined via window
|
|
9740
|
+
// functions — the same shape `renderHome` consumes (products
|
|
9741
|
+
// without a price render `—`; products without media render the
|
|
9742
|
+
// text-only PRODUCT_CARD fallback). Replaces the prior 1 + 24×3
|
|
9743
|
+
// per-product fan-out over the D1 bridge. Order (updated_at DESC,
|
|
9744
|
+
// id DESC) matches the prior `products.list` order.
|
|
9745
|
+
var decorated = await deps.catalog.batch.decoratedActive({ currency: "USD", limit: 24 });
|
|
9746
|
+
var products = decorated.rows;
|
|
9708
9747
|
var ccy = await _currencyForReq(req);
|
|
9709
9748
|
var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, _requestUrls(req), ccy));
|
|
9710
9749
|
_send(res, 200, html);
|
|
9711
9750
|
});
|
|
9712
9751
|
|
|
9713
|
-
// Decorate one product row with the display columns the search
|
|
9714
|
-
// cards need (first variant's price, first media) AND the facet
|
|
9715
|
-
// fields the searchFacets primitive computes against: `collection`
|
|
9716
|
-
// (array of collection slugs the product belongs to), `price_minor`
|
|
9717
|
-
// (the starting price), and `in_stock` (any variant with available
|
|
9718
|
-
// stock). Composes the wired catalog / collections primitives; a
|
|
9719
|
-
// collection facet only populates when `deps.collections` is present.
|
|
9720
|
-
async function _decorateForSearch(p) {
|
|
9721
|
-
var variants = await deps.catalog.variants.listForProduct(p.id);
|
|
9722
|
-
var startingPrice = null;
|
|
9723
|
-
var inStock = false;
|
|
9724
|
-
for (var vi = 0; vi < variants.length; vi += 1) {
|
|
9725
|
-
if (vi === 0) {
|
|
9726
|
-
var price = await deps.catalog.prices.current(variants[0].id, "USD");
|
|
9727
|
-
if (price) startingPrice = price;
|
|
9728
|
-
}
|
|
9729
|
-
var inv = await deps.catalog.inventory.get(variants[vi].sku);
|
|
9730
|
-
if (inv && (Number(inv.stock_on_hand) - Number(inv.stock_held)) > 0) inStock = true;
|
|
9731
|
-
}
|
|
9732
|
-
var media = await deps.catalog.media.listForProduct(p.id);
|
|
9733
|
-
var heroMedia = media.length ? media[0] : null;
|
|
9734
|
-
var collectionSlugs = [];
|
|
9735
|
-
if (deps.collections && typeof deps.collections.collectionsForProduct === "function") {
|
|
9736
|
-
var cols = await deps.collections.collectionsForProduct(p.id);
|
|
9737
|
-
for (var ci = 0; ci < cols.length; ci += 1) {
|
|
9738
|
-
if (cols[ci] && cols[ci].slug) collectionSlugs.push(cols[ci].slug);
|
|
9739
|
-
}
|
|
9740
|
-
}
|
|
9741
|
-
return Object.assign({}, p, {
|
|
9742
|
-
starting_price_minor: startingPrice ? startingPrice.amount_minor : null,
|
|
9743
|
-
starting_price_currency: startingPrice ? startingPrice.currency : "USD",
|
|
9744
|
-
hero_media: heroMedia,
|
|
9745
|
-
collection: collectionSlugs,
|
|
9746
|
-
price_minor: startingPrice ? startingPrice.amount_minor : null,
|
|
9747
|
-
in_stock: inStock,
|
|
9748
|
-
});
|
|
9749
|
-
}
|
|
9750
|
-
|
|
9751
9752
|
// Pull the facetable universe for a set of search terms: every
|
|
9752
9753
|
// active product matching ANY term (canonical query + synonym
|
|
9753
9754
|
// expansions) on title / description, decorated once. The
|
|
9754
9755
|
// searchFacets primitive consumes this through a `catalog.list`
|
|
9755
9756
|
// adapter and walks the rows in-memory for counts; the route reuses
|
|
9756
9757
|
// the same rows for the narrowed result grid (one decoration pass,
|
|
9757
|
-
// no double round trip).
|
|
9758
|
+
// no double round trip). One batched query (an OR-of-LIKE term clause
|
|
9759
|
+
// + a collection_members `IN` + a grouped-inventory `IN`) replaces
|
|
9760
|
+
// the prior per-term search + per-row decoration fan-out, and aligns
|
|
9761
|
+
// the `collection` facet field with the edge: MANUAL collection
|
|
9762
|
+
// membership only (smart-collection rule matches no longer populate
|
|
9763
|
+
// the search facet — the edge has always faceted manual-only, so this
|
|
9764
|
+
// removes the cross-substrate drift). Order (updated_at DESC, id DESC)
|
|
9765
|
+
// matches the edge `searchFacetableProducts`.
|
|
9758
9766
|
async function _facetableUniverse(terms) {
|
|
9759
|
-
|
|
9760
|
-
var rows = [];
|
|
9761
|
-
for (var ti = 0; ti < terms.length; ti += 1) {
|
|
9762
|
-
var term = typeof terms[ti] === "string" ? terms[ti].trim() : "";
|
|
9763
|
-
if (!term.length) continue;
|
|
9764
|
-
var page = await deps.catalog.products.search({ q: term, status: "active", limit: 100 });
|
|
9765
|
-
for (var i = 0; i < page.rows.length; i += 1) {
|
|
9766
|
-
var p = page.rows[i];
|
|
9767
|
-
if (byId[p.id]) continue;
|
|
9768
|
-
byId[p.id] = true;
|
|
9769
|
-
rows.push(await _decorateForSearch(p));
|
|
9770
|
-
}
|
|
9771
|
-
}
|
|
9772
|
-
return rows;
|
|
9767
|
+
return (await deps.catalog.batch.searchDecorate({ terms: terms, currency: "USD" })).rows;
|
|
9773
9768
|
}
|
|
9774
9769
|
|
|
9775
9770
|
router.get("/search", async function (req, res) {
|
|
@@ -9872,77 +9867,88 @@ function mount(router, deps) {
|
|
|
9872
9867
|
if (!slug) return _send(res, 400, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
9873
9868
|
var product = await deps.catalog.products.bySlug(slug);
|
|
9874
9869
|
if (!product) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
9875
|
-
|
|
9876
|
-
|
|
9870
|
+
// Variants + their current USD price in one query (the LEFT JOIN
|
|
9871
|
+
// collapses the prior per-variant `prices.current` loop). `vwp.rows`
|
|
9872
|
+
// is byte-identical to `variants.listForProduct` (options parsed),
|
|
9873
|
+
// `vwp.prices` is the { variantId: priceRow } map the renderer +
|
|
9874
|
+
// first-variant pricing read.
|
|
9875
|
+
var vwp = await deps.catalog.batch.variantsWithPrices(product.id, "USD");
|
|
9876
|
+
var variants = vwp.rows;
|
|
9877
|
+
var prices = vwp.prices;
|
|
9877
9878
|
// Per-SKU inventory map driving the truthful availability badge +
|
|
9878
|
-
// JSON-LD.
|
|
9879
|
-
//
|
|
9880
|
-
//
|
|
9881
|
-
//
|
|
9882
|
-
//
|
|
9879
|
+
// JSON-LD. One batched read keyed by SKU, full row (the availability
|
|
9880
|
+
// resolver reads `low_stock_threshold` for the "Only N left" nudge).
|
|
9881
|
+
// Best-effort: a SKU with no inventory row (or a read failure) is
|
|
9882
|
+
// omitted from the map, which the renderer treats as available — the
|
|
9883
|
+
// never-block-on-missing-inventory stance the cart-hold path already
|
|
9884
|
+
// takes. Only populated when the operator has wired the inventory
|
|
9885
|
+
// batch surface.
|
|
9883
9886
|
var inventory = {};
|
|
9884
|
-
|
|
9885
|
-
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
try {
|
|
9889
|
-
var invRow = await deps.catalog.inventory.get(variants[i].sku);
|
|
9890
|
-
if (invRow) inventory[variants[i].sku] = invRow;
|
|
9891
|
-
} catch (_e) { /* drop-silent — missing inventory reads as available */ }
|
|
9892
|
-
}
|
|
9887
|
+
if (variants.length && deps.catalog.batch && typeof deps.catalog.batch.inventoryRowsForSkus === "function") {
|
|
9888
|
+
try {
|
|
9889
|
+
inventory = await deps.catalog.batch.inventoryRowsForSkus(variants.map(function (v) { return v.sku; }));
|
|
9890
|
+
} catch (_e) { inventory = {}; /* drop-silent — missing inventory reads as available */ }
|
|
9893
9891
|
}
|
|
9894
|
-
// Media — first row drives the hero image, the next three feed
|
|
9895
|
-
// the thumbnail strip. `listForProduct` is product-level only;
|
|
9896
|
-
// variant-level media (`listForVariant`) would feed a swap-on-
|
|
9897
|
-
// variant-select interaction we don't ship yet.
|
|
9898
|
-
var media = await deps.catalog.media.listForProduct(product.id);
|
|
9899
9892
|
// Render cart count from the current session's cart, if any.
|
|
9900
9893
|
var cartCount = await _cartCountForReq(req);
|
|
9901
|
-
//
|
|
9902
|
-
//
|
|
9903
|
-
//
|
|
9904
|
-
//
|
|
9905
|
-
// resilience.
|
|
9894
|
+
// The remaining supplementary PDP reads are independent of each
|
|
9895
|
+
// other, so they run concurrently (mirroring the edge's Promise.all
|
|
9896
|
+
// at worker/index.js). Each preserves its own degrade-to-empty
|
|
9897
|
+
// guard so a missing/unmigrated table never 500s the buy path.
|
|
9906
9898
|
var reviewSummary, reviewRows, reviewCta;
|
|
9907
|
-
if (deps.reviews) {
|
|
9908
|
-
try {
|
|
9909
|
-
reviewSummary = await deps.reviews.summaryForProduct(product.id);
|
|
9910
|
-
reviewRows = (await deps.reviews.listForProduct(product.id, { limit: 10 })).rows;
|
|
9911
|
-
} catch (_e) { reviewSummary = undefined; reviewRows = []; }
|
|
9912
|
-
// The form route enforces auth + the verified-purchase gate, so
|
|
9913
|
-
// the CTA links there unconditionally; logged-out shoppers get
|
|
9914
|
-
// redirected to login, non-purchasers get a clear "not eligible".
|
|
9915
|
-
reviewCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
|
|
9916
|
-
b.template.escapeHtml(product.slug) + "/review\">Write a review</a>";
|
|
9917
|
-
}
|
|
9918
|
-
// Wishlist social-proof count — degrades to 0 on a read failure
|
|
9919
|
-
// (e.g. table not yet migrated) rather than 500-ing the PDP.
|
|
9920
9899
|
var wishlistCount = 0;
|
|
9921
|
-
if (deps.wishlist) {
|
|
9922
|
-
try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
|
|
9923
|
-
catch (_e) { wishlistCount = 0; }
|
|
9924
|
-
}
|
|
9925
|
-
// Published Q&A — approved questions + their approved answers. A
|
|
9926
|
-
// failed read (e.g. the product_qa tables not yet migrated) degrades
|
|
9927
|
-
// to the empty state rather than 500-ing the PDP — Q&A is
|
|
9928
|
-
// supplementary to the buy path, like reviews. Mirrors the edge
|
|
9929
|
-
// renderer's missing-table resilience.
|
|
9930
9900
|
var qaQuestions, qaCta;
|
|
9931
|
-
|
|
9932
|
-
|
|
9933
|
-
|
|
9934
|
-
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
9938
|
-
|
|
9939
|
-
|
|
9940
|
-
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9901
|
+
var media;
|
|
9902
|
+
await Promise.all([
|
|
9903
|
+
// Media — first row drives the hero image, the next three feed the
|
|
9904
|
+
// thumbnail strip. `listForProduct` is product-level only.
|
|
9905
|
+
(async function () {
|
|
9906
|
+
media = await deps.catalog.media.listForProduct(product.id);
|
|
9907
|
+
})(),
|
|
9908
|
+
// Published reviews aggregate + list.
|
|
9909
|
+
(async function () {
|
|
9910
|
+
if (!deps.reviews) return;
|
|
9911
|
+
try {
|
|
9912
|
+
reviewSummary = await deps.reviews.summaryForProduct(product.id);
|
|
9913
|
+
reviewRows = (await deps.reviews.listForProduct(product.id, { limit: 10 })).rows;
|
|
9914
|
+
} catch (_e) { reviewSummary = undefined; reviewRows = []; }
|
|
9915
|
+
// The form route enforces auth + the verified-purchase gate, so
|
|
9916
|
+
// the CTA links there unconditionally; logged-out shoppers get
|
|
9917
|
+
// redirected to login, non-purchasers get a clear "not eligible".
|
|
9918
|
+
reviewCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
|
|
9919
|
+
b.template.escapeHtml(product.slug) + "/review\">Write a review</a>";
|
|
9920
|
+
})(),
|
|
9921
|
+
// Wishlist social-proof count — degrades to 0 on a read failure.
|
|
9922
|
+
(async function () {
|
|
9923
|
+
if (!deps.wishlist) return;
|
|
9924
|
+
try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
|
|
9925
|
+
catch (_e) { wishlistCount = 0; }
|
|
9926
|
+
})(),
|
|
9927
|
+
// Published Q&A — approved questions + their approved answers. The
|
|
9928
|
+
// answers for ALL questions land in one batched
|
|
9929
|
+
// `answersForQuestions` read (one D1 hop) instead of a per-question
|
|
9930
|
+
// loop, then are stitched back onto each question in order.
|
|
9931
|
+
(async function () {
|
|
9932
|
+
if (!deps.productQa) return;
|
|
9933
|
+
qaQuestions = [];
|
|
9934
|
+
try {
|
|
9935
|
+
var qList = (await deps.productQa.questionsForProduct({ product_id: product.id, limit: 20 })).rows;
|
|
9936
|
+
var answersByQ = await deps.productQa.answersForQuestions(
|
|
9937
|
+
qList.map(function (q) { return q.id; }),
|
|
9938
|
+
{ limit: 20 },
|
|
9939
|
+
);
|
|
9940
|
+
for (var qi = 0; qi < qList.length; qi += 1) {
|
|
9941
|
+
var qrow = qList[qi];
|
|
9942
|
+
qrow.answers = answersByQ[qrow.id] || [];
|
|
9943
|
+
qaQuestions.push(qrow);
|
|
9944
|
+
}
|
|
9945
|
+
} catch (_e) { qaQuestions = []; }
|
|
9946
|
+
// The form route enforces auth, so the CTA links there
|
|
9947
|
+
// unconditionally; logged-out shoppers get redirected to login.
|
|
9948
|
+
qaCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
|
|
9949
|
+
b.template.escapeHtml(product.slug) + "/question\">Ask a question</a>";
|
|
9950
|
+
})(),
|
|
9951
|
+
]);
|
|
9946
9952
|
// Log the view for a signed-in customer so it surfaces on their
|
|
9947
9953
|
// "Recently viewed" account page. Drop-silent — a recording failure
|
|
9948
9954
|
// (table not migrated, write contention) must never break the PDP
|
package/package.json
CHANGED