@blamejs/blamejs-shop 0.2.19 → 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,10 @@ 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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.2.18 (2026-05-29) — **Admin: a clear "payments aren't live" warning, and void a gift card.** Two admin-console fixes. The landing page now warns that payments aren't live whenever Stripe isn't configured — independently of the setup wizard — so an operator who finishes the identity wizard isn't misled into thinking customers can check out when they still can't. And gift cards can now be voided from the console: the card detail screen gets a confirm-gated Void action for active cards, composing the existing void capability. **Added:** *Gift-card void* — The gift-card detail screen shows a confirm-gated "Void card" action for active cards. Voiding goes through a confirmation step and is content-negotiated like the rest of the console (a bearer-token client gets the voided card as JSON; already-redeemed cards are refused). **Changed:** *Payment-readiness banner on the admin landing* — The admin landing shows a "Payments aren't live yet — customers can't check out. See Integrations" banner whenever Stripe isn't enabled, gated on live payment readiness rather than on whether the setup wizard was marked complete. The existing not-set-up identity banner is unchanged; both can appear together.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.19",
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
@@ -3156,16 +3173,83 @@ function _subscriptionIsCancelable(sub) {
3156
3173
  return CANCELABLE_SUB_STATUSES.indexOf(sub.status) !== -1 && Number(sub.cancel_at_period_end) !== 1;
3157
3174
  }
3158
3175
 
3176
+ // Local control-state, derived the same way subscriptionControls derives
3177
+ // it — `cancelled_at` set ⇒ cancelled, else `paused_at` set ⇒ paused,
3178
+ // else active. This is the lifecycle the self-manage controls act on; it
3179
+ // rides alongside Stripe's `status` (the billing mirror), which the
3180
+ // controls primitive never writes.
3181
+ function _subscriptionControlState(sub) {
3182
+ if (sub.cancelled_at != null) return "cancelled";
3183
+ if (sub.paused_at != null) return "paused";
3184
+ return "active";
3185
+ }
3186
+
3187
+ // The 90-day reactivation grace mirrors subscriptionControls.REACTIVATE_
3188
+ // GRACE_MS. A cancelled row is reactivatable from the storefront while
3189
+ // the cancellation is inside that window; past it, the primitive refuses
3190
+ // and the customer must re-subscribe.
3191
+ var REACTIVATE_GRACE_MS = b.constants.TIME.days(90);
3192
+ function _subscriptionIsReactivatable(sub) {
3193
+ return sub.cancelled_at != null && (Date.now() - Number(sub.cancelled_at)) <= REACTIVATE_GRACE_MS;
3194
+ }
3195
+
3196
+ // The cadence-change <select> options — mirrors subscriptionControls.
3197
+ // FREQUENCIES. Pre-selects the row's current frequency when set.
3198
+ var SUB_FREQUENCIES = ["weekly", "biweekly", "monthly", "quarterly", "semiannual", "annual"];
3199
+ function _frequencyOptions(current) {
3200
+ var esc = b.template.escapeHtml;
3201
+ var out = "";
3202
+ for (var i = 0; i < SUB_FREQUENCIES.length; i += 1) {
3203
+ var f = SUB_FREQUENCIES[i];
3204
+ out += "<option value=\"" + f + "\"" + (f === current ? " selected" : "") + ">" + esc(f) + "</option>";
3205
+ }
3206
+ return out;
3207
+ }
3208
+
3209
+ // Map the ?ok=<kind> self-manage redirect marker to confirmation copy
3210
+ // rendered (role="status") at the top of the list. Unknown markers
3211
+ // degrade to no notice so a forged query can't inject copy. The legacy
3212
+ // ?canceled=1 marker keeps its own message (set by the route).
3213
+ var SUBSCRIPTION_OK = {
3214
+ paused: "Your subscription is paused.",
3215
+ resumed: "Your subscription has resumed.",
3216
+ skipped: "Your next shipment has been skipped.",
3217
+ quantity: "Quantity updated.",
3218
+ frequency: "Delivery frequency updated.",
3219
+ reactivated: "Your subscription has been reactivated.",
3220
+ };
3221
+ function _subscriptionOkCopy(kind) {
3222
+ return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_OK, kind) ? SUBSCRIPTION_OK[kind] : null;
3223
+ }
3224
+
3225
+ // Self-manage failures round-trip a fixed ?error=<code> so the list can
3226
+ // echo a human message without reflecting an attacker-controlled string.
3227
+ // Unknown codes → no notice.
3228
+ var SUBSCRIPTION_ERR = {
3229
+ quantity: "Enter a quantity of 1 or more.",
3230
+ frequency: "Choose a valid delivery frequency.",
3231
+ state: "That change isn't available for this subscription right now.",
3232
+ grace: "This subscription was cancelled too long ago to reactivate. Start a new one instead.",
3233
+ };
3234
+ function _subscriptionErrorCopy(code) {
3235
+ if (code == null) return null;
3236
+ return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_ERR, code) ? SUBSCRIPTION_ERR[code] : null;
3237
+ }
3238
+
3159
3239
  // Customer-facing subscription list. `opts.subscriptions` is an array of
3160
3240
  // subscription rows, each optionally carrying a resolved `plan` (joined
3161
3241
  // by the route). `opts.can_cancel` is false when the deploy has no
3162
3242
  // payment handle wired — the list renders read-only with a note, since
3163
- // cancel composes Stripe. Empty state points at the catalog (creation is
3164
- // a separate Stripe-subscription-checkout surface, not built here).
3243
+ // cancel composes Stripe. `opts.self_manage` adds the pause / resume /
3244
+ // skip / change-quantity / change-frequency / reactivate controls (wired
3245
+ // only when the subscriptionControls primitive is available). Empty state
3246
+ // points at the catalog (creation is a separate Stripe-subscription-
3247
+ // checkout surface, not built here).
3165
3248
  function renderAccountSubscriptions(opts) {
3166
3249
  var esc = b.template.escapeHtml;
3167
3250
  var subs = opts.subscriptions || [];
3168
3251
  var canCancel = opts.can_cancel !== false;
3252
+ var selfManage = opts.self_manage === true;
3169
3253
  var rowsHtml = "";
3170
3254
  for (var i = 0; i < subs.length; i += 1) {
3171
3255
  var s = subs[i];
@@ -3196,6 +3280,20 @@ function renderAccountSubscriptions(opts) {
3196
3280
  } else if (periodEnd) {
3197
3281
  renewalNote = "Renews <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>";
3198
3282
  }
3283
+ var controlState = _subscriptionControlState(s);
3284
+ // Local-state meta line (paused / cancelled) so the customer sees the
3285
+ // self-managed cadence state distinct from the Stripe billing pill.
3286
+ var stateNote = "";
3287
+ if (selfManage && controlState === "paused") {
3288
+ if (s.paused_until != null) {
3289
+ var pUntil = new Date(Number(s.paused_until)).toISOString().slice(0, 10);
3290
+ stateNote = "Paused until <time datetime=\"" + esc(pUntil) + "\">" + esc(pUntil) + "</time>";
3291
+ } else {
3292
+ stateNote = "Paused";
3293
+ }
3294
+ } else if (selfManage && controlState === "cancelled") {
3295
+ stateNote = "Cancelled";
3296
+ }
3199
3297
  var cancelControl = "";
3200
3298
  if (canCancel && _subscriptionIsCancelable(s)) {
3201
3299
  // The cancel decision (and its "immediate vs. at period end"
@@ -3205,6 +3303,52 @@ function renderAccountSubscriptions(opts) {
3205
3303
  cancelControl =
3206
3304
  "<a class=\"btn-ghost btn-ghost--sm subscription-card__cancel-link\" href=\"/account/subscriptions/" + esc(s.id) + "/cancel\">Cancel subscription</a>";
3207
3305
  }
3306
+ // Self-manage controls — state-gated, mirroring the primitive's FSM
3307
+ // (active ⇒ pause / skip / change-qty / change-freq; paused ⇒ resume;
3308
+ // cancelled-within-grace ⇒ reactivate). Pause is confirm-gated like
3309
+ // cancel (a second server-rendered screen, no inline confirm()); the
3310
+ // reversible controls post directly. Quantity / frequency take input
3311
+ // validated server-side.
3312
+ var manageControls = "";
3313
+ if (selfManage) {
3314
+ var actId = esc(s.id);
3315
+ var ctrls = "";
3316
+ if (controlState === "active") {
3317
+ ctrls +=
3318
+ "<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/subscriptions/" + actId + "/pause\">Pause</a>";
3319
+ ctrls +=
3320
+ "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/skip\">" +
3321
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Skip next shipment</button>" +
3322
+ "</form>";
3323
+ ctrls +=
3324
+ "<form class=\"subscription-card__control subscription-card__control--qty\" method=\"post\" action=\"/account/subscriptions/" + actId + "/quantity\">" +
3325
+ "<label class=\"form-field form-field--inline\">" +
3326
+ "<span class=\"form-field__label\">Quantity</span>" +
3327
+ "<input type=\"number\" name=\"quantity\" min=\"1\" step=\"1\" value=\"" + esc(String(s.quantity == null ? 1 : s.quantity)) + "\" required>" +
3328
+ "</label>" +
3329
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update quantity</button>" +
3330
+ "</form>";
3331
+ ctrls +=
3332
+ "<form class=\"subscription-card__control subscription-card__control--freq\" method=\"post\" action=\"/account/subscriptions/" + actId + "/frequency\">" +
3333
+ "<label class=\"form-field form-field--inline\">" +
3334
+ "<span class=\"form-field__label\">Frequency</span>" +
3335
+ "<select name=\"frequency\" required>" + _frequencyOptions(s.frequency || null) + "</select>" +
3336
+ "</label>" +
3337
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update frequency</button>" +
3338
+ "</form>";
3339
+ } else if (controlState === "paused") {
3340
+ ctrls +=
3341
+ "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/resume\">" +
3342
+ "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Resume</button>" +
3343
+ "</form>";
3344
+ } else if (controlState === "cancelled" && _subscriptionIsReactivatable(s)) {
3345
+ ctrls +=
3346
+ "<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/reactivate\">" +
3347
+ "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Reactivate</button>" +
3348
+ "</form>";
3349
+ }
3350
+ if (ctrls) manageControls = "<div class=\"subscription-card__manage\">" + ctrls + "</div>";
3351
+ }
3208
3352
  rowsHtml +=
3209
3353
  "<li class=\"subscription-card\">" +
3210
3354
  "<div class=\"subscription-card__head\">" +
@@ -3212,6 +3356,8 @@ function renderAccountSubscriptions(opts) {
3212
3356
  _subscriptionStatusBadge(s.status) +
3213
3357
  "</div>" +
3214
3358
  (renewalNote ? "<p class=\"subscription-card__meta\">" + renewalNote + "</p>" : "") +
3359
+ (stateNote ? "<p class=\"subscription-card__state\">" + stateNote + "</p>" : "") +
3360
+ manageControls +
3215
3361
  cancelControl +
3216
3362
  "</li>";
3217
3363
  }
@@ -3225,6 +3371,15 @@ function renderAccountSubscriptions(opts) {
3225
3371
  "<p class=\"account-empty__lede\">You have no active subscriptions.</p>" +
3226
3372
  "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
3227
3373
  "</div>";
3374
+ // The notice is either the success copy from a self-manage ?ok=<kind>
3375
+ // round-trip (role="status") or an error string the POST handler passes
3376
+ // back (role="alert"). The legacy cancel notice arrives as opts.notice.
3377
+ var noticeHtml = "";
3378
+ if (opts.error) {
3379
+ noticeHtml = "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.error)) + "</p>";
3380
+ } else if (opts.notice) {
3381
+ noticeHtml = "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(String(opts.notice)) + "</p>";
3382
+ }
3228
3383
  var body =
3229
3384
  "<section class=\"account-subscriptions\">" +
3230
3385
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -3232,7 +3387,7 @@ function renderAccountSubscriptions(opts) {
3232
3387
  "<li aria-current=\"page\">Subscriptions</li>" +
3233
3388
  "</ol></nav>" +
3234
3389
  "<h1 class=\"account-subscriptions__title\">Subscriptions</h1>" +
3235
- (opts.notice ? "<p class=\"form-notice\" role=\"status\">" + esc(String(opts.notice)) + "</p>" : "") +
3390
+ noticeHtml +
3236
3391
  inner +
3237
3392
  "</section>";
3238
3393
  return _wrap({
@@ -3317,6 +3472,52 @@ function renderSubscriptionCancelConfirm(opts) {
3317
3472
  });
3318
3473
  }
3319
3474
 
3475
+ // Server-rendered confirmation step for pausing a subscription. Pause is
3476
+ // a reversible-but-deliberate state change, so it gets the same confirm-
3477
+ // GET → POST gate the cancel flow uses (CSP forbids an inline confirm()).
3478
+ // The form posts straight back to /pause; a "Keep it active" link returns
3479
+ // to the list. JS-off-native.
3480
+ function renderSubscriptionPauseConfirm(opts) {
3481
+ var esc = b.template.escapeHtml;
3482
+ var s = opts.subscription || {};
3483
+ var plan = s.plan || null;
3484
+ var planSummary = "this subscription";
3485
+ if (plan) {
3486
+ var ccy = String(plan.currency || "usd").toUpperCase();
3487
+ var every = Number(plan.interval_count) > 1
3488
+ ? "every " + Number(plan.interval_count) + " " + String(plan.interval) + "s"
3489
+ : "per " + String(plan.interval);
3490
+ var priceStr = "";
3491
+ try { priceStr = pricing.format(Number(plan.amount_minor), ccy) + " "; } catch (_e) { priceStr = ""; }
3492
+ planSummary = priceStr + every;
3493
+ }
3494
+ var pauseForm =
3495
+ "<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/pause\">" +
3496
+ "<p class=\"account-confirm__option\">Pausing holds your deliveries until you resume. No shipments go out and you won't be billed for the paused period; resume any time to pick back up.</p>" +
3497
+ "<button type=\"submit\" class=\"btn-primary\">Pause subscription</button>" +
3498
+ "</form>";
3499
+ var body =
3500
+ "<section class=\"account-confirm\">" +
3501
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3502
+ "<li><a href=\"/account\">Account</a></li>" +
3503
+ "<li><a href=\"/account/subscriptions\">Subscriptions</a></li>" +
3504
+ "<li aria-current=\"page\">Pause</li>" +
3505
+ "</ol></nav>" +
3506
+ "<h1 class=\"account-confirm__title\">Pause " + esc(planSummary) + "?</h1>" +
3507
+ "<div class=\"account-confirm__actions\">" +
3508
+ pauseForm +
3509
+ "<a class=\"btn-ghost\" href=\"/account/subscriptions\">Keep it active</a>" +
3510
+ "</div>" +
3511
+ "</section>";
3512
+ return _wrap({
3513
+ title: "Pause subscription",
3514
+ shop_name: opts.shop_name || "blamejs.shop",
3515
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3516
+ theme_css: opts.theme_css,
3517
+ body: body,
3518
+ });
3519
+ }
3520
+
3320
3521
  // Product-level "Save to wishlist" control + social-proof count.
3321
3522
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
3322
3523
  // so both paths emit identical markup. Action-only label — the toggle
@@ -3381,6 +3582,27 @@ function renderProduct(opts) {
3381
3582
  var vTitle = v.title || (Object.keys(v.options || {}).map(function (k) { return v.options[k]; }).join(" / ") || "Default");
3382
3583
  return { id: v.id, sku: v.sku, title: vTitle, price: priceStr };
3383
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);
3384
3606
  if (opts.theme) {
3385
3607
  return opts.theme.render("product", {
3386
3608
  title: opts.product.title,
@@ -3398,6 +3620,10 @@ function renderProduct(opts) {
3398
3620
  qty_breaks_html: _buildQtyBreaks(opts.qty_breaks),
3399
3621
  wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
3400
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,
3401
3627
  asset_css_main: opts.theme.assetUrl("css/main.css"),
3402
3628
  });
3403
3629
  }
@@ -3430,7 +3656,8 @@ function renderProduct(opts) {
3430
3656
  .replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
3431
3657
  .replace("RAW_BUNDLES_PLACEHOLDER", bundlesHtml)
3432
3658
  .replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
3433
- .replace("RAW_QA_PLACEHOLDER", qaHtml);
3659
+ .replace("RAW_QA_PLACEHOLDER", qaHtml)
3660
+ .replace("RAW_RELATED_PLACEHOLDER", relatedHtml);
3434
3661
  // Product-specific OpenGraph + Twitter Card values so shares
3435
3662
  // unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
3436
3663
  // the default shop-level description + brand logo.
@@ -5968,6 +6195,66 @@ function mount(router, deps) {
5968
6195
  };
5969
6196
  }
5970
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
+
5971
6258
  // Resolve the cart for this request — read session_id from the
5972
6259
  // sealed cookie, create one (and the cart) if absent. Returns
5973
6260
  // the cart row OR null when the cart was just created (caller can
@@ -6476,6 +6763,10 @@ function mount(router, deps) {
6476
6763
  var qtyBreaks = firstVariant && firstPrice
6477
6764
  ? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency, ccyFmt)
6478
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);
6479
6770
  var html = renderProduct(Object.assign({
6480
6771
  product: product,
6481
6772
  variants: variants,
@@ -6490,6 +6781,7 @@ function mount(router, deps) {
6490
6781
  bundle_offers: bundleOffers,
6491
6782
  qty_breaks: qtyBreaks,
6492
6783
  wishlist_count: wishlistCount,
6784
+ related: related,
6493
6785
  shop_name: shopName,
6494
6786
  cart_count: cartCount,
6495
6787
  theme: theme,
@@ -8637,6 +8929,13 @@ function mount(router, deps) {
8637
8929
  // flow) is a separate surface; this is the management view.
8638
8930
  if (subscriptions) {
8639
8931
  var subsCanCancel = !!deps.payment;
8932
+ // Lifecycle controls (pause / resume / skip / change-qty / change-
8933
+ // freq / reactivate) mount only when the subscriptionControls
8934
+ // primitive is wired. Independent of Stripe — the controls write
8935
+ // local columns on the subscription row, not the upstream billing
8936
+ // state — so they're available even on a deploy with no payment
8937
+ // handle. The list above stays a read-only view when this is absent.
8938
+ var subControls = deps.subscriptionControls || null;
8640
8939
 
8641
8940
  function _subsAuth(req, res) {
8642
8941
  var auth;
@@ -8702,15 +9001,159 @@ function mount(router, deps) {
8702
9001
  var cartCount = await _cartCountForReq(req);
8703
9002
  var url = req.url ? new URL(req.url, "http://localhost") : null;
8704
9003
  var canceled = !!(url && url.searchParams.get("canceled"));
9004
+ // Success copy comes from either the legacy ?canceled=1 marker or
9005
+ // a self-manage ?ok=<kind> round-trip; an ?error message a control
9006
+ // POST bounces back surfaces as an alert. Unknown ?ok keys → no
9007
+ // notice (a forged query can't inject copy).
9008
+ var okKind = url ? url.searchParams.get("ok") : null;
9009
+ var notice = canceled
9010
+ ? "Your subscription has been canceled."
9011
+ : _subscriptionOkCopy(okKind);
9012
+ var errKind = url ? url.searchParams.get("error") : null;
8705
9013
  _send(res, 200, renderAccountSubscriptions({
8706
9014
  subscriptions: rows,
8707
9015
  can_cancel: subsCanCancel,
8708
- notice: canceled ? "Your subscription has been canceled." : null,
9016
+ self_manage: !!subControls,
9017
+ notice: notice,
9018
+ error: _subscriptionErrorCopy(errKind),
8709
9019
  shop_name: shopName,
8710
9020
  cart_count: cartCount,
8711
9021
  }));
8712
9022
  });
8713
9023
 
9024
+ // Self-manage lifecycle — pause / resume / skip / change-quantity /
9025
+ // change-frequency / reactivate. Each route owns its subscription
9026
+ // via the same ownership check the cancel flow uses (a forged/foreign
9027
+ // id 404s before any write), then composes the matching control
9028
+ // method with a `customer` actor. State-machine refusals from the
9029
+ // primitive (e.g. pause a cancelled sub) and shape errors bounce back
9030
+ // to the list with a fixed ?error code rather than 500-ing. Wired
9031
+ // independently of Stripe (the controls write local columns).
9032
+ if (subControls) {
9033
+ var SELF_ACTOR = { actor_type: "customer", actor_id: null };
9034
+
9035
+ // Translate a control-method rejection into the list redirect.
9036
+ // Validation TypeErrors and FSM/grace refusals (which carry a
9037
+ // `code`) map to a fixed ?error; anything else rethrows (a real
9038
+ // 500, e.g. an unmigrated table on a misconfigured deploy).
9039
+ function _controlError(e) {
9040
+ if (e && e.code === "SUBSCRIPTION_REACTIVATE_GRACE_EXPIRED") return "grace";
9041
+ if (e && e.code === "SUBSCRIPTION_STATE_REFUSED") return "state";
9042
+ if (e && e.code === "SUBSCRIPTION_NOT_FOUND") return "state";
9043
+ if (e instanceof TypeError) return "state";
9044
+ return null;
9045
+ }
9046
+ function _redirect(res, suffix) {
9047
+ res.status(303);
9048
+ res.setHeader && res.setHeader("location", "/account/subscriptions" + suffix);
9049
+ return res.end ? res.end() : res.send("");
9050
+ }
9051
+
9052
+ // Pause is confirm-gated (GET → POST), mirroring cancel — a
9053
+ // deliberate, reversible hold. The confirm page only renders for a
9054
+ // currently-active subscription; a paused/cancelled row bounces
9055
+ // back to the list.
9056
+ router.get("/account/subscriptions/:id/pause", async function (req, res) {
9057
+ var auth = _subsAuth(req, res); if (!auth) return;
9058
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9059
+ if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
9060
+ if (sub.plan_id != null) {
9061
+ try { sub.plan = await subscriptions.plans.get(sub.plan_id); }
9062
+ catch (_e) { sub.plan = null; }
9063
+ }
9064
+ var cartCount = await _cartCountForReq(req);
9065
+ _send(res, 200, renderSubscriptionPauseConfirm({
9066
+ subscription: sub,
9067
+ shop_name: shopName,
9068
+ cart_count: cartCount,
9069
+ }));
9070
+ });
9071
+
9072
+ router.post("/account/subscriptions/:id/pause", async function (req, res) {
9073
+ var auth = _subsAuth(req, res); if (!auth) return;
9074
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9075
+ try {
9076
+ await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
9077
+ } catch (e) {
9078
+ var code = _controlError(e); if (code == null) throw e;
9079
+ return _redirect(res, "?error=" + code);
9080
+ }
9081
+ return _redirect(res, "?ok=paused");
9082
+ });
9083
+
9084
+ router.post("/account/subscriptions/:id/resume", async function (req, res) {
9085
+ var auth = _subsAuth(req, res); if (!auth) return;
9086
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9087
+ try {
9088
+ await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
9089
+ } catch (e) {
9090
+ var code = _controlError(e); if (code == null) throw e;
9091
+ return _redirect(res, "?error=" + code);
9092
+ }
9093
+ return _redirect(res, "?ok=resumed");
9094
+ });
9095
+
9096
+ router.post("/account/subscriptions/:id/skip", async function (req, res) {
9097
+ var auth = _subsAuth(req, res); if (!auth) return;
9098
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9099
+ try {
9100
+ await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
9101
+ } catch (e) {
9102
+ var code = _controlError(e); if (code == null) throw e;
9103
+ return _redirect(res, "?error=" + code);
9104
+ }
9105
+ return _redirect(res, "?ok=skipped");
9106
+ });
9107
+
9108
+ router.post("/account/subscriptions/:id/quantity", async function (req, res) {
9109
+ var auth = _subsAuth(req, res); if (!auth) return;
9110
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9111
+ // Backend validates: a non-positive / non-integer / missing value
9112
+ // is a client error → bounce with the quantity error code rather
9113
+ // than handing garbage to the primitive (which would throw a
9114
+ // TypeError mapped to a generic "state" message).
9115
+ var qty = parseInt(String((req.body || {}).quantity), 10);
9116
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "?error=quantity");
9117
+ try {
9118
+ await subControls.changeQuantity({ subscription_id: sub.id, new_quantity: qty, reason: "customer self-service quantity change", actor: SELF_ACTOR });
9119
+ } catch (e) {
9120
+ if (e instanceof TypeError) return _redirect(res, "?error=quantity");
9121
+ var code = _controlError(e); if (code == null) throw e;
9122
+ return _redirect(res, "?error=" + code);
9123
+ }
9124
+ return _redirect(res, "?ok=quantity");
9125
+ });
9126
+
9127
+ router.post("/account/subscriptions/:id/frequency", async function (req, res) {
9128
+ var auth = _subsAuth(req, res); if (!auth) return;
9129
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9130
+ // Backend validates: reject anything outside the allowed cadence
9131
+ // enum before composing the primitive.
9132
+ var freq = String((req.body || {}).frequency || "");
9133
+ if (SUB_FREQUENCIES.indexOf(freq) === -1) return _redirect(res, "?error=frequency");
9134
+ try {
9135
+ await subControls.changeFrequency({ subscription_id: sub.id, new_frequency: freq, reason: "customer self-service frequency change", actor: SELF_ACTOR });
9136
+ } catch (e) {
9137
+ if (e instanceof TypeError) return _redirect(res, "?error=frequency");
9138
+ var code = _controlError(e); if (code == null) throw e;
9139
+ return _redirect(res, "?error=" + code);
9140
+ }
9141
+ return _redirect(res, "?ok=frequency");
9142
+ });
9143
+
9144
+ router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
9145
+ var auth = _subsAuth(req, res); if (!auth) return;
9146
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
9147
+ try {
9148
+ await subControls.reactivate({ subscription_id: sub.id, reason: "customer self-service reactivate", actor: SELF_ACTOR });
9149
+ } catch (e) {
9150
+ var code = _controlError(e); if (code == null) throw e;
9151
+ return _redirect(res, "?error=" + code);
9152
+ }
9153
+ return _redirect(res, "?ok=reactivated");
9154
+ });
9155
+ }
9156
+
8714
9157
  // Cancel mounts only when payment is wired (cancel composes Stripe).
8715
9158
  // Without payment the list above stays read-only with a note.
8716
9159
  if (deps.payment) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.19",
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": {