@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/CHANGELOG.md +4 -0
- package/README.md +2 -0
- package/lib/asset-manifest.json +7 -3
- package/lib/currency-display.js +201 -4
- package/lib/storefront.js +672 -40
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +3 -0
- package/lib/vendor/blamejs/api-snapshot.json +93 -2
- package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
- package/lib/vendor/blamejs/index.js +4 -0
- package/lib/vendor/blamejs/lib/ai-aedt-bias-audit.js +180 -0
- package/lib/vendor/blamejs/lib/crdt.js +453 -0
- package/lib/vendor/blamejs/lib/crypto-xwing.js +213 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.3.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.4.json +23 -0
- package/lib/vendor/blamejs/release-notes/v0.13.5.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-aedt-bias-audit.test.js +107 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crdt.test.js +158 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-xwing.test.js +120 -0
- package/package.json +2 -2
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>© {{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
|
-
|
|
260
|
-
|
|
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)
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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 ?
|
|
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:
|
|
3256
|
-
total:
|
|
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 =
|
|
3264
|
-
var total =
|
|
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:
|
|
4015
|
-
amount_str:
|
|
4016
|
-
discount_str: discountMinor > 0 ?
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|