@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 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.20",
2
+ "version": "0.2.21",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {