@blamejs/blamejs-shop 0.2.20 → 0.2.21
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/storefront.js +110 -2
- 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.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.21 (2026-05-29) — **Related products ("You may also like") on the product page.** Product pages now show a related-products rail. The picks are the other products in the product's primary collection, ordered deterministically and capped at four, rendered with the same product-card markup the home and search grids use — identical on the edge and origin paths. The rail is hidden when a product has no related items. This surfaces existing catalog relationships to shoppers without any new data or configuration. **Added:** *Related-products rail on the PDP* — A "You may also like" section on each product page lists other active products from the same primary collection (deterministic order, up to four), using the standard product-card markup byte-identically across the edge and origin renders. Hidden when there are no related products.
|
|
12
|
+
|
|
11
13
|
- v0.2.20 (2026-05-29) — **Manage a subscription yourself — pause, skip, change, or reactivate.** The subscriptions account screen previously only let a customer cancel. It now exposes the full self-service set: pause and resume, skip the next shipment, change the quantity, change the delivery frequency, and reactivate a recently-cancelled subscription within its grace window. Each action is state-gated (only the controls that apply to a subscription's current state are shown), confirm-aware where it changes billing, and reports back with a status notice. No more cancelling just to make a change. **Added:** *Subscription self-management* — `/account/subscriptions` gains Pause / Resume, Skip next shipment, Change quantity, Change frequency, and Reactivate (within the grace window) alongside Cancel. Controls are shown only when valid for the subscription's state; quantity and frequency are validated server-side; pause and cancel go through a confirmation step.
|
|
12
14
|
|
|
13
15
|
- v0.2.19 (2026-05-29) — **Search results render the same product cards as the rest of the store.** A product with no image showed an outdated, text-only card on the search-results page when that page was served from the edge, while the home and category grids (and the origin-rendered search page) used the current placeholder-illustration card. The edge search page now emits the same card markup as every other grid, so a no-image product looks identical wherever it appears. A render-level parity check now pins the search page's markup byte-for-byte across the edge and origin paths so this kind of drift is caught automatically. **Fixed:** *Consistent no-image product card on search* — The edge-rendered search results page used a stale text-only card for products without a hero image; it now renders the placeholder-illustration card used across the rest of the storefront, byte-identical to the origin render.
|
package/lib/asset-manifest.json
CHANGED
package/lib/storefront.js
CHANGED
|
@@ -725,6 +725,22 @@ function _buildProductCard(p) {
|
|
|
725
725
|
});
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
+
// PDP "You may also like" rail. `related` is the pre-decorated card
|
|
729
|
+
// list [{ slug, title, price, image_url, image_alt }] the PDP renderer
|
|
730
|
+
// builds from the same-collection picks. Reuses the catalog grid +
|
|
731
|
+
// product-card markup so it inherits the storefront's card styling.
|
|
732
|
+
// Returns "" when there's nothing to show so the PDP renders no empty
|
|
733
|
+
// rail. Mirrored byte-for-byte by worker/render/product.js#_buildRelatedProducts.
|
|
734
|
+
function _buildRelatedProducts(related) {
|
|
735
|
+
related = related || [];
|
|
736
|
+
if (related.length === 0) return "";
|
|
737
|
+
var cards = related.map(function (p) { return _buildProductCard(p); }).join("");
|
|
738
|
+
return "<section class=\"catalog-section pdp-recommendations\" aria-labelledby=\"pdp-related-title\">" +
|
|
739
|
+
"<header class=\"section-head\"><h2 id=\"pdp-related-title\" class=\"section-head__title\">You may also like</h2></header>" +
|
|
740
|
+
"<div class=\"grid\">" + cards + "</div>" +
|
|
741
|
+
"</section>";
|
|
742
|
+
}
|
|
743
|
+
|
|
728
744
|
var HOME_HERO =
|
|
729
745
|
"<section class=\"hero hero--dark\">\n" +
|
|
730
746
|
" <div class=\"hero__bg\" aria-hidden=\"true\">\n" +
|
|
@@ -1535,7 +1551,8 @@ var PRODUCT_PAGE =
|
|
|
1535
1551
|
" RAW_BUNDLES_PLACEHOLDER\n" +
|
|
1536
1552
|
" RAW_REVIEWS_PLACEHOLDER\n" +
|
|
1537
1553
|
" RAW_QA_PLACEHOLDER\n" +
|
|
1538
|
-
"</section>\n"
|
|
1554
|
+
"</section>\n" +
|
|
1555
|
+
"RAW_RELATED_PLACEHOLDER";
|
|
1539
1556
|
|
|
1540
1557
|
// PDP gallery markup — composed once per render call from the
|
|
1541
1558
|
// product's media rows. When media is present, the first row drives
|
|
@@ -3565,6 +3582,27 @@ function renderProduct(opts) {
|
|
|
3565
3582
|
var vTitle = v.title || (Object.keys(v.options || {}).map(function (k) { return v.options[k]; }).join(" / ") || "Default");
|
|
3566
3583
|
return { id: v.id, sku: v.sku, title: vTitle, price: priceStr };
|
|
3567
3584
|
});
|
|
3585
|
+
// "You may also like" rail — same-collection picks the route decorated
|
|
3586
|
+
// with each card's hero media + first-variant price (minor units). The
|
|
3587
|
+
// price string is formatted here with the page's own `fmt` so it tracks
|
|
3588
|
+
// the active currency context exactly as the buy-box prices do. Mirrors
|
|
3589
|
+
// the edge renderer (`worker/render/product.js`) so the section is
|
|
3590
|
+
// byte-identical across substrates. Built once, shared by the theme
|
|
3591
|
+
// branch's raw slot and the inline-template branch's placeholder.
|
|
3592
|
+
var relatedAssetPrefix = opts.asset_prefix || "/assets/";
|
|
3593
|
+
var relatedCards = (opts.related || []).map(function (r) {
|
|
3594
|
+
var priceStr = Number.isInteger(r.price_minor)
|
|
3595
|
+
? fmt(r.price_minor, r.price_currency || "USD")
|
|
3596
|
+
: "—";
|
|
3597
|
+
return {
|
|
3598
|
+
slug: r.slug,
|
|
3599
|
+
title: r.title,
|
|
3600
|
+
price: priceStr,
|
|
3601
|
+
image_url: r.hero_r2_key ? (relatedAssetPrefix + r.hero_r2_key) : null,
|
|
3602
|
+
image_alt: r.hero_r2_key ? (r.hero_alt_text || r.title) : null,
|
|
3603
|
+
};
|
|
3604
|
+
});
|
|
3605
|
+
var relatedHtml = _buildRelatedProducts(relatedCards);
|
|
3568
3606
|
if (opts.theme) {
|
|
3569
3607
|
return opts.theme.render("product", {
|
|
3570
3608
|
title: opts.product.title,
|
|
@@ -3582,6 +3620,10 @@ function renderProduct(opts) {
|
|
|
3582
3620
|
qty_breaks_html: _buildQtyBreaks(opts.qty_breaks),
|
|
3583
3621
|
wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
|
|
3584
3622
|
compare_html: _buildCompare(opts.product.id),
|
|
3623
|
+
// Pre-rendered "You may also like" rail for the theme's
|
|
3624
|
+
// `{{{ related_html }}}` raw slot. Empty string when the product
|
|
3625
|
+
// has no same-collection siblings to show.
|
|
3626
|
+
related_html: relatedHtml,
|
|
3585
3627
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
3586
3628
|
});
|
|
3587
3629
|
}
|
|
@@ -3614,7 +3656,8 @@ function renderProduct(opts) {
|
|
|
3614
3656
|
.replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
|
|
3615
3657
|
.replace("RAW_BUNDLES_PLACEHOLDER", bundlesHtml)
|
|
3616
3658
|
.replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
|
|
3617
|
-
.replace("RAW_QA_PLACEHOLDER", qaHtml)
|
|
3659
|
+
.replace("RAW_QA_PLACEHOLDER", qaHtml)
|
|
3660
|
+
.replace("RAW_RELATED_PLACEHOLDER", relatedHtml);
|
|
3618
3661
|
// Product-specific OpenGraph + Twitter Card values so shares
|
|
3619
3662
|
// unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
|
|
3620
3663
|
// the default shop-level description + brand logo.
|
|
@@ -6152,6 +6195,66 @@ function mount(router, deps) {
|
|
|
6152
6195
|
};
|
|
6153
6196
|
}
|
|
6154
6197
|
|
|
6198
|
+
// "You may also like" picks for a PDP. Deterministic same-collection
|
|
6199
|
+
// query so the edge (worker/data/catalog.js#listRelatedProducts) and
|
|
6200
|
+
// the container produce the SAME ordered list and the rail renders
|
|
6201
|
+
// byte-identically wherever the PDP is served: the source product's
|
|
6202
|
+
// primary collection (lowest membership position), then the other
|
|
6203
|
+
// active members of that collection ordered by membership position
|
|
6204
|
+
// then product id, self excluded, capped at `limit`. The signal-based
|
|
6205
|
+
// recommendations engine (co-purchase + RANDOM filler) is intentionally
|
|
6206
|
+
// NOT used here — its order isn't reproducible at the edge, which has
|
|
6207
|
+
// no engine handle and can't replay RANDOM(). Each pick is decorated
|
|
6208
|
+
// with its first variant's current USD price (minor units) + first
|
|
6209
|
+
// media row; the renderer formats the price string with the page's own
|
|
6210
|
+
// currency formatter so the rail tracks the active display currency.
|
|
6211
|
+
// Best-effort: a read failure (tables not migrated) returns [] so the
|
|
6212
|
+
// PDP renders without the rail rather than 500-ing.
|
|
6213
|
+
async function _relatedProductsFor(productId, limit) {
|
|
6214
|
+
var query = function (sql, params) { return b.externalDb.query(sql, params); };
|
|
6215
|
+
try {
|
|
6216
|
+
var primary = (await query(
|
|
6217
|
+
"SELECT collection_slug FROM collection_members WHERE product_id = ?1 " +
|
|
6218
|
+
"ORDER BY position ASC, id ASC LIMIT 1",
|
|
6219
|
+
[productId],
|
|
6220
|
+
)).rows[0];
|
|
6221
|
+
if (!primary) return [];
|
|
6222
|
+
var siblingRows = (await query(
|
|
6223
|
+
"SELECT cm.product_id AS pid FROM collection_members cm " +
|
|
6224
|
+
"JOIN products p ON p.id = cm.product_id " +
|
|
6225
|
+
"WHERE cm.collection_slug = ?1 AND cm.product_id != ?2 AND p.status = 'active' " +
|
|
6226
|
+
"ORDER BY cm.position ASC, cm.product_id ASC LIMIT ?3",
|
|
6227
|
+
[primary.collection_slug, productId, limit],
|
|
6228
|
+
)).rows;
|
|
6229
|
+
var out = [];
|
|
6230
|
+
for (var i = 0; i < siblingRows.length; i += 1) {
|
|
6231
|
+
var pid = siblingRows[i].pid;
|
|
6232
|
+
var prod = await deps.catalog.products.get(pid);
|
|
6233
|
+
if (!prod || prod.status !== "active") continue;
|
|
6234
|
+
var priceMinor = null;
|
|
6235
|
+
var priceCurrency = "USD";
|
|
6236
|
+
var variants = await deps.catalog.variants.listForProduct(pid);
|
|
6237
|
+
if (variants.length) {
|
|
6238
|
+
var pr = await deps.catalog.prices.current(variants[0].id, "USD");
|
|
6239
|
+
if (pr) { priceMinor = pr.amount_minor; priceCurrency = pr.currency; }
|
|
6240
|
+
}
|
|
6241
|
+
var media = await deps.catalog.media.listForProduct(pid);
|
|
6242
|
+
var hero = media.length ? media[0] : null;
|
|
6243
|
+
out.push({
|
|
6244
|
+
slug: prod.slug,
|
|
6245
|
+
title: prod.title,
|
|
6246
|
+
hero_r2_key: hero ? hero.r2_key : null,
|
|
6247
|
+
hero_alt_text: hero ? (hero.alt_text || prod.title) : null,
|
|
6248
|
+
price_minor: priceMinor,
|
|
6249
|
+
price_currency: priceCurrency,
|
|
6250
|
+
});
|
|
6251
|
+
}
|
|
6252
|
+
return out;
|
|
6253
|
+
} catch (_e) {
|
|
6254
|
+
return [];
|
|
6255
|
+
}
|
|
6256
|
+
}
|
|
6257
|
+
|
|
6155
6258
|
// Resolve the cart for this request — read session_id from the
|
|
6156
6259
|
// sealed cookie, create one (and the cart) if absent. Returns
|
|
6157
6260
|
// the cart row OR null when the cart was just created (caller can
|
|
@@ -6660,6 +6763,10 @@ function mount(router, deps) {
|
|
|
6660
6763
|
var qtyBreaks = firstVariant && firstPrice
|
|
6661
6764
|
? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency, ccyFmt)
|
|
6662
6765
|
: [];
|
|
6766
|
+
// "You may also like" — same-collection picks (deterministic order,
|
|
6767
|
+
// mirrored at the edge). Best-effort inside the helper; an empty list
|
|
6768
|
+
// hides the rail.
|
|
6769
|
+
var related = await _relatedProductsFor(product.id, 4);
|
|
6663
6770
|
var html = renderProduct(Object.assign({
|
|
6664
6771
|
product: product,
|
|
6665
6772
|
variants: variants,
|
|
@@ -6674,6 +6781,7 @@ function mount(router, deps) {
|
|
|
6674
6781
|
bundle_offers: bundleOffers,
|
|
6675
6782
|
qty_breaks: qtyBreaks,
|
|
6676
6783
|
wishlist_count: wishlistCount,
|
|
6784
|
+
related: related,
|
|
6677
6785
|
shop_name: shopName,
|
|
6678
6786
|
cart_count: cartCount,
|
|
6679
6787
|
theme: theme,
|
package/package.json
CHANGED