@blamejs/blamejs-shop 0.2.18 → 0.2.20
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 +339 -4
- 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.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
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- v0.2.17 (2026-05-29) — **Manage your passkeys and edit your profile from your account.** Signed-in customers can now manage their own passkeys and edit their profile, instead of only being able to enrol a passkey at registration. A new account screen lists each enrolled passkey (device transport + when it was added) with an add-another flow and a confirm-gated revoke; revocation is scoped to the signed-in account and refuses to remove your last sign-in method when no other (OAuth) method is linked, so you can't lock yourself out. A separate profile screen edits the display name. **Added:** *Passkey management* — `/account/passkeys` lists your enrolled passkeys, lets you register an additional one, and revoke one you no longer use (behind a confirmation step). Revocation only ever affects your own credentials, and the last remaining sign-in method is protected against removal. · *Profile editing* — `/account/profile` edits your display name with a confirmation notice. Account links for both screens were added to the account dashboard.
|
package/lib/asset-manifest.json
CHANGED
package/lib/storefront.js
CHANGED
|
@@ -3156,16 +3156,83 @@ function _subscriptionIsCancelable(sub) {
|
|
|
3156
3156
|
return CANCELABLE_SUB_STATUSES.indexOf(sub.status) !== -1 && Number(sub.cancel_at_period_end) !== 1;
|
|
3157
3157
|
}
|
|
3158
3158
|
|
|
3159
|
+
// Local control-state, derived the same way subscriptionControls derives
|
|
3160
|
+
// it — `cancelled_at` set ⇒ cancelled, else `paused_at` set ⇒ paused,
|
|
3161
|
+
// else active. This is the lifecycle the self-manage controls act on; it
|
|
3162
|
+
// rides alongside Stripe's `status` (the billing mirror), which the
|
|
3163
|
+
// controls primitive never writes.
|
|
3164
|
+
function _subscriptionControlState(sub) {
|
|
3165
|
+
if (sub.cancelled_at != null) return "cancelled";
|
|
3166
|
+
if (sub.paused_at != null) return "paused";
|
|
3167
|
+
return "active";
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
// The 90-day reactivation grace mirrors subscriptionControls.REACTIVATE_
|
|
3171
|
+
// GRACE_MS. A cancelled row is reactivatable from the storefront while
|
|
3172
|
+
// the cancellation is inside that window; past it, the primitive refuses
|
|
3173
|
+
// and the customer must re-subscribe.
|
|
3174
|
+
var REACTIVATE_GRACE_MS = b.constants.TIME.days(90);
|
|
3175
|
+
function _subscriptionIsReactivatable(sub) {
|
|
3176
|
+
return sub.cancelled_at != null && (Date.now() - Number(sub.cancelled_at)) <= REACTIVATE_GRACE_MS;
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// The cadence-change <select> options — mirrors subscriptionControls.
|
|
3180
|
+
// FREQUENCIES. Pre-selects the row's current frequency when set.
|
|
3181
|
+
var SUB_FREQUENCIES = ["weekly", "biweekly", "monthly", "quarterly", "semiannual", "annual"];
|
|
3182
|
+
function _frequencyOptions(current) {
|
|
3183
|
+
var esc = b.template.escapeHtml;
|
|
3184
|
+
var out = "";
|
|
3185
|
+
for (var i = 0; i < SUB_FREQUENCIES.length; i += 1) {
|
|
3186
|
+
var f = SUB_FREQUENCIES[i];
|
|
3187
|
+
out += "<option value=\"" + f + "\"" + (f === current ? " selected" : "") + ">" + esc(f) + "</option>";
|
|
3188
|
+
}
|
|
3189
|
+
return out;
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
// Map the ?ok=<kind> self-manage redirect marker to confirmation copy
|
|
3193
|
+
// rendered (role="status") at the top of the list. Unknown markers
|
|
3194
|
+
// degrade to no notice so a forged query can't inject copy. The legacy
|
|
3195
|
+
// ?canceled=1 marker keeps its own message (set by the route).
|
|
3196
|
+
var SUBSCRIPTION_OK = {
|
|
3197
|
+
paused: "Your subscription is paused.",
|
|
3198
|
+
resumed: "Your subscription has resumed.",
|
|
3199
|
+
skipped: "Your next shipment has been skipped.",
|
|
3200
|
+
quantity: "Quantity updated.",
|
|
3201
|
+
frequency: "Delivery frequency updated.",
|
|
3202
|
+
reactivated: "Your subscription has been reactivated.",
|
|
3203
|
+
};
|
|
3204
|
+
function _subscriptionOkCopy(kind) {
|
|
3205
|
+
return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_OK, kind) ? SUBSCRIPTION_OK[kind] : null;
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// Self-manage failures round-trip a fixed ?error=<code> so the list can
|
|
3209
|
+
// echo a human message without reflecting an attacker-controlled string.
|
|
3210
|
+
// Unknown codes → no notice.
|
|
3211
|
+
var SUBSCRIPTION_ERR = {
|
|
3212
|
+
quantity: "Enter a quantity of 1 or more.",
|
|
3213
|
+
frequency: "Choose a valid delivery frequency.",
|
|
3214
|
+
state: "That change isn't available for this subscription right now.",
|
|
3215
|
+
grace: "This subscription was cancelled too long ago to reactivate. Start a new one instead.",
|
|
3216
|
+
};
|
|
3217
|
+
function _subscriptionErrorCopy(code) {
|
|
3218
|
+
if (code == null) return null;
|
|
3219
|
+
return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_ERR, code) ? SUBSCRIPTION_ERR[code] : null;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3159
3222
|
// Customer-facing subscription list. `opts.subscriptions` is an array of
|
|
3160
3223
|
// subscription rows, each optionally carrying a resolved `plan` (joined
|
|
3161
3224
|
// by the route). `opts.can_cancel` is false when the deploy has no
|
|
3162
3225
|
// payment handle wired — the list renders read-only with a note, since
|
|
3163
|
-
// cancel composes Stripe.
|
|
3164
|
-
//
|
|
3226
|
+
// cancel composes Stripe. `opts.self_manage` adds the pause / resume /
|
|
3227
|
+
// skip / change-quantity / change-frequency / reactivate controls (wired
|
|
3228
|
+
// only when the subscriptionControls primitive is available). Empty state
|
|
3229
|
+
// points at the catalog (creation is a separate Stripe-subscription-
|
|
3230
|
+
// checkout surface, not built here).
|
|
3165
3231
|
function renderAccountSubscriptions(opts) {
|
|
3166
3232
|
var esc = b.template.escapeHtml;
|
|
3167
3233
|
var subs = opts.subscriptions || [];
|
|
3168
3234
|
var canCancel = opts.can_cancel !== false;
|
|
3235
|
+
var selfManage = opts.self_manage === true;
|
|
3169
3236
|
var rowsHtml = "";
|
|
3170
3237
|
for (var i = 0; i < subs.length; i += 1) {
|
|
3171
3238
|
var s = subs[i];
|
|
@@ -3196,6 +3263,20 @@ function renderAccountSubscriptions(opts) {
|
|
|
3196
3263
|
} else if (periodEnd) {
|
|
3197
3264
|
renewalNote = "Renews <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>";
|
|
3198
3265
|
}
|
|
3266
|
+
var controlState = _subscriptionControlState(s);
|
|
3267
|
+
// Local-state meta line (paused / cancelled) so the customer sees the
|
|
3268
|
+
// self-managed cadence state distinct from the Stripe billing pill.
|
|
3269
|
+
var stateNote = "";
|
|
3270
|
+
if (selfManage && controlState === "paused") {
|
|
3271
|
+
if (s.paused_until != null) {
|
|
3272
|
+
var pUntil = new Date(Number(s.paused_until)).toISOString().slice(0, 10);
|
|
3273
|
+
stateNote = "Paused until <time datetime=\"" + esc(pUntil) + "\">" + esc(pUntil) + "</time>";
|
|
3274
|
+
} else {
|
|
3275
|
+
stateNote = "Paused";
|
|
3276
|
+
}
|
|
3277
|
+
} else if (selfManage && controlState === "cancelled") {
|
|
3278
|
+
stateNote = "Cancelled";
|
|
3279
|
+
}
|
|
3199
3280
|
var cancelControl = "";
|
|
3200
3281
|
if (canCancel && _subscriptionIsCancelable(s)) {
|
|
3201
3282
|
// The cancel decision (and its "immediate vs. at period end"
|
|
@@ -3205,6 +3286,52 @@ function renderAccountSubscriptions(opts) {
|
|
|
3205
3286
|
cancelControl =
|
|
3206
3287
|
"<a class=\"btn-ghost btn-ghost--sm subscription-card__cancel-link\" href=\"/account/subscriptions/" + esc(s.id) + "/cancel\">Cancel subscription</a>";
|
|
3207
3288
|
}
|
|
3289
|
+
// Self-manage controls — state-gated, mirroring the primitive's FSM
|
|
3290
|
+
// (active ⇒ pause / skip / change-qty / change-freq; paused ⇒ resume;
|
|
3291
|
+
// cancelled-within-grace ⇒ reactivate). Pause is confirm-gated like
|
|
3292
|
+
// cancel (a second server-rendered screen, no inline confirm()); the
|
|
3293
|
+
// reversible controls post directly. Quantity / frequency take input
|
|
3294
|
+
// validated server-side.
|
|
3295
|
+
var manageControls = "";
|
|
3296
|
+
if (selfManage) {
|
|
3297
|
+
var actId = esc(s.id);
|
|
3298
|
+
var ctrls = "";
|
|
3299
|
+
if (controlState === "active") {
|
|
3300
|
+
ctrls +=
|
|
3301
|
+
"<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/subscriptions/" + actId + "/pause\">Pause</a>";
|
|
3302
|
+
ctrls +=
|
|
3303
|
+
"<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/skip\">" +
|
|
3304
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Skip next shipment</button>" +
|
|
3305
|
+
"</form>";
|
|
3306
|
+
ctrls +=
|
|
3307
|
+
"<form class=\"subscription-card__control subscription-card__control--qty\" method=\"post\" action=\"/account/subscriptions/" + actId + "/quantity\">" +
|
|
3308
|
+
"<label class=\"form-field form-field--inline\">" +
|
|
3309
|
+
"<span class=\"form-field__label\">Quantity</span>" +
|
|
3310
|
+
"<input type=\"number\" name=\"quantity\" min=\"1\" step=\"1\" value=\"" + esc(String(s.quantity == null ? 1 : s.quantity)) + "\" required>" +
|
|
3311
|
+
"</label>" +
|
|
3312
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update quantity</button>" +
|
|
3313
|
+
"</form>";
|
|
3314
|
+
ctrls +=
|
|
3315
|
+
"<form class=\"subscription-card__control subscription-card__control--freq\" method=\"post\" action=\"/account/subscriptions/" + actId + "/frequency\">" +
|
|
3316
|
+
"<label class=\"form-field form-field--inline\">" +
|
|
3317
|
+
"<span class=\"form-field__label\">Frequency</span>" +
|
|
3318
|
+
"<select name=\"frequency\" required>" + _frequencyOptions(s.frequency || null) + "</select>" +
|
|
3319
|
+
"</label>" +
|
|
3320
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Update frequency</button>" +
|
|
3321
|
+
"</form>";
|
|
3322
|
+
} else if (controlState === "paused") {
|
|
3323
|
+
ctrls +=
|
|
3324
|
+
"<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/resume\">" +
|
|
3325
|
+
"<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Resume</button>" +
|
|
3326
|
+
"</form>";
|
|
3327
|
+
} else if (controlState === "cancelled" && _subscriptionIsReactivatable(s)) {
|
|
3328
|
+
ctrls +=
|
|
3329
|
+
"<form class=\"subscription-card__control\" method=\"post\" action=\"/account/subscriptions/" + actId + "/reactivate\">" +
|
|
3330
|
+
"<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Reactivate</button>" +
|
|
3331
|
+
"</form>";
|
|
3332
|
+
}
|
|
3333
|
+
if (ctrls) manageControls = "<div class=\"subscription-card__manage\">" + ctrls + "</div>";
|
|
3334
|
+
}
|
|
3208
3335
|
rowsHtml +=
|
|
3209
3336
|
"<li class=\"subscription-card\">" +
|
|
3210
3337
|
"<div class=\"subscription-card__head\">" +
|
|
@@ -3212,6 +3339,8 @@ function renderAccountSubscriptions(opts) {
|
|
|
3212
3339
|
_subscriptionStatusBadge(s.status) +
|
|
3213
3340
|
"</div>" +
|
|
3214
3341
|
(renewalNote ? "<p class=\"subscription-card__meta\">" + renewalNote + "</p>" : "") +
|
|
3342
|
+
(stateNote ? "<p class=\"subscription-card__state\">" + stateNote + "</p>" : "") +
|
|
3343
|
+
manageControls +
|
|
3215
3344
|
cancelControl +
|
|
3216
3345
|
"</li>";
|
|
3217
3346
|
}
|
|
@@ -3225,6 +3354,15 @@ function renderAccountSubscriptions(opts) {
|
|
|
3225
3354
|
"<p class=\"account-empty__lede\">You have no active subscriptions.</p>" +
|
|
3226
3355
|
"<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
|
|
3227
3356
|
"</div>";
|
|
3357
|
+
// The notice is either the success copy from a self-manage ?ok=<kind>
|
|
3358
|
+
// round-trip (role="status") or an error string the POST handler passes
|
|
3359
|
+
// back (role="alert"). The legacy cancel notice arrives as opts.notice.
|
|
3360
|
+
var noticeHtml = "";
|
|
3361
|
+
if (opts.error) {
|
|
3362
|
+
noticeHtml = "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.error)) + "</p>";
|
|
3363
|
+
} else if (opts.notice) {
|
|
3364
|
+
noticeHtml = "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(String(opts.notice)) + "</p>";
|
|
3365
|
+
}
|
|
3228
3366
|
var body =
|
|
3229
3367
|
"<section class=\"account-subscriptions\">" +
|
|
3230
3368
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -3232,7 +3370,7 @@ function renderAccountSubscriptions(opts) {
|
|
|
3232
3370
|
"<li aria-current=\"page\">Subscriptions</li>" +
|
|
3233
3371
|
"</ol></nav>" +
|
|
3234
3372
|
"<h1 class=\"account-subscriptions__title\">Subscriptions</h1>" +
|
|
3235
|
-
|
|
3373
|
+
noticeHtml +
|
|
3236
3374
|
inner +
|
|
3237
3375
|
"</section>";
|
|
3238
3376
|
return _wrap({
|
|
@@ -3317,6 +3455,52 @@ function renderSubscriptionCancelConfirm(opts) {
|
|
|
3317
3455
|
});
|
|
3318
3456
|
}
|
|
3319
3457
|
|
|
3458
|
+
// Server-rendered confirmation step for pausing a subscription. Pause is
|
|
3459
|
+
// a reversible-but-deliberate state change, so it gets the same confirm-
|
|
3460
|
+
// GET → POST gate the cancel flow uses (CSP forbids an inline confirm()).
|
|
3461
|
+
// The form posts straight back to /pause; a "Keep it active" link returns
|
|
3462
|
+
// to the list. JS-off-native.
|
|
3463
|
+
function renderSubscriptionPauseConfirm(opts) {
|
|
3464
|
+
var esc = b.template.escapeHtml;
|
|
3465
|
+
var s = opts.subscription || {};
|
|
3466
|
+
var plan = s.plan || null;
|
|
3467
|
+
var planSummary = "this subscription";
|
|
3468
|
+
if (plan) {
|
|
3469
|
+
var ccy = String(plan.currency || "usd").toUpperCase();
|
|
3470
|
+
var every = Number(plan.interval_count) > 1
|
|
3471
|
+
? "every " + Number(plan.interval_count) + " " + String(plan.interval) + "s"
|
|
3472
|
+
: "per " + String(plan.interval);
|
|
3473
|
+
var priceStr = "";
|
|
3474
|
+
try { priceStr = pricing.format(Number(plan.amount_minor), ccy) + " "; } catch (_e) { priceStr = ""; }
|
|
3475
|
+
planSummary = priceStr + every;
|
|
3476
|
+
}
|
|
3477
|
+
var pauseForm =
|
|
3478
|
+
"<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/pause\">" +
|
|
3479
|
+
"<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>" +
|
|
3480
|
+
"<button type=\"submit\" class=\"btn-primary\">Pause subscription</button>" +
|
|
3481
|
+
"</form>";
|
|
3482
|
+
var body =
|
|
3483
|
+
"<section class=\"account-confirm\">" +
|
|
3484
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
3485
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
3486
|
+
"<li><a href=\"/account/subscriptions\">Subscriptions</a></li>" +
|
|
3487
|
+
"<li aria-current=\"page\">Pause</li>" +
|
|
3488
|
+
"</ol></nav>" +
|
|
3489
|
+
"<h1 class=\"account-confirm__title\">Pause " + esc(planSummary) + "?</h1>" +
|
|
3490
|
+
"<div class=\"account-confirm__actions\">" +
|
|
3491
|
+
pauseForm +
|
|
3492
|
+
"<a class=\"btn-ghost\" href=\"/account/subscriptions\">Keep it active</a>" +
|
|
3493
|
+
"</div>" +
|
|
3494
|
+
"</section>";
|
|
3495
|
+
return _wrap({
|
|
3496
|
+
title: "Pause subscription",
|
|
3497
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
3498
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
3499
|
+
theme_css: opts.theme_css,
|
|
3500
|
+
body: body,
|
|
3501
|
+
});
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3320
3504
|
// Product-level "Save to wishlist" control + social-proof count.
|
|
3321
3505
|
// Byte-compatible with the edge renderer (`worker/render/product.js`)
|
|
3322
3506
|
// so both paths emit identical markup. Action-only label — the toggle
|
|
@@ -8637,6 +8821,13 @@ function mount(router, deps) {
|
|
|
8637
8821
|
// flow) is a separate surface; this is the management view.
|
|
8638
8822
|
if (subscriptions) {
|
|
8639
8823
|
var subsCanCancel = !!deps.payment;
|
|
8824
|
+
// Lifecycle controls (pause / resume / skip / change-qty / change-
|
|
8825
|
+
// freq / reactivate) mount only when the subscriptionControls
|
|
8826
|
+
// primitive is wired. Independent of Stripe — the controls write
|
|
8827
|
+
// local columns on the subscription row, not the upstream billing
|
|
8828
|
+
// state — so they're available even on a deploy with no payment
|
|
8829
|
+
// handle. The list above stays a read-only view when this is absent.
|
|
8830
|
+
var subControls = deps.subscriptionControls || null;
|
|
8640
8831
|
|
|
8641
8832
|
function _subsAuth(req, res) {
|
|
8642
8833
|
var auth;
|
|
@@ -8702,15 +8893,159 @@ function mount(router, deps) {
|
|
|
8702
8893
|
var cartCount = await _cartCountForReq(req);
|
|
8703
8894
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
8704
8895
|
var canceled = !!(url && url.searchParams.get("canceled"));
|
|
8896
|
+
// Success copy comes from either the legacy ?canceled=1 marker or
|
|
8897
|
+
// a self-manage ?ok=<kind> round-trip; an ?error message a control
|
|
8898
|
+
// POST bounces back surfaces as an alert. Unknown ?ok keys → no
|
|
8899
|
+
// notice (a forged query can't inject copy).
|
|
8900
|
+
var okKind = url ? url.searchParams.get("ok") : null;
|
|
8901
|
+
var notice = canceled
|
|
8902
|
+
? "Your subscription has been canceled."
|
|
8903
|
+
: _subscriptionOkCopy(okKind);
|
|
8904
|
+
var errKind = url ? url.searchParams.get("error") : null;
|
|
8705
8905
|
_send(res, 200, renderAccountSubscriptions({
|
|
8706
8906
|
subscriptions: rows,
|
|
8707
8907
|
can_cancel: subsCanCancel,
|
|
8708
|
-
|
|
8908
|
+
self_manage: !!subControls,
|
|
8909
|
+
notice: notice,
|
|
8910
|
+
error: _subscriptionErrorCopy(errKind),
|
|
8709
8911
|
shop_name: shopName,
|
|
8710
8912
|
cart_count: cartCount,
|
|
8711
8913
|
}));
|
|
8712
8914
|
});
|
|
8713
8915
|
|
|
8916
|
+
// Self-manage lifecycle — pause / resume / skip / change-quantity /
|
|
8917
|
+
// change-frequency / reactivate. Each route owns its subscription
|
|
8918
|
+
// via the same ownership check the cancel flow uses (a forged/foreign
|
|
8919
|
+
// id 404s before any write), then composes the matching control
|
|
8920
|
+
// method with a `customer` actor. State-machine refusals from the
|
|
8921
|
+
// primitive (e.g. pause a cancelled sub) and shape errors bounce back
|
|
8922
|
+
// to the list with a fixed ?error code rather than 500-ing. Wired
|
|
8923
|
+
// independently of Stripe (the controls write local columns).
|
|
8924
|
+
if (subControls) {
|
|
8925
|
+
var SELF_ACTOR = { actor_type: "customer", actor_id: null };
|
|
8926
|
+
|
|
8927
|
+
// Translate a control-method rejection into the list redirect.
|
|
8928
|
+
// Validation TypeErrors and FSM/grace refusals (which carry a
|
|
8929
|
+
// `code`) map to a fixed ?error; anything else rethrows (a real
|
|
8930
|
+
// 500, e.g. an unmigrated table on a misconfigured deploy).
|
|
8931
|
+
function _controlError(e) {
|
|
8932
|
+
if (e && e.code === "SUBSCRIPTION_REACTIVATE_GRACE_EXPIRED") return "grace";
|
|
8933
|
+
if (e && e.code === "SUBSCRIPTION_STATE_REFUSED") return "state";
|
|
8934
|
+
if (e && e.code === "SUBSCRIPTION_NOT_FOUND") return "state";
|
|
8935
|
+
if (e instanceof TypeError) return "state";
|
|
8936
|
+
return null;
|
|
8937
|
+
}
|
|
8938
|
+
function _redirect(res, suffix) {
|
|
8939
|
+
res.status(303);
|
|
8940
|
+
res.setHeader && res.setHeader("location", "/account/subscriptions" + suffix);
|
|
8941
|
+
return res.end ? res.end() : res.send("");
|
|
8942
|
+
}
|
|
8943
|
+
|
|
8944
|
+
// Pause is confirm-gated (GET → POST), mirroring cancel — a
|
|
8945
|
+
// deliberate, reversible hold. The confirm page only renders for a
|
|
8946
|
+
// currently-active subscription; a paused/cancelled row bounces
|
|
8947
|
+
// back to the list.
|
|
8948
|
+
router.get("/account/subscriptions/:id/pause", async function (req, res) {
|
|
8949
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
8950
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
8951
|
+
if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
|
|
8952
|
+
if (sub.plan_id != null) {
|
|
8953
|
+
try { sub.plan = await subscriptions.plans.get(sub.plan_id); }
|
|
8954
|
+
catch (_e) { sub.plan = null; }
|
|
8955
|
+
}
|
|
8956
|
+
var cartCount = await _cartCountForReq(req);
|
|
8957
|
+
_send(res, 200, renderSubscriptionPauseConfirm({
|
|
8958
|
+
subscription: sub,
|
|
8959
|
+
shop_name: shopName,
|
|
8960
|
+
cart_count: cartCount,
|
|
8961
|
+
}));
|
|
8962
|
+
});
|
|
8963
|
+
|
|
8964
|
+
router.post("/account/subscriptions/:id/pause", async function (req, res) {
|
|
8965
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
8966
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
8967
|
+
try {
|
|
8968
|
+
await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
|
|
8969
|
+
} catch (e) {
|
|
8970
|
+
var code = _controlError(e); if (code == null) throw e;
|
|
8971
|
+
return _redirect(res, "?error=" + code);
|
|
8972
|
+
}
|
|
8973
|
+
return _redirect(res, "?ok=paused");
|
|
8974
|
+
});
|
|
8975
|
+
|
|
8976
|
+
router.post("/account/subscriptions/:id/resume", async function (req, res) {
|
|
8977
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
8978
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
8979
|
+
try {
|
|
8980
|
+
await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
|
|
8981
|
+
} catch (e) {
|
|
8982
|
+
var code = _controlError(e); if (code == null) throw e;
|
|
8983
|
+
return _redirect(res, "?error=" + code);
|
|
8984
|
+
}
|
|
8985
|
+
return _redirect(res, "?ok=resumed");
|
|
8986
|
+
});
|
|
8987
|
+
|
|
8988
|
+
router.post("/account/subscriptions/:id/skip", async function (req, res) {
|
|
8989
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
8990
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
8991
|
+
try {
|
|
8992
|
+
await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
|
|
8993
|
+
} catch (e) {
|
|
8994
|
+
var code = _controlError(e); if (code == null) throw e;
|
|
8995
|
+
return _redirect(res, "?error=" + code);
|
|
8996
|
+
}
|
|
8997
|
+
return _redirect(res, "?ok=skipped");
|
|
8998
|
+
});
|
|
8999
|
+
|
|
9000
|
+
router.post("/account/subscriptions/:id/quantity", async function (req, res) {
|
|
9001
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
9002
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
9003
|
+
// Backend validates: a non-positive / non-integer / missing value
|
|
9004
|
+
// is a client error → bounce with the quantity error code rather
|
|
9005
|
+
// than handing garbage to the primitive (which would throw a
|
|
9006
|
+
// TypeError mapped to a generic "state" message).
|
|
9007
|
+
var qty = parseInt(String((req.body || {}).quantity), 10);
|
|
9008
|
+
if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "?error=quantity");
|
|
9009
|
+
try {
|
|
9010
|
+
await subControls.changeQuantity({ subscription_id: sub.id, new_quantity: qty, reason: "customer self-service quantity change", actor: SELF_ACTOR });
|
|
9011
|
+
} catch (e) {
|
|
9012
|
+
if (e instanceof TypeError) return _redirect(res, "?error=quantity");
|
|
9013
|
+
var code = _controlError(e); if (code == null) throw e;
|
|
9014
|
+
return _redirect(res, "?error=" + code);
|
|
9015
|
+
}
|
|
9016
|
+
return _redirect(res, "?ok=quantity");
|
|
9017
|
+
});
|
|
9018
|
+
|
|
9019
|
+
router.post("/account/subscriptions/:id/frequency", async function (req, res) {
|
|
9020
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
9021
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
9022
|
+
// Backend validates: reject anything outside the allowed cadence
|
|
9023
|
+
// enum before composing the primitive.
|
|
9024
|
+
var freq = String((req.body || {}).frequency || "");
|
|
9025
|
+
if (SUB_FREQUENCIES.indexOf(freq) === -1) return _redirect(res, "?error=frequency");
|
|
9026
|
+
try {
|
|
9027
|
+
await subControls.changeFrequency({ subscription_id: sub.id, new_frequency: freq, reason: "customer self-service frequency change", actor: SELF_ACTOR });
|
|
9028
|
+
} catch (e) {
|
|
9029
|
+
if (e instanceof TypeError) return _redirect(res, "?error=frequency");
|
|
9030
|
+
var code = _controlError(e); if (code == null) throw e;
|
|
9031
|
+
return _redirect(res, "?error=" + code);
|
|
9032
|
+
}
|
|
9033
|
+
return _redirect(res, "?ok=frequency");
|
|
9034
|
+
});
|
|
9035
|
+
|
|
9036
|
+
router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
|
|
9037
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
9038
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
9039
|
+
try {
|
|
9040
|
+
await subControls.reactivate({ subscription_id: sub.id, reason: "customer self-service reactivate", actor: SELF_ACTOR });
|
|
9041
|
+
} catch (e) {
|
|
9042
|
+
var code = _controlError(e); if (code == null) throw e;
|
|
9043
|
+
return _redirect(res, "?error=" + code);
|
|
9044
|
+
}
|
|
9045
|
+
return _redirect(res, "?ok=reactivated");
|
|
9046
|
+
});
|
|
9047
|
+
}
|
|
9048
|
+
|
|
8714
9049
|
// Cancel mounts only when payment is wired (cancel composes Stripe).
|
|
8715
9050
|
// Without payment the list above stays read-only with a note.
|
|
8716
9051
|
if (deps.payment) {
|
package/package.json
CHANGED