@blamejs/blamejs-shop 0.1.34 → 0.1.36

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,6 +27,7 @@
27
27
 
28
28
  var emailModule = require("./email");
29
29
  var pricing = require("./pricing");
30
+ var currencyDisplayModule = require("./currency-display");
30
31
 
31
32
  var b = require("./vendor/blamejs");
32
33
 
@@ -77,6 +78,52 @@ var _render = emailModule._render;
77
78
  // the bound R2 bucket. The 1536×1024 source PNG is committed
78
79
  // only to .template/ (local-only) and uploaded once via
79
80
  // `wrangler r2 object put`.
81
+
82
+ // Cookie-consent banner — present in the chrome of every page (both
83
+ // the container render below and each worker/render/*.js LAYOUT, kept
84
+ // byte-identical). GDPR (EU 2016/679 art. 6 + 7) + ePrivacy (2002/58/EC
85
+ // art. 5(3)) demand informed, specific, opt-in consent BEFORE any non-
86
+ // strictly-necessary cookie / tracker is set, with default-deny on the
87
+ // toggleable categories and a withdraw path. The banner is a plain
88
+ // server-rendered form that POSTs to /consent — it works with no client
89
+ // JS at all (essential-only browsing, accept, reject, and the granular
90
+ // preference center are all reachable JS-off).
91
+ //
92
+ // Because the storefront's read pages are edge-cached for cookie-less
93
+ // visitors (worker/index.js `_edgeRenderCached`), the banner can't be
94
+ // server-conditionally omitted on a cached page — the cached HTML is one
95
+ // document shared across visitors. So the banner ships in every document
96
+ // and the dismissal is cookie-driven on the client: the consent island
97
+ // (themes/default/assets/js/consent.js) reads the non-sealed
98
+ // `shop_consent_set` flag cookie and hides the banner when a choice
99
+ // already exists. The authoritative decision lives in the sealed
100
+ // `shop_consent` cookie + the cookie-consent ledger (server-side); the
101
+ // flag cookie is a non-authoritative "has decided" hint that only drives
102
+ // banner visibility. Nothing in this block reflects a visitor-supplied
103
+ // value, so there is no interpolation and no escape surface.
104
+ //
105
+ // `hidden` is the default visible state inversion: the dialog renders
106
+ // visible for a visitor with no decision (JS-off included). The island
107
+ // adds `data-consent-decided` to <html> and the CSS hides the dialog,
108
+ // so a returning visitor never sees it. A visitor with JS disabled who
109
+ // has already decided sees the banner again — but every control still
110
+ // works server-side, so they re-confirm rather than hit a dead end.
111
+ var CONSENT_BANNER =
112
+ " <div class=\"consent-banner\" id=\"consent-banner\" role=\"dialog\" aria-modal=\"false\" aria-labelledby=\"consent-title\" aria-describedby=\"consent-desc\">\n" +
113
+ " <div class=\"consent-banner__inner\">\n" +
114
+ " <div class=\"consent-banner__copy\">\n" +
115
+ " <h2 class=\"consent-banner__title\" id=\"consent-title\">Your privacy choices</h2>\n" +
116
+ " <p class=\"consent-banner__desc\" id=\"consent-desc\">We use strictly-necessary cookies to run the shop (your session, security tokens, and this choice itself). Optional cookies — functional, analytics, marketing, and preferences — are off until you turn them on. You can change this any time from <a href=\"/cookies\">Manage cookies</a>.</p>\n" +
117
+ " </div>\n" +
118
+ " <form class=\"consent-banner__actions\" method=\"post\" action=\"/consent\">\n" +
119
+ " <input type=\"hidden\" name=\"return_to\" value=\"/\" data-consent-return>\n" +
120
+ " <button type=\"submit\" name=\"choice\" value=\"accept_all\" class=\"btn-primary consent-banner__btn\">Accept all</button>\n" +
121
+ " <button type=\"submit\" name=\"choice\" value=\"reject\" class=\"btn-ghost consent-banner__btn\">Reject non-essential</button>\n" +
122
+ " <a class=\"consent-banner__manage\" href=\"/cookies\">Manage preferences</a>\n" +
123
+ " </form>\n" +
124
+ " </div>\n" +
125
+ " </div>\n";
126
+
80
127
  var LAYOUT =
81
128
  "<!DOCTYPE html>\n" +
82
129
  "<html lang=\"en\">\n" +
@@ -188,15 +235,19 @@ var LAYOUT =
188
235
  " </ul>\n" +
189
236
  " </div>\n" +
190
237
  " </div>\n" +
238
+ " RAW_CURRENCY_SWITCHER\n" +
191
239
  " <div class=\"site-footer__copy\">\n" +
192
240
  " <p>&copy; {{year}} {{shop_name}} — built on blamejs · Apache 2.0 licensed.</p>\n" +
193
241
  " <ul>\n" +
194
242
  " <li><a href=\"/SECURITY.md\">Security</a></li>\n" +
195
243
  " <li><a href=\"/privacy\">Privacy</a></li>\n" +
196
244
  " <li><a href=\"/terms\">Terms</a></li>\n" +
245
+ " <li><a href=\"/cookies\">Manage cookies</a></li>\n" +
197
246
  " </ul>\n" +
198
247
  " </div>\n" +
199
248
  " </footer>\n" +
249
+ CONSENT_BANNER +
250
+ "RAW_CONSENT_SCRIPT" +
200
251
  "</body>\n" +
201
252
  "</html>\n";
202
253
 
@@ -254,10 +305,73 @@ function _assetSri(relUnderThemeAssets) {
254
305
  var entry = _assetManifest.assets[relUnderThemeAssets];
255
306
  return (entry && entry.integrity) || null;
256
307
  }
257
- function _islandScript(name) {
308
+ function _islandScript(name, opts) {
258
309
  var sri = _assetSri("js/" + name);
259
- return "<script src=\"" + _assetUrl("js/" + name) + "\"" +
260
- (sri ? " integrity=\"" + sri + "\"" : "") + " defer></script>";
310
+ // Optional `id` (so an island can find its own <script> at runtime) and
311
+ // `policy` (the active consent policy version, stamped for the consent
312
+ // island to compare against the flag cookie). Both are charset-safe by
313
+ // construction at the call site, so they go in without escaping.
314
+ var idAttr = (opts && opts.id) ? " id=\"" + opts.id + "\"" : "";
315
+ var policyAttr = (opts && opts.policy) ? " data-consent-policy=\"" + opts.policy + "\"" : "";
316
+ return "<script" + idAttr + " src=\"" + _assetUrl("js/" + name) + "\"" +
317
+ (sri ? " integrity=\"" + sri + "\"" : "") + " defer" + policyAttr + "></script>";
318
+ }
319
+
320
+ // Multi-currency display switcher — a GET form in the footer listing the
321
+ // operator's display currencies. Selecting one POSTs to /currency, which
322
+ // sets the sealed `shop_ccy` cookie and redirects back. The currently
323
+ // selected currency is pre-checked. Absent a presenter (feature not
324
+ // wired) the whole block is empty so older deploys render unchanged.
325
+ // `currencies` is the operator's allow-list; `selected` is the active
326
+ // display currency; `note` is the "charged in <base>" disclosure (present
327
+ // only when a non-base currency is active).
328
+ function _buildCurrencySwitcher(opts) {
329
+ if (!opts || !Array.isArray(opts.currencies) || opts.currencies.length < 2) return "";
330
+ var esc = function (s) { return b.template.escapeHtml(String(s)); };
331
+ var selected = opts.selected || opts.currencies[0];
332
+ var options = opts.currencies.map(function (c) {
333
+ var sel = c === selected ? " selected" : "";
334
+ return "<option value=\"" + esc(c) + "\"" + sel + ">" + esc(c) + "</option>";
335
+ }).join("");
336
+ // GET-action would expose the choice in the URL + cache key; a POST
337
+ // keeps it in the sealed cookie and bypasses the edge cache via the
338
+ // 303 redirect. `redirect_to` carries the current path so the visitor
339
+ // lands back where they were. The form auto-submits via the island
340
+ // script when present; without JS the explicit "Set" button submits.
341
+ var noteHtml = opts.note
342
+ ? "<p class=\"currency-switcher__note\">" + esc(opts.note) + "</p>"
343
+ : "";
344
+ return "<div class=\"currency-switcher\">\n" +
345
+ " <form class=\"currency-switcher__form\" method=\"post\" action=\"/currency\">\n" +
346
+ " <input type=\"hidden\" name=\"redirect_to\" value=\"" + esc(opts.redirect_to || "/") + "\">\n" +
347
+ " <label class=\"currency-switcher__label\" for=\"currency-select\">Display currency</label>\n" +
348
+ " <select id=\"currency-select\" name=\"currency\" class=\"currency-switcher__select\" data-currency-switcher>" + options + "</select>\n" +
349
+ " <button type=\"submit\" class=\"currency-switcher__btn\">Set</button>\n" +
350
+ " </form>\n" +
351
+ " " + noteHtml + "\n" +
352
+ " </div>";
353
+ }
354
+
355
+ // The per-request price formatter a renderer uses for every displayed
356
+ // price. When the route handler resolved a display-currency presenter and
357
+ // threaded it in as `opts.format_price`, that's used (it converts base →
358
+ // display currency + applies the display rounding rule). Otherwise the
359
+ // renderer falls back to `pricing.format` — identical to pre-feature
360
+ // behaviour, so an un-wired store or any renderer the route handler
361
+ // didn't thread keeps formatting in the catalog currency.
362
+ function _priceFormatter(opts) {
363
+ return (opts && typeof opts.format_price === "function") ? opts.format_price : pricing.format;
364
+ }
365
+
366
+ // Lift the currency-switcher fields off a renderer's opts so they reach
367
+ // `_wrap` unchanged. Keeps the per-renderer `_wrap` call sites short.
368
+ function _currencyWrapOpts(opts) {
369
+ return {
370
+ currency_options: opts.currency_options,
371
+ currency_selected: opts.currency_selected,
372
+ currency_note: opts.currency_note,
373
+ currency_redirect_to: opts.currency_redirect_to,
374
+ };
261
375
  }
262
376
 
263
377
  function _wrap(opts) {
@@ -278,6 +392,15 @@ function _wrap(opts) {
278
392
  var ogDescription = opts.og_description || "Open-source ecommerce framework built on blamejs. Server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.";
279
393
  var ogImage = opts.og_image || "/assets/brand/logo.png";
280
394
  var ogUrl = opts.og_url || "";
395
+ // Multi-currency display switcher — populated only when the operator
396
+ // configured >1 display currency (opts.currency_options). The block is
397
+ // empty otherwise, so a single-currency store renders unchanged.
398
+ var switcherHtml = _buildCurrencySwitcher({
399
+ currencies: opts.currency_options,
400
+ selected: opts.currency_selected,
401
+ note: opts.currency_note,
402
+ redirect_to: opts.currency_redirect_to,
403
+ });
281
404
  return _render(LAYOUT, {
282
405
  title: opts.title,
283
406
  shop_name: shopName,
@@ -291,7 +414,10 @@ function _wrap(opts) {
291
414
  og_image: ogImage,
292
415
  og_url: ogUrl,
293
416
  body: "RAW_BODY_PLACEHOLDER",
294
- }).replace("RAW_CSS_INTEGRITY", themeCssIntegrity).replace("RAW_BODY_PLACEHOLDER", opts.body);
417
+ }).replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
418
+ .replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
419
+ .replace("RAW_CURRENCY_SWITCHER", switcherHtml)
420
+ .replace("RAW_BODY_PLACEHOLDER", opts.body);
295
421
  // The body is RAW HTML (already rendered + escaped at the
296
422
  // per-fragment level). The placeholder swap is post-render so the
297
423
  // outer renderer's HTML-escape doesn't double-escape the inner
@@ -550,9 +676,10 @@ function renderHome(opts) {
550
676
  var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
551
677
  var title = opts.title || "Shop";
552
678
  var assetPrefix = opts.asset_prefix || "/assets/";
679
+ var fmt = _priceFormatter(opts);
553
680
  var products = opts.products.map(function (p) {
554
681
  var priceStr = p.starting_price_minor != null
555
- ? pricing.format(p.starting_price_minor, p.starting_price_currency || "USD")
682
+ ? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
556
683
  : "—";
557
684
  // Hero image — first media row attached to the product (the
558
685
  // route handler bundles it in via `p.hero_media`). Image-less
@@ -627,13 +754,13 @@ function renderHome(opts) {
627
754
  // designed surface even when no products are loaded yet —
628
755
  // visitors land on the storefront shell, not a tech demo.
629
756
  var body = hero + catalog;
630
- return _wrap({
757
+ return _wrap(Object.assign({
631
758
  title: title,
632
759
  shop_name: shopName,
633
760
  cart_count: cartCount,
634
761
  theme_css: opts.theme_css,
635
762
  body: body,
636
- });
763
+ }, _currencyWrapOpts(opts)));
637
764
  }
638
765
 
639
766
  // ---- search results -----------------------------------------------------
@@ -857,9 +984,10 @@ function renderSearch(opts) {
857
984
  .replace("RAW_CLEAR", clearLink);
858
985
  } else {
859
986
  var assetPrefix = opts.asset_prefix || "/assets/";
987
+ var fmt = _priceFormatter(opts);
860
988
  var cards = products.map(function (p) {
861
989
  var priceStr = p.starting_price_minor != null
862
- ? pricing.format(p.starting_price_minor, p.starting_price_currency || "USD")
990
+ ? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
863
991
  : "—";
864
992
  var imageUrl = p.hero_media ? assetPrefix + p.hero_media.r2_key : null;
865
993
  var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
@@ -875,14 +1003,14 @@ function renderSearch(opts) {
875
1003
  } else {
876
1004
  body = header + correctionHtml + chipsHtml + resultsInner;
877
1005
  }
878
- return _wrap({
1006
+ return _wrap(Object.assign({
879
1007
  title: "Search",
880
1008
  shop_name: opts.shop_name || "blamejs.shop",
881
1009
  cart_count: opts.cart_count,
882
1010
  search_q: opts.q,
883
1011
  theme_css: opts.theme_css,
884
1012
  body: body,
885
- });
1013
+ }, _currencyWrapOpts(opts)));
886
1014
  }
887
1015
 
888
1016
  // ---- product detail -----------------------------------------------------
@@ -2597,9 +2725,10 @@ function renderProduct(opts) {
2597
2725
  var shopName = opts.shop_name || "blamejs.shop";
2598
2726
  var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
2599
2727
  var description = opts.product.description || "";
2728
+ var fmt = _priceFormatter(opts);
2600
2729
  var rendered = variants.map(function (v) {
2601
2730
  var price = prices[v.id];
2602
- var priceStr = price ? pricing.format(price.amount_minor, price.currency) : "—";
2731
+ var priceStr = price ? fmt(price.amount_minor, price.currency) : "—";
2603
2732
  var vTitle = v.title || (Object.keys(v.options || {}).map(function (k) { return v.options[k]; }).join(" / ") || "Default");
2604
2733
  return { id: v.id, sku: v.sku, title: vTitle, price: priceStr };
2605
2734
  });
@@ -2705,7 +2834,7 @@ function renderProduct(opts) {
2705
2834
  });
2706
2835
  jsonLd = (jsonLd || "") + breadcrumbJsonLd;
2707
2836
 
2708
- return _wrap({
2837
+ return _wrap(Object.assign({
2709
2838
  title: opts.product.title,
2710
2839
  shop_name: shopName,
2711
2840
  cart_count: cartCount,
@@ -2715,7 +2844,7 @@ function renderProduct(opts) {
2715
2844
  og_description: description || ("Browse " + opts.product.title + " on " + shopName + "."),
2716
2845
  og_image: ogImage,
2717
2846
  body: body + jsonLd,
2718
- });
2847
+ }, _currencyWrapOpts(opts)));
2719
2848
  }
2720
2849
 
2721
2850
  // ---- cart --------------------------------------------------------------
@@ -3242,6 +3371,7 @@ function renderCart(opts) {
3242
3371
  // route handler bundles it in. Lines without an entry render with
3243
3372
  // a dashed-placeholder tile + the SKU as the fallback title.
3244
3373
  var lookup = opts.product_lookup || {};
3374
+ var fmt = _priceFormatter(opts);
3245
3375
  var rendered = lines.map(function (l) {
3246
3376
  var match = lookup[l.variant_id] || null;
3247
3377
  var prod = match && match.product;
@@ -3252,16 +3382,16 @@ function renderCart(opts) {
3252
3382
  id: l.id,
3253
3383
  sku: l.sku,
3254
3384
  qty: String(l.qty),
3255
- unit: pricing.format(l.unit_amount_minor, l.unit_currency),
3256
- total: pricing.format(l.qty * l.unit_amount_minor, l.unit_currency),
3385
+ unit: fmt(l.unit_amount_minor, l.unit_currency),
3386
+ total: fmt(l.qty * l.unit_amount_minor, l.unit_currency),
3257
3387
  product_title: (prod && prod.title) || l.sku,
3258
3388
  product_url: prod ? ("/products/" + prod.slug) : "#",
3259
3389
  image_url: imageUrl,
3260
3390
  image_alt: imageAlt,
3261
3391
  };
3262
3392
  });
3263
- var subtotal = pricing.format(totals.subtotal_minor, totals.currency);
3264
- var total = pricing.format(totals.grand_total_minor, totals.currency);
3393
+ var subtotal = fmt(totals.subtotal_minor, totals.currency);
3394
+ var total = fmt(totals.grand_total_minor, totals.currency);
3265
3395
  if (opts.theme) {
3266
3396
  return opts.theme.render("cart", {
3267
3397
  title: "Cart",
@@ -3307,13 +3437,13 @@ function renderCart(opts) {
3307
3437
  total: total,
3308
3438
  }).replace("RAW_LINES", rows);
3309
3439
  }
3310
- return _wrap({
3440
+ return _wrap(Object.assign({
3311
3441
  title: "Cart",
3312
3442
  shop_name: shopName,
3313
3443
  cart_count: lines.length,
3314
3444
  theme_css: opts.theme_css,
3315
3445
  body: body,
3316
- });
3446
+ }, _currencyWrapOpts(opts)));
3317
3447
  }
3318
3448
 
3319
3449
  // ---- admin landing (HTML — the rest of /admin/* is JSON API) ----------
@@ -3422,6 +3552,82 @@ function renderNewsletterError(opts) {
3422
3552
  });
3423
3553
  }
3424
3554
 
3555
+ // ---- cookie preference center ------------------------------------------
3556
+
3557
+ // The four toggleable categories + their operator-facing copy, mirroring
3558
+ // lib/cookie-consent.js's category taxonomy. Order is fixed so the page
3559
+ // reads the same every render.
3560
+ var CONSENT_CATEGORY_COPY = [
3561
+ { key: "functional", name: "Functional", desc: "Remember-me, locale, and currency-selector cookies that make the shop more convenient." },
3562
+ { key: "analytics", name: "Analytics", desc: "Aggregate, privacy-respecting usage measurement so we can see which pages help and which don't." },
3563
+ { key: "marketing", name: "Marketing", desc: "Advertising and retargeting pixels. Off unless you turn them on; a Do-Not-Track or Global Privacy Control signal keeps them off regardless." },
3564
+ { key: "preferences", name: "Preferences", desc: "Your saved UI tweaks — dark mode, list density, and similar non-essential settings." },
3565
+ ];
3566
+
3567
+ // The /cookies manage page. Pre-checks each toggle from the visitor's
3568
+ // stored decision (all off when there's no decision yet — default-deny).
3569
+ // `decision` is the shape `_readConsentDecision` returns, or null.
3570
+ // `notice` is an optional operator-fixed confirmation string (e.g. after
3571
+ // a save) — never a reflected visitor value.
3572
+ function renderCookiePreferences(opts) {
3573
+ opts = opts || {};
3574
+ var decision = opts.decision || null;
3575
+ var noticeHtml = "";
3576
+ if (opts.notice === "saved") {
3577
+ noticeHtml = "<p class=\"consent-page__notice\" role=\"status\">Your cookie preferences were saved. They take effect immediately and you can change them again any time on this page.</p>";
3578
+ } else if (opts.notice === "invalid") {
3579
+ noticeHtml = "<p class=\"consent-page__notice\" role=\"alert\">That submission wasn't understood, so nothing changed. Choose your categories below and save again.</p>";
3580
+ }
3581
+
3582
+ var cats = "";
3583
+ for (var i = 0; i < CONSENT_CATEGORY_COPY.length; i += 1) {
3584
+ var c = CONSENT_CATEGORY_COPY[i];
3585
+ var on = decision && decision.categories && decision.categories[c.key] === true;
3586
+ cats +=
3587
+ "<div class=\"consent-cat\">" +
3588
+ "<div class=\"consent-cat__head\">" +
3589
+ "<h2 class=\"consent-cat__name\">" + c.name + "</h2>" +
3590
+ "<label class=\"consent-toggle\">" +
3591
+ "<span class=\"skip-link\">Allow " + c.name + " cookies</span>" +
3592
+ "<input type=\"checkbox\" name=\"cat_" + c.key + "\" value=\"1\"" + (on ? " checked" : "") + ">" +
3593
+ "</label>" +
3594
+ "</div>" +
3595
+ "<p class=\"consent-cat__desc\">" + c.desc + "</p>" +
3596
+ "</div>";
3597
+ }
3598
+
3599
+ var body =
3600
+ "<section class=\"consent-page\">" +
3601
+ "<p class=\"eyebrow\">Privacy</p>" +
3602
+ "<h1>Cookie preferences</h1>" +
3603
+ "<p class=\"consent-page__lede\">Strictly-necessary cookies — your session, security tokens, and this choice itself — are always on because the shop can't run without them. Everything below is optional and off by default.</p>" +
3604
+ noticeHtml +
3605
+ "<form method=\"post\" action=\"/consent\">" +
3606
+ "<input type=\"hidden\" name=\"return_to\" value=\"/cookies\">" +
3607
+ "<div class=\"consent-cat\">" +
3608
+ "<div class=\"consent-cat__head\">" +
3609
+ "<h2 class=\"consent-cat__name\">Strictly necessary</h2>" +
3610
+ "<span class=\"consent-cat__always\">Always on</span>" +
3611
+ "</div>" +
3612
+ "<p class=\"consent-cat__desc\">Session, CSRF protection, and your cookie choice. These can't be switched off.</p>" +
3613
+ "</div>" +
3614
+ cats +
3615
+ "<div class=\"consent-page__actions\">" +
3616
+ "<button type=\"submit\" name=\"choice\" value=\"granular\" class=\"btn-primary\">Save preferences</button>" +
3617
+ "<button type=\"submit\" name=\"choice\" value=\"accept_all\" class=\"btn-ghost\">Accept all</button>" +
3618
+ "<button type=\"submit\" name=\"choice\" value=\"reject\" class=\"btn-ghost\">Reject non-essential</button>" +
3619
+ "</div>" +
3620
+ "</form>" +
3621
+ "</section>";
3622
+ return _wrap({
3623
+ title: "Cookie preferences",
3624
+ shop_name: opts.shop_name || "blamejs.shop",
3625
+ cart_count: opts.cart_count,
3626
+ theme_css: opts.theme_css,
3627
+ body: body,
3628
+ });
3629
+ }
3630
+
3425
3631
  // ---- 404 ---------------------------------------------------------------
3426
3632
 
3427
3633
  function renderNotFound(opts) {
@@ -3504,6 +3710,14 @@ var OAUTH_COOKIE_NAME = "shop_oauth";
3504
3710
  // Sealed so it can't be forged to mis-attribute a signup.
3505
3711
  var REFERRAL_COOKIE_NAME = "shop_ref";
3506
3712
 
3713
+ // Sealed cookie holding the visitor's chosen DISPLAY currency (ISO 4217).
3714
+ // Display-only: the cart / order / payment currency is unchanged — this
3715
+ // only selects which currency the price strings are rendered in. Sealed
3716
+ // so a tampered value can't smuggle a non-allow-listed code past the
3717
+ // reader; a garbage value reads as "unset" and the storefront renders in
3718
+ // the base currency. Path "/" so the choice persists across the catalog.
3719
+ var CURRENCY_COOKIE_NAME = "shop_ccy";
3720
+
3507
3721
  // Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
3508
3722
  var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
3509
3723
 
@@ -3589,6 +3803,166 @@ function _readReferralEnv(req) {
3589
3803
  try { return JSON.parse(raw); } catch (_e) { return null; }
3590
3804
  }
3591
3805
 
3806
+ // ---- cookie-consent cookies --------------------------------------------
3807
+ //
3808
+ // Two cookies carry a visitor's cookie-consent decision:
3809
+ //
3810
+ // * `shop_consent` — the authoritative decision, vault-sealed +
3811
+ // HttpOnly. Holds { categories, policy_version,
3812
+ // ts }. Read server-side to gate non-essential
3813
+ // cookies/trackers and to pre-check the manage
3814
+ // page's toggles. A tampered / truncated / stale
3815
+ // value fails the seal and reads as "no decision"
3816
+ // (the banner reshows) rather than throwing.
3817
+ // * `shop_consent_set` — a non-sealed, NON-HttpOnly "1" flag the consent
3818
+ // island reads to hide the banner on edge-cached
3819
+ // pages. Non-authoritative: it only drives banner
3820
+ // visibility, never the actual cookie/tracker gate.
3821
+ //
3822
+ // The four toggleable categories mirror lib/cookie-consent.js's
3823
+ // TOGGLEABLE_CATEGORIES; strictly-necessary is implicit-on and never
3824
+ // stored here.
3825
+ var CONSENT_COOKIE_NAME = "shop_consent";
3826
+ var CONSENT_FLAG_COOKIE_NAME = "shop_consent_set";
3827
+ var CONSENT_TOGGLEABLE = ["functional", "analytics", "marketing", "preferences"];
3828
+
3829
+ // The consent policy version visitors are being asked to consent to. A
3830
+ // stored decision (sealed cookie) captured under an older version is no
3831
+ // longer authoritative: the gate stops honoring it and the banner re-
3832
+ // prompts, so an operator who materially changes their cookie policy and
3833
+ // bumps the version re-collects consent rather than coasting on stale
3834
+ // opt-ins. Mirrors lib/cookie-consent.js's `policy_version` charset so the
3835
+ // value is safe to stamp into a cookie value and an HTML attribute; an
3836
+ // out-of-charset / over-length / missing value falls back to "v1" (the
3837
+ // initial version, matching the edge worker's stamped default). The active
3838
+ // value is read live from `deps.cookieConsent.policyVersion` per request in
3839
+ // the server gate (so a runtime bump takes effect immediately); the module
3840
+ // snapshot drives the page-stamped value set once at mount.
3841
+ var _activeConsentPolicy = "v1";
3842
+ function _sanitizeConsentPolicy(v) {
3843
+ return (typeof v === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(v)) ? v : "v1";
3844
+ }
3845
+
3846
+ // Persist a decision: the sealed authoritative cookie + the non-sealed
3847
+ // flag. Both expire in 180 days — ICO / CNIL guidance treats ~6 months as
3848
+ // the upper bound before a consent re-prompt; the cookie-consent ledger
3849
+ // keeps the durable audit record regardless of cookie lifetime.
3850
+ function _setConsentCookies(res, decision) {
3851
+ var T = b.constants.TIME;
3852
+ var exp = new Date(Date.now() + T.days(180));
3853
+ _cookieJar().writeSealed(res, CONSENT_COOKIE_NAME, JSON.stringify(decision), { expires: exp });
3854
+ // Not HttpOnly — the consent island must read it from document.cookie to
3855
+ // decide whether to hide the banner. Its value is the policy version the
3856
+ // decision was captured under (charset-constrained, no decision detail),
3857
+ // so the island can compare it to the active version stamped on its
3858
+ // <script> tag and re-prompt when the operator has bumped the policy. A
3859
+ // script-readable version string leaks nothing the server would act on.
3860
+ var flagVal = _sanitizeConsentPolicy(decision && decision.policy_version);
3861
+ _cookieJar().write(res, CONSENT_FLAG_COOKIE_NAME, flagVal, { expires: exp, httpOnly: false });
3862
+ }
3863
+
3864
+ // The visitor's stored decision, or null when none / malformed / stale.
3865
+ // Shape-validates the unsealed payload so a forged-but-unsealable or schema-
3866
+ // drifted value reads as "no decision". `activePolicy` is the policy version
3867
+ // currently in force (defaults to the module snapshot; the server routes
3868
+ // pass the live `deps.cookieConsent.policyVersion`): a decision whose
3869
+ // `policy_version` doesn't match it — including an unversioned legacy value
3870
+ // — reads as "no decision" so the operator's policy bump re-collects consent
3871
+ // rather than the gate honoring opt-ins captured under a superseded policy.
3872
+ function _readConsentDecision(req, activePolicy) {
3873
+ var raw = _cookieJar().readSealed(req, CONSENT_COOKIE_NAME);
3874
+ if (raw === null) return null;
3875
+ var parsed;
3876
+ try { parsed = JSON.parse(raw); } catch (_e) { return null; }
3877
+ if (!parsed || typeof parsed !== "object" || !parsed.categories || typeof parsed.categories !== "object") {
3878
+ return null;
3879
+ }
3880
+ var active = (arguments.length > 1) ? _sanitizeConsentPolicy(activePolicy) : _activeConsentPolicy;
3881
+ if (parsed.policy_version !== active) return null;
3882
+ var cats = {};
3883
+ for (var i = 0; i < CONSENT_TOGGLEABLE.length; i += 1) {
3884
+ cats[CONSENT_TOGGLEABLE[i]] = parsed.categories[CONSENT_TOGGLEABLE[i]] === true;
3885
+ }
3886
+ return {
3887
+ categories: cats,
3888
+ policy_version: parsed.policy_version,
3889
+ ts: Number.isFinite(parsed.ts) ? parsed.ts : null,
3890
+ };
3891
+ }
3892
+
3893
+ // Server-side gating hook. Returns true when `category` may emit a
3894
+ // cookie / tag / pixel byte for this request. Strictly-necessary is
3895
+ // always allowed; the four toggleable categories consult the stored
3896
+ // decision (default-deny when absent). DNT / Sec-GPC collapse analytics
3897
+ // + marketing to false regardless of the stored opt-in (browser-level
3898
+ // opt-out wins — same rule the cookie-consent ledger records and
3899
+ // honors). This is the single function a future analytics / marketing
3900
+ // island gates its render on:
3901
+ //
3902
+ // if (_consentAllows(req, "analytics", _liveConsentPolicy())) body += _islandScript("analytics.js");
3903
+ //
3904
+ // so a tracker is never injected into the document unless the visitor
3905
+ // opted that category in.
3906
+ function _consentAllows(req, category, activePolicy) {
3907
+ if (category === "strictly_necessary") return true;
3908
+ if (CONSENT_TOGGLEABLE.indexOf(category) === -1) return false;
3909
+ var decision = (arguments.length > 2)
3910
+ ? _readConsentDecision(req, activePolicy)
3911
+ : _readConsentDecision(req);
3912
+ if (!decision) return false;
3913
+ if ((category === "analytics" || category === "marketing") && _browserOptOut(req)) return false;
3914
+ return decision.categories[category] === true;
3915
+ }
3916
+
3917
+ // DNT (Do-Not-Track) header set to "1". Defensive read — missing /
3918
+ // garbage reads as "no signal".
3919
+ function _dntSignal(req) {
3920
+ var h = (req && req.headers) || {};
3921
+ return String(h["dnt"] || h["DNT"] || "") === "1";
3922
+ }
3923
+
3924
+ // Sec-GPC (Global Privacy Control) header set to "1".
3925
+ function _gpcSignal(req) {
3926
+ var h = (req && req.headers) || {};
3927
+ return String(h["sec-gpc"] || h["Sec-GPC"] || "") === "1";
3928
+ }
3929
+
3930
+ // Either DNT or GPC is an implicit deny for analytics + marketing.
3931
+ function _browserOptOut(req) {
3932
+ return _dntSignal(req) || _gpcSignal(req);
3933
+ }
3934
+
3935
+ // Coarse UA classifier for the consent ledger row — matches
3936
+ // lib/cookie-consent.js's UA_CLASS_VALUES. Defensive: unknown / missing
3937
+ // UA reads as "unknown".
3938
+ function _uaClass(req) {
3939
+ var ua = String((req && req.headers && (req.headers["user-agent"] || req.headers["User-Agent"])) || "").toLowerCase();
3940
+ if (!ua) return "unknown";
3941
+ if (/bot|crawl|spider|slurp|bingpreview|headless/.test(ua)) return "bot";
3942
+ if (/ipad|tablet|kindle|playbook|silk/.test(ua)) return "tablet";
3943
+ if (/mobi|iphone|android.*mobile|phone/.test(ua)) return "mobile";
3944
+ if (/windows|macintosh|linux|cros|x11/.test(ua)) return "desktop";
3945
+ return "unknown";
3946
+ }
3947
+
3948
+ var CCY_SHAPE_RE = /^[A-Z]{3}$/;
3949
+
3950
+ // The visitor's chosen display currency, or null. Sealed so the value
3951
+ // can't be forged; a missing / malformed / mis-shaped value reads as null
3952
+ // (→ base-currency display), never throws.
3953
+ function _readCurrencyCookie(req) {
3954
+ var raw = _cookieJar().readSealed(req, CURRENCY_COOKIE_NAME);
3955
+ if (raw === null || !CCY_SHAPE_RE.test(raw)) return null;
3956
+ return raw;
3957
+ }
3958
+ function _setCurrencyCookie(res, code) {
3959
+ var T = b.constants.TIME;
3960
+ _cookieJar().writeSealed(res, CURRENCY_COOKIE_NAME, code, { expires: new Date(Date.now() + T.days(180)) });
3961
+ }
3962
+ function _clearCurrencyCookie(res) {
3963
+ _cookieJar().clear(res, CURRENCY_COOKIE_NAME);
3964
+ }
3965
+
3592
3966
  // ---- account-page renderers --------------------------------------------
3593
3967
 
3594
3968
  var ACCOUNT_LOGIN_PAGE =
@@ -3808,6 +4182,20 @@ function mount(router, deps) {
3808
4182
  // Stripe via the primitive); without it the list stays read-only.
3809
4183
  var subscriptions = deps.subscriptions || null;
3810
4184
 
4185
+ // Active cookie-consent policy version. `_liveConsentPolicy()` reads it
4186
+ // from the consent primitive per request so a runtime `policyVersion`
4187
+ // bump takes effect on the gate immediately, and refreshes the module
4188
+ // snapshot the page-stamp reads — the snapshot exists because the
4189
+ // module-level renderers (`_wrap`) don't close over `deps`, so consulting
4190
+ // the live version (every consent route does) keeps the stamped value in
4191
+ // step without threading it through every render call. Set once here so
4192
+ // the value is correct from the first render after boot.
4193
+ function _liveConsentPolicy() {
4194
+ _activeConsentPolicy = _sanitizeConsentPolicy(deps.cookieConsent && deps.cookieConsent.policyVersion);
4195
+ return _activeConsentPolicy;
4196
+ }
4197
+ _liveConsentPolicy();
4198
+
3811
4199
  function _send(res, status, html) {
3812
4200
  res.status(status);
3813
4201
  res.setHeader && res.setHeader("content-type", "text/html; charset=utf-8");
@@ -3842,6 +4230,76 @@ function mount(router, deps) {
3842
4230
  return lines.length;
3843
4231
  }
3844
4232
 
4233
+ // ---- multi-currency display -------------------------------------------
4234
+ //
4235
+ // Opt-in: wired only when the operator supplies `deps.currencyDisplay`
4236
+ // (an FX-rate cache instance) AND `deps.currency_display_options` (the
4237
+ // allow-list of display currencies, base first). Absent either, every
4238
+ // render call gets an empty currency bundle and prices stay in the base
4239
+ // currency exactly as before. `deps.currencyRounding` is optional —
4240
+ // present, it applies the per-currency display-increment rule (CHF 0.05,
4241
+ // SEK 0.10); absent, conversion uses banker's rounding only.
4242
+ var _ccyBase = (deps.currency_base || "USD").toUpperCase();
4243
+ var _ccyOptions = Array.isArray(deps.currency_display_options)
4244
+ ? deps.currency_display_options.map(function (c) { return String(c).toUpperCase(); })
4245
+ : null;
4246
+ // The feature mounts (the /currency route + the per-request bundle)
4247
+ // whenever an FX instance is wired AND the boot-time allow-list names
4248
+ // >1 currency. The per-request `currency_config` resolver can narrow /
4249
+ // widen the live allow-list, but mounting is decided once at boot from
4250
+ // the seed so a single-currency store never registers the route.
4251
+ var _ccyEnabled = !!(deps.currencyDisplay && _ccyOptions && _ccyOptions.length > 1);
4252
+ var _ccyConfig = typeof deps.currency_config === "function" ? deps.currency_config : null;
4253
+
4254
+ // Per-request currency bundle merged into every render call's opts:
4255
+ // format_price — sync base→display formatter (or pricing.format)
4256
+ // currency_options — the switcher's option list
4257
+ // currency_selected — the visitor's active display currency
4258
+ // currency_note — "charged in <base>" disclosure (when converting)
4259
+ // currency_redirect_to — the path the switcher returns the visitor to
4260
+ // A read / conversion failure degrades to the base bundle (prices in the
4261
+ // base currency) — a broken FX backend never breaks a priced page.
4262
+ async function _currencyForReq(req) {
4263
+ if (!_ccyEnabled) return {};
4264
+ var path = String((req && req.url) || "/").split("?")[0] || "/";
4265
+ // Resolve the live base + allow-list (config override, else the boot
4266
+ // seed). A resolver failure falls back to the seed.
4267
+ var base = _ccyBase;
4268
+ var options = _ccyOptions;
4269
+ if (_ccyConfig) {
4270
+ try {
4271
+ var cfg = await _ccyConfig();
4272
+ if (cfg && cfg.base) base = cfg.base;
4273
+ if (cfg && Array.isArray(cfg.options) && cfg.options.length) options = cfg.options;
4274
+ } catch (_e) { /* keep the boot seed */ }
4275
+ }
4276
+ var bundle = {
4277
+ currency_options: options,
4278
+ currency_selected: base,
4279
+ currency_redirect_to: path,
4280
+ };
4281
+ var chosen = _readCurrencyCookie(req);
4282
+ try {
4283
+ var presenter = await currencyDisplayModule.loadPresenter({
4284
+ fx: deps.currencyDisplay,
4285
+ rounding: deps.currencyRounding || null,
4286
+ baseCurrency: base,
4287
+ displayCurrency: chosen,
4288
+ });
4289
+ // The presenter only activates when the chosen currency is in the
4290
+ // live allow-list AND has a usable rate. A cookie naming a currency
4291
+ // the operator since removed from the list resolves to base.
4292
+ if (presenter && presenter.active && options.indexOf(presenter.displayCurrency) !== -1) {
4293
+ bundle.format_price = presenter.format;
4294
+ bundle.currency_selected = presenter.displayCurrency;
4295
+ if (presenter.note) bundle.currency_note = presenter.note;
4296
+ }
4297
+ } catch (_e) {
4298
+ // FX / rounding backend unavailable — fall back to base display.
4299
+ }
4300
+ return bundle;
4301
+ }
4302
+
3845
4303
  // Absolute shareable referral link for a code. Prefers the operator's
3846
4304
  // configured origin (deps.shop_origin / SHOP_ORIGIN) so the link is
3847
4305
  // stable across the edge/container split; falls back to the request's
@@ -3965,8 +4423,9 @@ function mount(router, deps) {
3965
4423
  // reachable via multiple variant SKUs. Drop-silent on a primitive
3966
4424
  // error so a bundles read failure can't 500 the PDP — the rail just
3967
4425
  // doesn't render (mirrors the reviews/Q&A degrade-to-empty stance).
3968
- async function _resolveBundleOffers(variantSkus, currency) {
4426
+ async function _resolveBundleOffers(variantSkus, currency, fmtPrice) {
3969
4427
  if (!deps.bundles) return [];
4428
+ var fmt = typeof fmtPrice === "function" ? fmtPrice : pricing.format;
3970
4429
  var seen = Object.create(null);
3971
4430
  var offers = [];
3972
4431
  try {
@@ -4011,9 +4470,9 @@ function mount(router, deps) {
4011
4470
  bundle_sku: bundle.bundle_sku,
4012
4471
  title: bundle.title,
4013
4472
  components: componentsOut,
4014
- list_total_str: pricing.format(listMinor, cur),
4015
- amount_str: pricing.format(amountMinor, cur),
4016
- discount_str: discountMinor > 0 ? pricing.format(discountMinor, cur) : null,
4473
+ list_total_str: fmt(listMinor, cur),
4474
+ amount_str: fmt(amountMinor, cur),
4475
+ discount_str: discountMinor > 0 ? fmt(discountMinor, cur) : null,
4017
4476
  available: available,
4018
4477
  unavailable_reason: available
4019
4478
  ? null
@@ -4037,8 +4496,9 @@ function mount(router, deps) {
4037
4496
  // that quantity. Rows read as ascending ranges ("1–4", "5–9",
4038
4497
  // "10+"). Returns [] when no active sku-scoped tier set exists.
4039
4498
  // Drop-silent on a read failure so the PDP still renders.
4040
- async function _resolveQtyBreaks(sku, unitMinor, currency) {
4499
+ async function _resolveQtyBreaks(sku, unitMinor, currency, fmtPrice) {
4041
4500
  if (!deps.quantityDiscounts || unitMinor == null) return [];
4501
+ var fmt = typeof fmtPrice === "function" ? fmtPrice : pricing.format;
4042
4502
  var breakdown;
4043
4503
  try {
4044
4504
  breakdown = await deps.quantityDiscounts.tierBreakdown({
@@ -4061,7 +4521,7 @@ function mount(router, deps) {
4061
4521
  if (sorted[0].min_quantity > 1) {
4062
4522
  out.push({
4063
4523
  label: "1–" + (sorted[0].min_quantity - 1),
4064
- unit_str: pricing.format(unitMinor, currency),
4524
+ unit_str: fmt(unitMinor, currency),
4065
4525
  });
4066
4526
  }
4067
4527
  for (var i = 0; i < sorted.length; i += 1) {
@@ -4069,7 +4529,7 @@ function mount(router, deps) {
4069
4529
  var next = sorted[i + 1];
4070
4530
  var label = next ? (t.min_quantity + "–" + (next.min_quantity - 1)) : (t.min_quantity + "+");
4071
4531
  var unit = t.sample_discounted_unit_minor != null ? t.sample_discounted_unit_minor : unitMinor;
4072
- out.push({ label: label, unit_str: pricing.format(unit, currency) });
4532
+ out.push({ label: label, unit_str: fmt(unit, currency) });
4073
4533
  }
4074
4534
  return out;
4075
4535
  }
@@ -4109,7 +4569,35 @@ function mount(router, deps) {
4109
4569
  return out;
4110
4570
  }
4111
4571
 
4112
- router.get("/", async function (_req, res) {
4572
+ // POST /currency set (or clear) the visitor's display-currency choice.
4573
+ // Registered only when multi-currency display is wired. Display-only:
4574
+ // this NEVER touches the cart / order / payment currency — it sets the
4575
+ // sealed `shop_ccy` cookie that selects which currency price strings are
4576
+ // rendered in, then 303-redirects back so the new choice takes effect on
4577
+ // the next render. A choice outside the operator's allow-list clears the
4578
+ // cookie (→ base-currency display) rather than persisting a code the
4579
+ // switcher would never offer.
4580
+ if (_ccyEnabled) {
4581
+ router.post("/currency", function (req, res) {
4582
+ var body = req.body || {};
4583
+ var chosen = typeof body.currency === "string" ? body.currency.toUpperCase() : "";
4584
+ // `redirect_to` is constrained to a same-origin path (leading single
4585
+ // slash, no scheme / host / protocol-relative `//`) so the switcher
4586
+ // can't be turned into an open redirect.
4587
+ var rawTo = typeof body.redirect_to === "string" ? body.redirect_to : "/";
4588
+ var to = (/^\/(?!\/)/.test(rawTo)) ? rawTo : "/";
4589
+ if (chosen === _ccyBase || _ccyOptions.indexOf(chosen) === -1) {
4590
+ _clearCurrencyCookie(res);
4591
+ } else {
4592
+ _setCurrencyCookie(res, chosen);
4593
+ }
4594
+ res.status(303);
4595
+ res.setHeader && res.setHeader("location", to);
4596
+ return res.end ? res.end() : res.send("");
4597
+ });
4598
+ }
4599
+
4600
+ router.get("/", async function (req, res) {
4113
4601
  var page = await deps.catalog.products.list({ status: "active", limit: 24 });
4114
4602
  // Best-effort "starting price" + "hero media" lookup. Each
4115
4603
  // product on the home grid carries its first variant's USD
@@ -4134,7 +4622,8 @@ function mount(router, deps) {
4134
4622
  hero_media: heroMedia,
4135
4623
  }));
4136
4624
  }
4137
- var html = renderHome({ products: products, shop_name: shopName, theme: theme });
4625
+ var ccy = await _currencyForReq(req);
4626
+ var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, ccy));
4138
4627
  _send(res, 200, html);
4139
4628
  });
4140
4629
 
@@ -4264,7 +4753,8 @@ function mount(router, deps) {
4264
4753
  cartCount = lines.length;
4265
4754
  }
4266
4755
  }
4267
- _send(res, 200, renderSearch({
4756
+ var ccy = await _currencyForReq(req);
4757
+ _send(res, 200, renderSearch(Object.assign({
4268
4758
  q: q,
4269
4759
  products: products,
4270
4760
  facets: facetGroups,
@@ -4272,7 +4762,7 @@ function mount(router, deps) {
4272
4762
  corrected_query: correctedQ,
4273
4763
  shop_name: shopName,
4274
4764
  cart_count: cartCount,
4275
- }));
4765
+ }, ccy)));
4276
4766
  });
4277
4767
 
4278
4768
  router.get("/products/:slug", async function (req, res) {
@@ -4365,14 +4855,16 @@ function mount(router, deps) {
4365
4855
  // so the buy path renders regardless. The quantity-break table is
4366
4856
  // built against the first variant's list price — the band a shopper
4367
4857
  // sees on the PDP matches what the cart charges at that quantity.
4858
+ var ccy = await _currencyForReq(req);
4859
+ var ccyFmt = ccy.format_price || null;
4368
4860
  var variantSkus = variants.map(function (v) { return v.sku; });
4369
- var bundleOffers = await _resolveBundleOffers(variantSkus, "USD");
4861
+ var bundleOffers = await _resolveBundleOffers(variantSkus, "USD", ccyFmt);
4370
4862
  var firstVariant = variants[0] || null;
4371
4863
  var firstPrice = firstVariant ? prices[firstVariant.id] : null;
4372
4864
  var qtyBreaks = firstVariant && firstPrice
4373
- ? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency)
4865
+ ? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency, ccyFmt)
4374
4866
  : [];
4375
- var html = renderProduct({
4867
+ var html = renderProduct(Object.assign({
4376
4868
  product: product,
4377
4869
  variants: variants,
4378
4870
  prices: prices,
@@ -4388,7 +4880,7 @@ function mount(router, deps) {
4388
4880
  shop_name: shopName,
4389
4881
  cart_count: cartCount,
4390
4882
  theme: theme,
4391
- });
4883
+ }, ccy));
4392
4884
  _send(res, 200, html);
4393
4885
  });
4394
4886
 
@@ -4703,19 +5195,20 @@ function mount(router, deps) {
4703
5195
  }
4704
5196
 
4705
5197
  router.get("/cart", async function (req, res) {
5198
+ var ccy = await _currencyForReq(req);
4706
5199
  var sid = _readSidCookie(req);
4707
5200
  if (!sid) {
4708
- return _send(res, 200, renderCart({
5201
+ return _send(res, 200, renderCart(Object.assign({
4709
5202
  lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
4710
5203
  shop_name: shopName, theme: theme,
4711
- }));
5204
+ }, ccy)));
4712
5205
  }
4713
5206
  var c = await deps.cart.bySession(sid);
4714
5207
  if (!c) {
4715
- return _send(res, 200, renderCart({
5208
+ return _send(res, 200, renderCart(Object.assign({
4716
5209
  lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
4717
5210
  shop_name: shopName, theme: theme,
4718
- }));
5211
+ }, ccy)));
4719
5212
  }
4720
5213
  var rawLines = await deps.cart.listLines(c.id);
4721
5214
  // Reapply the active quantity-break for each line at its current
@@ -4742,14 +5235,14 @@ function mount(router, deps) {
4742
5235
  hero_media: media.length ? media[0] : null,
4743
5236
  };
4744
5237
  }
4745
- _send(res, 200, renderCart({
5238
+ _send(res, 200, renderCart(Object.assign({
4746
5239
  lines: lines,
4747
5240
  totals: totals,
4748
5241
  product_lookup: productLookup,
4749
5242
  can_save: !!(deps.saveForLater && deps.customers),
4750
5243
  shop_name: shopName,
4751
5244
  theme: theme,
4752
- }));
5245
+ }, ccy)));
4753
5246
  });
4754
5247
 
4755
5248
  // ---- checkout flow -------------------------------------------------
@@ -6966,6 +7459,144 @@ function mount(router, deps) {
6966
7459
  });
6967
7460
  }
6968
7461
 
7462
+ // ---- cookie consent -----------------------------------------------------
7463
+ //
7464
+ // GDPR (EU 2016/679 art. 6 + 7) + ePrivacy (2002/58/EC art. 5(3)) opt-in
7465
+ // for non-strictly-necessary cookies. The banner ships in the chrome of
7466
+ // every page (see CONSENT_BANNER in the layout); these two routes back
7467
+ // it. No auth, no client JS required — the banner form and the manage
7468
+ // page both work server-rendered for a guest with scripting disabled.
7469
+ //
7470
+ // The decision is written to a sealed first-party cookie (the gate) AND
7471
+ // recorded in the cookie-consent ledger (the audit trail) when the
7472
+ // primitive is wired. CSRF / origin / fetch-metadata defenses are the
7473
+ // framework middleware already on every POST — no per-route re-check.
7474
+
7475
+ // Same-origin path guard for `return_to`. Accepts a single leading slash
7476
+ // followed by a non-slash (so `//evil.example` and absolute URLs are
7477
+ // refused), capping length defensively. Anything else collapses to the
7478
+ // safe default the caller passes.
7479
+ function _consentReturnTo(raw, fallback) {
7480
+ if (typeof raw === "string" && raw.length <= 512 && /^\/[^/]/.test(raw)) return raw;
7481
+ return fallback;
7482
+ }
7483
+
7484
+ // Translate a posted choice + per-category checkboxes into the four
7485
+ // toggleable booleans. `accept_all` turns every category on; `reject`
7486
+ // turns every category off; `granular` reads each `cat_<key>` checkbox
7487
+ // (present + "1" == on, absent == off — the unchecked-by-default
7488
+ // ePrivacy shape). Returns null for an unknown choice so the caller can
7489
+ // 400 a malformed submit.
7490
+ function _consentCategoriesFromBody(body) {
7491
+ var choice = body && body.choice;
7492
+ var cats = { functional: false, analytics: false, marketing: false, preferences: false };
7493
+ if (choice === "accept_all") {
7494
+ cats.functional = cats.analytics = cats.marketing = cats.preferences = true;
7495
+ return cats;
7496
+ }
7497
+ if (choice === "reject") {
7498
+ return cats;
7499
+ }
7500
+ if (choice === "granular") {
7501
+ for (var i = 0; i < CONSENT_TOGGLEABLE.length; i += 1) {
7502
+ var k = CONSENT_TOGGLEABLE[i];
7503
+ cats[k] = body["cat_" + k] === "1";
7504
+ }
7505
+ return cats;
7506
+ }
7507
+ return null;
7508
+ }
7509
+
7510
+ // Ensure a session id exists so the consent decision is keyed to a
7511
+ // stable (hashed-at-the-ledger) session. Reuses the existing shop_sid
7512
+ // cookie when present; mints one otherwise — consent is a guest-
7513
+ // reachable flow, so it can't assume a cart already created the sid.
7514
+ function _ensureSid(req, res) {
7515
+ var sid = _readSidCookie(req);
7516
+ if (!sid) {
7517
+ sid = b.uuid.v7();
7518
+ _setSidCookie(res, sid);
7519
+ }
7520
+ return sid;
7521
+ }
7522
+
7523
+ // POST /consent — set the decision. Drives both the banner (Accept all /
7524
+ // Reject) and the manage page's granular save. Writes the sealed gate
7525
+ // cookie + the non-sealed flag cookie, records the decision in the
7526
+ // cookie-consent ledger (best-effort — a ledger hiccup never blocks the
7527
+ // decision from taking effect), then 303s back to a safe same-origin
7528
+ // return_to.
7529
+ router.post("/consent", async function (req, res) {
7530
+ var body = req.body || {};
7531
+ var cats = _consentCategoriesFromBody(body);
7532
+ var fromManage = (typeof body.return_to === "string" && body.return_to.indexOf("/cookies") === 0);
7533
+
7534
+ // Malformed (unknown / missing choice) → 400. Re-render the manage
7535
+ // page so the visitor lands somewhere actionable rather than on a
7536
+ // bare error string.
7537
+ if (!cats) {
7538
+ var cartCount400 = 0;
7539
+ try { cartCount400 = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
7540
+ return _send(res, 400, renderCookiePreferences({
7541
+ shop_name: shopName,
7542
+ cart_count: cartCount400,
7543
+ theme: theme,
7544
+ decision: _readConsentDecision(req, _liveConsentPolicy()),
7545
+ notice: "invalid",
7546
+ }));
7547
+ }
7548
+
7549
+ var sid = _ensureSid(req, res);
7550
+ var decision = {
7551
+ categories: cats,
7552
+ policy_version: _liveConsentPolicy(),
7553
+ ts: Date.now(),
7554
+ };
7555
+ _setConsentCookies(res, decision);
7556
+
7557
+ // Durable audit trail. The cookie-consent ledger hashes the session id
7558
+ // itself; we pass the raw sid + the browser DNT / GPC signals + a
7559
+ // coarse UA class so the operator can prove to a supervisory authority
7560
+ // both what was chosen and that a browser-level opt-out was honored.
7561
+ if (deps.cookieConsent) {
7562
+ try {
7563
+ await deps.cookieConsent.recordConsent({
7564
+ session_id: sid,
7565
+ categories: cats,
7566
+ ua_class: _uaClass(req),
7567
+ dnt: _dntSignal(req),
7568
+ gpc: _gpcSignal(req),
7569
+ });
7570
+ } catch (_e) { /* drop-silent — the gate cookie is authoritative; the ledger write is the audit trail and must not block the decision */ }
7571
+ }
7572
+
7573
+ var dest = _consentReturnTo(body.return_to, "/");
7574
+ res.status(303);
7575
+ res.setHeader && res.setHeader("location", fromManage ? "/cookies?saved=1" : dest);
7576
+ return res.end ? res.end() : res.send("");
7577
+ });
7578
+
7579
+ // GET /cookies — the preference center. Linked from the footer's
7580
+ // "Manage cookies" and the banner's "Manage preferences". Pre-checks
7581
+ // each toggle from the stored decision (all off when none exists). The
7582
+ // `?saved=1` query renders a confirmation notice after a save 303.
7583
+ router.get("/cookies", async function (req, res) {
7584
+ var cartCount = 0;
7585
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
7586
+ var saved = false;
7587
+ try {
7588
+ var u = new URL(req.url, "http://localhost");
7589
+ saved = u.searchParams.get("saved") === "1";
7590
+ } catch (_e) { saved = false; }
7591
+ return _send(res, 200, renderCookiePreferences({
7592
+ shop_name: shopName,
7593
+ cart_count: cartCount,
7594
+ theme: theme,
7595
+ decision: _readConsentDecision(req, _liveConsentPolicy()),
7596
+ notice: saved ? "saved" : null,
7597
+ }));
7598
+ });
7599
+
6969
7600
  // robots.txt — minimal crawl policy. Allow everything except
6970
7601
  // the admin API + cart + account + checkout / pay / orders (these
6971
7602
  // are session-scoped or operator-only, no crawl value), and
@@ -7083,6 +7714,7 @@ module.exports = {
7083
7714
  renderAccountRegister: renderAccountRegister,
7084
7715
  renderAccount: renderAccount,
7085
7716
  renderAccountSubscriptions: renderAccountSubscriptions,
7717
+ renderCookiePreferences: renderCookiePreferences,
7086
7718
  renderNotFound: renderNotFound,
7087
7719
  // Layout exposed so operators forking the framework can override.
7088
7720
  _wrap: _wrap,