@blamejs/blamejs-shop 0.1.19 → 0.1.22
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 +6 -0
- package/README.md +8 -2
- package/SECURITY.md +12 -0
- package/lib/admin.js +944 -1
- package/lib/checkout.js +192 -22
- package/lib/collections.js +4 -3
- package/lib/customers.js +102 -0
- package/lib/giftcards.js +40 -0
- package/lib/order.js +22 -0
- package/lib/storefront.js +462 -70
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +10 -0
- package/lib/vendor/blamejs/README.md +4 -0
- package/lib/vendor/blamejs/api-snapshot.json +129 -2
- package/lib/vendor/blamejs/index.js +12 -0
- package/lib/vendor/blamejs/lib/json-merge-patch.js +93 -0
- package/lib/vendor/blamejs/lib/json-patch.js +206 -0
- package/lib/vendor/blamejs/lib/json-path.js +638 -0
- package/lib/vendor/blamejs/lib/json-pointer.js +109 -0
- package/lib/vendor/blamejs/lib/jtd.js +234 -0
- package/lib/vendor/blamejs/lib/link-header.js +169 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.57.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.58.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.12.60.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.61.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.62.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-merge-patch.test.js +81 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-patch.test.js +184 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-path.test.js +113 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/jtd.test.js +52 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/link-header.test.js +96 -0
- package/package.json +4 -2
package/lib/storefront.js
CHANGED
|
@@ -86,7 +86,7 @@ var LAYOUT =
|
|
|
86
86
|
" <title>{{title}} — {{shop_name}}</title>\n" +
|
|
87
87
|
" <meta name=\"description\" content=\"{{og_description}}\">\n" +
|
|
88
88
|
" <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
|
|
89
|
-
" <link rel=\"stylesheet\" href=\"{{theme_css}}\">\n" +
|
|
89
|
+
" <link rel=\"stylesheet\" href=\"{{theme_css}}\"RAW_CSS_INTEGRITY>\n" +
|
|
90
90
|
" <meta property=\"og:type\" content=\"{{og_type}}\">\n" +
|
|
91
91
|
" <meta property=\"og:site_name\" content=\"{{shop_name}}\">\n" +
|
|
92
92
|
" <meta property=\"og:title\" content=\"{{og_title}}\">\n" +
|
|
@@ -211,10 +211,57 @@ var LAYOUT =
|
|
|
211
211
|
// forces every active session to re-fetch.
|
|
212
212
|
var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" + require("../package.json").version;
|
|
213
213
|
|
|
214
|
+
// Footer copyright year — resolved once at module load. It's a near-static
|
|
215
|
+
// value (changes once a year); a `new Date()` allocation on every page
|
|
216
|
+
// render was wasteful. Containers are long-lived and restart often enough
|
|
217
|
+
// that a year-boundary staleness window doesn't matter for a copyright line.
|
|
218
|
+
var _COPYRIGHT_YEAR = String(new Date().getUTCFullYear());
|
|
219
|
+
|
|
220
|
+
// Client "island" scripts are served as external assets (same `?v=` cache-
|
|
221
|
+
// bust as the CSS), never inline — the storefront's strict `script-src
|
|
222
|
+
// 'self'` CSP blocks inline <script>, so an inline island silently fails
|
|
223
|
+
// in production. `'self'` allows these /assets/ files; the R2 asset sync
|
|
224
|
+
// (npm run sync-assets) uploads them alongside the stylesheets.
|
|
225
|
+
var _ASSET_JS_VERSION = require("../package.json").version;
|
|
226
|
+
var _assetFs = require("node:fs"); // allow:non-shop-require — one sync read of a bundled in-repo asset to compute its SRI hash via b.crypto.sri (the primitive needs the bytes); no remote/write, and b.staticServe.integrity is async so can't run at module load
|
|
227
|
+
|
|
228
|
+
// Subresource Integrity for the static assets. `b.crypto.sri` (SHA-384,
|
|
229
|
+
// W3C SRI 1.0) hashes the exact bytes the asset ships with; the browser
|
|
230
|
+
// refuses to run/apply the resource if the served bytes don't match — a
|
|
231
|
+
// defense against an R2/edge compromise or on-path injection. Computed
|
|
232
|
+
// once per asset from the in-repo file (the same bytes the deploy's asset
|
|
233
|
+
// sync uploads to R2), cached, and emitted as the `integrity` attribute.
|
|
234
|
+
// Same-origin, so no `crossorigin` is required for the check to run.
|
|
235
|
+
var _SRI_CACHE = {};
|
|
236
|
+
function _assetSri(relUnderThemeAssets) {
|
|
237
|
+
if (Object.prototype.hasOwnProperty.call(_SRI_CACHE, relUnderThemeAssets)) {
|
|
238
|
+
return _SRI_CACHE[relUnderThemeAssets];
|
|
239
|
+
}
|
|
240
|
+
var sri = null;
|
|
241
|
+
try {
|
|
242
|
+
// Forward slashes resolve cross-platform for fs reads; avoids a
|
|
243
|
+
// node:path require for one join.
|
|
244
|
+
var p = __dirname + "/../themes/default/assets/" + relUnderThemeAssets;
|
|
245
|
+
sri = b.crypto.sri(_assetFs.readFileSync(p), { algorithm: "sha384" });
|
|
246
|
+
} catch (_e) { sri = null; } // asset absent (e.g. unit-test cwd) → omit integrity, don't crash render
|
|
247
|
+
_SRI_CACHE[relUnderThemeAssets] = sri;
|
|
248
|
+
return sri;
|
|
249
|
+
}
|
|
250
|
+
function _islandScript(name) {
|
|
251
|
+
var sri = _assetSri("js/" + name);
|
|
252
|
+
return "<script src=\"/assets/themes/default/js/" + name + "?v=" + _ASSET_JS_VERSION + "\"" +
|
|
253
|
+
(sri ? " integrity=\"" + sri + "\"" : "") + " defer></script>";
|
|
254
|
+
}
|
|
255
|
+
|
|
214
256
|
function _wrap(opts) {
|
|
215
257
|
var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
|
|
216
258
|
? opts.theme_css
|
|
217
259
|
: DEFAULT_THEME_CSS_URL;
|
|
260
|
+
// SRI for the stylesheet — only for the default theme CSS we ship (and
|
|
261
|
+
// can hash); a custom operator-supplied `theme_css` is their asset with
|
|
262
|
+
// an unknown body, so it's left without an integrity attribute.
|
|
263
|
+
var cssSri = (themeCss === DEFAULT_THEME_CSS_URL) ? _assetSri("css/main.css") : null;
|
|
264
|
+
var themeCssIntegrity = cssSri ? " integrity=\"" + cssSri + "\"" : "";
|
|
218
265
|
// OpenGraph / Twitter Card defaults — every page sets reasonable
|
|
219
266
|
// fallbacks; per-page renderers (PDP, etc.) can override via
|
|
220
267
|
// `opts.og_*` for a product-specific share preview.
|
|
@@ -228,16 +275,16 @@ function _wrap(opts) {
|
|
|
228
275
|
title: opts.title,
|
|
229
276
|
shop_name: shopName,
|
|
230
277
|
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
231
|
-
year:
|
|
278
|
+
year: _COPYRIGHT_YEAR,
|
|
232
279
|
search_q: opts.search_q == null ? "" : opts.search_q,
|
|
233
|
-
theme_css:
|
|
280
|
+
theme_css: themeCss,
|
|
234
281
|
og_type: ogType,
|
|
235
282
|
og_title: ogTitle,
|
|
236
283
|
og_description: ogDescription,
|
|
237
284
|
og_image: ogImage,
|
|
238
285
|
og_url: ogUrl,
|
|
239
286
|
body: "RAW_BODY_PLACEHOLDER",
|
|
240
|
-
}).replace("RAW_BODY_PLACEHOLDER", opts.body);
|
|
287
|
+
}).replace("RAW_CSS_INTEGRITY", themeCssIntegrity).replace("RAW_BODY_PLACEHOLDER", opts.body);
|
|
241
288
|
// The body is RAW HTML (already rendered + escaped at the
|
|
242
289
|
// per-fragment level). The placeholder swap is post-render so the
|
|
243
290
|
// outer renderer's HTML-escape doesn't double-escape the inner
|
|
@@ -1371,6 +1418,107 @@ function renderReturns(opts) {
|
|
|
1371
1418
|
});
|
|
1372
1419
|
}
|
|
1373
1420
|
|
|
1421
|
+
// Subscription status pill — mirrors `_returnStatusBadge`. The status
|
|
1422
|
+
// string is one of Stripe's enum values (active, trialing, past_due,
|
|
1423
|
+
// canceled, …), surfaced as a CSS-classed badge the theme can style.
|
|
1424
|
+
function _subscriptionStatusBadge(status) {
|
|
1425
|
+
return "<span class=\"subscription-status subscription-status--" + b.template.escapeHtml(String(status)) + "\">" +
|
|
1426
|
+
b.template.escapeHtml(String(status)) + "</span>";
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// A subscription is cancelable from the storefront while it's still
|
|
1430
|
+
// billing — active or trialing, and not already wound down at period
|
|
1431
|
+
// end. A canceled / past_due / incomplete row shows no Cancel control.
|
|
1432
|
+
var CANCELABLE_SUB_STATUSES = ["active", "trialing", "past_due"];
|
|
1433
|
+
function _subscriptionIsCancelable(sub) {
|
|
1434
|
+
return CANCELABLE_SUB_STATUSES.indexOf(sub.status) !== -1 && Number(sub.cancel_at_period_end) !== 1;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Customer-facing subscription list. `opts.subscriptions` is an array of
|
|
1438
|
+
// subscription rows, each optionally carrying a resolved `plan` (joined
|
|
1439
|
+
// by the route). `opts.can_cancel` is false when the deploy has no
|
|
1440
|
+
// payment handle wired — the list renders read-only with a note, since
|
|
1441
|
+
// cancel composes Stripe. Empty state points at the catalog (creation is
|
|
1442
|
+
// a separate Stripe-subscription-checkout surface, not built here).
|
|
1443
|
+
function renderAccountSubscriptions(opts) {
|
|
1444
|
+
var esc = b.template.escapeHtml;
|
|
1445
|
+
var subs = opts.subscriptions || [];
|
|
1446
|
+
var canCancel = opts.can_cancel !== false;
|
|
1447
|
+
var rowsHtml = "";
|
|
1448
|
+
for (var i = 0; i < subs.length; i += 1) {
|
|
1449
|
+
var s = subs[i];
|
|
1450
|
+
var plan = s.plan || null;
|
|
1451
|
+
var planSummary = "";
|
|
1452
|
+
if (plan) {
|
|
1453
|
+
var every = Number(plan.interval_count) > 1
|
|
1454
|
+
? "every " + Number(plan.interval_count) + " " + esc(String(plan.interval)) + "s"
|
|
1455
|
+
: "per " + esc(String(plan.interval));
|
|
1456
|
+
// Plans mirror Stripe's lowercase currency; pricing.format wants
|
|
1457
|
+
// the uppercase ISO 4217 code. A malformed currency / amount on a
|
|
1458
|
+
// row falls back to the interval-only summary rather than throwing
|
|
1459
|
+
// out of the renderer.
|
|
1460
|
+
var ccy = String(plan.currency || "usd").toUpperCase();
|
|
1461
|
+
var priceStr;
|
|
1462
|
+
try { priceStr = esc(pricing.format(Number(plan.amount_minor), ccy)) + " "; }
|
|
1463
|
+
catch (_e) { priceStr = ""; }
|
|
1464
|
+
planSummary = priceStr + every;
|
|
1465
|
+
} else {
|
|
1466
|
+
planSummary = "Plan unavailable";
|
|
1467
|
+
}
|
|
1468
|
+
var periodEnd = s.current_period_end
|
|
1469
|
+
? new Date(Number(s.current_period_end)).toISOString().slice(0, 10)
|
|
1470
|
+
: "";
|
|
1471
|
+
var renewalNote = "";
|
|
1472
|
+
if (Number(s.cancel_at_period_end) === 1 && periodEnd) {
|
|
1473
|
+
renewalNote = "Ends <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>";
|
|
1474
|
+
} else if (periodEnd) {
|
|
1475
|
+
renewalNote = "Renews <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>";
|
|
1476
|
+
}
|
|
1477
|
+
var cancelControl = "";
|
|
1478
|
+
if (canCancel && _subscriptionIsCancelable(s)) {
|
|
1479
|
+
cancelControl =
|
|
1480
|
+
"<form class=\"subscription-card__cancel\" method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/cancel\">" +
|
|
1481
|
+
"<label class=\"subscription-card__when\">" +
|
|
1482
|
+
"<input type=\"checkbox\" name=\"immediate\" value=\"1\"> Cancel immediately (skip the rest of the period)" +
|
|
1483
|
+
"</label>" +
|
|
1484
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Cancel subscription</button>" +
|
|
1485
|
+
"</form>";
|
|
1486
|
+
}
|
|
1487
|
+
rowsHtml +=
|
|
1488
|
+
"<li class=\"subscription-card\">" +
|
|
1489
|
+
"<div class=\"subscription-card__head\">" +
|
|
1490
|
+
"<span class=\"subscription-card__plan\">" + planSummary + "</span>" +
|
|
1491
|
+
_subscriptionStatusBadge(s.status) +
|
|
1492
|
+
"</div>" +
|
|
1493
|
+
(renewalNote ? "<p class=\"subscription-card__meta\">" + renewalNote + "</p>" : "") +
|
|
1494
|
+
cancelControl +
|
|
1495
|
+
"</li>";
|
|
1496
|
+
}
|
|
1497
|
+
var note = canCancel
|
|
1498
|
+
? ""
|
|
1499
|
+
: "<p class=\"subscription-note\">Cancellation isn't available on this store yet. Contact support to make changes.</p>";
|
|
1500
|
+
var inner = rowsHtml
|
|
1501
|
+
? note + "<ul class=\"subscription-list\">" + rowsHtml + "</ul>"
|
|
1502
|
+
: "<p class=\"subscription-empty\">You have no active subscriptions.</p>";
|
|
1503
|
+
var body =
|
|
1504
|
+
"<section class=\"account-subscriptions\">" +
|
|
1505
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1506
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
1507
|
+
"<li aria-current=\"page\">Subscriptions</li>" +
|
|
1508
|
+
"</ol></nav>" +
|
|
1509
|
+
"<h1 class=\"account-subscriptions__title\">Subscriptions</h1>" +
|
|
1510
|
+
(opts.notice ? "<p class=\"form-notice\" role=\"status\">" + esc(String(opts.notice)) + "</p>" : "") +
|
|
1511
|
+
inner +
|
|
1512
|
+
"</section>";
|
|
1513
|
+
return _wrap({
|
|
1514
|
+
title: "Subscriptions",
|
|
1515
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1516
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1517
|
+
theme_css: opts.theme_css,
|
|
1518
|
+
body: body,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1374
1522
|
// Product-level "Save to wishlist" control + social-proof count.
|
|
1375
1523
|
// Byte-compatible with the edge renderer (`worker/render/product.js`)
|
|
1376
1524
|
// so both paths emit identical markup. Action-only label — the toggle
|
|
@@ -1593,6 +1741,7 @@ var CHECKOUT_PAGE =
|
|
|
1593
1741
|
" <label class=\"form-field\"><span class=\"form-field__label\">State / Region</span><input type=\"text\" name=\"state\" maxlength=\"5\" autocomplete=\"address-level1\" class=\"form-field__input--xs\"></label>\n" +
|
|
1594
1742
|
" <label class=\"form-field\"><span class=\"form-field__label\">Postal code</span><input type=\"text\" name=\"postal\" maxlength=\"16\" autocomplete=\"postal-code\" class=\"form-field__input--sm\"></label>\n" +
|
|
1595
1743
|
" </div>\n" +
|
|
1744
|
+
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Gift card code <span class=\"small\">(optional)</span></span><input type=\"text\" name=\"gift_card_code\" autocomplete=\"off\" placeholder=\"XXXX-XXXX-XXXX-XXXX\" maxlength=\"24\"></label></div>\n" +
|
|
1596
1745
|
" <div class=\"checkout-summary\">\n" +
|
|
1597
1746
|
" <h3>Order summary</h3>\n" +
|
|
1598
1747
|
" <dl>\n" +
|
|
@@ -1638,6 +1787,63 @@ function renderCheckoutForm(opts) {
|
|
|
1638
1787
|
});
|
|
1639
1788
|
}
|
|
1640
1789
|
|
|
1790
|
+
// ---- gift-card balance check (customer-facing) -------------------------
|
|
1791
|
+
//
|
|
1792
|
+
// A bearer gift-card code is private — the page never confirms whether a
|
|
1793
|
+
// code "exists". A recognized active card shows its balance; anything else
|
|
1794
|
+
// (unknown, malformed, expired, voided, redeemed) shows the same generic
|
|
1795
|
+
// "couldn't find a balance" message so the page is not a code-probing
|
|
1796
|
+
// oracle. Server-rendered; the result re-renders the same page with the
|
|
1797
|
+
// outcome inline.
|
|
1798
|
+
var GIFT_CARD_PAGE =
|
|
1799
|
+
"<section class=\"checkout-page\" style=\"max-width:32rem;margin:0 auto;\">\n" +
|
|
1800
|
+
" <header class=\"section-head\">\n" +
|
|
1801
|
+
" <p class=\"eyebrow\">Gift cards</p>\n" +
|
|
1802
|
+
" <h1 class=\"section-head__title\">Check a gift card balance</h1>\n" +
|
|
1803
|
+
" <p class=\"section-head__lede\">Enter the code printed on your gift card to see what's left to spend. Apply it at checkout.</p>\n" +
|
|
1804
|
+
" </header>\n" +
|
|
1805
|
+
" RAW_RESULT" +
|
|
1806
|
+
" <form method=\"post\" action=\"/gift-cards/balance\" class=\"form-stack\">\n" +
|
|
1807
|
+
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Gift card code</span><input type=\"text\" name=\"code\" autocomplete=\"off\" placeholder=\"XXXX-XXXX-XXXX-XXXX\" maxlength=\"24\" required></label></div>\n" +
|
|
1808
|
+
" <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Check balance</button></div>\n" +
|
|
1809
|
+
" </form>\n" +
|
|
1810
|
+
"</section>\n";
|
|
1811
|
+
|
|
1812
|
+
function renderGiftCardBalance(opts) {
|
|
1813
|
+
opts = opts || {};
|
|
1814
|
+
var shopName = opts.shop_name || "blamejs.shop";
|
|
1815
|
+
var resultHtml = "";
|
|
1816
|
+
if (opts.balance != null) {
|
|
1817
|
+
var bal = pricing.format(opts.balance.balance_minor, opts.balance.currency);
|
|
1818
|
+
resultHtml =
|
|
1819
|
+
"<div class=\"checkout-summary\"><h3>Balance</h3><dl>" +
|
|
1820
|
+
"<div class=\"checkout-summary__total\"><dt>Available</dt><dd>" + b.template.escapeHtml(bal) + "</dd></div>" +
|
|
1821
|
+
"</dl></div>";
|
|
1822
|
+
} else if (opts.not_found) {
|
|
1823
|
+
resultHtml =
|
|
1824
|
+
"<p class=\"auth-form__message auth-form__message--err\">" +
|
|
1825
|
+
"We couldn't find a balance for that code. Check the characters and try again." +
|
|
1826
|
+
"</p>";
|
|
1827
|
+
}
|
|
1828
|
+
if (opts.theme) {
|
|
1829
|
+
return opts.theme.render("gift-card-balance", {
|
|
1830
|
+
title: "Gift card balance",
|
|
1831
|
+
shop_name: shopName,
|
|
1832
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1833
|
+
result: resultHtml,
|
|
1834
|
+
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
var body = GIFT_CARD_PAGE.replace("RAW_RESULT", resultHtml);
|
|
1838
|
+
return _wrap({
|
|
1839
|
+
title: "Gift card balance",
|
|
1840
|
+
shop_name: shopName,
|
|
1841
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1842
|
+
theme_css: opts.theme_css,
|
|
1843
|
+
body: body,
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1641
1847
|
function _paypalCheckoutBlock(clientId, currency) {
|
|
1642
1848
|
var esc = b.template.escapeHtml;
|
|
1643
1849
|
var cid = esc(String(clientId));
|
|
@@ -1818,20 +2024,23 @@ function renderOrder(opts) {
|
|
|
1818
2024
|
var tax = pricing.format(o.tax_minor, o.currency);
|
|
1819
2025
|
var shipping = pricing.format(o.shipping_minor, o.currency);
|
|
1820
2026
|
var total = pricing.format(o.grand_total_minor, o.currency);
|
|
2027
|
+
var recs = opts.recommendations || [];
|
|
1821
2028
|
if (opts.theme) {
|
|
1822
2029
|
return opts.theme.render("order", {
|
|
1823
|
-
title:
|
|
1824
|
-
shop_name:
|
|
1825
|
-
cart_count:
|
|
1826
|
-
order_id:
|
|
1827
|
-
status:
|
|
1828
|
-
lines:
|
|
1829
|
-
has_lines:
|
|
1830
|
-
subtotal:
|
|
1831
|
-
tax:
|
|
1832
|
-
shipping:
|
|
1833
|
-
total:
|
|
1834
|
-
|
|
2030
|
+
title: "Order " + o.id,
|
|
2031
|
+
shop_name: shopName,
|
|
2032
|
+
cart_count: cartCount,
|
|
2033
|
+
order_id: o.id,
|
|
2034
|
+
status: o.status,
|
|
2035
|
+
lines: rendered,
|
|
2036
|
+
has_lines: rendered.length > 0,
|
|
2037
|
+
subtotal: subtotal,
|
|
2038
|
+
tax: tax,
|
|
2039
|
+
shipping: shipping,
|
|
2040
|
+
total: total,
|
|
2041
|
+
recommendations: recs,
|
|
2042
|
+
has_recommendations: recs.length > 0,
|
|
2043
|
+
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
1835
2044
|
});
|
|
1836
2045
|
}
|
|
1837
2046
|
function _orderEsc(s) { return b.template.escapeHtml(s); }
|
|
@@ -1858,12 +2067,24 @@ function renderOrder(opts) {
|
|
|
1858
2067
|
shipping: shipping,
|
|
1859
2068
|
total: total,
|
|
1860
2069
|
}).replace("RAW_LINES", rows);
|
|
2070
|
+
// Post-purchase cross-sell rail — reuses the catalog grid + product-card
|
|
2071
|
+
// markup (so it inherits the storefront's card styling), rendered only
|
|
2072
|
+
// when the picker returned something.
|
|
2073
|
+
var railHtml = "";
|
|
2074
|
+
if (recs.length) {
|
|
2075
|
+
var railCards = recs.map(function (p) { return _buildProductCard(p); }).join("");
|
|
2076
|
+
railHtml =
|
|
2077
|
+
"<section class=\"catalog-section order-recommendations\">" +
|
|
2078
|
+
"<header class=\"section-head\"><h2 class=\"section-head__title\">Customers also bought</h2></header>" +
|
|
2079
|
+
"<div class=\"grid\">" + railCards + "</div>" +
|
|
2080
|
+
"</section>";
|
|
2081
|
+
}
|
|
1861
2082
|
return _wrap({
|
|
1862
2083
|
title: "Order " + o.id,
|
|
1863
2084
|
shop_name: shopName,
|
|
1864
2085
|
cart_count: cartCount,
|
|
1865
2086
|
theme_css: opts.theme_css,
|
|
1866
|
-
body: body,
|
|
2087
|
+
body: body + railHtml,
|
|
1867
2088
|
});
|
|
1868
2089
|
}
|
|
1869
2090
|
|
|
@@ -2272,28 +2493,7 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
2272
2493
|
" RAW_LOGIN_OAUTH\n" +
|
|
2273
2494
|
" <p class=\"auth-card__alt\">New here? <a href=\"/account/register\">Create an account →</a></p>\n" +
|
|
2274
2495
|
" </div>\n" +
|
|
2275
|
-
"
|
|
2276
|
-
" (function () {\n" +
|
|
2277
|
-
" function _b64uToBuf(s){ s = s.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4)s+='='; var raw=atob(s); var arr=new Uint8Array(raw.length); for(var i=0;i<raw.length;i++)arr[i]=raw.charCodeAt(i); return arr.buffer; }\n" +
|
|
2278
|
-
" function _bufToB64u(buf){ var b=new Uint8Array(buf), s=''; for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''); }\n" + // allow:inline-base64url-three-replace — browser-side helper; the page has no `b.crypto` to call, btoa is the runtime-built-in equivalent
|
|
2279
|
-
" var form=document.getElementById('login-form');\n" +
|
|
2280
|
-
" var msg=document.getElementById('login-message');\n" +
|
|
2281
|
-
" form.addEventListener('submit', async function(ev){\n" +
|
|
2282
|
-
" ev.preventDefault(); msg.textContent='Requesting challenge...';\n" +
|
|
2283
|
-
" try {\n" +
|
|
2284
|
-
" var beginR = await fetch('/account/passkey/login-begin', { method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body: JSON.stringify({ email: document.getElementById('email').value }) });\n" +
|
|
2285
|
-
" if (!beginR.ok) { msg.textContent = 'Sign-in unavailable.'; return; }\n" +
|
|
2286
|
-
" var options = await beginR.json();\n" +
|
|
2287
|
-
" options.challenge = _b64uToBuf(options.challenge);\n" +
|
|
2288
|
-
" if (options.allowCredentials) options.allowCredentials = options.allowCredentials.map(function(c){ return Object.assign({}, c, { id: _b64uToBuf(c.id) }); });\n" +
|
|
2289
|
-
" var assertion = await navigator.credentials.get({ publicKey: options });\n" +
|
|
2290
|
-
" var payload = { id: assertion.id, rawId: _bufToB64u(assertion.rawId), type: assertion.type, response: { authenticatorData: _bufToB64u(assertion.response.authenticatorData), clientDataJSON: _bufToB64u(assertion.response.clientDataJSON), signature: _bufToB64u(assertion.response.signature), userHandle: assertion.response.userHandle ? _bufToB64u(assertion.response.userHandle) : null } };\n" +
|
|
2291
|
-
" var finishR = await fetch('/account/passkey/login-finish', { method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });\n" +
|
|
2292
|
-
" if (finishR.ok) { window.location.href = '/account'; } else { msg.textContent = 'Sign-in failed.'; }\n" +
|
|
2293
|
-
" } catch (e) { msg.textContent = (e && e.message) || 'Sign-in error.'; }\n" +
|
|
2294
|
-
" });\n" +
|
|
2295
|
-
" })();\n" +
|
|
2296
|
-
" </script>\n" +
|
|
2496
|
+
" RAW_LOGIN_SCRIPT\n" +
|
|
2297
2497
|
"</section>\n";
|
|
2298
2498
|
|
|
2299
2499
|
var LOGIN_ERROR_MESSAGES = {
|
|
@@ -2321,7 +2521,8 @@ function renderAccountLogin(opts) {
|
|
|
2321
2521
|
: "";
|
|
2322
2522
|
var body = ACCOUNT_LOGIN_PAGE
|
|
2323
2523
|
.replace("RAW_LOGIN_OAUTH", oauthHtml)
|
|
2324
|
-
.replace("RAW_LOGIN_ERROR", errHtml)
|
|
2524
|
+
.replace("RAW_LOGIN_ERROR", errHtml)
|
|
2525
|
+
.replace("RAW_LOGIN_SCRIPT", _islandScript("passkey-login.js"));
|
|
2325
2526
|
return _wrap({
|
|
2326
2527
|
title: "Sign in",
|
|
2327
2528
|
shop_name: opts.shop_name || "blamejs.shop",
|
|
@@ -2345,29 +2546,7 @@ var ACCOUNT_REGISTER_PAGE =
|
|
|
2345
2546
|
" </form>\n" +
|
|
2346
2547
|
" <p class=\"auth-card__alt\">Already have one? <a href=\"/account/login\">Sign in →</a></p>\n" +
|
|
2347
2548
|
" </div>\n" +
|
|
2348
|
-
"
|
|
2349
|
-
" (function () {\n" +
|
|
2350
|
-
" function _b64uToBuf(s){ s = s.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4)s+='='; var raw=atob(s); var arr=new Uint8Array(raw.length); for(var i=0;i<raw.length;i++)arr[i]=raw.charCodeAt(i); return arr.buffer; }\n" +
|
|
2351
|
-
" function _bufToB64u(buf){ var b=new Uint8Array(buf), s=''; for(var i=0;i<b.length;i++)s+=String.fromCharCode(b[i]); return btoa(s).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''); }\n" + // allow:inline-base64url-three-replace — browser-side helper; the page has no `b.crypto` to call, btoa is the runtime-built-in equivalent
|
|
2352
|
-
" var form=document.getElementById('reg-form');\n" +
|
|
2353
|
-
" var msg=document.getElementById('reg-message');\n" +
|
|
2354
|
-
" form.addEventListener('submit', async function(ev){\n" +
|
|
2355
|
-
" ev.preventDefault(); msg.textContent='Creating account...';\n" +
|
|
2356
|
-
" try {\n" +
|
|
2357
|
-
" var beginR = await fetch('/account/passkey/register-begin', { method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body: JSON.stringify({ email: document.getElementById('email').value, display_name: document.getElementById('display_name').value }) });\n" +
|
|
2358
|
-
" if (!beginR.ok) { msg.textContent = (await beginR.text()) || 'Registration unavailable.'; return; }\n" +
|
|
2359
|
-
" var options = await beginR.json();\n" +
|
|
2360
|
-
" options.challenge = _b64uToBuf(options.challenge);\n" +
|
|
2361
|
-
" options.user.id = _b64uToBuf(options.user.id);\n" +
|
|
2362
|
-
" if (options.excludeCredentials) options.excludeCredentials = options.excludeCredentials.map(function(c){ return Object.assign({}, c, { id: _b64uToBuf(c.id) }); });\n" +
|
|
2363
|
-
" var att = await navigator.credentials.create({ publicKey: options });\n" +
|
|
2364
|
-
" var payload = { id: att.id, rawId: _bufToB64u(att.rawId), type: att.type, response: { attestationObject: _bufToB64u(att.response.attestationObject), clientDataJSON: _bufToB64u(att.response.clientDataJSON), transports: att.response.getTransports ? att.response.getTransports() : [] } };\n" +
|
|
2365
|
-
" var finishR = await fetch('/account/passkey/register-finish', { method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });\n" +
|
|
2366
|
-
" if (finishR.ok) { window.location.href = '/account'; } else { msg.textContent = (await finishR.text()) || 'Enrollment failed.'; }\n" +
|
|
2367
|
-
" } catch (e) { msg.textContent = (e && e.message) || 'Registration error.'; }\n" +
|
|
2368
|
-
" });\n" +
|
|
2369
|
-
" })();\n" +
|
|
2370
|
-
" </script>\n" +
|
|
2549
|
+
" RAW_REGISTER_SCRIPT\n" +
|
|
2371
2550
|
"</section>\n";
|
|
2372
2551
|
|
|
2373
2552
|
function renderAccountRegister(opts) {
|
|
@@ -2377,7 +2556,7 @@ function renderAccountRegister(opts) {
|
|
|
2377
2556
|
shop_name: opts.shop_name || "blamejs.shop",
|
|
2378
2557
|
cart_count: opts.cart_count,
|
|
2379
2558
|
theme_css: opts.theme_css,
|
|
2380
|
-
body: ACCOUNT_REGISTER_PAGE,
|
|
2559
|
+
body: ACCOUNT_REGISTER_PAGE.replace("RAW_REGISTER_SCRIPT", _islandScript("passkey-register.js")),
|
|
2381
2560
|
});
|
|
2382
2561
|
}
|
|
2383
2562
|
|
|
@@ -2395,6 +2574,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
2395
2574
|
" <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
|
|
2396
2575
|
" <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
|
|
2397
2576
|
" <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
|
|
2577
|
+
" <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
|
|
2398
2578
|
" <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
|
|
2399
2579
|
" </div>\n" +
|
|
2400
2580
|
" </header>\n" +
|
|
@@ -2508,6 +2688,10 @@ function mount(router, deps) {
|
|
|
2508
2688
|
// the inline-string templates above stay in force (operators on
|
|
2509
2689
|
// older deploys keep their current look without a migration step).
|
|
2510
2690
|
var theme = deps.theme || null;
|
|
2691
|
+
// Subscription self-management — opts in /account/subscriptions. The
|
|
2692
|
+
// cancel route additionally needs the payment handle (cancel composes
|
|
2693
|
+
// Stripe via the primitive); without it the list stays read-only.
|
|
2694
|
+
var subscriptions = deps.subscriptions || null;
|
|
2511
2695
|
|
|
2512
2696
|
function _send(res, status, html) {
|
|
2513
2697
|
res.status(status);
|
|
@@ -2883,8 +3067,17 @@ function mount(router, deps) {
|
|
|
2883
3067
|
ship_to: shipTo,
|
|
2884
3068
|
selected_shipping_id: defaultShipId || "std",
|
|
2885
3069
|
customer: { email: body.email, name: body.name },
|
|
3070
|
+
gift_card_code: body.gift_card_code || undefined,
|
|
2886
3071
|
idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
|
|
2887
3072
|
});
|
|
3073
|
+
// When a gift card fully covered the order there's no Stripe
|
|
3074
|
+
// intent — the order is already paid. Skip the pay-cookie +
|
|
3075
|
+
// pay page and land the customer straight on the confirmation.
|
|
3076
|
+
if (!result.payment_intent) {
|
|
3077
|
+
res.status(303);
|
|
3078
|
+
res.setHeader && res.setHeader("location", "/orders/" + result.order.id);
|
|
3079
|
+
return res.end ? res.end() : res.send("");
|
|
3080
|
+
}
|
|
2888
3081
|
// Set a short-lived pay cookie so /pay/:order_id can serve the
|
|
2889
3082
|
// client_secret without re-running confirm. Scoped to /pay/ +
|
|
2890
3083
|
// SameSite=Strict so it's only ever sent to the pay route.
|
|
@@ -2895,7 +3088,12 @@ function mount(router, deps) {
|
|
|
2895
3088
|
res.setHeader && res.setHeader("location", "/pay/" + result.order.id);
|
|
2896
3089
|
return res.end ? res.end() : res.send("");
|
|
2897
3090
|
} catch (e) {
|
|
2898
|
-
|
|
3091
|
+
// A bad customer input (malformed shape OR a gift-card code the
|
|
3092
|
+
// customer typed that can't be applied) is a 400, not a 500 —
|
|
3093
|
+
// the gift-card errors carry a GIFTCARD_* code so a fat-fingered
|
|
3094
|
+
// code re-prompts rather than 500-ing the checkout.
|
|
3095
|
+
var clientErr = (e instanceof TypeError) || (e && typeof e.code === "string" && e.code.indexOf("GIFTCARD_") === 0);
|
|
3096
|
+
res.status(clientErr ? 400 : 500);
|
|
2899
3097
|
var msg = (e && e.message) || "checkout failed";
|
|
2900
3098
|
return res.end ? res.end(msg) : res.send(msg);
|
|
2901
3099
|
}
|
|
@@ -2933,14 +3131,22 @@ function mount(router, deps) {
|
|
|
2933
3131
|
ship_to: shipTo,
|
|
2934
3132
|
selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
|
|
2935
3133
|
customer: { email: body.email, name: body.name },
|
|
3134
|
+
gift_card_code: body.gift_card_code || undefined,
|
|
2936
3135
|
idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
|
|
2937
3136
|
return_url: body.return_url || undefined,
|
|
2938
3137
|
cancel_url: body.cancel_url || undefined,
|
|
2939
3138
|
});
|
|
3139
|
+
// Gift card fully covered the order — no PayPal order id to
|
|
3140
|
+
// approve. Tell the button to redirect straight to the
|
|
3141
|
+
// confirmation page instead of opening the PayPal popup.
|
|
3142
|
+
if (!created.paypal_order_id) {
|
|
3143
|
+
return _json(200, { paid_by_gift_card: true, order_id: created.order.id, redirect: "/orders/" + created.order.id });
|
|
3144
|
+
}
|
|
2940
3145
|
// The PayPal JS SDK's createOrder expects `{ id }`.
|
|
2941
3146
|
return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
|
|
2942
3147
|
} catch (e) {
|
|
2943
|
-
|
|
3148
|
+
var gcErr = e && typeof e.code === "string" && e.code.indexOf("GIFTCARD_") === 0;
|
|
3149
|
+
return _json((e instanceof TypeError || gcErr) ? 400 : 502, { error: (e && e.message) || "paypal-create-failed" });
|
|
2944
3150
|
}
|
|
2945
3151
|
});
|
|
2946
3152
|
|
|
@@ -3019,11 +3225,41 @@ function mount(router, deps) {
|
|
|
3019
3225
|
hero_media: media.length ? media[0] : null,
|
|
3020
3226
|
};
|
|
3021
3227
|
}
|
|
3228
|
+
// "Customers also bought" rail — co-purchase signals anchored on the
|
|
3229
|
+
// order's own items (and excluding them, so we never recommend what
|
|
3230
|
+
// was just bought). Best-effort: a read failure (engine not wired /
|
|
3231
|
+
// tables not migrated) degrades to no rail rather than 500-ing the
|
|
3232
|
+
// confirmation page.
|
|
3233
|
+
var recommendations = [];
|
|
3234
|
+
if (deps.recommendations) {
|
|
3235
|
+
try {
|
|
3236
|
+
var orderProductIds = [];
|
|
3237
|
+
for (var li = 0; li < (o.lines || []).length; li += 1) {
|
|
3238
|
+
var look = productLookup[o.lines[li].variant_id];
|
|
3239
|
+
if (look && look.product && orderProductIds.indexOf(look.product.id) === -1) {
|
|
3240
|
+
orderProductIds.push(look.product.id);
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
if (orderProductIds.length) {
|
|
3244
|
+
// recommendForCart aggregates the co-purchase signal across
|
|
3245
|
+
// EVERY purchased product (not just the first) and pivots the
|
|
3246
|
+
// category-popular fallback off the order's dominant
|
|
3247
|
+
// collection; it also self-excludes the order's own products,
|
|
3248
|
+
// so a multi-item order's rail reflects the whole order.
|
|
3249
|
+
var picks = await deps.recommendations.recommendForCart(orderProductIds, { limit: 4 });
|
|
3250
|
+
for (var pi = 0; pi < picks.length; pi += 1) {
|
|
3251
|
+
var card = await _decorateProductCard(picks[pi].product_id);
|
|
3252
|
+
if (card) recommendations.push(card);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
} catch (_e) { recommendations = []; }
|
|
3256
|
+
}
|
|
3022
3257
|
_send(res, 200, renderOrder({
|
|
3023
|
-
order:
|
|
3024
|
-
product_lookup:
|
|
3025
|
-
|
|
3026
|
-
|
|
3258
|
+
order: o,
|
|
3259
|
+
product_lookup: productLookup,
|
|
3260
|
+
recommendations: recommendations,
|
|
3261
|
+
shop_name: shopName,
|
|
3262
|
+
theme: theme,
|
|
3027
3263
|
}));
|
|
3028
3264
|
});
|
|
3029
3265
|
|
|
@@ -3084,6 +3320,47 @@ function mount(router, deps) {
|
|
|
3084
3320
|
}
|
|
3085
3321
|
}
|
|
3086
3322
|
|
|
3323
|
+
// ---- gift-card balance check (customer-facing) ---------------------
|
|
3324
|
+
//
|
|
3325
|
+
// Mounts when deps.giftcards is wired. GET renders the lookup form;
|
|
3326
|
+
// POST looks the code up and re-renders with the balance (or a
|
|
3327
|
+
// generic not-found). The page is deliberately not a code-existence
|
|
3328
|
+
// oracle: unknown, malformed, expired, voided, and redeemed codes
|
|
3329
|
+
// all produce the same "couldn't find a balance" outcome.
|
|
3330
|
+
if (deps.giftcards) {
|
|
3331
|
+
router.get("/gift-cards", async function (req, res) {
|
|
3332
|
+
_send(res, 200, renderGiftCardBalance({
|
|
3333
|
+
shop_name: shopName, theme: theme, cart_count: await _cartCountForReq(req),
|
|
3334
|
+
}));
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3337
|
+
router.post("/gift-cards/balance", async function (req, res) {
|
|
3338
|
+
var body = req.body || {};
|
|
3339
|
+
var code = typeof body.code === "string" ? body.code : "";
|
|
3340
|
+
var balance = null;
|
|
3341
|
+
var notFound = true;
|
|
3342
|
+
try {
|
|
3343
|
+
var view = await deps.giftcards.balance(code);
|
|
3344
|
+
// Only an active card with a positive balance surfaces a
|
|
3345
|
+
// number — an expired / voided / fully-redeemed card is the
|
|
3346
|
+
// same dead end as an unknown code to the customer.
|
|
3347
|
+
if (view && view.status === "active") {
|
|
3348
|
+
balance = { balance_minor: view.balance_minor, currency: view.currency };
|
|
3349
|
+
notFound = false;
|
|
3350
|
+
}
|
|
3351
|
+
} catch (e) {
|
|
3352
|
+
// A malformed code throws TypeError at the canonicalizer —
|
|
3353
|
+
// swallow it into the same generic not-found. Any other error
|
|
3354
|
+
// (e.g. a DB fault) bubbles so it isn't masked as not-found.
|
|
3355
|
+
if (!(e instanceof TypeError)) throw e;
|
|
3356
|
+
}
|
|
3357
|
+
_send(res, 200, renderGiftCardBalance({
|
|
3358
|
+
shop_name: shopName, theme: theme, cart_count: await _cartCountForReq(req),
|
|
3359
|
+
balance: balance, not_found: notFound,
|
|
3360
|
+
}));
|
|
3361
|
+
});
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3087
3364
|
// ---- customer accounts (passkey-only) ------------------------------
|
|
3088
3365
|
//
|
|
3089
3366
|
// Mount only when deps.customers is supplied (operator opts in by
|
|
@@ -3933,6 +4210,119 @@ function mount(router, deps) {
|
|
|
3933
4210
|
_addrAction("archive", function (id) { return deps.addresses.archive(id); });
|
|
3934
4211
|
}
|
|
3935
4212
|
|
|
4213
|
+
// Subscription self-management — the signed-in customer views their
|
|
4214
|
+
// own recurring subscriptions and cancels them. The list mounts on
|
|
4215
|
+
// the subscriptions primitive alone; the cancel route additionally
|
|
4216
|
+
// needs the payment handle (cancel composes Stripe through
|
|
4217
|
+
// `subscriptions.subscriptions.cancel`, mirroring the admin gate). A
|
|
4218
|
+
// subscription is fetched + ownership-checked against the authed
|
|
4219
|
+
// customer before any cancel — a guessed/forged id never reaches
|
|
4220
|
+
// another customer's row. Creation (a Stripe subscription-checkout
|
|
4221
|
+
// flow) is a separate surface; this is the management view.
|
|
4222
|
+
if (subscriptions) {
|
|
4223
|
+
var subsCanCancel = !!deps.payment;
|
|
4224
|
+
|
|
4225
|
+
function _subsAuth(req, res) {
|
|
4226
|
+
var auth;
|
|
4227
|
+
try { auth = _currentCustomer(req); }
|
|
4228
|
+
catch (e) {
|
|
4229
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
4230
|
+
throw e;
|
|
4231
|
+
}
|
|
4232
|
+
if (!auth) {
|
|
4233
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
4234
|
+
res.end ? res.end() : res.send("");
|
|
4235
|
+
return null;
|
|
4236
|
+
}
|
|
4237
|
+
return auth;
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
// Resolve the customer's subscriptions and join each to its plan.
|
|
4241
|
+
// Plans are batched: one `plans.get` per distinct plan_id, cached
|
|
4242
|
+
// across rows, so a customer with many subscriptions on the same
|
|
4243
|
+
// plan costs one plan read, not one per subscription (no N+1). A
|
|
4244
|
+
// read failure (table not migrated) degrades to an empty list
|
|
4245
|
+
// rather than 500-ing the account page.
|
|
4246
|
+
async function _subsForCustomer(customerId) {
|
|
4247
|
+
var rows;
|
|
4248
|
+
try { rows = await subscriptions.subscriptions.list({ customer_id: customerId }); }
|
|
4249
|
+
catch (e) {
|
|
4250
|
+
if (e instanceof TypeError) return [];
|
|
4251
|
+
throw e;
|
|
4252
|
+
}
|
|
4253
|
+
var planCache = {};
|
|
4254
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
4255
|
+
var pid = rows[i].plan_id;
|
|
4256
|
+
if (pid != null && !Object.prototype.hasOwnProperty.call(planCache, pid)) {
|
|
4257
|
+
try { planCache[pid] = await subscriptions.plans.get(pid); }
|
|
4258
|
+
catch (_e) { planCache[pid] = null; }
|
|
4259
|
+
}
|
|
4260
|
+
rows[i].plan = pid != null ? planCache[pid] : null;
|
|
4261
|
+
}
|
|
4262
|
+
return rows;
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
// Load the subscription named in :id and confirm it belongs to the
|
|
4266
|
+
// signed-in customer. A malformed id (guardUuid TypeError), a
|
|
4267
|
+
// missing row, or another customer's subscription all return null
|
|
4268
|
+
// after sending a 404 — never a 500, never a cross-customer cancel.
|
|
4269
|
+
async function _ownedSubscription(req, res, auth) {
|
|
4270
|
+
var sub;
|
|
4271
|
+
try { sub = await subscriptions.subscriptions.get(req.params && req.params.id); }
|
|
4272
|
+
catch (e) {
|
|
4273
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
4274
|
+
throw e;
|
|
4275
|
+
}
|
|
4276
|
+
if (!sub || sub.customer_id !== auth.customer_id) {
|
|
4277
|
+
_send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
4278
|
+
return null;
|
|
4279
|
+
}
|
|
4280
|
+
return sub;
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
router.get("/account/subscriptions", async function (req, res) {
|
|
4284
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
4285
|
+
var rows = await _subsForCustomer(auth.customer_id);
|
|
4286
|
+
var cartCount = await _cartCountForReq(req);
|
|
4287
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4288
|
+
var canceled = !!(url && url.searchParams.get("canceled"));
|
|
4289
|
+
_send(res, 200, renderAccountSubscriptions({
|
|
4290
|
+
subscriptions: rows,
|
|
4291
|
+
can_cancel: subsCanCancel,
|
|
4292
|
+
notice: canceled ? "Your subscription has been canceled." : null,
|
|
4293
|
+
shop_name: shopName,
|
|
4294
|
+
cart_count: cartCount,
|
|
4295
|
+
}));
|
|
4296
|
+
});
|
|
4297
|
+
|
|
4298
|
+
// Cancel mounts only when payment is wired (cancel composes Stripe).
|
|
4299
|
+
// Without payment the list above stays read-only with a note.
|
|
4300
|
+
if (deps.payment) {
|
|
4301
|
+
router.post("/account/subscriptions/:id/cancel", async function (req, res) {
|
|
4302
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
4303
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
4304
|
+
// Default cancel-at-period-end (the customer keeps access through
|
|
4305
|
+
// the period they've paid for); the form opts into immediate via
|
|
4306
|
+
// the `immediate` checkbox.
|
|
4307
|
+
var atPeriodEnd = !((req.body || {}).immediate === "1");
|
|
4308
|
+
try {
|
|
4309
|
+
await subscriptions.subscriptions.cancel(sub.id, { at_period_end: atPeriodEnd });
|
|
4310
|
+
} catch (e) {
|
|
4311
|
+
// A bad id was already screened by _ownedSubscription; a
|
|
4312
|
+
// downstream TypeError (shape) bounces back to the list with a
|
|
4313
|
+
// notice rather than 500-ing.
|
|
4314
|
+
if (e instanceof TypeError) {
|
|
4315
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions");
|
|
4316
|
+
return res.end ? res.end() : res.send("");
|
|
4317
|
+
}
|
|
4318
|
+
throw e;
|
|
4319
|
+
}
|
|
4320
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions?canceled=1");
|
|
4321
|
+
return res.end ? res.end() : res.send("");
|
|
4322
|
+
});
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
|
|
3936
4326
|
// Self-serve returns — a customer requests an RMA against one of
|
|
3937
4327
|
// their own orders and tracks its status. Operators action it via
|
|
3938
4328
|
// the admin /admin/returns queue. Needs the returns primitive + an
|
|
@@ -4370,11 +4760,13 @@ module.exports = {
|
|
|
4370
4760
|
renderProduct: renderProduct,
|
|
4371
4761
|
renderCart: renderCart,
|
|
4372
4762
|
renderCheckoutForm: renderCheckoutForm,
|
|
4763
|
+
renderGiftCardBalance: renderGiftCardBalance,
|
|
4373
4764
|
renderPayPage: renderPayPage,
|
|
4374
4765
|
renderOrder: renderOrder,
|
|
4375
4766
|
renderAccountLogin: renderAccountLogin,
|
|
4376
4767
|
renderAccountRegister: renderAccountRegister,
|
|
4377
4768
|
renderAccount: renderAccount,
|
|
4769
|
+
renderAccountSubscriptions: renderAccountSubscriptions,
|
|
4378
4770
|
renderNotFound: renderNotFound,
|
|
4379
4771
|
// Layout exposed so operators forking the framework can override.
|
|
4380
4772
|
_wrap: _wrap,
|