@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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +449 -6
- package/package.json +1 -1
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.
|
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
|
|
@@ -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.
|
|
3164
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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