@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/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=\"en\">\n" +
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\">Skip to content</a>\n" +
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> Open source · Apache 2.0</span>\n" +
153
- " <span class=\"utility-bar__msg\">Server-rendered HTML · post-quantum crypto on by default · zero npm runtime deps</span>\n" +
154
- " <a class=\"utility-bar__link\" href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">Star on GitHub →</a>\n" +
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\">Search products</label>\n" +
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=\"Search the catalog\" autocomplete=\"off\" spellcheck=\"false\" maxlength=\"200\">\n" +
166
- " <button type=\"submit\">Search</button>\n" +
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=\"/\">Shop</a>\n" +
171
- " <a class=\"site-nav__link\" href=\"#framework\">Framework</a>\n" +
172
- " <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"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" +
173
- " <a class=\"cart-pill\" href=\"/cart\" aria-label=\"Cart, {{cart_count}} items\"><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" +
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\">Stay in the loop</p>\n" +
184
- " <h2 id=\"newsletter-title\">Get release notes the day they ship.</h2>\n" +
185
- " <p class=\"newsletter-band__lede\">No marketing emails. A single short note when there's a new framework release, a security advisory, or a primitive worth knowing about.</p>\n" +
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\">Email address</label>\n" +
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\">Subscribe</button>\n" +
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\">An open-source shop framework — server-rendered HTML, zero npm runtime dependencies, security defaults on.</p>\n" +
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>Shop</h4>\n" +
221
+ " <h4>{{footer_shop_heading}}</h4>\n" +
208
222
  " <ul>\n" +
209
- " <li><a href=\"/\">All products</a></li>\n" +
210
- " <li><a href=\"/collections\">Collections</a></li>\n" +
211
- " <li><a href=\"/categories\">Categories</a></li>\n" +
212
- " <li><a href=\"/?sort=new\">New arrivals</a></li>\n" +
213
- " <li><a href=\"/?sort=sale\">On sale</a></li>\n" +
214
- " <li><a href=\"/compare\">Compare</a></li>\n" +
215
- " <li><a href=\"/cart\">Cart</a></li>\n" +
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>Framework</h4>\n" +
233
+ " <h4>{{footer_framework_heading}}</h4>\n" +
220
234
  " <ul>\n" +
221
- " <li><a href=\"https://github.com/blamejs/blamejs.shop\" rel=\"noopener\">Source on GitHub</a></li>\n" +
222
- " <li><a href=\"https://github.com/blamejs/blamejs\" rel=\"noopener\">blamejs core</a></li>\n" +
223
- " <li><a href=\"/SECURITY.md\">Security policy</a></li>\n" +
224
- " <li><a href=\"/CHANGELOG.md\">Changelog</a></li>\n" +
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>Operators</h4>\n" +
242
+ " <h4>{{footer_operators_heading}}</h4>\n" +
229
243
  " <ul>\n" +
230
- " <li><a href=\"/account\">Account</a></li>\n" +
231
- " <li><a href=\"/orders\">Orders</a></li>\n" +
232
- " <li><a href=\"/admin\">Admin</a></li>\n" +
233
- " <li><a href=\"mailto:hello@blamejs.shop\">Contact</a></li>\n" +
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>&copy; {{year}} {{shop_name}} — built on blamejs · Apache 2.0 licensed.</p>\n" +
254
+ " <p>&copy; {{year}} {{shop_name}} — {{footer_copy_suffix}}</p>\n" +
239
255
  " <ul>\n" +
240
- " <li><a href=\"/SECURITY.md\">Security</a></li>\n" +
241
- " <li><a href=\"/privacy\">Privacy</a></li>\n" +
242
- " <li><a href=\"/terms\">Terms</a></li>\n" +
243
- " <li><a href=\"/cookies\">Manage cookies</a></li>\n" +
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
- return _render(LAYOUT, {
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: opts.cart_count == null ? 0 : opts.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
- }).replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
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
- ? pricing.format(p.starting_price_minor, p.starting_price_currency || "USD")
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
- ? pricing.format(p.starting_price_minor, p.starting_price_currency || "USD")
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 ? pricing.format(price.amount_minor, price.currency) : "—";
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: pricing.format(l.unit_amount_minor, l.unit_currency),
3313
- total: pricing.format(l.qty * l.unit_amount_minor, l.unit_currency),
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 = pricing.format(totals.subtotal_minor, totals.currency);
3321
- var total = pricing.format(totals.grand_total_minor, totals.currency);
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: pricing.format(listMinor, cur),
4304
- amount_str: pricing.format(amountMinor, cur),
4305
- discount_str: discountMinor > 0 ? pricing.format(discountMinor, cur) : null,
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: pricing.format(unitMinor, currency),
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: pricing.format(unit, currency) });
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
- router.get("/", async function (_req, res) {
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 html = renderHome({ products: products, shop_name: shopName, theme: theme });
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
- _send(res, 200, renderSearch({
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 -------------------------------------------------