@blamejs/blamejs-shop 0.1.36 → 0.1.38
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/README.md +1 -0
- package/lib/admin.js +205 -42
- package/lib/asset-manifest.json +5 -5
- package/lib/storefront.js +706 -74
- package/lib/translations.js +203 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +2 -1
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/index.js +1 -0
- package/lib/vendor/blamejs/lib/ai-disclosure.js +2 -3
- package/lib/vendor/blamejs/lib/ai-frontier-protocol.js +196 -0
- package/lib/vendor/blamejs/lib/archive-gz.js +5 -3
- package/lib/vendor/blamejs/lib/archive-read.js +84 -35
- package/lib/vendor/blamejs/lib/archive-tar-read.js +86 -31
- package/lib/vendor/blamejs/lib/archive-tar.js +2 -3
- package/lib/vendor/blamejs/lib/backup/index.js +2 -3
- package/lib/vendor/blamejs/lib/cose.js +4 -3
- package/lib/vendor/blamejs/lib/mail-server-jmap.js +9 -9
- package/lib/vendor/blamejs/lib/mail-server-submission.js +3 -2
- package/lib/vendor/blamejs/lib/mdoc.js +14 -14
- package/lib/vendor/blamejs/lib/network-dnssec.js +10 -8
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.6.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.7.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.8.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-frontier-protocol.test.js +83 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +50 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +89 -0
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -28,9 +28,22 @@
|
|
|
28
28
|
var emailModule = require("./email");
|
|
29
29
|
var pricing = require("./pricing");
|
|
30
30
|
var currencyDisplayModule = require("./currency-display");
|
|
31
|
+
var translationsModule = require("./translations");
|
|
32
|
+
var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
|
|
31
33
|
|
|
32
34
|
var b = require("./vendor/blamejs");
|
|
33
35
|
|
|
36
|
+
// Per-request locale context store. The storefront's locale middleware
|
|
37
|
+
// resolves the request's locale + chrome strings once and seeds this
|
|
38
|
+
// store; every `_wrap` call on the same request reads the resolved
|
|
39
|
+
// context back out without the ~30 renderers having to thread it through
|
|
40
|
+
// their opts. `enterWith` scopes the value to the request's async
|
|
41
|
+
// execution context (each inbound request runs its own `handle()` chain),
|
|
42
|
+
// so concurrent requests never see each other's locale. A renderer
|
|
43
|
+
// reached outside a request (a unit test calling `renderHome` directly)
|
|
44
|
+
// finds no store and falls back to the English baseline.
|
|
45
|
+
var _localeAls = new AsyncLocalStorage();
|
|
46
|
+
|
|
34
47
|
// Payment-webhook signatures (Stripe's HMAC, PayPal's, …) are computed over
|
|
35
48
|
// the EXACT raw request bytes, but the global JSON body-parser reparses and
|
|
36
49
|
// discards them. This middleware buffers the raw body for the given POST
|
|
@@ -126,7 +139,7 @@ var CONSENT_BANNER =
|
|
|
126
139
|
|
|
127
140
|
var LAYOUT =
|
|
128
141
|
"<!DOCTYPE html>\n" +
|
|
129
|
-
"<html lang=\"
|
|
142
|
+
"<html lang=\"{{lang}}\" dir=\"{{dir}}\">\n" +
|
|
130
143
|
"<head>\n" +
|
|
131
144
|
" <meta charset=\"utf-8\">\n" +
|
|
132
145
|
" <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
|
|
@@ -146,13 +159,13 @@ var LAYOUT =
|
|
|
146
159
|
" <meta name=\"twitter:image\" content=\"{{og_image}}\">\n" +
|
|
147
160
|
"</head>\n" +
|
|
148
161
|
"<body>\n" +
|
|
149
|
-
" <a class=\"skip-link\" href=\"#main\">
|
|
162
|
+
" <a class=\"skip-link\" href=\"#main\">{{skip_to_content}}</a>\n" +
|
|
150
163
|
"\n" +
|
|
151
164
|
" <div class=\"utility-bar\" role=\"complementary\">\n" +
|
|
152
165
|
" <div class=\"utility-bar__inner\">\n" +
|
|
153
|
-
" <span class=\"utility-bar__pill\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span>
|
|
154
|
-
" <span class=\"utility-bar__msg\">
|
|
155
|
-
" <a class=\"utility-bar__link\" href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">
|
|
166
|
+
" <span class=\"utility-bar__pill\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> {{util_pill}}</span>\n" +
|
|
167
|
+
" <span class=\"utility-bar__msg\">{{util_msg}}</span>\n" +
|
|
168
|
+
" <a class=\"utility-bar__link\" href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">{{util_star}}</a>\n" +
|
|
156
169
|
" </div>\n" +
|
|
157
170
|
" </div>\n" +
|
|
158
171
|
"\n" +
|
|
@@ -161,17 +174,17 @@ var LAYOUT =
|
|
|
161
174
|
" <a href=\"/\" class=\"brand\" aria-label=\"{{shop_name}}\"><img src=\"/assets/brand/logo.png\" alt=\"{{shop_name}}\"></a>\n" +
|
|
162
175
|
" <form class=\"site-search\" action=\"/search\" method=\"get\" role=\"search\">\n" +
|
|
163
176
|
" <div class=\"site-search__inner\">\n" +
|
|
164
|
-
" <label for=\"site-search-q\" class=\"skip-link\">
|
|
177
|
+
" <label for=\"site-search-q\" class=\"skip-link\">{{search_label}}</label>\n" +
|
|
165
178
|
" <svg class=\"site-search__icon\" viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M21 21l-4.35-4.35M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg>\n" +
|
|
166
|
-
" <input id=\"site-search-q\" type=\"search\" name=\"q\" value=\"{{search_q}}\" placeholder=\"
|
|
167
|
-
" <button type=\"submit\">
|
|
179
|
+
" <input id=\"site-search-q\" type=\"search\" name=\"q\" value=\"{{search_q}}\" placeholder=\"{{search_placeholder}}\" autocomplete=\"off\" spellcheck=\"false\" maxlength=\"200\">\n" +
|
|
180
|
+
" <button type=\"submit\">{{search_submit}}</button>\n" +
|
|
168
181
|
" </div>\n" +
|
|
169
182
|
" </form>\n" +
|
|
170
183
|
" <nav class=\"site-nav\" aria-label=\"Primary\">\n" +
|
|
171
|
-
" <a class=\"site-nav__link\" href=\"/\">
|
|
172
|
-
" <a class=\"site-nav__link\" href=\"#framework\">
|
|
173
|
-
" <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"
|
|
174
|
-
" <a class=\"cart-pill\" href=\"/cart\" aria-label=\"
|
|
184
|
+
" <a class=\"site-nav__link\" href=\"/\">{{nav_shop}}</a>\n" +
|
|
185
|
+
" <a class=\"site-nav__link\" href=\"#framework\">{{nav_framework}}</a>\n" +
|
|
186
|
+
" <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"{{nav_account}}\"><svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg></a>\n" +
|
|
187
|
+
" <a class=\"cart-pill\" href=\"/cart\" aria-label=\"{{nav_cart_aria}}\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 12.1a2 2 0 0 0 2 1.6h7.6a2 2 0 0 0 1.95-1.55L21 8H6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"10\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/><circle cx=\"17\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/></svg><span class=\"cart-pill__count\">{{cart_count}}</span></a>\n" +
|
|
175
188
|
" </nav>\n" +
|
|
176
189
|
" </div>\n" +
|
|
177
190
|
" </header>\n" +
|
|
@@ -181,14 +194,14 @@ var LAYOUT =
|
|
|
181
194
|
" <section class=\"newsletter-band\" aria-labelledby=\"newsletter-title\">\n" +
|
|
182
195
|
" <div class=\"newsletter-band__inner\">\n" +
|
|
183
196
|
" <div class=\"newsletter-band__copy\">\n" +
|
|
184
|
-
" <p class=\"eyebrow eyebrow--on-dark\">
|
|
185
|
-
" <h2 id=\"newsletter-title\">
|
|
186
|
-
" <p class=\"newsletter-band__lede\">
|
|
197
|
+
" <p class=\"eyebrow eyebrow--on-dark\">{{newsletter_eyebrow}}</p>\n" +
|
|
198
|
+
" <h2 id=\"newsletter-title\">{{newsletter_title}}</h2>\n" +
|
|
199
|
+
" <p class=\"newsletter-band__lede\">{{newsletter_lede}}</p>\n" +
|
|
187
200
|
" </div>\n" +
|
|
188
201
|
" <form class=\"newsletter-band__form\" method=\"post\" action=\"/newsletter\">\n" +
|
|
189
|
-
" <label class=\"skip-link\" for=\"newsletter-email\">
|
|
202
|
+
" <label class=\"skip-link\" for=\"newsletter-email\">{{newsletter_email}}</label>\n" +
|
|
190
203
|
" <input id=\"newsletter-email\" type=\"email\" name=\"email\" required placeholder=\"you@example.com\" autocomplete=\"email\">\n" +
|
|
191
|
-
" <button type=\"submit\">
|
|
204
|
+
" <button type=\"submit\">{{newsletter_submit}}</button>\n" +
|
|
192
205
|
" </form>\n" +
|
|
193
206
|
" </div>\n" +
|
|
194
207
|
" </section>\n" +
|
|
@@ -197,7 +210,7 @@ var LAYOUT =
|
|
|
197
210
|
" <div class=\"site-footer__inner\">\n" +
|
|
198
211
|
" <div class=\"site-footer__brand-col\">\n" +
|
|
199
212
|
" <img class=\"site-footer__logo\" src=\"/assets/brand/logo.png\" alt=\"{{shop_name}}\">\n" +
|
|
200
|
-
" <p class=\"site-footer__tagline\">
|
|
213
|
+
" <p class=\"site-footer__tagline\">{{footer_tagline}}</p>\n" +
|
|
201
214
|
" <ul class=\"site-footer__social\" aria-label=\"Project links\">\n" +
|
|
202
215
|
" <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\" aria-label=\"GitHub\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11 11 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.3v3.4c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5Z\" fill=\"currentColor\"/></svg></a></li>\n" +
|
|
203
216
|
" <li><a href=\"https://npmjs.com/package/blamejs\" rel=\"noopener\" aria-label=\"npm\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M2 7v10h6v-7h3v7h11V7H2Zm15 8h-2v-5h-3v5h-1V9h6v6Z\" fill=\"currentColor\"/></svg></a></li>\n" +
|
|
@@ -205,44 +218,45 @@ var LAYOUT =
|
|
|
205
218
|
" </ul>\n" +
|
|
206
219
|
" </div>\n" +
|
|
207
220
|
" <div class=\"site-footer__col\">\n" +
|
|
208
|
-
" <h4>
|
|
221
|
+
" <h4>{{footer_shop_heading}}</h4>\n" +
|
|
209
222
|
" <ul>\n" +
|
|
210
|
-
" <li><a href=\"/\">
|
|
211
|
-
" <li><a href=\"/collections\">
|
|
212
|
-
" <li><a href=\"/categories\">
|
|
213
|
-
" <li><a href=\"/?sort=new\">
|
|
214
|
-
" <li><a href=\"/?sort=sale\">
|
|
215
|
-
" <li><a href=\"/compare\">
|
|
216
|
-
" <li><a href=\"/cart\">
|
|
223
|
+
" <li><a href=\"/\">{{footer_shop_all}}</a></li>\n" +
|
|
224
|
+
" <li><a href=\"/collections\">{{footer_shop_collections}}</a></li>\n" +
|
|
225
|
+
" <li><a href=\"/categories\">{{footer_shop_categories}}</a></li>\n" +
|
|
226
|
+
" <li><a href=\"/?sort=new\">{{footer_shop_new}}</a></li>\n" +
|
|
227
|
+
" <li><a href=\"/?sort=sale\">{{footer_shop_sale}}</a></li>\n" +
|
|
228
|
+
" <li><a href=\"/compare\">{{footer_shop_compare}}</a></li>\n" +
|
|
229
|
+
" <li><a href=\"/cart\">{{footer_shop_cart}}</a></li>\n" +
|
|
217
230
|
" </ul>\n" +
|
|
218
231
|
" </div>\n" +
|
|
219
232
|
" <div class=\"site-footer__col\">\n" +
|
|
220
|
-
" <h4>
|
|
233
|
+
" <h4>{{footer_framework_heading}}</h4>\n" +
|
|
221
234
|
" <ul>\n" +
|
|
222
|
-
" <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">
|
|
223
|
-
" <li><a href=\"https://github.com/blamejs/blamejs\" rel=\"noopener\">
|
|
224
|
-
" <li><a href=\"/SECURITY.md\">
|
|
225
|
-
" <li><a href=\"/CHANGELOG.md\">
|
|
235
|
+
" <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">{{footer_framework_source}}</a></li>\n" +
|
|
236
|
+
" <li><a href=\"https://github.com/blamejs/blamejs\" rel=\"noopener\">{{footer_framework_core}}</a></li>\n" +
|
|
237
|
+
" <li><a href=\"/SECURITY.md\">{{footer_framework_security}}</a></li>\n" +
|
|
238
|
+
" <li><a href=\"/CHANGELOG.md\">{{footer_framework_changelog}}</a></li>\n" +
|
|
226
239
|
" </ul>\n" +
|
|
227
240
|
" </div>\n" +
|
|
228
241
|
" <div class=\"site-footer__col\">\n" +
|
|
229
|
-
" <h4>
|
|
242
|
+
" <h4>{{footer_operators_heading}}</h4>\n" +
|
|
230
243
|
" <ul>\n" +
|
|
231
|
-
" <li><a href=\"/account\">
|
|
232
|
-
" <li><a href=\"/orders\">
|
|
233
|
-
" <li><a href=\"/admin\">
|
|
234
|
-
" <li><a href=\"mailto:hello@blamejs.shop\">
|
|
244
|
+
" <li><a href=\"/account\">{{footer_operators_account}}</a></li>\n" +
|
|
245
|
+
" <li><a href=\"/orders\">{{footer_operators_orders}}</a></li>\n" +
|
|
246
|
+
" <li><a href=\"/admin\">{{footer_operators_admin}}</a></li>\n" +
|
|
247
|
+
" <li><a href=\"mailto:hello@blamejs.shop\">{{footer_operators_contact}}</a></li>\n" +
|
|
235
248
|
" </ul>\n" +
|
|
236
249
|
" </div>\n" +
|
|
237
250
|
" </div>\n" +
|
|
238
251
|
" RAW_CURRENCY_SWITCHER\n" +
|
|
252
|
+
" RAW_LOCALE_SWITCHER\n" +
|
|
239
253
|
" <div class=\"site-footer__copy\">\n" +
|
|
240
|
-
" <p>© {{year}} {{shop_name}} —
|
|
254
|
+
" <p>© {{year}} {{shop_name}} — {{footer_copy_suffix}}</p>\n" +
|
|
241
255
|
" <ul>\n" +
|
|
242
|
-
" <li><a href=\"/SECURITY.md\">
|
|
243
|
-
" <li><a href=\"/privacy\">
|
|
244
|
-
" <li><a href=\"/terms\">
|
|
245
|
-
" <li><a href=\"/cookies\">
|
|
256
|
+
" <li><a href=\"/SECURITY.md\">{{footer_legal_security}}</a></li>\n" +
|
|
257
|
+
" <li><a href=\"/privacy\">{{footer_legal_privacy}}</a></li>\n" +
|
|
258
|
+
" <li><a href=\"/terms\">{{footer_legal_terms}}</a></li>\n" +
|
|
259
|
+
" <li><a href=\"/cookies\">{{footer_legal_cookies}}</a></li>\n" +
|
|
246
260
|
" </ul>\n" +
|
|
247
261
|
" </div>\n" +
|
|
248
262
|
" </footer>\n" +
|
|
@@ -374,6 +388,20 @@ function _currencyWrapOpts(opts) {
|
|
|
374
388
|
};
|
|
375
389
|
}
|
|
376
390
|
|
|
391
|
+
// Default locale context — the English chrome baseline, `lang="en"`,
|
|
392
|
+
// `dir="ltr"`, no switcher. Any page rendered without a resolved
|
|
393
|
+
// `locale_ctx` (an internal renderer that doesn't thread one, a deploy
|
|
394
|
+
// with no locale policy) falls back to this, so the chrome is always
|
|
395
|
+
// complete English — never a raw `{{key}}` and never a 500. The cart
|
|
396
|
+
// aria carries a literal `{count}` here because the baseline isn't tied
|
|
397
|
+
// to a request; `_wrap` interpolates the real count below.
|
|
398
|
+
var _DEFAULT_LOCALE_CTX = {
|
|
399
|
+
lang: "en",
|
|
400
|
+
dir: "ltr",
|
|
401
|
+
chrome: translationsModule.chromeDefaults(),
|
|
402
|
+
switcher_html: "",
|
|
403
|
+
};
|
|
404
|
+
|
|
377
405
|
function _wrap(opts) {
|
|
378
406
|
var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
|
|
379
407
|
? opts.theme_css
|
|
@@ -401,10 +429,30 @@ function _wrap(opts) {
|
|
|
401
429
|
note: opts.currency_note,
|
|
402
430
|
redirect_to: opts.currency_redirect_to,
|
|
403
431
|
});
|
|
404
|
-
|
|
432
|
+
|
|
433
|
+
// Locale context — resolved per request by the storefront's locale
|
|
434
|
+
// middleware and read back from the async-local store; an explicit
|
|
435
|
+
// `opts.locale_ctx` (a renderer that threads its own) takes precedence.
|
|
436
|
+
// Absent both, the English baseline applies. The chrome strings are
|
|
437
|
+
// HTML-escaped by `_render` per substitution like every other
|
|
438
|
+
// placeholder; the switcher form is raw HTML (already escaped at its
|
|
439
|
+
// own build site) so it's spliced post-render like the body.
|
|
440
|
+
var storeCtx = _localeAls.getStore();
|
|
441
|
+
var localeCtx = (opts.locale_ctx && opts.locale_ctx.chrome)
|
|
442
|
+
? opts.locale_ctx
|
|
443
|
+
: ((storeCtx && storeCtx.chrome) ? storeCtx : _DEFAULT_LOCALE_CTX);
|
|
444
|
+
var chrome = localeCtx.chrome;
|
|
445
|
+
var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
|
|
446
|
+
// The cart aria-label carries the count: when the resolved string is
|
|
447
|
+
// the request-localised one it already has the count baked in; the
|
|
448
|
+
// English baseline default ships a literal `{count}` we fill here so
|
|
449
|
+
// the un-threaded fallback path still reads naturally.
|
|
450
|
+
var cartAria = String(chrome.nav_cart_aria).replace("{count}", String(cartCount));
|
|
451
|
+
|
|
452
|
+
var vars = {
|
|
405
453
|
title: opts.title,
|
|
406
454
|
shop_name: shopName,
|
|
407
|
-
cart_count:
|
|
455
|
+
cart_count: cartCount,
|
|
408
456
|
year: _COPYRIGHT_YEAR,
|
|
409
457
|
search_q: opts.search_q == null ? "" : opts.search_q,
|
|
410
458
|
theme_css: themeCss,
|
|
@@ -413,10 +461,29 @@ function _wrap(opts) {
|
|
|
413
461
|
og_description: ogDescription,
|
|
414
462
|
og_image: ogImage,
|
|
415
463
|
og_url: ogUrl,
|
|
464
|
+
lang: localeCtx.lang || "en",
|
|
465
|
+
dir: localeCtx.dir || "ltr",
|
|
466
|
+
nav_cart_aria: cartAria,
|
|
416
467
|
body: "RAW_BODY_PLACEHOLDER",
|
|
417
|
-
}
|
|
468
|
+
};
|
|
469
|
+
// Layer in every chrome string the LAYOUT references. `nav_cart_aria`
|
|
470
|
+
// is handled above (it needs the count interpolated); the two
|
|
471
|
+
// `locale_switcher_*` strings live only inside the switcher form
|
|
472
|
+
// (spliced via RAW_LOCALE_SWITCHER), not in the LAYOUT body — the
|
|
473
|
+
// strict `_render` refuses an unused placeholder, so they're skipped
|
|
474
|
+
// here.
|
|
475
|
+
var chromeKeys = Object.keys(chrome);
|
|
476
|
+
for (var ci = 0; ci < chromeKeys.length; ci += 1) {
|
|
477
|
+
var ck = chromeKeys[ci];
|
|
478
|
+
if (ck === "nav_cart_aria" || ck === "locale_switcher_label" || ck === "locale_switcher_submit") continue;
|
|
479
|
+
vars[ck] = chrome[ck];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return _render(LAYOUT, vars)
|
|
483
|
+
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
418
484
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
419
485
|
.replace("RAW_CURRENCY_SWITCHER", switcherHtml)
|
|
486
|
+
.replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "")
|
|
420
487
|
.replace("RAW_BODY_PLACEHOLDER", opts.body);
|
|
421
488
|
// The body is RAW HTML (already rendered + escaped at the
|
|
422
489
|
// per-fragment level). The placeholder swap is post-render so the
|
|
@@ -798,8 +865,8 @@ var FACET_GROUP_HEAD =
|
|
|
798
865
|
|
|
799
866
|
var FACET_OPTION =
|
|
800
867
|
"<li class=\"facet-option\">\n" +
|
|
801
|
-
" <a class=\"facet-option__link{{selected_class}}\" href=\"{{href}}\" rel=\"nofollow\"{{
|
|
802
|
-
" <span class=\"facet-option__box\" aria-hidden=\"true\">{{box}}</span
|
|
868
|
+
" <a class=\"facet-option__link{{selected_class}}\" href=\"{{href}}\" rel=\"nofollow\"{{aria_current}}>\n" +
|
|
869
|
+
" <span class=\"facet-option__box\" aria-hidden=\"true\">{{box}}</span>{{selected_cue}}\n" +
|
|
803
870
|
" <span class=\"facet-option__label\">{{label}}</span>\n" +
|
|
804
871
|
" <span class=\"facet-option__count\">{{count}}</span>\n" +
|
|
805
872
|
" </a>\n" +
|
|
@@ -854,11 +921,13 @@ function _renderSearchFacets(facets, filters, q) {
|
|
|
854
921
|
optionsHtml += _render(FACET_OPTION, {
|
|
855
922
|
href: _searchUrl(q, _toggleFilter(filters, facet.key, opt.value)),
|
|
856
923
|
selected_class: opt.selected ? " is-selected" : "",
|
|
857
|
-
|
|
924
|
+
aria_current: "RAW_ARIA",
|
|
858
925
|
box: opt.selected ? "✓" : "",
|
|
926
|
+
selected_cue: "RAW_CUE",
|
|
859
927
|
label: opt.label,
|
|
860
928
|
count: String(opt.count),
|
|
861
|
-
}).replace("RAW_ARIA", opt.selected ? " aria-
|
|
929
|
+
}).replace("RAW_ARIA", opt.selected ? " aria-current=\"true\"" : "")
|
|
930
|
+
.replace("RAW_CUE", opt.selected ? "<span class=\"sr-only\">Selected: </span>" : "");
|
|
862
931
|
rendered += 1;
|
|
863
932
|
}
|
|
864
933
|
if (rendered === 0) continue;
|
|
@@ -1568,7 +1637,19 @@ function renderWishlist(opts) {
|
|
|
1568
1637
|
}
|
|
1569
1638
|
var inner = rowsHtml
|
|
1570
1639
|
? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
|
|
1571
|
-
: "<
|
|
1640
|
+
: "<div class=\"account-empty\">" +
|
|
1641
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\">♡</p>" +
|
|
1642
|
+
"<p class=\"account-empty__lede\">You haven't saved anything yet. Browse the shop and tap <strong>Save to wishlist</strong> on products you want to keep an eye on.</p>" +
|
|
1643
|
+
"<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
|
|
1644
|
+
"</div>";
|
|
1645
|
+
// Success confirmation after a wishlist toggle, surfaced via
|
|
1646
|
+
// role="status". Driven by the ?ok=<kind> redirect marker; unknown
|
|
1647
|
+
// keys render nothing so a forged query can't inject copy.
|
|
1648
|
+
var WISHLIST_OK = { added: "Saved to your wishlist.", removed: "Removed from your wishlist." };
|
|
1649
|
+
var okMsg = WISHLIST_OK[opts.notice];
|
|
1650
|
+
var notice = okMsg
|
|
1651
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(okMsg) + "</p>"
|
|
1652
|
+
: "";
|
|
1572
1653
|
var body =
|
|
1573
1654
|
"<section class=\"account-wishlist\">" +
|
|
1574
1655
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -1576,6 +1657,7 @@ function renderWishlist(opts) {
|
|
|
1576
1657
|
"<li aria-current=\"page\">Saved items</li>" +
|
|
1577
1658
|
"</ol></nav>" +
|
|
1578
1659
|
"<h1 class=\"account-wishlist__title\">Saved items</h1>" +
|
|
1660
|
+
notice +
|
|
1579
1661
|
inner +
|
|
1580
1662
|
"</section>";
|
|
1581
1663
|
return _wrap({
|
|
@@ -1804,7 +1886,19 @@ function renderSaved(opts) {
|
|
|
1804
1886
|
}
|
|
1805
1887
|
var inner = rowsHtml
|
|
1806
1888
|
? "<ul class=\"saved-list\">" + rowsHtml + "</ul>"
|
|
1807
|
-
: "<
|
|
1889
|
+
: "<div class=\"account-empty\">" +
|
|
1890
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\">🔖</p>" +
|
|
1891
|
+
"<p class=\"account-empty__lede\">Nothing saved for later. Use <strong>Save for later</strong> on a cart item to move it here without losing it.</p>" +
|
|
1892
|
+
"<a class=\"btn-secondary\" href=\"/cart\">View your cart →</a>" +
|
|
1893
|
+
"</div>";
|
|
1894
|
+
// Success confirmation after a saved-list mutation (currently the
|
|
1895
|
+
// Remove action), surfaced via role="status". Unknown ?ok=<kind> keys
|
|
1896
|
+
// render nothing so a forged query can't inject copy.
|
|
1897
|
+
var SAVED_OK = { removed: "Removed from your saved items.", moved: "Moved to your cart." };
|
|
1898
|
+
var savedMsg = SAVED_OK[opts.notice];
|
|
1899
|
+
var notice = savedMsg
|
|
1900
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(savedMsg) + "</p>"
|
|
1901
|
+
: "";
|
|
1808
1902
|
var body =
|
|
1809
1903
|
"<section class=\"account-saved\">" +
|
|
1810
1904
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -1812,6 +1906,7 @@ function renderSaved(opts) {
|
|
|
1812
1906
|
"<li aria-current=\"page\">Saved for later</li>" +
|
|
1813
1907
|
"</ol></nav>" +
|
|
1814
1908
|
"<h1 class=\"account-saved__title\">Saved for later</h1>" +
|
|
1909
|
+
notice +
|
|
1815
1910
|
inner +
|
|
1816
1911
|
"</section>";
|
|
1817
1912
|
return _wrap({
|
|
@@ -1891,16 +1986,32 @@ function renderAddresses(opts) {
|
|
|
1891
1986
|
"<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/addresses/" + esc(a.id) + "/edit\">Edit</a>" +
|
|
1892
1987
|
(Number(a.is_default_shipping) === 1 ? "" : "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/default-shipping\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set default shipping</button></form>") +
|
|
1893
1988
|
(Number(a.is_default_billing) === 1 ? "" : "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/default-billing\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set default billing</button></form>") +
|
|
1894
|
-
"<
|
|
1989
|
+
"<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/addresses/" + esc(a.id) + "/remove\">Remove</a>" +
|
|
1895
1990
|
"</div>" +
|
|
1896
1991
|
"</li>";
|
|
1897
1992
|
}
|
|
1898
1993
|
var listHtml = rowsHtml
|
|
1899
1994
|
? "<ul class=\"address-list\">" + rowsHtml + "</ul>"
|
|
1900
|
-
: "<
|
|
1995
|
+
: "<div class=\"account-empty\">" +
|
|
1996
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\">🏠</p>" +
|
|
1997
|
+
"<p class=\"account-empty__lede\">No saved addresses yet. Add one below to speed up checkout.</p>" +
|
|
1998
|
+
"</div>";
|
|
1901
1999
|
var notice = opts.notice
|
|
1902
2000
|
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
1903
2001
|
: "";
|
|
2002
|
+
// Success confirmation after an address mutation (add / edit / remove /
|
|
2003
|
+
// set-default), surfaced at the top of the list via role="status" so a
|
|
2004
|
+
// screen reader announces it without stealing focus. Driven by the
|
|
2005
|
+
// ?ok=<kind> redirect the POST handlers set on success. A remove also
|
|
2006
|
+
// threads ?undo=<id> so the notice can offer a one-click un-archive.
|
|
2007
|
+
var undoForm = opts.undo_id
|
|
2008
|
+
? " <form class=\"form-notice__undo\" method=\"post\" action=\"/account/addresses/" + esc(String(opts.undo_id)) + "/unarchive\">" +
|
|
2009
|
+
"<button type=\"submit\" class=\"btn-link\">Undo</button>" +
|
|
2010
|
+
"</form>"
|
|
2011
|
+
: "";
|
|
2012
|
+
var success = opts.success
|
|
2013
|
+
? "<div class=\"form-notice form-notice--ok\" role=\"status\"><span>" + esc(String(opts.success)) + "</span>" + undoForm + "</div>"
|
|
2014
|
+
: "";
|
|
1904
2015
|
var editing = opts.edit || null;
|
|
1905
2016
|
var formHeading = editing ? "Edit address" : "Add an address";
|
|
1906
2017
|
var formAction = editing ? ("/account/addresses/" + editing.id) : "/account/addresses";
|
|
@@ -1911,6 +2022,7 @@ function renderAddresses(opts) {
|
|
|
1911
2022
|
"<li aria-current=\"page\">Addresses</li>" +
|
|
1912
2023
|
"</ol></nav>" +
|
|
1913
2024
|
"<h1 class=\"account-addresses__title\">Addresses</h1>" +
|
|
2025
|
+
success +
|
|
1914
2026
|
listHtml +
|
|
1915
2027
|
"<h2 class=\"account-addresses__form-title\">" + esc(formHeading) + "</h2>" +
|
|
1916
2028
|
notice +
|
|
@@ -1925,6 +2037,45 @@ function renderAddresses(opts) {
|
|
|
1925
2037
|
});
|
|
1926
2038
|
}
|
|
1927
2039
|
|
|
2040
|
+
// Server-rendered confirmation step for removing a saved address. CSP
|
|
2041
|
+
// forbids an inline confirm() dialog, so the destructive action is gated
|
|
2042
|
+
// by a second page: a POST form that actually archives, plus a Cancel
|
|
2043
|
+
// link back to the list. JS-off-native — no client script involved.
|
|
2044
|
+
function renderAddressRemoveConfirm(opts) {
|
|
2045
|
+
var esc = b.template.escapeHtml;
|
|
2046
|
+
var a = opts.address || {};
|
|
2047
|
+
var lines = [a.recipient_name, a.company, a.street_line1, a.street_line2,
|
|
2048
|
+
[a.city, a.region, a.postal_code].filter(Boolean).join(", "), a.country, a.phone]
|
|
2049
|
+
.filter(function (x) { return x != null && String(x).length; })
|
|
2050
|
+
.map(function (x) { return "<span>" + esc(String(x)) + "</span>"; }).join("");
|
|
2051
|
+
var body =
|
|
2052
|
+
"<section class=\"account-confirm\">" +
|
|
2053
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
2054
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
2055
|
+
"<li><a href=\"/account/addresses\">Addresses</a></li>" +
|
|
2056
|
+
"<li aria-current=\"page\">Remove address</li>" +
|
|
2057
|
+
"</ol></nav>" +
|
|
2058
|
+
"<h1 class=\"account-confirm__title\">Remove this address?</h1>" +
|
|
2059
|
+
(a.label ? "<p class=\"address-card__label\">" + esc(a.label) + "</p>" : "") +
|
|
2060
|
+
"<address class=\"address-card__body\">" + lines + "</address>" +
|
|
2061
|
+
"<p class=\"account-confirm__lede\">This removes the address from your address book. " +
|
|
2062
|
+
"You can add it again later.</p>" +
|
|
2063
|
+
"<div class=\"account-confirm__actions\">" +
|
|
2064
|
+
"<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/archive\">" +
|
|
2065
|
+
"<button type=\"submit\" class=\"btn-primary\">Remove address</button>" +
|
|
2066
|
+
"</form>" +
|
|
2067
|
+
"<a class=\"btn-ghost\" href=\"/account/addresses\">Cancel</a>" +
|
|
2068
|
+
"</div>" +
|
|
2069
|
+
"</section>";
|
|
2070
|
+
return _wrap({
|
|
2071
|
+
title: "Remove address",
|
|
2072
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
2073
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
2074
|
+
theme_css: opts.theme_css,
|
|
2075
|
+
body: body,
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
|
|
1928
2079
|
// Storefront collection index — operator-curated + smart product lists.
|
|
1929
2080
|
function renderCollectionList(opts) {
|
|
1930
2081
|
var esc = b.template.escapeHtml;
|
|
@@ -2235,7 +2386,21 @@ function renderReturns(opts) {
|
|
|
2235
2386
|
}
|
|
2236
2387
|
var inner = rowsHtml
|
|
2237
2388
|
? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
|
|
2238
|
-
: "<
|
|
2389
|
+
: "<div class=\"account-empty\">" +
|
|
2390
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\">↩</p>" +
|
|
2391
|
+
"<p class=\"account-empty__lede\">No returns yet. Start one from an order in your account.</p>" +
|
|
2392
|
+
"<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</a>" +
|
|
2393
|
+
"</div>";
|
|
2394
|
+
// Success confirmation after submitting a return request. The RMA code
|
|
2395
|
+
// round-trips on the ?ok=<code> redirect so the notice can echo it back
|
|
2396
|
+
// — the operator-readable handle the customer references in support.
|
|
2397
|
+
// Validate the shape (RMA-YYMMDD-AAAAA) so a forged query can't inject
|
|
2398
|
+
// arbitrary copy into the status message.
|
|
2399
|
+
var notice = "";
|
|
2400
|
+
if (opts.rma_code && /^RMA-\d{6}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{5}$/.test(String(opts.rma_code))) {
|
|
2401
|
+
notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Return request received — RMA " +
|
|
2402
|
+
esc(String(opts.rma_code)) + ". We'll email you when it's reviewed.</p>";
|
|
2403
|
+
}
|
|
2239
2404
|
var body =
|
|
2240
2405
|
"<section class=\"account-returns\">" +
|
|
2241
2406
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -2243,6 +2408,7 @@ function renderReturns(opts) {
|
|
|
2243
2408
|
"<li aria-current=\"page\">Returns</li>" +
|
|
2244
2409
|
"</ol></nav>" +
|
|
2245
2410
|
"<h1 class=\"account-returns__title\">Returns</h1>" +
|
|
2411
|
+
notice +
|
|
2246
2412
|
inner +
|
|
2247
2413
|
"</section>";
|
|
2248
2414
|
return _wrap({
|
|
@@ -2625,13 +2791,12 @@ function renderAccountSubscriptions(opts) {
|
|
|
2625
2791
|
}
|
|
2626
2792
|
var cancelControl = "";
|
|
2627
2793
|
if (canCancel && _subscriptionIsCancelable(s)) {
|
|
2794
|
+
// The cancel decision (and its "immediate vs. at period end"
|
|
2795
|
+
// consequence) is confirmed on a dedicated page — CSP forbids an
|
|
2796
|
+
// inline confirm() dialog, so the destructive step is a second
|
|
2797
|
+
// server-rendered screen rather than a one-click POST.
|
|
2628
2798
|
cancelControl =
|
|
2629
|
-
"<
|
|
2630
|
-
"<label class=\"subscription-card__when\">" +
|
|
2631
|
-
"<input type=\"checkbox\" name=\"immediate\" value=\"1\"> Cancel immediately (skip the rest of the period)" +
|
|
2632
|
-
"</label>" +
|
|
2633
|
-
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Cancel subscription</button>" +
|
|
2634
|
-
"</form>";
|
|
2799
|
+
"<a class=\"btn-ghost btn-ghost--sm subscription-card__cancel-link\" href=\"/account/subscriptions/" + esc(s.id) + "/cancel\">Cancel subscription</a>";
|
|
2635
2800
|
}
|
|
2636
2801
|
rowsHtml +=
|
|
2637
2802
|
"<li class=\"subscription-card\">" +
|
|
@@ -2648,7 +2813,11 @@ function renderAccountSubscriptions(opts) {
|
|
|
2648
2813
|
: "<p class=\"subscription-note\">Cancellation isn't available on this store yet. Contact support to make changes.</p>";
|
|
2649
2814
|
var inner = rowsHtml
|
|
2650
2815
|
? note + "<ul class=\"subscription-list\">" + rowsHtml + "</ul>"
|
|
2651
|
-
: "<
|
|
2816
|
+
: "<div class=\"account-empty\">" +
|
|
2817
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\">↻</p>" +
|
|
2818
|
+
"<p class=\"account-empty__lede\">You have no active subscriptions.</p>" +
|
|
2819
|
+
"<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
|
|
2820
|
+
"</div>";
|
|
2652
2821
|
var body =
|
|
2653
2822
|
"<section class=\"account-subscriptions\">" +
|
|
2654
2823
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -2668,6 +2837,79 @@ function renderAccountSubscriptions(opts) {
|
|
|
2668
2837
|
});
|
|
2669
2838
|
}
|
|
2670
2839
|
|
|
2840
|
+
// Server-rendered confirmation step for canceling a subscription. CSP
|
|
2841
|
+
// forbids an inline confirm() dialog, so the destructive choice is gated
|
|
2842
|
+
// by this page: it spells out the period-end date and, for the
|
|
2843
|
+
// "cancel immediately" path, the days of access forfeited. Two POST
|
|
2844
|
+
// forms (at-period-end / immediate) plus a Cancel link back to the list.
|
|
2845
|
+
// JS-off-native.
|
|
2846
|
+
function renderSubscriptionCancelConfirm(opts) {
|
|
2847
|
+
var esc = b.template.escapeHtml;
|
|
2848
|
+
var s = opts.subscription || {};
|
|
2849
|
+
var plan = s.plan || null;
|
|
2850
|
+
var planSummary = "this subscription";
|
|
2851
|
+
if (plan) {
|
|
2852
|
+
var ccy = String(plan.currency || "usd").toUpperCase();
|
|
2853
|
+
var every = Number(plan.interval_count) > 1
|
|
2854
|
+
? "every " + Number(plan.interval_count) + " " + String(plan.interval) + "s"
|
|
2855
|
+
: "per " + String(plan.interval);
|
|
2856
|
+
var priceStr = "";
|
|
2857
|
+
try { priceStr = pricing.format(Number(plan.amount_minor), ccy) + " "; } catch (_e) { priceStr = ""; }
|
|
2858
|
+
planSummary = priceStr + every;
|
|
2859
|
+
}
|
|
2860
|
+
var periodEndMs = Number(s.current_period_end) || 0;
|
|
2861
|
+
var periodEnd = periodEndMs ? new Date(periodEndMs).toISOString().slice(0, 10) : "";
|
|
2862
|
+
// Days of paid access forfeited if canceled immediately rather than at
|
|
2863
|
+
// period end — computed from now to the period boundary, floored at 0.
|
|
2864
|
+
var forfeitDays = 0;
|
|
2865
|
+
if (periodEndMs) {
|
|
2866
|
+
forfeitDays = Math.max(0, Math.ceil((periodEndMs - Date.now()) / b.constants.TIME.days(1)));
|
|
2867
|
+
}
|
|
2868
|
+
var periodLine = periodEnd
|
|
2869
|
+
? "<p class=\"account-confirm__lede\">Your current period is paid through " +
|
|
2870
|
+
"<time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>.</p>"
|
|
2871
|
+
: "";
|
|
2872
|
+
var atPeriodEndForm =
|
|
2873
|
+
"<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/cancel\">" +
|
|
2874
|
+
"<p class=\"account-confirm__option\">Cancel at period end" +
|
|
2875
|
+
(periodEnd ? " — keep access until <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>, then it won't renew." : ", so it won't renew.") +
|
|
2876
|
+
"</p>" +
|
|
2877
|
+
"<button type=\"submit\" class=\"btn-primary\">Cancel at period end</button>" +
|
|
2878
|
+
"</form>";
|
|
2879
|
+
var immediateForm =
|
|
2880
|
+
"<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/cancel\">" +
|
|
2881
|
+
"<input type=\"hidden\" name=\"immediate\" value=\"1\">" +
|
|
2882
|
+
"<p class=\"account-confirm__option\">Cancel immediately" +
|
|
2883
|
+
(forfeitDays > 0
|
|
2884
|
+
? " — access ends now and you forfeit the remaining " + forfeitDays + " day" + (forfeitDays === 1 ? "" : "s") + " of this period."
|
|
2885
|
+
: " — access ends now.") +
|
|
2886
|
+
"</p>" +
|
|
2887
|
+
"<button type=\"submit\" class=\"btn-ghost\">Cancel immediately</button>" +
|
|
2888
|
+
"</form>";
|
|
2889
|
+
var body =
|
|
2890
|
+
"<section class=\"account-confirm\">" +
|
|
2891
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
2892
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
2893
|
+
"<li><a href=\"/account/subscriptions\">Subscriptions</a></li>" +
|
|
2894
|
+
"<li aria-current=\"page\">Cancel</li>" +
|
|
2895
|
+
"</ol></nav>" +
|
|
2896
|
+
"<h1 class=\"account-confirm__title\">Cancel " + esc(planSummary) + "?</h1>" +
|
|
2897
|
+
periodLine +
|
|
2898
|
+
"<div class=\"account-confirm__actions\">" +
|
|
2899
|
+
atPeriodEndForm +
|
|
2900
|
+
immediateForm +
|
|
2901
|
+
"<a class=\"btn-ghost\" href=\"/account/subscriptions\">Keep my subscription</a>" +
|
|
2902
|
+
"</div>" +
|
|
2903
|
+
"</section>";
|
|
2904
|
+
return _wrap({
|
|
2905
|
+
title: "Cancel subscription",
|
|
2906
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
2907
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
2908
|
+
theme_css: opts.theme_css,
|
|
2909
|
+
body: body,
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2671
2913
|
// Product-level "Save to wishlist" control + social-proof count.
|
|
2672
2914
|
// Byte-compatible with the edge renderer (`worker/render/product.js`)
|
|
2673
2915
|
// so both paths emit identical markup. Action-only label — the toggle
|
|
@@ -3963,6 +4205,39 @@ function _clearCurrencyCookie(res) {
|
|
|
3963
4205
|
_cookieJar().clear(res, CURRENCY_COOKIE_NAME);
|
|
3964
4206
|
}
|
|
3965
4207
|
|
|
4208
|
+
// ---- locale cookie -----------------------------------------------------
|
|
4209
|
+
//
|
|
4210
|
+
// The visitor's locale choice rides an UNSEALED cookie. Unlike the
|
|
4211
|
+
// session / auth / referral cookies, the locale carries no authority —
|
|
4212
|
+
// it only selects which operator-configured, server-resolved locale the
|
|
4213
|
+
// chrome renders in (every candidate is validated against the active
|
|
4214
|
+
// policy's supported list; an unknown / garbage value resolves to the
|
|
4215
|
+
// default locale). It is deliberately not vault-sealed so the value
|
|
4216
|
+
// resolves IDENTICALLY on both render substrates: the edge Worker has no
|
|
4217
|
+
// vault and couldn't unseal a sealed cookie, which would split the
|
|
4218
|
+
// edge/container resolution. The framework cookie primitive still
|
|
4219
|
+
// applies the RFC 6265 grammar, the `Secure` / `SameSite=Lax` / `Path=/`
|
|
4220
|
+
// invariants, and (in production) the cookie-prefix policy. A read still
|
|
4221
|
+
// shape-validates the value (BCP-47) before it reaches the resolver, so
|
|
4222
|
+
// a tampered cookie is dropped, never trusted.
|
|
4223
|
+
var LOCALE_COOKIE_NAME = "shop_locale";
|
|
4224
|
+
// BCP-47 envelope mirror of localeRouter.LOCALE_RE — the cookie value is
|
|
4225
|
+
// shape-checked before it's handed to the resolver so a hostile cookie
|
|
4226
|
+
// can't smuggle a non-tag string through.
|
|
4227
|
+
var LOCALE_COOKIE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
4228
|
+
var LOCALE_COOKIE_MAX = 35;
|
|
4229
|
+
|
|
4230
|
+
function _readLocaleCookie(req) {
|
|
4231
|
+
var v = _cookieJar().read(req, LOCALE_COOKIE_NAME);
|
|
4232
|
+
if (!v || typeof v !== "string" || v.length > LOCALE_COOKIE_MAX) return null;
|
|
4233
|
+
return LOCALE_COOKIE_RE.test(v) ? v : null;
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
function _setLocaleCookie(res, locale) {
|
|
4237
|
+
var T = b.constants.TIME;
|
|
4238
|
+
_cookieJar().write(res, LOCALE_COOKIE_NAME, locale, { expires: new Date(Date.now() + T.days(365)) });
|
|
4239
|
+
}
|
|
4240
|
+
|
|
3966
4241
|
// ---- account-page renderers --------------------------------------------
|
|
3967
4242
|
|
|
3968
4243
|
var ACCOUNT_LOGIN_PAGE =
|
|
@@ -4300,6 +4575,234 @@ function mount(router, deps) {
|
|
|
4300
4575
|
return bundle;
|
|
4301
4576
|
}
|
|
4302
4577
|
|
|
4578
|
+
// ---- locale / i18n chrome ---------------------------------------------
|
|
4579
|
+
//
|
|
4580
|
+
// The storefront renders its UI chrome (nav, footer, search controls,
|
|
4581
|
+
// newsletter band, locale switcher) localised per request. The locale
|
|
4582
|
+
// is resolved the SAME way the edge Worker resolves it — cookie-first,
|
|
4583
|
+
// then `?lang=`, then Accept-Language, then the policy default — so a
|
|
4584
|
+
// visitor sees identical chrome whether the request is served at the
|
|
4585
|
+
// edge or by this container. The strings come from `b.i18n` over the
|
|
4586
|
+
// chrome catalog in `lib/translations.js`, layered with the operator's
|
|
4587
|
+
// `ui`/`chrome` translation rows; a missing key falls back to the
|
|
4588
|
+
// English baseline (never a raw key).
|
|
4589
|
+
//
|
|
4590
|
+
// `deps.chromeI18n` is the `b.i18n` instance (built once at boot via
|
|
4591
|
+
// translations.createChromeI18n with the operator's overrides). When
|
|
4592
|
+
// absent — a deploy with no locale policy seeded — the storefront
|
|
4593
|
+
// renders the English baseline and shows no switcher. `deps.localeRouter`
|
|
4594
|
+
// is the optional resolver; `deps.localeOptions` carries the default
|
|
4595
|
+
// locale + the active-locale list the switcher renders.
|
|
4596
|
+
var chromeI18n = deps.chromeI18n || null;
|
|
4597
|
+
var localeRouter = deps.localeRouter || null;
|
|
4598
|
+
var localeOptions = deps.localeOptions || {};
|
|
4599
|
+
var defaultLocale = localeOptions.defaultLocale || translationsModule.BASELINE_LOCALE;
|
|
4600
|
+
// Active locales the switcher offers: [{ tag, label }]. Operators
|
|
4601
|
+
// declare the label (the autonym — "Deutsch", "Français") in
|
|
4602
|
+
// localeOptions; absent that, the tag itself is the label.
|
|
4603
|
+
var activeLocales = Array.isArray(localeOptions.locales) ? localeOptions.locales : [];
|
|
4604
|
+
// Supported tags for the cookie/`?lang=` direct-match fast path.
|
|
4605
|
+
var supportedTags = activeLocales.map(function (l) { return l.tag; });
|
|
4606
|
+
// i18n RTL helper — `b.i18n.dir({ locale })` returns "rtl"/"ltr".
|
|
4607
|
+
function _dirFor(locale) {
|
|
4608
|
+
if (!chromeI18n) return "ltr";
|
|
4609
|
+
try { return chromeI18n.dir({ locale: locale }); } catch (_e) { return "ltr"; }
|
|
4610
|
+
}
|
|
4611
|
+
|
|
4612
|
+
// Per-locale resolved chrome string set, memoised — the strings don't
|
|
4613
|
+
// change per request (only the cart count, interpolated at render
|
|
4614
|
+
// time), so resolve each locale once.
|
|
4615
|
+
var _chromeByLocale = Object.create(null);
|
|
4616
|
+
function _chromeFor(locale) {
|
|
4617
|
+
if (!chromeI18n) return translationsModule.chromeDefaults();
|
|
4618
|
+
if (_chromeByLocale[locale]) return _chromeByLocale[locale];
|
|
4619
|
+
var resolved = translationsModule.resolveChrome(chromeI18n, locale);
|
|
4620
|
+
_chromeByLocale[locale] = resolved;
|
|
4621
|
+
return resolved;
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
// Match a candidate tag against the supported set — the SAME rule the
|
|
4625
|
+
// locale-router uses: a case-insensitive direct hit, then a primary-
|
|
4626
|
+
// subtag match (`en-GB` candidate -> supported `en`). Returns the
|
|
4627
|
+
// matched supported tag (catalog casing) or null. Kept in-process so
|
|
4628
|
+
// resolution is synchronous — the middleware must seed the async-local
|
|
4629
|
+
// store within one synchronous tick (an `await` inside the middleware
|
|
4630
|
+
// would set the store in the awaited frame, not the request's).
|
|
4631
|
+
var _supportedLowerToTag = Object.create(null);
|
|
4632
|
+
for (var _si = 0; _si < supportedTags.length; _si += 1) {
|
|
4633
|
+
_supportedLowerToTag[String(supportedTags[_si]).toLowerCase()] = supportedTags[_si];
|
|
4634
|
+
}
|
|
4635
|
+
function _matchSupported(candidate) {
|
|
4636
|
+
if (!candidate) return null;
|
|
4637
|
+
var lowered = String(candidate).toLowerCase();
|
|
4638
|
+
if (Object.prototype.hasOwnProperty.call(_supportedLowerToTag, lowered)) {
|
|
4639
|
+
return _supportedLowerToTag[lowered];
|
|
4640
|
+
}
|
|
4641
|
+
var primary = lowered.split("-")[0];
|
|
4642
|
+
if (Object.prototype.hasOwnProperty.call(_supportedLowerToTag, primary)) {
|
|
4643
|
+
return _supportedLowerToTag[primary];
|
|
4644
|
+
}
|
|
4645
|
+
return null;
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
// Parse an Accept-Language header into a q-sorted list of tags. A
|
|
4649
|
+
// garbage header yields an empty list (the resolver then falls to the
|
|
4650
|
+
// default). Mirrors the locale-router's parser shape.
|
|
4651
|
+
function _parseAcceptLanguage(raw) {
|
|
4652
|
+
if (typeof raw !== "string" || !raw.length || raw.length > 4096) return [];
|
|
4653
|
+
var out = [];
|
|
4654
|
+
var parts = raw.split(",");
|
|
4655
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
4656
|
+
var part = parts[i].trim();
|
|
4657
|
+
if (!part) continue;
|
|
4658
|
+
var semi = part.indexOf(";");
|
|
4659
|
+
var tag = (semi === -1 ? part : part.slice(0, semi)).trim();
|
|
4660
|
+
var q = 1.0;
|
|
4661
|
+
if (semi !== -1) {
|
|
4662
|
+
var m = /q=([0-9.]+)/.exec(part.slice(semi + 1));
|
|
4663
|
+
if (m) { var n = parseFloat(m[1]); if (isFinite(n) && n >= 0 && n <= 1) q = n; }
|
|
4664
|
+
}
|
|
4665
|
+
if (tag === "*" || !LOCALE_COOKIE_RE.test(tag) || tag.length > LOCALE_COOKIE_MAX) continue;
|
|
4666
|
+
out.push({ tag: tag, q: q, order: i });
|
|
4667
|
+
}
|
|
4668
|
+
out.sort(function (a, c) { return a.q !== c.q ? c.q - a.q : a.order - c.order; });
|
|
4669
|
+
return out;
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
// Resolve the request's locale synchronously, in the SAME precedence
|
|
4673
|
+
// the edge Worker / locale-router use: an explicit `?lang=` choice,
|
|
4674
|
+
// then the persisted cookie, then the browser's Accept-Language list,
|
|
4675
|
+
// then the policy default. Only a tag that resolves into the active
|
|
4676
|
+
// supported set is honoured; anything else (unknown code, garbage
|
|
4677
|
+
// cookie) falls through to the default. Never throws.
|
|
4678
|
+
function _resolveRequestLocale(req) {
|
|
4679
|
+
var queryLocale = null;
|
|
4680
|
+
if (req.query && typeof req.query.lang === "string" &&
|
|
4681
|
+
LOCALE_COOKIE_RE.test(req.query.lang) && req.query.lang.length <= LOCALE_COOKIE_MAX) {
|
|
4682
|
+
queryLocale = req.query.lang;
|
|
4683
|
+
}
|
|
4684
|
+
var qMatch = _matchSupported(queryLocale);
|
|
4685
|
+
if (qMatch) return qMatch;
|
|
4686
|
+
|
|
4687
|
+
var cMatch = _matchSupported(_readLocaleCookie(req));
|
|
4688
|
+
if (cMatch) return cMatch;
|
|
4689
|
+
|
|
4690
|
+
var alHeader = req.headers && (req.headers["accept-language"] || req.headers["Accept-Language"]);
|
|
4691
|
+
var alList = _parseAcceptLanguage(alHeader);
|
|
4692
|
+
for (var i = 0; i < alList.length; i += 1) {
|
|
4693
|
+
var alMatch = _matchSupported(alList[i].tag);
|
|
4694
|
+
if (alMatch) return alMatch;
|
|
4695
|
+
}
|
|
4696
|
+
return defaultLocale;
|
|
4697
|
+
}
|
|
4698
|
+
|
|
4699
|
+
// Best-effort: record the resolution in the locale-router's audit log
|
|
4700
|
+
// (locale_resolutions_log). Fire-and-forget — never awaited, never
|
|
4701
|
+
// blocks the request, never throws into the hot path. Wired only when
|
|
4702
|
+
// the locale-router is present.
|
|
4703
|
+
function _logResolution(req, locale) {
|
|
4704
|
+
if (!localeRouter || typeof localeRouter.resolveLocale !== "function") return;
|
|
4705
|
+
try {
|
|
4706
|
+
var hostHeader = (req.headers && (req.headers.host || req.headers.Host)) || "localhost";
|
|
4707
|
+
var p = localeRouter.resolveLocale({
|
|
4708
|
+
request: {
|
|
4709
|
+
host: String(hostHeader),
|
|
4710
|
+
path: req.pathname || (String(req.url || "/").split("?")[0]) || "/",
|
|
4711
|
+
cookie_locale: locale,
|
|
4712
|
+
accept_language: undefined,
|
|
4713
|
+
},
|
|
4714
|
+
});
|
|
4715
|
+
if (p && typeof p.then === "function") p.then(function () {}, function () {});
|
|
4716
|
+
} catch (_e) { /* drop-silent — the audit log is non-critical */ }
|
|
4717
|
+
}
|
|
4718
|
+
|
|
4719
|
+
// Build the server-rendered locale switcher form. A GET form (works
|
|
4720
|
+
// with JS off) that submits the chosen `lang` to /locale, which sets
|
|
4721
|
+
// the cookie and 303-redirects back to the page the visitor was on
|
|
4722
|
+
// (validated same-origin). Rendered only when there's more than one
|
|
4723
|
+
// active locale to choose between.
|
|
4724
|
+
function _switcherHtml(req, activeLocale, chrome) {
|
|
4725
|
+
if (activeLocales.length < 2) return "";
|
|
4726
|
+
var esc = b.template.escapeHtml;
|
|
4727
|
+
// `to` carries the current path+query so the redirect lands the
|
|
4728
|
+
// visitor back where they were. Validated server-side in /locale.
|
|
4729
|
+
var here = req.pathname || (String(req.url || "/").split("?")[0]) || "/";
|
|
4730
|
+
var options = activeLocales.map(function (l) {
|
|
4731
|
+
var sel = l.tag === activeLocale ? " selected" : "";
|
|
4732
|
+
return "<option value=\"" + esc(l.tag) + "\"" + sel + ">" + esc(l.label || l.tag) + "</option>";
|
|
4733
|
+
}).join("");
|
|
4734
|
+
return "" +
|
|
4735
|
+
" <form class=\"locale-switcher\" method=\"get\" action=\"/locale\">\n" +
|
|
4736
|
+
" <label class=\"locale-switcher__label\" for=\"locale-switcher-select\">" + esc(chrome.locale_switcher_label) + "</label>\n" +
|
|
4737
|
+
" <input type=\"hidden\" name=\"to\" value=\"" + esc(here) + "\">\n" +
|
|
4738
|
+
" <select id=\"locale-switcher-select\" class=\"locale-switcher__select\" name=\"lang\">" + options + "</select>\n" +
|
|
4739
|
+
" <button class=\"locale-switcher__submit\" type=\"submit\">" + esc(chrome.locale_switcher_submit) + "</button>\n" +
|
|
4740
|
+
" </form>\n";
|
|
4741
|
+
}
|
|
4742
|
+
|
|
4743
|
+
// The per-request locale context the layout reads via the async-local
|
|
4744
|
+
// store. Resolved SYNCHRONOUSLY so the middleware can seed the store
|
|
4745
|
+
// within one tick. Shape: { locale, lang, dir, chrome, switcher_html }.
|
|
4746
|
+
// Never throws — any failure falls back to the default locale.
|
|
4747
|
+
function _localeCtx(req) {
|
|
4748
|
+
var locale = defaultLocale;
|
|
4749
|
+
try { locale = _resolveRequestLocale(req); } catch (_e) { locale = defaultLocale; }
|
|
4750
|
+
var chrome = _chromeFor(locale);
|
|
4751
|
+
return {
|
|
4752
|
+
locale: locale,
|
|
4753
|
+
lang: locale,
|
|
4754
|
+
dir: _dirFor(locale),
|
|
4755
|
+
chrome: chrome,
|
|
4756
|
+
switcher_html: _switcherHtml(req, locale, chrome),
|
|
4757
|
+
};
|
|
4758
|
+
}
|
|
4759
|
+
|
|
4760
|
+
// Seed the per-request locale context into the async-local store so
|
|
4761
|
+
// every `_wrap` on this request reads the resolved chrome. The
|
|
4762
|
+
// middleware is SYNCHRONOUS: `AsyncLocalStorage.enterWith` only
|
|
4763
|
+
// propagates to the request's downstream handlers when it runs in the
|
|
4764
|
+
// same synchronous tick the router awaits — an `await` inside the
|
|
4765
|
+
// middleware would scope the store to the awaited frame, not the
|
|
4766
|
+
// request. Resolution is best-effort (falls back to the English
|
|
4767
|
+
// baseline). The audit-log write is fired off without awaiting so it
|
|
4768
|
+
// never blocks the render. Only mounted when the router exposes `.use`.
|
|
4769
|
+
if (typeof router.use === "function") {
|
|
4770
|
+
router.use(function localeMiddleware(req, _res, next) {
|
|
4771
|
+
try {
|
|
4772
|
+
var ctx = _localeCtx(req);
|
|
4773
|
+
_localeAls.enterWith(ctx);
|
|
4774
|
+
_logResolution(req, ctx.locale);
|
|
4775
|
+
} catch (_e) { /* drop-silent — baseline applies */ }
|
|
4776
|
+
next();
|
|
4777
|
+
});
|
|
4778
|
+
}
|
|
4779
|
+
|
|
4780
|
+
// Persist a locale choice. A GET form (works with JS off) from the
|
|
4781
|
+
// footer switcher submits `lang` (the chosen tag) + `to` (the path to
|
|
4782
|
+
// return to). We validate the tag against the active locale set, set
|
|
4783
|
+
// the unsealed `shop_locale` cookie, and 303-redirect to the `to`
|
|
4784
|
+
// path — refusing anything that isn't a same-origin absolute path so
|
|
4785
|
+
// the redirect can't be turned into an open-redirect. Unknown / bad
|
|
4786
|
+
// `lang` still redirects (to "/" or the validated `to`) without
|
|
4787
|
+
// setting a cookie, so a hostile link can't 500 the route.
|
|
4788
|
+
router.get("/locale", function (req, res) {
|
|
4789
|
+
var q = req.query || {};
|
|
4790
|
+
var to = (typeof q.to === "string") ? q.to : "/";
|
|
4791
|
+
// Same-origin path only: must start with a single "/" (not "//" — a
|
|
4792
|
+
// protocol-relative URL — and not a scheme). Anything else falls
|
|
4793
|
+
// back to the home path.
|
|
4794
|
+
if (to.charAt(0) !== "/" || to.charAt(1) === "/" || to.indexOf("\\") !== -1 || /[\x00-\x1f\x7f]/.test(to)) {
|
|
4795
|
+
to = "/";
|
|
4796
|
+
}
|
|
4797
|
+
var lang = (typeof q.lang === "string") ? q.lang : "";
|
|
4798
|
+
if (lang && LOCALE_COOKIE_RE.test(lang) && lang.length <= LOCALE_COOKIE_MAX && supportedTags.indexOf(lang) !== -1) {
|
|
4799
|
+
_setLocaleCookie(res, lang);
|
|
4800
|
+
}
|
|
4801
|
+
res.status(303);
|
|
4802
|
+
res.setHeader && res.setHeader("location", to);
|
|
4803
|
+
return res.end ? res.end() : res.send("");
|
|
4804
|
+
});
|
|
4805
|
+
|
|
4303
4806
|
// Absolute shareable referral link for a code. Prefers the operator's
|
|
4304
4807
|
// configured origin (deps.shop_origin / SHOP_ORIGIN) so the link is
|
|
4305
4808
|
// stable across the edge/container split; falls back to the request's
|
|
@@ -5473,6 +5976,20 @@ function mount(router, deps) {
|
|
|
5473
5976
|
if (!orderId) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
5474
5977
|
var o = await deps.order.get(orderId);
|
|
5475
5978
|
if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
5979
|
+
// Ownership gate against IDOR: an order's confirmation page exposes
|
|
5980
|
+
// the customer's name, address, and line items by UUID alone. An order
|
|
5981
|
+
// that BELONGS to a customer (customer_id set) is viewable only by that
|
|
5982
|
+
// signed-in customer — anyone else (a different customer OR an
|
|
5983
|
+
// unauthenticated request) 404s rather than leaking it. A guest order
|
|
5984
|
+
// carries no customer_id and remains reachable via its unguessable URL
|
|
5985
|
+
// (the capability-URL model), so BOTH the just-placed-as-guest path AND
|
|
5986
|
+
// the signed-in-shopper-with-an-anonymous-cart path (checkout.confirm
|
|
5987
|
+
// derives the order from a cart that has no customer_id) still render
|
|
5988
|
+
// their own confirmation here.
|
|
5989
|
+
var orderAuth = _currentCustomerEnv(req);
|
|
5990
|
+
if (o.customer_id && (!orderAuth || o.customer_id !== orderAuth.customer_id)) {
|
|
5991
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
5992
|
+
}
|
|
5476
5993
|
// Same variant_id → {product, hero_media} lookup pattern as the
|
|
5477
5994
|
// cart route, applied to the order's frozen line items so the
|
|
5478
5995
|
// post-checkout page shows what the customer bought visually.
|
|
@@ -6229,22 +6746,26 @@ function mount(router, deps) {
|
|
|
6229
6746
|
return res.end ? res.end() : res.send("");
|
|
6230
6747
|
}
|
|
6231
6748
|
var productId = (req.body || {}).product_id;
|
|
6749
|
+
var removed = false;
|
|
6232
6750
|
try {
|
|
6233
6751
|
var already = await deps.wishlist.isWishlisted({ customer_id: auth.customer_id, product_id: productId });
|
|
6234
|
-
if (already) await deps.wishlist.remove({ customer_id: auth.customer_id, product_id: productId });
|
|
6752
|
+
if (already) { await deps.wishlist.remove({ customer_id: auth.customer_id, product_id: productId }); removed = true; }
|
|
6235
6753
|
else await deps.wishlist.add({ customer_id: auth.customer_id, product_id: productId });
|
|
6236
6754
|
} catch (e) {
|
|
6237
6755
|
res.status(e instanceof TypeError ? 400 : 500);
|
|
6238
6756
|
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
6239
6757
|
}
|
|
6758
|
+
var okKind = removed ? "removed" : "added";
|
|
6240
6759
|
var rt = (req.body || {}).return_to;
|
|
6241
6760
|
var dest;
|
|
6242
6761
|
if (typeof rt === "string" && /^\/[^/]/.test(rt)) {
|
|
6243
|
-
|
|
6762
|
+
// Only thread the success marker when returning to the wishlist
|
|
6763
|
+
// page (the PDP heart toggle is its own visible cue).
|
|
6764
|
+
dest = rt.indexOf("/account/wishlist") === 0 ? "/account/wishlist?ok=" + okKind : rt;
|
|
6244
6765
|
} else {
|
|
6245
6766
|
var product = null;
|
|
6246
6767
|
try { product = await deps.catalog.products.get(productId); } catch (_e) { product = null; }
|
|
6247
|
-
dest = product ? ("/products/" + encodeURIComponent(product.slug)) : "/account/wishlist";
|
|
6768
|
+
dest = product ? ("/products/" + encodeURIComponent(product.slug)) : ("/account/wishlist?ok=" + okKind);
|
|
6248
6769
|
}
|
|
6249
6770
|
res.status(303); res.setHeader && res.setHeader("location", dest);
|
|
6250
6771
|
return res.end ? res.end() : res.send("");
|
|
@@ -6275,8 +6796,10 @@ function mount(router, deps) {
|
|
|
6275
6796
|
items.push({ product: product, hero_media: media.length ? media[0] : null });
|
|
6276
6797
|
}
|
|
6277
6798
|
var cartCount = await _cartCountForReq(req);
|
|
6799
|
+
var wlUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6278
6800
|
_send(res, 200, renderWishlist({
|
|
6279
6801
|
items: items,
|
|
6802
|
+
notice: wlUrl ? wlUrl.searchParams.get("ok") : null,
|
|
6280
6803
|
shop_name: shopName,
|
|
6281
6804
|
cart_count: cartCount,
|
|
6282
6805
|
asset_prefix: deps.asset_prefix || "/assets/",
|
|
@@ -6347,8 +6870,10 @@ function mount(router, deps) {
|
|
|
6347
6870
|
items.push({ save: row, product: product, hero_media: media.length ? media[0] : null });
|
|
6348
6871
|
}
|
|
6349
6872
|
var cartCount = await _cartCountForReq(req);
|
|
6873
|
+
var savedUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6350
6874
|
_send(res, 200, renderSaved({
|
|
6351
6875
|
items: items,
|
|
6876
|
+
notice: savedUrl ? savedUrl.searchParams.get("ok") : null,
|
|
6352
6877
|
shop_name: shopName,
|
|
6353
6878
|
cart_count: cartCount,
|
|
6354
6879
|
asset_prefix: deps.asset_prefix || "/assets/",
|
|
@@ -6406,7 +6931,7 @@ function mount(router, deps) {
|
|
|
6406
6931
|
res.status(e instanceof TypeError ? 400 : 500);
|
|
6407
6932
|
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
6408
6933
|
}
|
|
6409
|
-
res.status(303); res.setHeader && res.setHeader("location", "/account/saved");
|
|
6934
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/saved?ok=removed");
|
|
6410
6935
|
return res.end ? res.end() : res.send("");
|
|
6411
6936
|
});
|
|
6412
6937
|
}
|
|
@@ -6446,13 +6971,33 @@ function mount(router, deps) {
|
|
|
6446
6971
|
}
|
|
6447
6972
|
return addr;
|
|
6448
6973
|
}
|
|
6974
|
+
// Map the ?ok=<kind> redirect marker the POST handlers set on
|
|
6975
|
+
// success to the human confirmation copy rendered (role="status")
|
|
6976
|
+
// at the top of the list. Unknown markers degrade to no notice.
|
|
6977
|
+
function _addrSuccessCopy(kind) {
|
|
6978
|
+
if (kind === "added") return "Address saved.";
|
|
6979
|
+
if (kind === "updated") return "Address updated.";
|
|
6980
|
+
if (kind === "removed") return "Address removed.";
|
|
6981
|
+
if (kind === "restored") return "Address restored.";
|
|
6982
|
+
if (kind === "default-shipping") return "Default shipping address updated.";
|
|
6983
|
+
if (kind === "default-billing") return "Default billing address updated.";
|
|
6984
|
+
return null;
|
|
6985
|
+
}
|
|
6449
6986
|
async function _renderAddrPage(req, res, auth, editAddr, notice, code) {
|
|
6450
6987
|
var rows = await deps.addresses.listForCustomer(auth.customer_id, { limit: 50 });
|
|
6451
6988
|
var cartCount = await _cartCountForReq(req);
|
|
6989
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6990
|
+
var okKind = url ? url.searchParams.get("ok") : null;
|
|
6991
|
+
var success = _addrSuccessCopy(okKind);
|
|
6992
|
+
// An Undo control is only offered for a just-removed row, and only
|
|
6993
|
+
// when the ?undo=<id> marker round-trips a real owned address id.
|
|
6994
|
+
var undoId = (okKind === "removed" && url) ? url.searchParams.get("undo") : null;
|
|
6452
6995
|
_send(res, code || 200, renderAddresses({
|
|
6453
6996
|
addresses: rows,
|
|
6454
6997
|
edit: editAddr || null,
|
|
6455
6998
|
notice: notice || null,
|
|
6999
|
+
success: success,
|
|
7000
|
+
undo_id: undoId || null,
|
|
6456
7001
|
shop_name: shopName,
|
|
6457
7002
|
cart_count: cartCount,
|
|
6458
7003
|
}));
|
|
@@ -6496,7 +7041,7 @@ function mount(router, deps) {
|
|
|
6496
7041
|
if (e instanceof TypeError) return _renderAddrPage(req, res, auth, null, (e && e.message) || "Please check the address.", 400);
|
|
6497
7042
|
throw e;
|
|
6498
7043
|
}
|
|
6499
|
-
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
|
|
7044
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=added");
|
|
6500
7045
|
return res.end ? res.end() : res.send("");
|
|
6501
7046
|
});
|
|
6502
7047
|
|
|
@@ -6512,11 +7057,11 @@ function mount(router, deps) {
|
|
|
6512
7057
|
}
|
|
6513
7058
|
throw e;
|
|
6514
7059
|
}
|
|
6515
|
-
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
|
|
7060
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=updated");
|
|
6516
7061
|
return res.end ? res.end() : res.send("");
|
|
6517
7062
|
});
|
|
6518
7063
|
|
|
6519
|
-
function _addrAction(verb, fn) {
|
|
7064
|
+
function _addrAction(verb, okKind, fn) {
|
|
6520
7065
|
router.post("/account/addresses/:id/" + verb, async function (req, res) {
|
|
6521
7066
|
var auth = _addrAuth(req, res); if (!auth) return;
|
|
6522
7067
|
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
@@ -6525,13 +7070,64 @@ function mount(router, deps) {
|
|
|
6525
7070
|
res.status(e instanceof TypeError ? 400 : 500);
|
|
6526
7071
|
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
6527
7072
|
}
|
|
6528
|
-
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
|
|
7073
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=" + okKind);
|
|
6529
7074
|
return res.end ? res.end() : res.send("");
|
|
6530
7075
|
});
|
|
6531
7076
|
}
|
|
6532
|
-
_addrAction("default-shipping", function (id) { return deps.addresses.setDefaultShipping(id); });
|
|
6533
|
-
_addrAction("default-billing", function (id) { return deps.addresses.setDefaultBilling(id); });
|
|
6534
|
-
|
|
7077
|
+
_addrAction("default-shipping", "default-shipping", function (id) { return deps.addresses.setDefaultShipping(id); });
|
|
7078
|
+
_addrAction("default-billing", "default-billing", function (id) { return deps.addresses.setDefaultBilling(id); });
|
|
7079
|
+
|
|
7080
|
+
// Remove is destructive and CSP forbids a confirm() dialog, so it
|
|
7081
|
+
// routes through a server-rendered confirm page first; the POST that
|
|
7082
|
+
// actually archives lives behind that page. The list then surfaces a
|
|
7083
|
+
// success notice with an Undo (unarchive) control.
|
|
7084
|
+
router.get("/account/addresses/:id/remove", async function (req, res) {
|
|
7085
|
+
var auth = _addrAuth(req, res); if (!auth) return;
|
|
7086
|
+
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
7087
|
+
var cartCount = await _cartCountForReq(req);
|
|
7088
|
+
_send(res, 200, renderAddressRemoveConfirm({
|
|
7089
|
+
address: addr,
|
|
7090
|
+
shop_name: shopName,
|
|
7091
|
+
cart_count: cartCount,
|
|
7092
|
+
}));
|
|
7093
|
+
});
|
|
7094
|
+
|
|
7095
|
+
router.post("/account/addresses/:id/archive", async function (req, res) {
|
|
7096
|
+
var auth = _addrAuth(req, res); if (!auth) return;
|
|
7097
|
+
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
7098
|
+
try { await deps.addresses.archive(addr.id); }
|
|
7099
|
+
catch (e) {
|
|
7100
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
7101
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
7102
|
+
}
|
|
7103
|
+
res.status(303);
|
|
7104
|
+
res.setHeader && res.setHeader("location", "/account/addresses?ok=removed&undo=" + encodeURIComponent(addr.id));
|
|
7105
|
+
return res.end ? res.end() : res.send("");
|
|
7106
|
+
});
|
|
7107
|
+
|
|
7108
|
+
// Undo path for a just-removed address. Unlike the by-id routes
|
|
7109
|
+
// above, this one resolves the row WITH archived rows included (the
|
|
7110
|
+
// address is archived by definition here) but still enforces
|
|
7111
|
+
// customer ownership before un-archiving.
|
|
7112
|
+
router.post("/account/addresses/:id/unarchive", async function (req, res) {
|
|
7113
|
+
var auth = _addrAuth(req, res); if (!auth) return;
|
|
7114
|
+
var addr;
|
|
7115
|
+
try { addr = await deps.addresses.get(req.params && req.params.id); }
|
|
7116
|
+
catch (e) {
|
|
7117
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return; }
|
|
7118
|
+
throw e;
|
|
7119
|
+
}
|
|
7120
|
+
if (!addr || addr.customer_id !== auth.customer_id) {
|
|
7121
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
7122
|
+
}
|
|
7123
|
+
try { await deps.addresses.unarchive(addr.id); }
|
|
7124
|
+
catch (e) {
|
|
7125
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
7126
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
7127
|
+
}
|
|
7128
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=restored");
|
|
7129
|
+
return res.end ? res.end() : res.send("");
|
|
7130
|
+
});
|
|
6535
7131
|
}
|
|
6536
7132
|
|
|
6537
7133
|
// Subscription self-management — the signed-in customer views their
|
|
@@ -6622,6 +7218,31 @@ function mount(router, deps) {
|
|
|
6622
7218
|
// Cancel mounts only when payment is wired (cancel composes Stripe).
|
|
6623
7219
|
// Without payment the list above stays read-only with a note.
|
|
6624
7220
|
if (deps.payment) {
|
|
7221
|
+
// Confirmation step (GET) ahead of the destructive POST. Renders
|
|
7222
|
+
// the period-end date + the days-forfeited consequence of an
|
|
7223
|
+
// immediate cancel. A subscription that isn't cancelable
|
|
7224
|
+
// (already canceled / winding down) redirects back to the list.
|
|
7225
|
+
router.get("/account/subscriptions/:id/cancel", async function (req, res) {
|
|
7226
|
+
var auth = _subsAuth(req, res); if (!auth) return;
|
|
7227
|
+
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
7228
|
+
if (!_subscriptionIsCancelable(sub)) {
|
|
7229
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions");
|
|
7230
|
+
return res.end ? res.end() : res.send("");
|
|
7231
|
+
}
|
|
7232
|
+
// Join the plan for the confirm-page summary (best-effort — a
|
|
7233
|
+
// missing plan degrades the heading to a generic phrase).
|
|
7234
|
+
if (sub.plan_id != null) {
|
|
7235
|
+
try { sub.plan = await subscriptions.plans.get(sub.plan_id); }
|
|
7236
|
+
catch (_e) { sub.plan = null; }
|
|
7237
|
+
}
|
|
7238
|
+
var cartCount = await _cartCountForReq(req);
|
|
7239
|
+
_send(res, 200, renderSubscriptionCancelConfirm({
|
|
7240
|
+
subscription: sub,
|
|
7241
|
+
shop_name: shopName,
|
|
7242
|
+
cart_count: cartCount,
|
|
7243
|
+
}));
|
|
7244
|
+
});
|
|
7245
|
+
|
|
6625
7246
|
router.post("/account/subscriptions/:id/cancel", async function (req, res) {
|
|
6626
7247
|
var auth = _subsAuth(req, res); if (!auth) return;
|
|
6627
7248
|
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
@@ -6688,7 +7309,13 @@ function mount(router, deps) {
|
|
|
6688
7309
|
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
6689
7310
|
var page = await deps.returns.listForCustomer(auth.customer_id, { limit: 50 });
|
|
6690
7311
|
var cartCount = await _cartCountForReq(req);
|
|
6691
|
-
|
|
7312
|
+
var retUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7313
|
+
_send(res, 200, renderReturns({
|
|
7314
|
+
rmas: page.rows,
|
|
7315
|
+
rma_code: retUrl ? retUrl.searchParams.get("ok") : null,
|
|
7316
|
+
shop_name: shopName,
|
|
7317
|
+
cart_count: cartCount,
|
|
7318
|
+
}));
|
|
6692
7319
|
});
|
|
6693
7320
|
|
|
6694
7321
|
router.get("/account/orders/:order_id/return", async function (req, res) {
|
|
@@ -6721,8 +7348,9 @@ function mount(router, deps) {
|
|
|
6721
7348
|
shop_name: shopName, cart_count: cartCount,
|
|
6722
7349
|
}));
|
|
6723
7350
|
}
|
|
7351
|
+
var requested;
|
|
6724
7352
|
try {
|
|
6725
|
-
await deps.returns.request({
|
|
7353
|
+
requested = await deps.returns.request({
|
|
6726
7354
|
order_id: order.id,
|
|
6727
7355
|
customer_id: auth.customer_id,
|
|
6728
7356
|
reason: body.reason,
|
|
@@ -6738,7 +7366,11 @@ function mount(router, deps) {
|
|
|
6738
7366
|
}
|
|
6739
7367
|
throw e;
|
|
6740
7368
|
}
|
|
6741
|
-
|
|
7369
|
+
// Round-trip the RMA code on the redirect so the list page can
|
|
7370
|
+
// confirm the request and echo the operator-readable handle.
|
|
7371
|
+
var rmaCode = requested && requested.rma_code ? requested.rma_code : "";
|
|
7372
|
+
res.status(303);
|
|
7373
|
+
res.setHeader && res.setHeader("location", "/account/returns?ok=" + encodeURIComponent(rmaCode));
|
|
6742
7374
|
return res.end ? res.end() : res.send("");
|
|
6743
7375
|
});
|
|
6744
7376
|
}
|