@blamejs/blamejs-shop 0.1.35 → 0.1.37
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 +2 -0
- package/lib/asset-manifest.json +3 -3
- package/lib/currency-display.js +201 -4
- package/lib/storefront.js +612 -80
- package/lib/translations.js +203 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
- package/lib/vendor/blamejs/index.js +2 -0
- package/lib/vendor/blamejs/lib/ai-aedt-bias-audit.js +180 -0
- package/lib/vendor/blamejs/lib/ai-frontier-protocol.js +196 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.5.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.6.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-aedt-bias-audit.test.js +107 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-frontier-protocol.test.js +83 -0
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -27,9 +27,23 @@
|
|
|
27
27
|
|
|
28
28
|
var emailModule = require("./email");
|
|
29
29
|
var pricing = require("./pricing");
|
|
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.
|
|
30
33
|
|
|
31
34
|
var b = require("./vendor/blamejs");
|
|
32
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
|
+
|
|
33
47
|
// Payment-webhook signatures (Stripe's HMAC, PayPal's, …) are computed over
|
|
34
48
|
// the EXACT raw request bytes, but the global JSON body-parser reparses and
|
|
35
49
|
// discards them. This middleware buffers the raw body for the given POST
|
|
@@ -125,7 +139,7 @@ var CONSENT_BANNER =
|
|
|
125
139
|
|
|
126
140
|
var LAYOUT =
|
|
127
141
|
"<!DOCTYPE html>\n" +
|
|
128
|
-
"<html lang=\"
|
|
142
|
+
"<html lang=\"{{lang}}\" dir=\"{{dir}}\">\n" +
|
|
129
143
|
"<head>\n" +
|
|
130
144
|
" <meta charset=\"utf-8\">\n" +
|
|
131
145
|
" <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
|
|
@@ -145,13 +159,13 @@ var LAYOUT =
|
|
|
145
159
|
" <meta name=\"twitter:image\" content=\"{{og_image}}\">\n" +
|
|
146
160
|
"</head>\n" +
|
|
147
161
|
"<body>\n" +
|
|
148
|
-
" <a class=\"skip-link\" href=\"#main\">
|
|
162
|
+
" <a class=\"skip-link\" href=\"#main\">{{skip_to_content}}</a>\n" +
|
|
149
163
|
"\n" +
|
|
150
164
|
" <div class=\"utility-bar\" role=\"complementary\">\n" +
|
|
151
165
|
" <div class=\"utility-bar__inner\">\n" +
|
|
152
|
-
" <span class=\"utility-bar__pill\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span>
|
|
153
|
-
" <span class=\"utility-bar__msg\">
|
|
154
|
-
" <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" +
|
|
155
169
|
" </div>\n" +
|
|
156
170
|
" </div>\n" +
|
|
157
171
|
"\n" +
|
|
@@ -160,17 +174,17 @@ var LAYOUT =
|
|
|
160
174
|
" <a href=\"/\" class=\"brand\" aria-label=\"{{shop_name}}\"><img src=\"/assets/brand/logo.png\" alt=\"{{shop_name}}\"></a>\n" +
|
|
161
175
|
" <form class=\"site-search\" action=\"/search\" method=\"get\" role=\"search\">\n" +
|
|
162
176
|
" <div class=\"site-search__inner\">\n" +
|
|
163
|
-
" <label for=\"site-search-q\" class=\"skip-link\">
|
|
177
|
+
" <label for=\"site-search-q\" class=\"skip-link\">{{search_label}}</label>\n" +
|
|
164
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" +
|
|
165
|
-
" <input id=\"site-search-q\" type=\"search\" name=\"q\" value=\"{{search_q}}\" placeholder=\"
|
|
166
|
-
" <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" +
|
|
167
181
|
" </div>\n" +
|
|
168
182
|
" </form>\n" +
|
|
169
183
|
" <nav class=\"site-nav\" aria-label=\"Primary\">\n" +
|
|
170
|
-
" <a class=\"site-nav__link\" href=\"/\">
|
|
171
|
-
" <a class=\"site-nav__link\" href=\"#framework\">
|
|
172
|
-
" <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"
|
|
173
|
-
" <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" +
|
|
174
188
|
" </nav>\n" +
|
|
175
189
|
" </div>\n" +
|
|
176
190
|
" </header>\n" +
|
|
@@ -180,14 +194,14 @@ var LAYOUT =
|
|
|
180
194
|
" <section class=\"newsletter-band\" aria-labelledby=\"newsletter-title\">\n" +
|
|
181
195
|
" <div class=\"newsletter-band__inner\">\n" +
|
|
182
196
|
" <div class=\"newsletter-band__copy\">\n" +
|
|
183
|
-
" <p class=\"eyebrow eyebrow--on-dark\">
|
|
184
|
-
" <h2 id=\"newsletter-title\">
|
|
185
|
-
" <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" +
|
|
186
200
|
" </div>\n" +
|
|
187
201
|
" <form class=\"newsletter-band__form\" method=\"post\" action=\"/newsletter\">\n" +
|
|
188
|
-
" <label class=\"skip-link\" for=\"newsletter-email\">
|
|
202
|
+
" <label class=\"skip-link\" for=\"newsletter-email\">{{newsletter_email}}</label>\n" +
|
|
189
203
|
" <input id=\"newsletter-email\" type=\"email\" name=\"email\" required placeholder=\"you@example.com\" autocomplete=\"email\">\n" +
|
|
190
|
-
" <button type=\"submit\">
|
|
204
|
+
" <button type=\"submit\">{{newsletter_submit}}</button>\n" +
|
|
191
205
|
" </form>\n" +
|
|
192
206
|
" </div>\n" +
|
|
193
207
|
" </section>\n" +
|
|
@@ -196,7 +210,7 @@ var LAYOUT =
|
|
|
196
210
|
" <div class=\"site-footer__inner\">\n" +
|
|
197
211
|
" <div class=\"site-footer__brand-col\">\n" +
|
|
198
212
|
" <img class=\"site-footer__logo\" src=\"/assets/brand/logo.png\" alt=\"{{shop_name}}\">\n" +
|
|
199
|
-
" <p class=\"site-footer__tagline\">
|
|
213
|
+
" <p class=\"site-footer__tagline\">{{footer_tagline}}</p>\n" +
|
|
200
214
|
" <ul class=\"site-footer__social\" aria-label=\"Project links\">\n" +
|
|
201
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" +
|
|
202
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" +
|
|
@@ -204,43 +218,45 @@ var LAYOUT =
|
|
|
204
218
|
" </ul>\n" +
|
|
205
219
|
" </div>\n" +
|
|
206
220
|
" <div class=\"site-footer__col\">\n" +
|
|
207
|
-
" <h4>
|
|
221
|
+
" <h4>{{footer_shop_heading}}</h4>\n" +
|
|
208
222
|
" <ul>\n" +
|
|
209
|
-
" <li><a href=\"/\">
|
|
210
|
-
" <li><a href=\"/collections\">
|
|
211
|
-
" <li><a href=\"/categories\">
|
|
212
|
-
" <li><a href=\"/?sort=new\">
|
|
213
|
-
" <li><a href=\"/?sort=sale\">
|
|
214
|
-
" <li><a href=\"/compare\">
|
|
215
|
-
" <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" +
|
|
216
230
|
" </ul>\n" +
|
|
217
231
|
" </div>\n" +
|
|
218
232
|
" <div class=\"site-footer__col\">\n" +
|
|
219
|
-
" <h4>
|
|
233
|
+
" <h4>{{footer_framework_heading}}</h4>\n" +
|
|
220
234
|
" <ul>\n" +
|
|
221
|
-
" <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">
|
|
222
|
-
" <li><a href=\"https://github.com/blamejs/blamejs\" rel=\"noopener\">
|
|
223
|
-
" <li><a href=\"/SECURITY.md\">
|
|
224
|
-
" <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" +
|
|
225
239
|
" </ul>\n" +
|
|
226
240
|
" </div>\n" +
|
|
227
241
|
" <div class=\"site-footer__col\">\n" +
|
|
228
|
-
" <h4>
|
|
242
|
+
" <h4>{{footer_operators_heading}}</h4>\n" +
|
|
229
243
|
" <ul>\n" +
|
|
230
|
-
" <li><a href=\"/account\">
|
|
231
|
-
" <li><a href=\"/orders\">
|
|
232
|
-
" <li><a href=\"/admin\">
|
|
233
|
-
" <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" +
|
|
234
248
|
" </ul>\n" +
|
|
235
249
|
" </div>\n" +
|
|
236
250
|
" </div>\n" +
|
|
251
|
+
" RAW_CURRENCY_SWITCHER\n" +
|
|
252
|
+
" RAW_LOCALE_SWITCHER\n" +
|
|
237
253
|
" <div class=\"site-footer__copy\">\n" +
|
|
238
|
-
" <p>© {{year}} {{shop_name}} —
|
|
254
|
+
" <p>© {{year}} {{shop_name}} — {{footer_copy_suffix}}</p>\n" +
|
|
239
255
|
" <ul>\n" +
|
|
240
|
-
" <li><a href=\"/SECURITY.md\">
|
|
241
|
-
" <li><a href=\"/privacy\">
|
|
242
|
-
" <li><a href=\"/terms\">
|
|
243
|
-
" <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" +
|
|
244
260
|
" </ul>\n" +
|
|
245
261
|
" </div>\n" +
|
|
246
262
|
" </footer>\n" +
|
|
@@ -315,6 +331,77 @@ function _islandScript(name, opts) {
|
|
|
315
331
|
(sri ? " integrity=\"" + sri + "\"" : "") + " defer" + policyAttr + "></script>";
|
|
316
332
|
}
|
|
317
333
|
|
|
334
|
+
// Multi-currency display switcher — a GET form in the footer listing the
|
|
335
|
+
// operator's display currencies. Selecting one POSTs to /currency, which
|
|
336
|
+
// sets the sealed `shop_ccy` cookie and redirects back. The currently
|
|
337
|
+
// selected currency is pre-checked. Absent a presenter (feature not
|
|
338
|
+
// wired) the whole block is empty so older deploys render unchanged.
|
|
339
|
+
// `currencies` is the operator's allow-list; `selected` is the active
|
|
340
|
+
// display currency; `note` is the "charged in <base>" disclosure (present
|
|
341
|
+
// only when a non-base currency is active).
|
|
342
|
+
function _buildCurrencySwitcher(opts) {
|
|
343
|
+
if (!opts || !Array.isArray(opts.currencies) || opts.currencies.length < 2) return "";
|
|
344
|
+
var esc = function (s) { return b.template.escapeHtml(String(s)); };
|
|
345
|
+
var selected = opts.selected || opts.currencies[0];
|
|
346
|
+
var options = opts.currencies.map(function (c) {
|
|
347
|
+
var sel = c === selected ? " selected" : "";
|
|
348
|
+
return "<option value=\"" + esc(c) + "\"" + sel + ">" + esc(c) + "</option>";
|
|
349
|
+
}).join("");
|
|
350
|
+
// GET-action would expose the choice in the URL + cache key; a POST
|
|
351
|
+
// keeps it in the sealed cookie and bypasses the edge cache via the
|
|
352
|
+
// 303 redirect. `redirect_to` carries the current path so the visitor
|
|
353
|
+
// lands back where they were. The form auto-submits via the island
|
|
354
|
+
// script when present; without JS the explicit "Set" button submits.
|
|
355
|
+
var noteHtml = opts.note
|
|
356
|
+
? "<p class=\"currency-switcher__note\">" + esc(opts.note) + "</p>"
|
|
357
|
+
: "";
|
|
358
|
+
return "<div class=\"currency-switcher\">\n" +
|
|
359
|
+
" <form class=\"currency-switcher__form\" method=\"post\" action=\"/currency\">\n" +
|
|
360
|
+
" <input type=\"hidden\" name=\"redirect_to\" value=\"" + esc(opts.redirect_to || "/") + "\">\n" +
|
|
361
|
+
" <label class=\"currency-switcher__label\" for=\"currency-select\">Display currency</label>\n" +
|
|
362
|
+
" <select id=\"currency-select\" name=\"currency\" class=\"currency-switcher__select\" data-currency-switcher>" + options + "</select>\n" +
|
|
363
|
+
" <button type=\"submit\" class=\"currency-switcher__btn\">Set</button>\n" +
|
|
364
|
+
" </form>\n" +
|
|
365
|
+
" " + noteHtml + "\n" +
|
|
366
|
+
" </div>";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The per-request price formatter a renderer uses for every displayed
|
|
370
|
+
// price. When the route handler resolved a display-currency presenter and
|
|
371
|
+
// threaded it in as `opts.format_price`, that's used (it converts base →
|
|
372
|
+
// display currency + applies the display rounding rule). Otherwise the
|
|
373
|
+
// renderer falls back to `pricing.format` — identical to pre-feature
|
|
374
|
+
// behaviour, so an un-wired store or any renderer the route handler
|
|
375
|
+
// didn't thread keeps formatting in the catalog currency.
|
|
376
|
+
function _priceFormatter(opts) {
|
|
377
|
+
return (opts && typeof opts.format_price === "function") ? opts.format_price : pricing.format;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Lift the currency-switcher fields off a renderer's opts so they reach
|
|
381
|
+
// `_wrap` unchanged. Keeps the per-renderer `_wrap` call sites short.
|
|
382
|
+
function _currencyWrapOpts(opts) {
|
|
383
|
+
return {
|
|
384
|
+
currency_options: opts.currency_options,
|
|
385
|
+
currency_selected: opts.currency_selected,
|
|
386
|
+
currency_note: opts.currency_note,
|
|
387
|
+
currency_redirect_to: opts.currency_redirect_to,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
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
|
+
|
|
318
405
|
function _wrap(opts) {
|
|
319
406
|
var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
|
|
320
407
|
? opts.theme_css
|
|
@@ -333,10 +420,39 @@ function _wrap(opts) {
|
|
|
333
420
|
var ogDescription = opts.og_description || "Open-source ecommerce framework built on blamejs. Server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.";
|
|
334
421
|
var ogImage = opts.og_image || "/assets/brand/logo.png";
|
|
335
422
|
var ogUrl = opts.og_url || "";
|
|
336
|
-
|
|
423
|
+
// Multi-currency display switcher — populated only when the operator
|
|
424
|
+
// configured >1 display currency (opts.currency_options). The block is
|
|
425
|
+
// empty otherwise, so a single-currency store renders unchanged.
|
|
426
|
+
var switcherHtml = _buildCurrencySwitcher({
|
|
427
|
+
currencies: opts.currency_options,
|
|
428
|
+
selected: opts.currency_selected,
|
|
429
|
+
note: opts.currency_note,
|
|
430
|
+
redirect_to: opts.currency_redirect_to,
|
|
431
|
+
});
|
|
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 = {
|
|
337
453
|
title: opts.title,
|
|
338
454
|
shop_name: shopName,
|
|
339
|
-
cart_count:
|
|
455
|
+
cart_count: cartCount,
|
|
340
456
|
year: _COPYRIGHT_YEAR,
|
|
341
457
|
search_q: opts.search_q == null ? "" : opts.search_q,
|
|
342
458
|
theme_css: themeCss,
|
|
@@ -345,9 +461,29 @@ function _wrap(opts) {
|
|
|
345
461
|
og_description: ogDescription,
|
|
346
462
|
og_image: ogImage,
|
|
347
463
|
og_url: ogUrl,
|
|
464
|
+
lang: localeCtx.lang || "en",
|
|
465
|
+
dir: localeCtx.dir || "ltr",
|
|
466
|
+
nav_cart_aria: cartAria,
|
|
348
467
|
body: "RAW_BODY_PLACEHOLDER",
|
|
349
|
-
}
|
|
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)
|
|
350
484
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
485
|
+
.replace("RAW_CURRENCY_SWITCHER", switcherHtml)
|
|
486
|
+
.replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "")
|
|
351
487
|
.replace("RAW_BODY_PLACEHOLDER", opts.body);
|
|
352
488
|
// The body is RAW HTML (already rendered + escaped at the
|
|
353
489
|
// per-fragment level). The placeholder swap is post-render so the
|
|
@@ -607,9 +743,10 @@ function renderHome(opts) {
|
|
|
607
743
|
var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
|
|
608
744
|
var title = opts.title || "Shop";
|
|
609
745
|
var assetPrefix = opts.asset_prefix || "/assets/";
|
|
746
|
+
var fmt = _priceFormatter(opts);
|
|
610
747
|
var products = opts.products.map(function (p) {
|
|
611
748
|
var priceStr = p.starting_price_minor != null
|
|
612
|
-
?
|
|
749
|
+
? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
|
|
613
750
|
: "—";
|
|
614
751
|
// Hero image — first media row attached to the product (the
|
|
615
752
|
// route handler bundles it in via `p.hero_media`). Image-less
|
|
@@ -684,13 +821,13 @@ function renderHome(opts) {
|
|
|
684
821
|
// designed surface even when no products are loaded yet —
|
|
685
822
|
// visitors land on the storefront shell, not a tech demo.
|
|
686
823
|
var body = hero + catalog;
|
|
687
|
-
return _wrap({
|
|
824
|
+
return _wrap(Object.assign({
|
|
688
825
|
title: title,
|
|
689
826
|
shop_name: shopName,
|
|
690
827
|
cart_count: cartCount,
|
|
691
828
|
theme_css: opts.theme_css,
|
|
692
829
|
body: body,
|
|
693
|
-
});
|
|
830
|
+
}, _currencyWrapOpts(opts)));
|
|
694
831
|
}
|
|
695
832
|
|
|
696
833
|
// ---- search results -----------------------------------------------------
|
|
@@ -914,9 +1051,10 @@ function renderSearch(opts) {
|
|
|
914
1051
|
.replace("RAW_CLEAR", clearLink);
|
|
915
1052
|
} else {
|
|
916
1053
|
var assetPrefix = opts.asset_prefix || "/assets/";
|
|
1054
|
+
var fmt = _priceFormatter(opts);
|
|
917
1055
|
var cards = products.map(function (p) {
|
|
918
1056
|
var priceStr = p.starting_price_minor != null
|
|
919
|
-
?
|
|
1057
|
+
? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
|
|
920
1058
|
: "—";
|
|
921
1059
|
var imageUrl = p.hero_media ? assetPrefix + p.hero_media.r2_key : null;
|
|
922
1060
|
var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
|
|
@@ -932,14 +1070,14 @@ function renderSearch(opts) {
|
|
|
932
1070
|
} else {
|
|
933
1071
|
body = header + correctionHtml + chipsHtml + resultsInner;
|
|
934
1072
|
}
|
|
935
|
-
return _wrap({
|
|
1073
|
+
return _wrap(Object.assign({
|
|
936
1074
|
title: "Search",
|
|
937
1075
|
shop_name: opts.shop_name || "blamejs.shop",
|
|
938
1076
|
cart_count: opts.cart_count,
|
|
939
1077
|
search_q: opts.q,
|
|
940
1078
|
theme_css: opts.theme_css,
|
|
941
1079
|
body: body,
|
|
942
|
-
});
|
|
1080
|
+
}, _currencyWrapOpts(opts)));
|
|
943
1081
|
}
|
|
944
1082
|
|
|
945
1083
|
// ---- product detail -----------------------------------------------------
|
|
@@ -2654,9 +2792,10 @@ function renderProduct(opts) {
|
|
|
2654
2792
|
var shopName = opts.shop_name || "blamejs.shop";
|
|
2655
2793
|
var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
|
|
2656
2794
|
var description = opts.product.description || "";
|
|
2795
|
+
var fmt = _priceFormatter(opts);
|
|
2657
2796
|
var rendered = variants.map(function (v) {
|
|
2658
2797
|
var price = prices[v.id];
|
|
2659
|
-
var priceStr = price ?
|
|
2798
|
+
var priceStr = price ? fmt(price.amount_minor, price.currency) : "—";
|
|
2660
2799
|
var vTitle = v.title || (Object.keys(v.options || {}).map(function (k) { return v.options[k]; }).join(" / ") || "Default");
|
|
2661
2800
|
return { id: v.id, sku: v.sku, title: vTitle, price: priceStr };
|
|
2662
2801
|
});
|
|
@@ -2762,7 +2901,7 @@ function renderProduct(opts) {
|
|
|
2762
2901
|
});
|
|
2763
2902
|
jsonLd = (jsonLd || "") + breadcrumbJsonLd;
|
|
2764
2903
|
|
|
2765
|
-
return _wrap({
|
|
2904
|
+
return _wrap(Object.assign({
|
|
2766
2905
|
title: opts.product.title,
|
|
2767
2906
|
shop_name: shopName,
|
|
2768
2907
|
cart_count: cartCount,
|
|
@@ -2772,7 +2911,7 @@ function renderProduct(opts) {
|
|
|
2772
2911
|
og_description: description || ("Browse " + opts.product.title + " on " + shopName + "."),
|
|
2773
2912
|
og_image: ogImage,
|
|
2774
2913
|
body: body + jsonLd,
|
|
2775
|
-
});
|
|
2914
|
+
}, _currencyWrapOpts(opts)));
|
|
2776
2915
|
}
|
|
2777
2916
|
|
|
2778
2917
|
// ---- cart --------------------------------------------------------------
|
|
@@ -3299,6 +3438,7 @@ function renderCart(opts) {
|
|
|
3299
3438
|
// route handler bundles it in. Lines without an entry render with
|
|
3300
3439
|
// a dashed-placeholder tile + the SKU as the fallback title.
|
|
3301
3440
|
var lookup = opts.product_lookup || {};
|
|
3441
|
+
var fmt = _priceFormatter(opts);
|
|
3302
3442
|
var rendered = lines.map(function (l) {
|
|
3303
3443
|
var match = lookup[l.variant_id] || null;
|
|
3304
3444
|
var prod = match && match.product;
|
|
@@ -3309,16 +3449,16 @@ function renderCart(opts) {
|
|
|
3309
3449
|
id: l.id,
|
|
3310
3450
|
sku: l.sku,
|
|
3311
3451
|
qty: String(l.qty),
|
|
3312
|
-
unit:
|
|
3313
|
-
total:
|
|
3452
|
+
unit: fmt(l.unit_amount_minor, l.unit_currency),
|
|
3453
|
+
total: fmt(l.qty * l.unit_amount_minor, l.unit_currency),
|
|
3314
3454
|
product_title: (prod && prod.title) || l.sku,
|
|
3315
3455
|
product_url: prod ? ("/products/" + prod.slug) : "#",
|
|
3316
3456
|
image_url: imageUrl,
|
|
3317
3457
|
image_alt: imageAlt,
|
|
3318
3458
|
};
|
|
3319
3459
|
});
|
|
3320
|
-
var subtotal =
|
|
3321
|
-
var total =
|
|
3460
|
+
var subtotal = fmt(totals.subtotal_minor, totals.currency);
|
|
3461
|
+
var total = fmt(totals.grand_total_minor, totals.currency);
|
|
3322
3462
|
if (opts.theme) {
|
|
3323
3463
|
return opts.theme.render("cart", {
|
|
3324
3464
|
title: "Cart",
|
|
@@ -3364,13 +3504,13 @@ function renderCart(opts) {
|
|
|
3364
3504
|
total: total,
|
|
3365
3505
|
}).replace("RAW_LINES", rows);
|
|
3366
3506
|
}
|
|
3367
|
-
return _wrap({
|
|
3507
|
+
return _wrap(Object.assign({
|
|
3368
3508
|
title: "Cart",
|
|
3369
3509
|
shop_name: shopName,
|
|
3370
3510
|
cart_count: lines.length,
|
|
3371
3511
|
theme_css: opts.theme_css,
|
|
3372
3512
|
body: body,
|
|
3373
|
-
});
|
|
3513
|
+
}, _currencyWrapOpts(opts)));
|
|
3374
3514
|
}
|
|
3375
3515
|
|
|
3376
3516
|
// ---- admin landing (HTML — the rest of /admin/* is JSON API) ----------
|
|
@@ -3637,6 +3777,14 @@ var OAUTH_COOKIE_NAME = "shop_oauth";
|
|
|
3637
3777
|
// Sealed so it can't be forged to mis-attribute a signup.
|
|
3638
3778
|
var REFERRAL_COOKIE_NAME = "shop_ref";
|
|
3639
3779
|
|
|
3780
|
+
// Sealed cookie holding the visitor's chosen DISPLAY currency (ISO 4217).
|
|
3781
|
+
// Display-only: the cart / order / payment currency is unchanged — this
|
|
3782
|
+
// only selects which currency the price strings are rendered in. Sealed
|
|
3783
|
+
// so a tampered value can't smuggle a non-allow-listed code past the
|
|
3784
|
+
// reader; a garbage value reads as "unset" and the storefront renders in
|
|
3785
|
+
// the base currency. Path "/" so the choice persists across the catalog.
|
|
3786
|
+
var CURRENCY_COOKIE_NAME = "shop_ccy";
|
|
3787
|
+
|
|
3640
3788
|
// Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
|
|
3641
3789
|
var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
3642
3790
|
|
|
@@ -3864,6 +4012,57 @@ function _uaClass(req) {
|
|
|
3864
4012
|
return "unknown";
|
|
3865
4013
|
}
|
|
3866
4014
|
|
|
4015
|
+
var CCY_SHAPE_RE = /^[A-Z]{3}$/;
|
|
4016
|
+
|
|
4017
|
+
// The visitor's chosen display currency, or null. Sealed so the value
|
|
4018
|
+
// can't be forged; a missing / malformed / mis-shaped value reads as null
|
|
4019
|
+
// (→ base-currency display), never throws.
|
|
4020
|
+
function _readCurrencyCookie(req) {
|
|
4021
|
+
var raw = _cookieJar().readSealed(req, CURRENCY_COOKIE_NAME);
|
|
4022
|
+
if (raw === null || !CCY_SHAPE_RE.test(raw)) return null;
|
|
4023
|
+
return raw;
|
|
4024
|
+
}
|
|
4025
|
+
function _setCurrencyCookie(res, code) {
|
|
4026
|
+
var T = b.constants.TIME;
|
|
4027
|
+
_cookieJar().writeSealed(res, CURRENCY_COOKIE_NAME, code, { expires: new Date(Date.now() + T.days(180)) });
|
|
4028
|
+
}
|
|
4029
|
+
function _clearCurrencyCookie(res) {
|
|
4030
|
+
_cookieJar().clear(res, CURRENCY_COOKIE_NAME);
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
// ---- locale cookie -----------------------------------------------------
|
|
4034
|
+
//
|
|
4035
|
+
// The visitor's locale choice rides an UNSEALED cookie. Unlike the
|
|
4036
|
+
// session / auth / referral cookies, the locale carries no authority —
|
|
4037
|
+
// it only selects which operator-configured, server-resolved locale the
|
|
4038
|
+
// chrome renders in (every candidate is validated against the active
|
|
4039
|
+
// policy's supported list; an unknown / garbage value resolves to the
|
|
4040
|
+
// default locale). It is deliberately not vault-sealed so the value
|
|
4041
|
+
// resolves IDENTICALLY on both render substrates: the edge Worker has no
|
|
4042
|
+
// vault and couldn't unseal a sealed cookie, which would split the
|
|
4043
|
+
// edge/container resolution. The framework cookie primitive still
|
|
4044
|
+
// applies the RFC 6265 grammar, the `Secure` / `SameSite=Lax` / `Path=/`
|
|
4045
|
+
// invariants, and (in production) the cookie-prefix policy. A read still
|
|
4046
|
+
// shape-validates the value (BCP-47) before it reaches the resolver, so
|
|
4047
|
+
// a tampered cookie is dropped, never trusted.
|
|
4048
|
+
var LOCALE_COOKIE_NAME = "shop_locale";
|
|
4049
|
+
// BCP-47 envelope mirror of localeRouter.LOCALE_RE — the cookie value is
|
|
4050
|
+
// shape-checked before it's handed to the resolver so a hostile cookie
|
|
4051
|
+
// can't smuggle a non-tag string through.
|
|
4052
|
+
var LOCALE_COOKIE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
4053
|
+
var LOCALE_COOKIE_MAX = 35;
|
|
4054
|
+
|
|
4055
|
+
function _readLocaleCookie(req) {
|
|
4056
|
+
var v = _cookieJar().read(req, LOCALE_COOKIE_NAME);
|
|
4057
|
+
if (!v || typeof v !== "string" || v.length > LOCALE_COOKIE_MAX) return null;
|
|
4058
|
+
return LOCALE_COOKIE_RE.test(v) ? v : null;
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
function _setLocaleCookie(res, locale) {
|
|
4062
|
+
var T = b.constants.TIME;
|
|
4063
|
+
_cookieJar().write(res, LOCALE_COOKIE_NAME, locale, { expires: new Date(Date.now() + T.days(365)) });
|
|
4064
|
+
}
|
|
4065
|
+
|
|
3867
4066
|
// ---- account-page renderers --------------------------------------------
|
|
3868
4067
|
|
|
3869
4068
|
var ACCOUNT_LOGIN_PAGE =
|
|
@@ -4131,6 +4330,304 @@ function mount(router, deps) {
|
|
|
4131
4330
|
return lines.length;
|
|
4132
4331
|
}
|
|
4133
4332
|
|
|
4333
|
+
// ---- multi-currency display -------------------------------------------
|
|
4334
|
+
//
|
|
4335
|
+
// Opt-in: wired only when the operator supplies `deps.currencyDisplay`
|
|
4336
|
+
// (an FX-rate cache instance) AND `deps.currency_display_options` (the
|
|
4337
|
+
// allow-list of display currencies, base first). Absent either, every
|
|
4338
|
+
// render call gets an empty currency bundle and prices stay in the base
|
|
4339
|
+
// currency exactly as before. `deps.currencyRounding` is optional —
|
|
4340
|
+
// present, it applies the per-currency display-increment rule (CHF 0.05,
|
|
4341
|
+
// SEK 0.10); absent, conversion uses banker's rounding only.
|
|
4342
|
+
var _ccyBase = (deps.currency_base || "USD").toUpperCase();
|
|
4343
|
+
var _ccyOptions = Array.isArray(deps.currency_display_options)
|
|
4344
|
+
? deps.currency_display_options.map(function (c) { return String(c).toUpperCase(); })
|
|
4345
|
+
: null;
|
|
4346
|
+
// The feature mounts (the /currency route + the per-request bundle)
|
|
4347
|
+
// whenever an FX instance is wired AND the boot-time allow-list names
|
|
4348
|
+
// >1 currency. The per-request `currency_config` resolver can narrow /
|
|
4349
|
+
// widen the live allow-list, but mounting is decided once at boot from
|
|
4350
|
+
// the seed so a single-currency store never registers the route.
|
|
4351
|
+
var _ccyEnabled = !!(deps.currencyDisplay && _ccyOptions && _ccyOptions.length > 1);
|
|
4352
|
+
var _ccyConfig = typeof deps.currency_config === "function" ? deps.currency_config : null;
|
|
4353
|
+
|
|
4354
|
+
// Per-request currency bundle merged into every render call's opts:
|
|
4355
|
+
// format_price — sync base→display formatter (or pricing.format)
|
|
4356
|
+
// currency_options — the switcher's option list
|
|
4357
|
+
// currency_selected — the visitor's active display currency
|
|
4358
|
+
// currency_note — "charged in <base>" disclosure (when converting)
|
|
4359
|
+
// currency_redirect_to — the path the switcher returns the visitor to
|
|
4360
|
+
// A read / conversion failure degrades to the base bundle (prices in the
|
|
4361
|
+
// base currency) — a broken FX backend never breaks a priced page.
|
|
4362
|
+
async function _currencyForReq(req) {
|
|
4363
|
+
if (!_ccyEnabled) return {};
|
|
4364
|
+
var path = String((req && req.url) || "/").split("?")[0] || "/";
|
|
4365
|
+
// Resolve the live base + allow-list (config override, else the boot
|
|
4366
|
+
// seed). A resolver failure falls back to the seed.
|
|
4367
|
+
var base = _ccyBase;
|
|
4368
|
+
var options = _ccyOptions;
|
|
4369
|
+
if (_ccyConfig) {
|
|
4370
|
+
try {
|
|
4371
|
+
var cfg = await _ccyConfig();
|
|
4372
|
+
if (cfg && cfg.base) base = cfg.base;
|
|
4373
|
+
if (cfg && Array.isArray(cfg.options) && cfg.options.length) options = cfg.options;
|
|
4374
|
+
} catch (_e) { /* keep the boot seed */ }
|
|
4375
|
+
}
|
|
4376
|
+
var bundle = {
|
|
4377
|
+
currency_options: options,
|
|
4378
|
+
currency_selected: base,
|
|
4379
|
+
currency_redirect_to: path,
|
|
4380
|
+
};
|
|
4381
|
+
var chosen = _readCurrencyCookie(req);
|
|
4382
|
+
try {
|
|
4383
|
+
var presenter = await currencyDisplayModule.loadPresenter({
|
|
4384
|
+
fx: deps.currencyDisplay,
|
|
4385
|
+
rounding: deps.currencyRounding || null,
|
|
4386
|
+
baseCurrency: base,
|
|
4387
|
+
displayCurrency: chosen,
|
|
4388
|
+
});
|
|
4389
|
+
// The presenter only activates when the chosen currency is in the
|
|
4390
|
+
// live allow-list AND has a usable rate. A cookie naming a currency
|
|
4391
|
+
// the operator since removed from the list resolves to base.
|
|
4392
|
+
if (presenter && presenter.active && options.indexOf(presenter.displayCurrency) !== -1) {
|
|
4393
|
+
bundle.format_price = presenter.format;
|
|
4394
|
+
bundle.currency_selected = presenter.displayCurrency;
|
|
4395
|
+
if (presenter.note) bundle.currency_note = presenter.note;
|
|
4396
|
+
}
|
|
4397
|
+
} catch (_e) {
|
|
4398
|
+
// FX / rounding backend unavailable — fall back to base display.
|
|
4399
|
+
}
|
|
4400
|
+
return bundle;
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
// ---- locale / i18n chrome ---------------------------------------------
|
|
4404
|
+
//
|
|
4405
|
+
// The storefront renders its UI chrome (nav, footer, search controls,
|
|
4406
|
+
// newsletter band, locale switcher) localised per request. The locale
|
|
4407
|
+
// is resolved the SAME way the edge Worker resolves it — cookie-first,
|
|
4408
|
+
// then `?lang=`, then Accept-Language, then the policy default — so a
|
|
4409
|
+
// visitor sees identical chrome whether the request is served at the
|
|
4410
|
+
// edge or by this container. The strings come from `b.i18n` over the
|
|
4411
|
+
// chrome catalog in `lib/translations.js`, layered with the operator's
|
|
4412
|
+
// `ui`/`chrome` translation rows; a missing key falls back to the
|
|
4413
|
+
// English baseline (never a raw key).
|
|
4414
|
+
//
|
|
4415
|
+
// `deps.chromeI18n` is the `b.i18n` instance (built once at boot via
|
|
4416
|
+
// translations.createChromeI18n with the operator's overrides). When
|
|
4417
|
+
// absent — a deploy with no locale policy seeded — the storefront
|
|
4418
|
+
// renders the English baseline and shows no switcher. `deps.localeRouter`
|
|
4419
|
+
// is the optional resolver; `deps.localeOptions` carries the default
|
|
4420
|
+
// locale + the active-locale list the switcher renders.
|
|
4421
|
+
var chromeI18n = deps.chromeI18n || null;
|
|
4422
|
+
var localeRouter = deps.localeRouter || null;
|
|
4423
|
+
var localeOptions = deps.localeOptions || {};
|
|
4424
|
+
var defaultLocale = localeOptions.defaultLocale || translationsModule.BASELINE_LOCALE;
|
|
4425
|
+
// Active locales the switcher offers: [{ tag, label }]. Operators
|
|
4426
|
+
// declare the label (the autonym — "Deutsch", "Français") in
|
|
4427
|
+
// localeOptions; absent that, the tag itself is the label.
|
|
4428
|
+
var activeLocales = Array.isArray(localeOptions.locales) ? localeOptions.locales : [];
|
|
4429
|
+
// Supported tags for the cookie/`?lang=` direct-match fast path.
|
|
4430
|
+
var supportedTags = activeLocales.map(function (l) { return l.tag; });
|
|
4431
|
+
// i18n RTL helper — `b.i18n.dir({ locale })` returns "rtl"/"ltr".
|
|
4432
|
+
function _dirFor(locale) {
|
|
4433
|
+
if (!chromeI18n) return "ltr";
|
|
4434
|
+
try { return chromeI18n.dir({ locale: locale }); } catch (_e) { return "ltr"; }
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
// Per-locale resolved chrome string set, memoised — the strings don't
|
|
4438
|
+
// change per request (only the cart count, interpolated at render
|
|
4439
|
+
// time), so resolve each locale once.
|
|
4440
|
+
var _chromeByLocale = Object.create(null);
|
|
4441
|
+
function _chromeFor(locale) {
|
|
4442
|
+
if (!chromeI18n) return translationsModule.chromeDefaults();
|
|
4443
|
+
if (_chromeByLocale[locale]) return _chromeByLocale[locale];
|
|
4444
|
+
var resolved = translationsModule.resolveChrome(chromeI18n, locale);
|
|
4445
|
+
_chromeByLocale[locale] = resolved;
|
|
4446
|
+
return resolved;
|
|
4447
|
+
}
|
|
4448
|
+
|
|
4449
|
+
// Match a candidate tag against the supported set — the SAME rule the
|
|
4450
|
+
// locale-router uses: a case-insensitive direct hit, then a primary-
|
|
4451
|
+
// subtag match (`en-GB` candidate -> supported `en`). Returns the
|
|
4452
|
+
// matched supported tag (catalog casing) or null. Kept in-process so
|
|
4453
|
+
// resolution is synchronous — the middleware must seed the async-local
|
|
4454
|
+
// store within one synchronous tick (an `await` inside the middleware
|
|
4455
|
+
// would set the store in the awaited frame, not the request's).
|
|
4456
|
+
var _supportedLowerToTag = Object.create(null);
|
|
4457
|
+
for (var _si = 0; _si < supportedTags.length; _si += 1) {
|
|
4458
|
+
_supportedLowerToTag[String(supportedTags[_si]).toLowerCase()] = supportedTags[_si];
|
|
4459
|
+
}
|
|
4460
|
+
function _matchSupported(candidate) {
|
|
4461
|
+
if (!candidate) return null;
|
|
4462
|
+
var lowered = String(candidate).toLowerCase();
|
|
4463
|
+
if (Object.prototype.hasOwnProperty.call(_supportedLowerToTag, lowered)) {
|
|
4464
|
+
return _supportedLowerToTag[lowered];
|
|
4465
|
+
}
|
|
4466
|
+
var primary = lowered.split("-")[0];
|
|
4467
|
+
if (Object.prototype.hasOwnProperty.call(_supportedLowerToTag, primary)) {
|
|
4468
|
+
return _supportedLowerToTag[primary];
|
|
4469
|
+
}
|
|
4470
|
+
return null;
|
|
4471
|
+
}
|
|
4472
|
+
|
|
4473
|
+
// Parse an Accept-Language header into a q-sorted list of tags. A
|
|
4474
|
+
// garbage header yields an empty list (the resolver then falls to the
|
|
4475
|
+
// default). Mirrors the locale-router's parser shape.
|
|
4476
|
+
function _parseAcceptLanguage(raw) {
|
|
4477
|
+
if (typeof raw !== "string" || !raw.length || raw.length > 4096) return [];
|
|
4478
|
+
var out = [];
|
|
4479
|
+
var parts = raw.split(",");
|
|
4480
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
4481
|
+
var part = parts[i].trim();
|
|
4482
|
+
if (!part) continue;
|
|
4483
|
+
var semi = part.indexOf(";");
|
|
4484
|
+
var tag = (semi === -1 ? part : part.slice(0, semi)).trim();
|
|
4485
|
+
var q = 1.0;
|
|
4486
|
+
if (semi !== -1) {
|
|
4487
|
+
var m = /q=([0-9.]+)/.exec(part.slice(semi + 1));
|
|
4488
|
+
if (m) { var n = parseFloat(m[1]); if (isFinite(n) && n >= 0 && n <= 1) q = n; }
|
|
4489
|
+
}
|
|
4490
|
+
if (tag === "*" || !LOCALE_COOKIE_RE.test(tag) || tag.length > LOCALE_COOKIE_MAX) continue;
|
|
4491
|
+
out.push({ tag: tag, q: q, order: i });
|
|
4492
|
+
}
|
|
4493
|
+
out.sort(function (a, c) { return a.q !== c.q ? c.q - a.q : a.order - c.order; });
|
|
4494
|
+
return out;
|
|
4495
|
+
}
|
|
4496
|
+
|
|
4497
|
+
// Resolve the request's locale synchronously, in the SAME precedence
|
|
4498
|
+
// the edge Worker / locale-router use: an explicit `?lang=` choice,
|
|
4499
|
+
// then the persisted cookie, then the browser's Accept-Language list,
|
|
4500
|
+
// then the policy default. Only a tag that resolves into the active
|
|
4501
|
+
// supported set is honoured; anything else (unknown code, garbage
|
|
4502
|
+
// cookie) falls through to the default. Never throws.
|
|
4503
|
+
function _resolveRequestLocale(req) {
|
|
4504
|
+
var queryLocale = null;
|
|
4505
|
+
if (req.query && typeof req.query.lang === "string" &&
|
|
4506
|
+
LOCALE_COOKIE_RE.test(req.query.lang) && req.query.lang.length <= LOCALE_COOKIE_MAX) {
|
|
4507
|
+
queryLocale = req.query.lang;
|
|
4508
|
+
}
|
|
4509
|
+
var qMatch = _matchSupported(queryLocale);
|
|
4510
|
+
if (qMatch) return qMatch;
|
|
4511
|
+
|
|
4512
|
+
var cMatch = _matchSupported(_readLocaleCookie(req));
|
|
4513
|
+
if (cMatch) return cMatch;
|
|
4514
|
+
|
|
4515
|
+
var alHeader = req.headers && (req.headers["accept-language"] || req.headers["Accept-Language"]);
|
|
4516
|
+
var alList = _parseAcceptLanguage(alHeader);
|
|
4517
|
+
for (var i = 0; i < alList.length; i += 1) {
|
|
4518
|
+
var alMatch = _matchSupported(alList[i].tag);
|
|
4519
|
+
if (alMatch) return alMatch;
|
|
4520
|
+
}
|
|
4521
|
+
return defaultLocale;
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
// Best-effort: record the resolution in the locale-router's audit log
|
|
4525
|
+
// (locale_resolutions_log). Fire-and-forget — never awaited, never
|
|
4526
|
+
// blocks the request, never throws into the hot path. Wired only when
|
|
4527
|
+
// the locale-router is present.
|
|
4528
|
+
function _logResolution(req, locale) {
|
|
4529
|
+
if (!localeRouter || typeof localeRouter.resolveLocale !== "function") return;
|
|
4530
|
+
try {
|
|
4531
|
+
var hostHeader = (req.headers && (req.headers.host || req.headers.Host)) || "localhost";
|
|
4532
|
+
var p = localeRouter.resolveLocale({
|
|
4533
|
+
request: {
|
|
4534
|
+
host: String(hostHeader),
|
|
4535
|
+
path: req.pathname || (String(req.url || "/").split("?")[0]) || "/",
|
|
4536
|
+
cookie_locale: locale,
|
|
4537
|
+
accept_language: undefined,
|
|
4538
|
+
},
|
|
4539
|
+
});
|
|
4540
|
+
if (p && typeof p.then === "function") p.then(function () {}, function () {});
|
|
4541
|
+
} catch (_e) { /* drop-silent — the audit log is non-critical */ }
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
// Build the server-rendered locale switcher form. A GET form (works
|
|
4545
|
+
// with JS off) that submits the chosen `lang` to /locale, which sets
|
|
4546
|
+
// the cookie and 303-redirects back to the page the visitor was on
|
|
4547
|
+
// (validated same-origin). Rendered only when there's more than one
|
|
4548
|
+
// active locale to choose between.
|
|
4549
|
+
function _switcherHtml(req, activeLocale, chrome) {
|
|
4550
|
+
if (activeLocales.length < 2) return "";
|
|
4551
|
+
var esc = b.template.escapeHtml;
|
|
4552
|
+
// `to` carries the current path+query so the redirect lands the
|
|
4553
|
+
// visitor back where they were. Validated server-side in /locale.
|
|
4554
|
+
var here = req.pathname || (String(req.url || "/").split("?")[0]) || "/";
|
|
4555
|
+
var options = activeLocales.map(function (l) {
|
|
4556
|
+
var sel = l.tag === activeLocale ? " selected" : "";
|
|
4557
|
+
return "<option value=\"" + esc(l.tag) + "\"" + sel + ">" + esc(l.label || l.tag) + "</option>";
|
|
4558
|
+
}).join("");
|
|
4559
|
+
return "" +
|
|
4560
|
+
" <form class=\"locale-switcher\" method=\"get\" action=\"/locale\">\n" +
|
|
4561
|
+
" <label class=\"locale-switcher__label\" for=\"locale-switcher-select\">" + esc(chrome.locale_switcher_label) + "</label>\n" +
|
|
4562
|
+
" <input type=\"hidden\" name=\"to\" value=\"" + esc(here) + "\">\n" +
|
|
4563
|
+
" <select id=\"locale-switcher-select\" class=\"locale-switcher__select\" name=\"lang\">" + options + "</select>\n" +
|
|
4564
|
+
" <button class=\"locale-switcher__submit\" type=\"submit\">" + esc(chrome.locale_switcher_submit) + "</button>\n" +
|
|
4565
|
+
" </form>\n";
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4568
|
+
// The per-request locale context the layout reads via the async-local
|
|
4569
|
+
// store. Resolved SYNCHRONOUSLY so the middleware can seed the store
|
|
4570
|
+
// within one tick. Shape: { locale, lang, dir, chrome, switcher_html }.
|
|
4571
|
+
// Never throws — any failure falls back to the default locale.
|
|
4572
|
+
function _localeCtx(req) {
|
|
4573
|
+
var locale = defaultLocale;
|
|
4574
|
+
try { locale = _resolveRequestLocale(req); } catch (_e) { locale = defaultLocale; }
|
|
4575
|
+
var chrome = _chromeFor(locale);
|
|
4576
|
+
return {
|
|
4577
|
+
locale: locale,
|
|
4578
|
+
lang: locale,
|
|
4579
|
+
dir: _dirFor(locale),
|
|
4580
|
+
chrome: chrome,
|
|
4581
|
+
switcher_html: _switcherHtml(req, locale, chrome),
|
|
4582
|
+
};
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
// Seed the per-request locale context into the async-local store so
|
|
4586
|
+
// every `_wrap` on this request reads the resolved chrome. The
|
|
4587
|
+
// middleware is SYNCHRONOUS: `AsyncLocalStorage.enterWith` only
|
|
4588
|
+
// propagates to the request's downstream handlers when it runs in the
|
|
4589
|
+
// same synchronous tick the router awaits — an `await` inside the
|
|
4590
|
+
// middleware would scope the store to the awaited frame, not the
|
|
4591
|
+
// request. Resolution is best-effort (falls back to the English
|
|
4592
|
+
// baseline). The audit-log write is fired off without awaiting so it
|
|
4593
|
+
// never blocks the render. Only mounted when the router exposes `.use`.
|
|
4594
|
+
if (typeof router.use === "function") {
|
|
4595
|
+
router.use(function localeMiddleware(req, _res, next) {
|
|
4596
|
+
try {
|
|
4597
|
+
var ctx = _localeCtx(req);
|
|
4598
|
+
_localeAls.enterWith(ctx);
|
|
4599
|
+
_logResolution(req, ctx.locale);
|
|
4600
|
+
} catch (_e) { /* drop-silent — baseline applies */ }
|
|
4601
|
+
next();
|
|
4602
|
+
});
|
|
4603
|
+
}
|
|
4604
|
+
|
|
4605
|
+
// Persist a locale choice. A GET form (works with JS off) from the
|
|
4606
|
+
// footer switcher submits `lang` (the chosen tag) + `to` (the path to
|
|
4607
|
+
// return to). We validate the tag against the active locale set, set
|
|
4608
|
+
// the unsealed `shop_locale` cookie, and 303-redirect to the `to`
|
|
4609
|
+
// path — refusing anything that isn't a same-origin absolute path so
|
|
4610
|
+
// the redirect can't be turned into an open-redirect. Unknown / bad
|
|
4611
|
+
// `lang` still redirects (to "/" or the validated `to`) without
|
|
4612
|
+
// setting a cookie, so a hostile link can't 500 the route.
|
|
4613
|
+
router.get("/locale", function (req, res) {
|
|
4614
|
+
var q = req.query || {};
|
|
4615
|
+
var to = (typeof q.to === "string") ? q.to : "/";
|
|
4616
|
+
// Same-origin path only: must start with a single "/" (not "//" — a
|
|
4617
|
+
// protocol-relative URL — and not a scheme). Anything else falls
|
|
4618
|
+
// back to the home path.
|
|
4619
|
+
if (to.charAt(0) !== "/" || to.charAt(1) === "/" || to.indexOf("\\") !== -1 || /[\x00-\x1f\x7f]/.test(to)) {
|
|
4620
|
+
to = "/";
|
|
4621
|
+
}
|
|
4622
|
+
var lang = (typeof q.lang === "string") ? q.lang : "";
|
|
4623
|
+
if (lang && LOCALE_COOKIE_RE.test(lang) && lang.length <= LOCALE_COOKIE_MAX && supportedTags.indexOf(lang) !== -1) {
|
|
4624
|
+
_setLocaleCookie(res, lang);
|
|
4625
|
+
}
|
|
4626
|
+
res.status(303);
|
|
4627
|
+
res.setHeader && res.setHeader("location", to);
|
|
4628
|
+
return res.end ? res.end() : res.send("");
|
|
4629
|
+
});
|
|
4630
|
+
|
|
4134
4631
|
// Absolute shareable referral link for a code. Prefers the operator's
|
|
4135
4632
|
// configured origin (deps.shop_origin / SHOP_ORIGIN) so the link is
|
|
4136
4633
|
// stable across the edge/container split; falls back to the request's
|
|
@@ -4254,8 +4751,9 @@ function mount(router, deps) {
|
|
|
4254
4751
|
// reachable via multiple variant SKUs. Drop-silent on a primitive
|
|
4255
4752
|
// error so a bundles read failure can't 500 the PDP — the rail just
|
|
4256
4753
|
// doesn't render (mirrors the reviews/Q&A degrade-to-empty stance).
|
|
4257
|
-
async function _resolveBundleOffers(variantSkus, currency) {
|
|
4754
|
+
async function _resolveBundleOffers(variantSkus, currency, fmtPrice) {
|
|
4258
4755
|
if (!deps.bundles) return [];
|
|
4756
|
+
var fmt = typeof fmtPrice === "function" ? fmtPrice : pricing.format;
|
|
4259
4757
|
var seen = Object.create(null);
|
|
4260
4758
|
var offers = [];
|
|
4261
4759
|
try {
|
|
@@ -4300,9 +4798,9 @@ function mount(router, deps) {
|
|
|
4300
4798
|
bundle_sku: bundle.bundle_sku,
|
|
4301
4799
|
title: bundle.title,
|
|
4302
4800
|
components: componentsOut,
|
|
4303
|
-
list_total_str:
|
|
4304
|
-
amount_str:
|
|
4305
|
-
discount_str: discountMinor > 0 ?
|
|
4801
|
+
list_total_str: fmt(listMinor, cur),
|
|
4802
|
+
amount_str: fmt(amountMinor, cur),
|
|
4803
|
+
discount_str: discountMinor > 0 ? fmt(discountMinor, cur) : null,
|
|
4306
4804
|
available: available,
|
|
4307
4805
|
unavailable_reason: available
|
|
4308
4806
|
? null
|
|
@@ -4326,8 +4824,9 @@ function mount(router, deps) {
|
|
|
4326
4824
|
// that quantity. Rows read as ascending ranges ("1–4", "5–9",
|
|
4327
4825
|
// "10+"). Returns [] when no active sku-scoped tier set exists.
|
|
4328
4826
|
// Drop-silent on a read failure so the PDP still renders.
|
|
4329
|
-
async function _resolveQtyBreaks(sku, unitMinor, currency) {
|
|
4827
|
+
async function _resolveQtyBreaks(sku, unitMinor, currency, fmtPrice) {
|
|
4330
4828
|
if (!deps.quantityDiscounts || unitMinor == null) return [];
|
|
4829
|
+
var fmt = typeof fmtPrice === "function" ? fmtPrice : pricing.format;
|
|
4331
4830
|
var breakdown;
|
|
4332
4831
|
try {
|
|
4333
4832
|
breakdown = await deps.quantityDiscounts.tierBreakdown({
|
|
@@ -4350,7 +4849,7 @@ function mount(router, deps) {
|
|
|
4350
4849
|
if (sorted[0].min_quantity > 1) {
|
|
4351
4850
|
out.push({
|
|
4352
4851
|
label: "1–" + (sorted[0].min_quantity - 1),
|
|
4353
|
-
unit_str:
|
|
4852
|
+
unit_str: fmt(unitMinor, currency),
|
|
4354
4853
|
});
|
|
4355
4854
|
}
|
|
4356
4855
|
for (var i = 0; i < sorted.length; i += 1) {
|
|
@@ -4358,7 +4857,7 @@ function mount(router, deps) {
|
|
|
4358
4857
|
var next = sorted[i + 1];
|
|
4359
4858
|
var label = next ? (t.min_quantity + "–" + (next.min_quantity - 1)) : (t.min_quantity + "+");
|
|
4360
4859
|
var unit = t.sample_discounted_unit_minor != null ? t.sample_discounted_unit_minor : unitMinor;
|
|
4361
|
-
out.push({ label: label, unit_str:
|
|
4860
|
+
out.push({ label: label, unit_str: fmt(unit, currency) });
|
|
4362
4861
|
}
|
|
4363
4862
|
return out;
|
|
4364
4863
|
}
|
|
@@ -4398,7 +4897,35 @@ function mount(router, deps) {
|
|
|
4398
4897
|
return out;
|
|
4399
4898
|
}
|
|
4400
4899
|
|
|
4401
|
-
|
|
4900
|
+
// POST /currency — set (or clear) the visitor's display-currency choice.
|
|
4901
|
+
// Registered only when multi-currency display is wired. Display-only:
|
|
4902
|
+
// this NEVER touches the cart / order / payment currency — it sets the
|
|
4903
|
+
// sealed `shop_ccy` cookie that selects which currency price strings are
|
|
4904
|
+
// rendered in, then 303-redirects back so the new choice takes effect on
|
|
4905
|
+
// the next render. A choice outside the operator's allow-list clears the
|
|
4906
|
+
// cookie (→ base-currency display) rather than persisting a code the
|
|
4907
|
+
// switcher would never offer.
|
|
4908
|
+
if (_ccyEnabled) {
|
|
4909
|
+
router.post("/currency", function (req, res) {
|
|
4910
|
+
var body = req.body || {};
|
|
4911
|
+
var chosen = typeof body.currency === "string" ? body.currency.toUpperCase() : "";
|
|
4912
|
+
// `redirect_to` is constrained to a same-origin path (leading single
|
|
4913
|
+
// slash, no scheme / host / protocol-relative `//`) so the switcher
|
|
4914
|
+
// can't be turned into an open redirect.
|
|
4915
|
+
var rawTo = typeof body.redirect_to === "string" ? body.redirect_to : "/";
|
|
4916
|
+
var to = (/^\/(?!\/)/.test(rawTo)) ? rawTo : "/";
|
|
4917
|
+
if (chosen === _ccyBase || _ccyOptions.indexOf(chosen) === -1) {
|
|
4918
|
+
_clearCurrencyCookie(res);
|
|
4919
|
+
} else {
|
|
4920
|
+
_setCurrencyCookie(res, chosen);
|
|
4921
|
+
}
|
|
4922
|
+
res.status(303);
|
|
4923
|
+
res.setHeader && res.setHeader("location", to);
|
|
4924
|
+
return res.end ? res.end() : res.send("");
|
|
4925
|
+
});
|
|
4926
|
+
}
|
|
4927
|
+
|
|
4928
|
+
router.get("/", async function (req, res) {
|
|
4402
4929
|
var page = await deps.catalog.products.list({ status: "active", limit: 24 });
|
|
4403
4930
|
// Best-effort "starting price" + "hero media" lookup. Each
|
|
4404
4931
|
// product on the home grid carries its first variant's USD
|
|
@@ -4423,7 +4950,8 @@ function mount(router, deps) {
|
|
|
4423
4950
|
hero_media: heroMedia,
|
|
4424
4951
|
}));
|
|
4425
4952
|
}
|
|
4426
|
-
var
|
|
4953
|
+
var ccy = await _currencyForReq(req);
|
|
4954
|
+
var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, ccy));
|
|
4427
4955
|
_send(res, 200, html);
|
|
4428
4956
|
});
|
|
4429
4957
|
|
|
@@ -4553,7 +5081,8 @@ function mount(router, deps) {
|
|
|
4553
5081
|
cartCount = lines.length;
|
|
4554
5082
|
}
|
|
4555
5083
|
}
|
|
4556
|
-
|
|
5084
|
+
var ccy = await _currencyForReq(req);
|
|
5085
|
+
_send(res, 200, renderSearch(Object.assign({
|
|
4557
5086
|
q: q,
|
|
4558
5087
|
products: products,
|
|
4559
5088
|
facets: facetGroups,
|
|
@@ -4561,7 +5090,7 @@ function mount(router, deps) {
|
|
|
4561
5090
|
corrected_query: correctedQ,
|
|
4562
5091
|
shop_name: shopName,
|
|
4563
5092
|
cart_count: cartCount,
|
|
4564
|
-
}));
|
|
5093
|
+
}, ccy)));
|
|
4565
5094
|
});
|
|
4566
5095
|
|
|
4567
5096
|
router.get("/products/:slug", async function (req, res) {
|
|
@@ -4654,14 +5183,16 @@ function mount(router, deps) {
|
|
|
4654
5183
|
// so the buy path renders regardless. The quantity-break table is
|
|
4655
5184
|
// built against the first variant's list price — the band a shopper
|
|
4656
5185
|
// sees on the PDP matches what the cart charges at that quantity.
|
|
5186
|
+
var ccy = await _currencyForReq(req);
|
|
5187
|
+
var ccyFmt = ccy.format_price || null;
|
|
4657
5188
|
var variantSkus = variants.map(function (v) { return v.sku; });
|
|
4658
|
-
var bundleOffers = await _resolveBundleOffers(variantSkus, "USD");
|
|
5189
|
+
var bundleOffers = await _resolveBundleOffers(variantSkus, "USD", ccyFmt);
|
|
4659
5190
|
var firstVariant = variants[0] || null;
|
|
4660
5191
|
var firstPrice = firstVariant ? prices[firstVariant.id] : null;
|
|
4661
5192
|
var qtyBreaks = firstVariant && firstPrice
|
|
4662
|
-
? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency)
|
|
5193
|
+
? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency, ccyFmt)
|
|
4663
5194
|
: [];
|
|
4664
|
-
var html = renderProduct({
|
|
5195
|
+
var html = renderProduct(Object.assign({
|
|
4665
5196
|
product: product,
|
|
4666
5197
|
variants: variants,
|
|
4667
5198
|
prices: prices,
|
|
@@ -4677,7 +5208,7 @@ function mount(router, deps) {
|
|
|
4677
5208
|
shop_name: shopName,
|
|
4678
5209
|
cart_count: cartCount,
|
|
4679
5210
|
theme: theme,
|
|
4680
|
-
});
|
|
5211
|
+
}, ccy));
|
|
4681
5212
|
_send(res, 200, html);
|
|
4682
5213
|
});
|
|
4683
5214
|
|
|
@@ -4992,19 +5523,20 @@ function mount(router, deps) {
|
|
|
4992
5523
|
}
|
|
4993
5524
|
|
|
4994
5525
|
router.get("/cart", async function (req, res) {
|
|
5526
|
+
var ccy = await _currencyForReq(req);
|
|
4995
5527
|
var sid = _readSidCookie(req);
|
|
4996
5528
|
if (!sid) {
|
|
4997
|
-
return _send(res, 200, renderCart({
|
|
5529
|
+
return _send(res, 200, renderCart(Object.assign({
|
|
4998
5530
|
lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
|
|
4999
5531
|
shop_name: shopName, theme: theme,
|
|
5000
|
-
}));
|
|
5532
|
+
}, ccy)));
|
|
5001
5533
|
}
|
|
5002
5534
|
var c = await deps.cart.bySession(sid);
|
|
5003
5535
|
if (!c) {
|
|
5004
|
-
return _send(res, 200, renderCart({
|
|
5536
|
+
return _send(res, 200, renderCart(Object.assign({
|
|
5005
5537
|
lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
|
|
5006
5538
|
shop_name: shopName, theme: theme,
|
|
5007
|
-
}));
|
|
5539
|
+
}, ccy)));
|
|
5008
5540
|
}
|
|
5009
5541
|
var rawLines = await deps.cart.listLines(c.id);
|
|
5010
5542
|
// Reapply the active quantity-break for each line at its current
|
|
@@ -5031,14 +5563,14 @@ function mount(router, deps) {
|
|
|
5031
5563
|
hero_media: media.length ? media[0] : null,
|
|
5032
5564
|
};
|
|
5033
5565
|
}
|
|
5034
|
-
_send(res, 200, renderCart({
|
|
5566
|
+
_send(res, 200, renderCart(Object.assign({
|
|
5035
5567
|
lines: lines,
|
|
5036
5568
|
totals: totals,
|
|
5037
5569
|
product_lookup: productLookup,
|
|
5038
5570
|
can_save: !!(deps.saveForLater && deps.customers),
|
|
5039
5571
|
shop_name: shopName,
|
|
5040
5572
|
theme: theme,
|
|
5041
|
-
}));
|
|
5573
|
+
}, ccy)));
|
|
5042
5574
|
});
|
|
5043
5575
|
|
|
5044
5576
|
// ---- checkout flow -------------------------------------------------
|