@blamejs/blamejs-shop 0.3.44 → 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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +508 -9
- package/lib/product-bulk-ops.js +10 -6
- package/lib/product-qa.js +40 -0
- package/lib/sales-tax-filings.js +8 -12
- package/lib/storefront.js +250 -244
- package/lib/subscription-analytics.js +30 -17
- package/lib/tax.js +20 -25
- package/lib/wishlist-alerts.js +3 -2
- package/lib/wishlist-digest.js +4 -1
- package/package.json +1 -1
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
|