@blamejs/blamejs-shop 0.1.31 → 0.1.33

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -0
  3. package/lib/admin.js +18 -10
  4. package/lib/asset-manifest.json +17 -5
  5. package/lib/bundles.js +28 -0
  6. package/lib/checkout.js +48 -2
  7. package/lib/storefront.js +792 -45
  8. package/lib/vendor/MANIFEST.json +2 -2
  9. package/lib/vendor/blamejs/CHANGELOG.md +8 -0
  10. package/lib/vendor/blamejs/README.md +3 -1
  11. package/lib/vendor/blamejs/api-snapshot.json +99 -2
  12. package/lib/vendor/blamejs/index.js +3 -0
  13. package/lib/vendor/blamejs/lib/crypto-oprf.js +110 -0
  14. package/lib/vendor/blamejs/lib/network-tsig.js +404 -0
  15. package/lib/vendor/blamejs/lib/network.js +1 -0
  16. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +44 -9
  17. package/lib/vendor/blamejs/lib/vendor/noble-curves.cjs +19 -0
  18. package/lib/vendor/blamejs/lib/worm.js +246 -0
  19. package/lib/vendor/blamejs/package.json +1 -1
  20. package/lib/vendor/blamejs/release-notes/v0.12.x.json +1844 -0
  21. package/lib/vendor/blamejs/release-notes/v0.13.0.json +22 -0
  22. package/lib/vendor/blamejs/release-notes/v0.13.1.json +18 -0
  23. package/lib/vendor/blamejs/scripts/vendor-update.sh +11 -1
  24. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +3 -1
  25. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-oprf.test.js +101 -0
  26. package/lib/vendor/blamejs/test/layer-0-primitives/network-tsig.test.js +149 -0
  27. package/lib/vendor/blamejs/test/layer-0-primitives/sandbox.test.js +2 -2
  28. package/lib/vendor/blamejs/test/layer-0-primitives/testing.test.js +3 -3
  29. package/lib/vendor/blamejs/test/layer-0-primitives/worm.test.js +126 -0
  30. package/package.json +2 -2
  31. package/lib/vendor/blamejs/release-notes/v0.12.0.json +0 -64
  32. package/lib/vendor/blamejs/release-notes/v0.12.1.json +0 -32
  33. package/lib/vendor/blamejs/release-notes/v0.12.10.json +0 -65
  34. package/lib/vendor/blamejs/release-notes/v0.12.11.json +0 -39
  35. package/lib/vendor/blamejs/release-notes/v0.12.12.json +0 -48
  36. package/lib/vendor/blamejs/release-notes/v0.12.13.json +0 -31
  37. package/lib/vendor/blamejs/release-notes/v0.12.14.json +0 -18
  38. package/lib/vendor/blamejs/release-notes/v0.12.15.json +0 -27
  39. package/lib/vendor/blamejs/release-notes/v0.12.16.json +0 -18
  40. package/lib/vendor/blamejs/release-notes/v0.12.17.json +0 -22
  41. package/lib/vendor/blamejs/release-notes/v0.12.18.json +0 -22
  42. package/lib/vendor/blamejs/release-notes/v0.12.19.json +0 -22
  43. package/lib/vendor/blamejs/release-notes/v0.12.2.json +0 -45
  44. package/lib/vendor/blamejs/release-notes/v0.12.20.json +0 -18
  45. package/lib/vendor/blamejs/release-notes/v0.12.21.json +0 -27
  46. package/lib/vendor/blamejs/release-notes/v0.12.22.json +0 -18
  47. package/lib/vendor/blamejs/release-notes/v0.12.23.json +0 -18
  48. package/lib/vendor/blamejs/release-notes/v0.12.24.json +0 -18
  49. package/lib/vendor/blamejs/release-notes/v0.12.25.json +0 -18
  50. package/lib/vendor/blamejs/release-notes/v0.12.26.json +0 -30
  51. package/lib/vendor/blamejs/release-notes/v0.12.27.json +0 -26
  52. package/lib/vendor/blamejs/release-notes/v0.12.28.json +0 -26
  53. package/lib/vendor/blamejs/release-notes/v0.12.29.json +0 -31
  54. package/lib/vendor/blamejs/release-notes/v0.12.3.json +0 -23
  55. package/lib/vendor/blamejs/release-notes/v0.12.30.json +0 -18
  56. package/lib/vendor/blamejs/release-notes/v0.12.31.json +0 -18
  57. package/lib/vendor/blamejs/release-notes/v0.12.32.json +0 -27
  58. package/lib/vendor/blamejs/release-notes/v0.12.33.json +0 -31
  59. package/lib/vendor/blamejs/release-notes/v0.12.34.json +0 -18
  60. package/lib/vendor/blamejs/release-notes/v0.12.35.json +0 -22
  61. package/lib/vendor/blamejs/release-notes/v0.12.36.json +0 -18
  62. package/lib/vendor/blamejs/release-notes/v0.12.37.json +0 -27
  63. package/lib/vendor/blamejs/release-notes/v0.12.38.json +0 -18
  64. package/lib/vendor/blamejs/release-notes/v0.12.39.json +0 -18
  65. package/lib/vendor/blamejs/release-notes/v0.12.4.json +0 -19
  66. package/lib/vendor/blamejs/release-notes/v0.12.40.json +0 -18
  67. package/lib/vendor/blamejs/release-notes/v0.12.41.json +0 -18
  68. package/lib/vendor/blamejs/release-notes/v0.12.42.json +0 -18
  69. package/lib/vendor/blamejs/release-notes/v0.12.43.json +0 -18
  70. package/lib/vendor/blamejs/release-notes/v0.12.44.json +0 -18
  71. package/lib/vendor/blamejs/release-notes/v0.12.45.json +0 -18
  72. package/lib/vendor/blamejs/release-notes/v0.12.46.json +0 -18
  73. package/lib/vendor/blamejs/release-notes/v0.12.47.json +0 -18
  74. package/lib/vendor/blamejs/release-notes/v0.12.48.json +0 -22
  75. package/lib/vendor/blamejs/release-notes/v0.12.49.json +0 -31
  76. package/lib/vendor/blamejs/release-notes/v0.12.5.json +0 -40
  77. package/lib/vendor/blamejs/release-notes/v0.12.50.json +0 -18
  78. package/lib/vendor/blamejs/release-notes/v0.12.51.json +0 -18
  79. package/lib/vendor/blamejs/release-notes/v0.12.52.json +0 -18
  80. package/lib/vendor/blamejs/release-notes/v0.12.53.json +0 -18
  81. package/lib/vendor/blamejs/release-notes/v0.12.54.json +0 -18
  82. package/lib/vendor/blamejs/release-notes/v0.12.55.json +0 -18
  83. package/lib/vendor/blamejs/release-notes/v0.12.56.json +0 -27
  84. package/lib/vendor/blamejs/release-notes/v0.12.57.json +0 -18
  85. package/lib/vendor/blamejs/release-notes/v0.12.58.json +0 -22
  86. package/lib/vendor/blamejs/release-notes/v0.12.6.json +0 -45
  87. package/lib/vendor/blamejs/release-notes/v0.12.60.json +0 -18
  88. package/lib/vendor/blamejs/release-notes/v0.12.61.json +0 -18
  89. package/lib/vendor/blamejs/release-notes/v0.12.62.json +0 -18
  90. package/lib/vendor/blamejs/release-notes/v0.12.63.json +0 -27
  91. package/lib/vendor/blamejs/release-notes/v0.12.64.json +0 -18
  92. package/lib/vendor/blamejs/release-notes/v0.12.65.json +0 -27
  93. package/lib/vendor/blamejs/release-notes/v0.12.66.json +0 -18
  94. package/lib/vendor/blamejs/release-notes/v0.12.68.json +0 -27
  95. package/lib/vendor/blamejs/release-notes/v0.12.69.json +0 -27
  96. package/lib/vendor/blamejs/release-notes/v0.12.7.json +0 -86
  97. package/lib/vendor/blamejs/release-notes/v0.12.8.json +0 -81
  98. package/lib/vendor/blamejs/release-notes/v0.12.9.json +0 -61
package/lib/storefront.js CHANGED
@@ -205,21 +205,31 @@ var LAYOUT =
205
205
  // each render call to override; absent that, every storefront page
206
206
  // references the shipped default theme — so a fresh install renders
207
207
  // styled out of the box without any wiring.
208
- // Asset integrity + version manifest — sha384 digests + the release
209
- // version, baked at build time (scripts/generate-asset-manifest.js) and
210
- // committed. Read here instead of hashing the files at render time so the
211
- // integrity attribute and the cache-buster are present in every runtime,
212
- // including the container image and the edge Worker, neither of which
213
- // ships the theme asset files to hash live. See lib/asset-manifest.json.
208
+ // Asset integrity + fingerprint + version manifest — sha384 digests and
209
+ // content-fingerprinted paths, baked at build time
210
+ // (scripts/generate-asset-manifest.js) and committed. Read here instead of
211
+ // hashing the files at render time so the integrity attribute and the
212
+ // fingerprinted URL are present in every runtime, including the container
213
+ // image and the edge Worker, neither of which ships the theme asset files
214
+ // to hash live. See lib/asset-manifest.json.
214
215
  var _assetManifest = require("./asset-manifest.json");
215
216
 
216
- // The default theme stylesheet ships from R2 at this path. The
217
- // `?v=` query is a build-time cache-buster operator uploads to R2
218
- // rewrite the bytes at the same path, and without the version
219
- // param browsers happily serve the previous cached CSS for the
220
- // five-minute default TTL. The manifest version bumps each release,
221
- // forcing every active session to re-fetch.
222
- var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" + _assetManifest.version;
217
+ // Content-fingerprinted asset URL `/assets/themes/default/<fingerprinted>`,
218
+ // where the fingerprint embeds a hash of the asset bytes (`main.<hash>.css`).
219
+ // The hash IS the cache-buster, so no `?v=` query is appended: each URL maps
220
+ // one-to-one onto a byte-content. This makes the Worker/R2 deploy order
221
+ // irrelevant a not-yet-synced asset 404s instead of poisoning SRI, and
222
+ // pages already in flight keep loading the old fingerprinted object that
223
+ // still exists in R2. An asset missing from the manifest (a custom operator
224
+ // theme whose bytes aren't ours to hash) falls back to its plain path.
225
+ function _assetUrl(relUnderThemeAssets) {
226
+ var entry = _assetManifest.assets[relUnderThemeAssets];
227
+ var fp = (entry && entry.fingerprinted) || relUnderThemeAssets;
228
+ return "/assets/themes/default/" + fp;
229
+ }
230
+
231
+ // The default theme stylesheet ships from R2 at its fingerprinted path.
232
+ var DEFAULT_THEME_CSS_URL = _assetUrl("css/main.css");
223
233
 
224
234
  // Footer copyright year — resolved once at module load. It's a near-static
225
235
  // value (changes once a year); a `new Date()` allocation on every page
@@ -227,12 +237,11 @@ var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" + _assetMan
227
237
  // that a year-boundary staleness window doesn't matter for a copyright line.
228
238
  var _COPYRIGHT_YEAR = String(new Date().getUTCFullYear());
229
239
 
230
- // Client "island" scripts are served as external assets (same `?v=` cache-
231
- // bust as the CSS), never inline — the storefront's strict `script-src
232
- // 'self'` CSP blocks inline <script>, so an inline island silently fails
233
- // in production. `'self'` allows these /assets/ files; the R2 asset sync
240
+ // Client "island" scripts are served as external assets (fingerprinted, same
241
+ // as the CSS), never inline — the storefront's strict `script-src 'self'`
242
+ // CSP blocks inline <script>, so an inline island silently fails in
243
+ // production. `'self'` allows these /assets/ files; the R2 asset sync
234
244
  // (npm run sync-assets) uploads them alongside the stylesheets.
235
- var _ASSET_JS_VERSION = _assetManifest.version;
236
245
 
237
246
  // Subresource Integrity for the static assets — the browser refuses to
238
247
  // run/apply a resource whose served bytes don't match (defense against an
@@ -242,11 +251,12 @@ var _ASSET_JS_VERSION = _assetManifest.version;
242
251
  // whose bytes aren't ours to hash) yields no attribute. Same-origin, so
243
252
  // no `crossorigin` is required for the check to run.
244
253
  function _assetSri(relUnderThemeAssets) {
245
- return _assetManifest.assets[relUnderThemeAssets] || null;
254
+ var entry = _assetManifest.assets[relUnderThemeAssets];
255
+ return (entry && entry.integrity) || null;
246
256
  }
247
257
  function _islandScript(name) {
248
258
  var sri = _assetSri("js/" + name);
249
- return "<script src=\"/assets/themes/default/js/" + name + "?v=" + _ASSET_JS_VERSION + "\"" +
259
+ return "<script src=\"" + _assetUrl("js/" + name) + "\"" +
250
260
  (sri ? " integrity=\"" + sri + "\"" : "") + " defer></script>";
251
261
  }
252
262
 
@@ -646,13 +656,171 @@ var SEARCH_EMPTY =
646
656
  " <p class=\"search-empty__icon\" aria-hidden=\"true\">⌕</p>\n" +
647
657
  " <h2>{{heading}}</h2>\n" +
648
658
  " <p>{{copy}}</p>\n" +
659
+ " {{clear_link}}\n" +
649
660
  " <a href=\"/\" class=\"btn-ghost\">Browse the full catalog</a>\n" +
650
661
  " </div>\n" +
651
662
  "</section>\n";
652
663
 
664
+ var SEARCH_CORRECTION =
665
+ "<p class=\"search-correction\">Showing results for <strong>{{correction}}</strong>.</p>\n";
666
+
667
+ var FACET_GROUP_HEAD =
668
+ "<fieldset class=\"facet-group\">\n" +
669
+ " <legend class=\"facet-group__title\">{{label}}</legend>\n" +
670
+ " <ul class=\"facet-group__options\">\n";
671
+
672
+ var FACET_OPTION =
673
+ "<li class=\"facet-option\">\n" +
674
+ " <a class=\"facet-option__link{{selected_class}}\" href=\"{{href}}\" rel=\"nofollow\"{{aria_pressed}}>\n" +
675
+ " <span class=\"facet-option__box\" aria-hidden=\"true\">{{box}}</span>\n" +
676
+ " <span class=\"facet-option__label\">{{label}}</span>\n" +
677
+ " <span class=\"facet-option__count\">{{count}}</span>\n" +
678
+ " </a>\n" +
679
+ "</li>\n";
680
+
681
+ var FACET_CHIP =
682
+ "<a class=\"facet-chip\" href=\"{{href}}\" rel=\"nofollow\">\n" +
683
+ " <span class=\"facet-chip__label\">{{label}}</span>\n" +
684
+ " <span class=\"facet-chip__x\" aria-hidden=\"true\">×</span>\n" +
685
+ " <span class=\"skip-link\">Remove filter</span>\n" +
686
+ "</a>\n";
687
+
688
+ // Build a `/search?...` URL from a query + applied-filters map. Mirrors
689
+ // the edge renderer's `_searchUrl` so the container and worker emit
690
+ // byte-identical filter links. `URLSearchParams` percent-encodes every
691
+ // value; the strict `_render` HTML-escapes it again when it lands in an
692
+ // `href` attribute.
693
+ function _searchUrl(q, filters) {
694
+ var sp = new URLSearchParams();
695
+ if (typeof q === "string" && q.length) sp.set("q", q);
696
+ var keys = Object.keys(filters).sort();
697
+ for (var i = 0; i < keys.length; i += 1) {
698
+ var vals = (filters[keys[i]] || []).slice().sort();
699
+ for (var j = 0; j < vals.length; j += 1) sp.append(keys[i], vals[j]);
700
+ }
701
+ var qs = sp.toString();
702
+ return qs.length ? "/search?" + qs : "/search";
703
+ }
704
+
705
+ function _toggleFilter(filters, key, value) {
706
+ var next = {};
707
+ var keys = Object.keys(filters);
708
+ for (var i = 0; i < keys.length; i += 1) next[keys[i]] = filters[keys[i]].slice();
709
+ var cur = next[key] || [];
710
+ var at = cur.indexOf(value);
711
+ if (at === -1) cur = cur.concat([value]);
712
+ else cur = cur.slice(0, at).concat(cur.slice(at + 1));
713
+ if (cur.length) next[key] = cur;
714
+ else delete next[key];
715
+ return next;
716
+ }
717
+
718
+ function _renderSearchFacets(facets, filters, q) {
719
+ var groups = [];
720
+ for (var f = 0; f < facets.length; f += 1) {
721
+ var facet = facets[f];
722
+ var optionsHtml = "";
723
+ var rendered = 0;
724
+ for (var o = 0; o < facet.options.length; o += 1) {
725
+ var opt = facet.options[o];
726
+ if (opt.count === 0 && !opt.selected) continue;
727
+ optionsHtml += _render(FACET_OPTION, {
728
+ href: _searchUrl(q, _toggleFilter(filters, facet.key, opt.value)),
729
+ selected_class: opt.selected ? " is-selected" : "",
730
+ aria_pressed: "RAW_ARIA",
731
+ box: opt.selected ? "✓" : "",
732
+ label: opt.label,
733
+ count: String(opt.count),
734
+ }).replace("RAW_ARIA", opt.selected ? " aria-pressed=\"true\"" : " aria-pressed=\"false\"");
735
+ rendered += 1;
736
+ }
737
+ if (rendered === 0) continue;
738
+ groups.push(_render(FACET_GROUP_HEAD, { label: facet.label }) + optionsHtml + " </ul>\n</fieldset>\n");
739
+ }
740
+ if (!groups.length) return "";
741
+ return "<aside class=\"search-facets\" aria-label=\"Filter results\">\n" +
742
+ "<h2 class=\"search-facets__title\">Filter</h2>\n" +
743
+ groups.join("") +
744
+ "</aside>\n";
745
+ }
746
+
747
+ function _renderSearchChips(facets, filters, q) {
748
+ var labelFor = {};
749
+ for (var f = 0; f < facets.length; f += 1) {
750
+ var byVal = {};
751
+ for (var o = 0; o < facets[f].options.length; o += 1) byVal[facets[f].options[o].value] = facets[f].options[o].label;
752
+ labelFor[facets[f].key] = { group: facets[f].label, values: byVal };
753
+ }
754
+ var chips = "";
755
+ var any = false;
756
+ var keys = Object.keys(filters).sort();
757
+ for (var k = 0; k < keys.length; k += 1) {
758
+ var meta = labelFor[keys[k]];
759
+ var vals = filters[keys[k]] || [];
760
+ for (var v = 0; v < vals.length; v += 1) {
761
+ var valLabel = meta && meta.values[vals[v]] != null ? meta.values[vals[v]] : vals[v];
762
+ var groupLabel = meta ? meta.group : keys[k];
763
+ chips += _render(FACET_CHIP, {
764
+ href: _searchUrl(q, _toggleFilter(filters, keys[k], vals[v])),
765
+ label: groupLabel + ": " + valLabel,
766
+ });
767
+ any = true;
768
+ }
769
+ }
770
+ if (!any) return "";
771
+ var clearAll = _render(
772
+ "<a class=\"facet-chip facet-chip--clear\" href=\"{{href}}\" rel=\"nofollow\">Clear all filters</a>\n",
773
+ { href: _searchUrl(q, {}) }
774
+ );
775
+ return "<div class=\"search-active-filters\" aria-label=\"Active filters\">\n" + chips + clearAll + "</div>\n";
776
+ }
777
+
778
+ // Hard caps mirroring `lib/search-facets.js`'s applied-filter
779
+ // validators so a hostile / stale URL can't blow the in-memory facet
780
+ // walk. Garbage values and unknown facet keys are dropped (not
781
+ // refused): a shopper landing on a link with a removed-facet param
782
+ // still gets a clean results page rather than a 500.
783
+ var SEARCH_MAX_FACET_KEYS = 32;
784
+ var SEARCH_MAX_FACET_VALUES = 64;
785
+ var SEARCH_MAX_VALUE_LEN = 256;
786
+ var SEARCH_CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
787
+
788
+ // Parse `?key=value` repeats off a parsed URL into the
789
+ // `{ facetKey: [value, ...] }` shape the searchFacets primitive
790
+ // consumes, keeping only keys that match a loaded facet definition and
791
+ // values that survive the length / control-byte guards. `facetDefs` is
792
+ // the `listFacets()` result.
793
+ function _parseSearchFilters(url, facetDefs) {
794
+ var out = {};
795
+ if (!url || !url.searchParams) return out;
796
+ var known = {};
797
+ for (var i = 0; i < facetDefs.length; i += 1) known[facetDefs[i].key] = true;
798
+ var keyCount = 0;
799
+ url.searchParams.forEach(function (rawVal, rawKey) {
800
+ if (rawKey === "q") return;
801
+ if (!Object.prototype.hasOwnProperty.call(known, rawKey)) return;
802
+ if (typeof rawVal !== "string") return;
803
+ if (!rawVal.length || rawVal.length > SEARCH_MAX_VALUE_LEN) return;
804
+ if (SEARCH_CONTROL_BYTE_RE.test(rawVal)) return;
805
+ if (!Object.prototype.hasOwnProperty.call(out, rawKey)) {
806
+ if (keyCount >= SEARCH_MAX_FACET_KEYS) return;
807
+ out[rawKey] = [];
808
+ keyCount += 1;
809
+ }
810
+ var arr = out[rawKey];
811
+ if (arr.length >= SEARCH_MAX_FACET_VALUES) return;
812
+ if (arr.indexOf(rawVal) !== -1) return;
813
+ arr.push(rawVal);
814
+ });
815
+ return out;
816
+ }
817
+
653
818
  function renderSearch(opts) {
654
819
  if (!opts || typeof opts.q !== "string") throw new TypeError("storefront.renderSearch: opts.q (string) required");
655
820
  var products = Array.isArray(opts.products) ? opts.products : [];
821
+ var facets = Array.isArray(opts.facets) ? opts.facets : [];
822
+ var filters = (opts.filters && typeof opts.filters === "object") ? opts.filters : {};
823
+ var hasFilters = Object.keys(filters).length > 0;
656
824
  var qTrim = opts.q.trim();
657
825
  var title, summary, emptyHeading, emptyCopy;
658
826
  if (qTrim.length === 0) {
@@ -669,10 +837,24 @@ function renderSearch(opts) {
669
837
  title = "“" + qTrim + "”";
670
838
  summary = "Showing " + products.length + " match" + (products.length === 1 ? "" : "es") + " for your query.";
671
839
  }
840
+
841
+ var correctionHtml = "";
842
+ if (qTrim.length > 0 && typeof opts.corrected_query === "string" &&
843
+ opts.corrected_query.length > 0 && opts.corrected_query !== qTrim) {
844
+ correctionHtml = _render(SEARCH_CORRECTION, { correction: opts.corrected_query });
845
+ }
846
+
847
+ var facetsHtml = (qTrim.length > 0) ? _renderSearchFacets(facets, filters, opts.q) : "";
848
+ var chipsHtml = (qTrim.length > 0) ? _renderSearchChips(facets, filters, opts.q) : "";
849
+
672
850
  var header = _render(SEARCH_HEADER, { title: title, summary: summary });
673
- var body;
851
+ var resultsInner;
674
852
  if (products.length === 0) {
675
- body = header + _render(SEARCH_EMPTY, { heading: emptyHeading, copy: emptyCopy });
853
+ var clearLink = hasFilters
854
+ ? _render("<a href=\"{{href}}\" class=\"btn-ghost\">Clear filters</a>", { href: _searchUrl(opts.q, {}) })
855
+ : "";
856
+ resultsInner = _render(SEARCH_EMPTY, { heading: emptyHeading, copy: emptyCopy, clear_link: "RAW_CLEAR" })
857
+ .replace("RAW_CLEAR", clearLink);
676
858
  } else {
677
859
  var assetPrefix = opts.asset_prefix || "/assets/";
678
860
  var cards = products.map(function (p) {
@@ -683,7 +865,15 @@ function renderSearch(opts) {
683
865
  var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
684
866
  return _buildProductCard({ title: p.title, price: priceStr, slug: p.slug, image_url: imageUrl, image_alt: imageAlt });
685
867
  }).join("\n");
686
- body = header + "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
868
+ resultsInner = "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
869
+ }
870
+ var body;
871
+ if (facetsHtml.length > 0) {
872
+ body = header + correctionHtml + chipsHtml +
873
+ "<div class=\"search-layout\">" + facetsHtml +
874
+ "<div class=\"search-layout__results\">" + resultsInner + "</div></div>";
875
+ } else {
876
+ body = header + correctionHtml + chipsHtml + resultsInner;
687
877
  }
688
878
  return _wrap({
689
879
  title: "Search",
@@ -745,10 +935,12 @@ var PRODUCT_PAGE =
745
935
  " </table>\n" +
746
936
  " </div>\n" +
747
937
  " </div>\n" +
938
+ " RAW_QTYBREAK_PLACEHOLDER\n" +
748
939
  " RAW_WISHLIST_PLACEHOLDER\n" +
749
940
  " RAW_COMPARE_PLACEHOLDER\n" +
750
941
  " </div>\n" +
751
942
  " </div>\n" +
943
+ " RAW_BUNDLES_PLACEHOLDER\n" +
752
944
  " RAW_REVIEWS_PLACEHOLDER\n" +
753
945
  " RAW_QA_PLACEHOLDER\n" +
754
946
  "</section>\n";
@@ -890,6 +1082,109 @@ function _buildReviews(summary, reviews, ctaHtml) {
890
1082
  "</section>";
891
1083
  }
892
1084
 
1085
+ // Builds the PDP "Bundle & save" rail from the offers the route
1086
+ // resolved. Each offer carries its component list (with display
1087
+ // titles + per-member quantity), the sum-of-parts list price, the
1088
+ // bundle price, and the saving — every figure already formatted to a
1089
+ // currency string by the route (price math stays server-side; the
1090
+ // builder is pure string assembly so the container + edge renderers
1091
+ // emit byte-identical markup). An unavailable offer (a member is
1092
+ // archived / out of stock) renders disabled with a reason instead of
1093
+ // the add form, so a customer never hits a broken atomic add. Returns
1094
+ // "" when there are no offers so the PDP shows no empty rail. Mirrors
1095
+ // the edge renderer (`worker/render/product.js#_buildBundles`).
1096
+ function _buildBundles(offers) {
1097
+ return _renderBundles(offers, b.template.escapeHtml);
1098
+ }
1099
+
1100
+ // Shared pure-string assembler. `esc` is the caller's HTML-escaper
1101
+ // (b.template.escapeHtml in the container, escapeHtml at the edge) —
1102
+ // both produce the same output, so the markup is identical across
1103
+ // render paths.
1104
+ function _renderBundles(offers, esc) {
1105
+ offers = offers || [];
1106
+ if (offers.length === 0) return "";
1107
+ var cards = "";
1108
+ for (var i = 0; i < offers.length; i += 1) {
1109
+ var o = offers[i];
1110
+ var members = "";
1111
+ for (var j = 0; j < o.components.length; j += 1) {
1112
+ var c = o.components[j];
1113
+ members +=
1114
+ "<li class=\"bundle-card__member\">" +
1115
+ "<span class=\"bundle-card__member-qty\">" + esc(String(c.quantity)) + "&times;</span> " +
1116
+ "<span class=\"bundle-card__member-title\">" + esc(String(c.title)) + "</span> " +
1117
+ "<code class=\"bundle-card__member-sku\">" + esc(String(c.sku)) + "</code>" +
1118
+ "</li>";
1119
+ }
1120
+ var pricing =
1121
+ "<div class=\"bundle-card__pricing\">" +
1122
+ "<span class=\"bundle-card__list\">Buy separately " + esc(o.list_total_str) + "</span>" +
1123
+ "<span class=\"bundle-card__price price\">Bundle price " + esc(o.amount_str) + "</span>" +
1124
+ (o.discount_str ? "<span class=\"bundle-card__save\">You save " + esc(o.discount_str) + "</span>" : "") +
1125
+ "</div>";
1126
+ var action;
1127
+ if (o.available) {
1128
+ action =
1129
+ "<form method=\"post\" action=\"/cart/bundle\" class=\"bundle-card__form\">" +
1130
+ "<input type=\"hidden\" name=\"bundle_sku\" value=\"" + esc(o.bundle_sku) + "\">" +
1131
+ "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Add bundle to cart</button>" +
1132
+ "</form>";
1133
+ } else {
1134
+ action =
1135
+ "<p class=\"bundle-card__unavailable\">" +
1136
+ esc(o.unavailable_reason || "This bundle is currently unavailable.") +
1137
+ "</p>";
1138
+ }
1139
+ cards +=
1140
+ "<article class=\"bundle-card" + (o.available ? "" : " bundle-card--unavailable") + "\">" +
1141
+ "<h3 class=\"bundle-card__title\">" + esc(String(o.title)) + "</h3>" +
1142
+ "<ul class=\"bundle-card__members\">" + members + "</ul>" +
1143
+ pricing +
1144
+ action +
1145
+ "</article>";
1146
+ }
1147
+ return "<section class=\"bundles\" aria-labelledby=\"bundles-title\">" +
1148
+ "<h2 id=\"bundles-title\" class=\"bundles__heading\">Bundle &amp; save</h2>" +
1149
+ "<div class=\"bundles__grid\">" + cards + "</div>" +
1150
+ "</section>";
1151
+ }
1152
+
1153
+ // Builds the PDP quantity-break table for the displayed variant. Each
1154
+ // row is a (range label, unit price) pair the route already resolved +
1155
+ // formatted (the unit price comes from the quantity-discount
1156
+ // primitive's tierBreakdown against the variant's list price — the math
1157
+ // is server-side; the builder only lays out strings). Returns "" when
1158
+ // the product has no active breaks so the PDP shows no empty table.
1159
+ // Mirrors the edge renderer (`worker/render/product.js#_buildQtyBreaks`).
1160
+ function _buildQtyBreaks(breaks) {
1161
+ return _renderQtyBreaks(breaks, b.template.escapeHtml);
1162
+ }
1163
+
1164
+ function _renderQtyBreaks(breaks, esc) {
1165
+ breaks = breaks || [];
1166
+ if (breaks.length === 0) return "";
1167
+ var rows = "";
1168
+ for (var i = 0; i < breaks.length; i += 1) {
1169
+ var br = breaks[i];
1170
+ rows +=
1171
+ "<tr>" +
1172
+ "<td class=\"qty-break__range\">" + esc(String(br.label)) + "</td>" +
1173
+ "<td class=\"qty-break__unit price\">" + esc(String(br.unit_str)) + "</td>" +
1174
+ "</tr>";
1175
+ }
1176
+ return "<div class=\"qty-breaks\">" +
1177
+ "<h2 class=\"qty-breaks__title\">Buy more, save more</h2>" +
1178
+ "<div class=\"table-scroll\">" +
1179
+ "<table class=\"qty-break-table\">" +
1180
+ "<thead><tr><th>Quantity</th><th>Price each</th></tr></thead>" +
1181
+ "<tbody>" + rows + "</tbody>" +
1182
+ "</table>" +
1183
+ "</div>" +
1184
+ "<p class=\"qty-breaks__note\">Discount applies automatically in your cart.</p>" +
1185
+ "</div>";
1186
+ }
1187
+
893
1188
  var REVIEW_FORM_PAGE =
894
1189
  "<section class=\"review-form-page\">\n" +
895
1190
  " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
@@ -2321,6 +2616,8 @@ function renderProduct(opts) {
2321
2616
  // include the slot; a custom theme opts in by adding it.
2322
2617
  reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
2323
2618
  qa_html: _buildProductQa(opts.qa_questions, opts.qa_cta),
2619
+ bundles_html: _buildBundles(opts.bundle_offers),
2620
+ qty_breaks_html: _buildQtyBreaks(opts.qty_breaks),
2324
2621
  wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
2325
2622
  compare_html: _buildCompare(opts.product.id),
2326
2623
  asset_css_main: opts.theme.assetUrl("css/main.css"),
@@ -2333,6 +2630,8 @@ function renderProduct(opts) {
2333
2630
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
2334
2631
  var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
2335
2632
  var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
2633
+ var bundlesHtml = _buildBundles(opts.bundle_offers);
2634
+ var qtyBreaksHtml = _buildQtyBreaks(opts.qty_breaks);
2336
2635
  var wishlistHtml = _buildWishlist(opts.product.id, opts.wishlist_count);
2337
2636
  var compareHtml = _buildCompare(opts.product.id);
2338
2637
  var body = _render(PRODUCT_PAGE, {
@@ -2342,8 +2641,10 @@ function renderProduct(opts) {
2342
2641
  })
2343
2642
  .replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
2344
2643
  .replace("RAW_ROWS_PLACEHOLDER", rows)
2644
+ .replace("RAW_QTYBREAK_PLACEHOLDER", qtyBreaksHtml)
2345
2645
  .replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
2346
2646
  .replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
2647
+ .replace("RAW_BUNDLES_PLACEHOLDER", bundlesHtml)
2347
2648
  .replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
2348
2649
  .replace("RAW_QA_PLACEHOLDER", qaHtml);
2349
2650
  // Product-specific OpenGraph + Twitter Card values so shares
@@ -3610,6 +3911,204 @@ function mount(router, deps) {
3610
3911
  return { sid: sid, cart: created };
3611
3912
  }
3612
3913
 
3914
+ // ---- bundle + quantity-discount pricing (server-authoritative) --------
3915
+ //
3916
+ // Every price below is recomputed from the catalog + the bundle /
3917
+ // quantity-discount primitives on each render. The client never
3918
+ // sends a price; the cart line's stored snapshot is the add-time
3919
+ // catalog price, and the quantity-break adjustment is reapplied each
3920
+ // time the cart renders (idempotent — the same line quantity always
3921
+ // yields the same adjusted unit), so a stale or forged client value
3922
+ // can't survive a round trip.
3923
+
3924
+ // A pricing.priceFor(sku) adapter over the catalog, for
3925
+ // bundles.priceBundle. Returns the current price row in `currency`
3926
+ // (or null when a member SKU has no price configured, which the
3927
+ // caller treats as "bundle unavailable"). Memoized per call site so
3928
+ // a bundle that repeats a SKU only hits the catalog once.
3929
+ function _skuPricer(currency) {
3930
+ var cache = Object.create(null);
3931
+ return {
3932
+ priceFor: async function (sku) {
3933
+ if (cache[sku] !== undefined) return cache[sku];
3934
+ var variant = await deps.catalog.variants.bySku(sku);
3935
+ if (!variant) { cache[sku] = null; return null; }
3936
+ var price = await deps.catalog.prices.current(variant.id, currency);
3937
+ cache[sku] = price
3938
+ ? { amount_minor: price.amount_minor, currency: price.currency }
3939
+ : null;
3940
+ return cache[sku];
3941
+ },
3942
+ };
3943
+ }
3944
+
3945
+ // Is a SKU buyable right now — a real, in-stock catalog variant?
3946
+ // A bundle with any unbuyable member is shown as unavailable so the
3947
+ // atomic add never half-completes. Inventory is optional: a SKU with
3948
+ // no inventory row is treated as available (the operator hasn't opted
3949
+ // into stock tracking for it), matching the rest of the storefront's
3950
+ // never-block-on-missing-inventory stance.
3951
+ async function _skuBuyable(sku) {
3952
+ var variant = await deps.catalog.variants.bySku(sku);
3953
+ if (!variant) return null;
3954
+ var inv = await deps.catalog.inventory.get(sku);
3955
+ if (inv && (inv.stock_on_hand - inv.stock_held) <= 0) return false;
3956
+ return variant;
3957
+ }
3958
+
3959
+ // Resolve the "Bundle & save" offers for a product's variant SKUs.
3960
+ // For each bundle a SKU belongs to: price it (sum-of-parts vs bundle
3961
+ // price via the primitive's stored discount), check every member is
3962
+ // buyable, and shape the display offer the renderer consumes. An
3963
+ // unpriceable or unbuyable-member bundle is surfaced as unavailable
3964
+ // (never hidden silently — the customer sees why). Dedupes bundles
3965
+ // reachable via multiple variant SKUs. Drop-silent on a primitive
3966
+ // error so a bundles read failure can't 500 the PDP — the rail just
3967
+ // doesn't render (mirrors the reviews/Q&A degrade-to-empty stance).
3968
+ async function _resolveBundleOffers(variantSkus, currency) {
3969
+ if (!deps.bundles) return [];
3970
+ var seen = Object.create(null);
3971
+ var offers = [];
3972
+ try {
3973
+ for (var s = 0; s < variantSkus.length; s += 1) {
3974
+ var bundleList = await deps.bundles.bundlesForComponent(variantSkus[s]);
3975
+ for (var i = 0; i < bundleList.length; i += 1) {
3976
+ var bundle = bundleList[i];
3977
+ if (seen[bundle.bundle_sku]) continue;
3978
+ seen[bundle.bundle_sku] = true;
3979
+
3980
+ // Decorate each member with a display title; flag unbuyable.
3981
+ var componentsOut = [];
3982
+ var allBuyable = true;
3983
+ for (var j = 0; j < bundle.components.length; j += 1) {
3984
+ var comp = bundle.components[j];
3985
+ var buyable = await _skuBuyable(comp.sku);
3986
+ if (!buyable) allBuyable = false;
3987
+ var memberVariant = buyable || (await deps.catalog.variants.bySku(comp.sku));
3988
+ var memberTitle = comp.sku;
3989
+ if (memberVariant) {
3990
+ var memberProduct = await deps.catalog.products.get(memberVariant.product_id);
3991
+ memberTitle = (memberProduct && memberProduct.title) || memberVariant.title || comp.sku;
3992
+ }
3993
+ componentsOut.push({ sku: comp.sku, quantity: comp.quantity, title: memberTitle });
3994
+ }
3995
+
3996
+ var priced = null;
3997
+ try {
3998
+ priced = await deps.bundles.priceBundle({ bundle_sku: bundle.bundle_sku, pricing: _skuPricer(currency) });
3999
+ } catch (_e) {
4000
+ // A missing member price / mixed currency makes the bundle
4001
+ // unpriceable — surface it as unavailable rather than 500.
4002
+ priced = null;
4003
+ }
4004
+
4005
+ var available = allBuyable && priced != null;
4006
+ var listMinor = priced ? priced.list_total_minor : 0;
4007
+ var amountMinor = priced ? priced.amount_minor : 0;
4008
+ var discountMinor = priced ? priced.discount_minor : 0;
4009
+ var cur = (priced && priced.currency) || currency;
4010
+ offers.push({
4011
+ bundle_sku: bundle.bundle_sku,
4012
+ title: bundle.title,
4013
+ 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,
4017
+ available: available,
4018
+ unavailable_reason: available
4019
+ ? null
4020
+ : (priced == null
4021
+ ? "Pricing for this bundle isn't available right now."
4022
+ : "One or more items in this bundle are out of stock."),
4023
+ });
4024
+ }
4025
+ }
4026
+ } catch (_e) {
4027
+ // Bundles table not migrated / read failure — degrade to no rail.
4028
+ return [];
4029
+ }
4030
+ return offers;
4031
+ }
4032
+
4033
+ // Resolve the quantity-break table rows for a variant's SKU, against
4034
+ // its list price. Composes the quantity-discount primitive's
4035
+ // tierBreakdown with a sample unit = the variant's current price, so
4036
+ // the displayed "price each" matches what the cart will charge at
4037
+ // that quantity. Rows read as ascending ranges ("1–4", "5–9",
4038
+ // "10+"). Returns [] when no active sku-scoped tier set exists.
4039
+ // Drop-silent on a read failure so the PDP still renders.
4040
+ async function _resolveQtyBreaks(sku, unitMinor, currency) {
4041
+ if (!deps.quantityDiscounts || unitMinor == null) return [];
4042
+ var breakdown;
4043
+ try {
4044
+ breakdown = await deps.quantityDiscounts.tierBreakdown({
4045
+ scope: "sku",
4046
+ scope_id: sku,
4047
+ sample_unit_price_minor: unitMinor,
4048
+ });
4049
+ } catch (_e) {
4050
+ return [];
4051
+ }
4052
+ var rows = breakdown.rows || [];
4053
+ if (rows.length === 0) return [];
4054
+ // Order by min_quantity ascending; build the implicit range each
4055
+ // tier covers (this tier's min up to the next tier's min minus 1,
4056
+ // open-ended on the last). The base price (below the first tier)
4057
+ // rides as the leading "1–(min-1)" row so the table shows the full
4058
+ // ladder including the undiscounted band.
4059
+ var sorted = rows.slice().sort(function (a, b2) { return a.min_quantity - b2.min_quantity; });
4060
+ var out = [];
4061
+ if (sorted[0].min_quantity > 1) {
4062
+ out.push({
4063
+ label: "1–" + (sorted[0].min_quantity - 1),
4064
+ unit_str: pricing.format(unitMinor, currency),
4065
+ });
4066
+ }
4067
+ for (var i = 0; i < sorted.length; i += 1) {
4068
+ var t = sorted[i];
4069
+ var next = sorted[i + 1];
4070
+ var label = next ? (t.min_quantity + "–" + (next.min_quantity - 1)) : (t.min_quantity + "+");
4071
+ 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) });
4073
+ }
4074
+ return out;
4075
+ }
4076
+
4077
+ // Reprice cart lines for display + totals by reapplying the active
4078
+ // quantity-break for each line's SKU at its current quantity. Returns
4079
+ // a shallow copy of each line with `unit_amount_minor` overwritten by
4080
+ // the discounted unit (the stored snapshot is never mutated — the
4081
+ // adjustment is recomputed every render, so it stays correct as the
4082
+ // quantity changes and as the operator edits tier schedules). When
4083
+ // the quantity-discount primitive isn't wired, the lines pass through
4084
+ // unchanged. Drop-silent per line so one unpriceable SKU can't break
4085
+ // the cart total.
4086
+ async function _repriceCartLines(lines) {
4087
+ if (!deps.quantityDiscounts) return lines;
4088
+ var out = [];
4089
+ for (var i = 0; i < lines.length; i += 1) {
4090
+ var l = lines[i];
4091
+ var unit = l.unit_amount_minor;
4092
+ try {
4093
+ var variant = await deps.catalog.variants.get(l.variant_id);
4094
+ var applied = await deps.quantityDiscounts.applyToLine({
4095
+ line: {
4096
+ sku: l.sku,
4097
+ quantity: l.qty,
4098
+ unit_price_minor: l.unit_amount_minor,
4099
+ product_id: variant ? variant.product_id : undefined,
4100
+ },
4101
+ });
4102
+ unit = applied.discounted_unit_minor;
4103
+ } catch (_e) {
4104
+ // Unpriceable line (no tier / bad shape) keeps its snapshot.
4105
+ unit = l.unit_amount_minor;
4106
+ }
4107
+ out.push(Object.assign({}, l, { unit_amount_minor: unit }));
4108
+ }
4109
+ return out;
4110
+ }
4111
+
3613
4112
  router.get("/", async function (_req, res) {
3614
4113
  var page = await deps.catalog.products.list({ status: "active", limit: 24 });
3615
4114
  // Best-effort "starting price" + "hero media" lookup. Each
@@ -3639,6 +4138,68 @@ function mount(router, deps) {
3639
4138
  _send(res, 200, html);
3640
4139
  });
3641
4140
 
4141
+ // Decorate one product row with the display columns the search
4142
+ // cards need (first variant's price, first media) AND the facet
4143
+ // fields the searchFacets primitive computes against: `collection`
4144
+ // (array of collection slugs the product belongs to), `price_minor`
4145
+ // (the starting price), and `in_stock` (any variant with available
4146
+ // stock). Composes the wired catalog / collections primitives; a
4147
+ // collection facet only populates when `deps.collections` is present.
4148
+ async function _decorateForSearch(p) {
4149
+ var variants = await deps.catalog.variants.listForProduct(p.id);
4150
+ var startingPrice = null;
4151
+ var inStock = false;
4152
+ for (var vi = 0; vi < variants.length; vi += 1) {
4153
+ if (vi === 0) {
4154
+ var price = await deps.catalog.prices.current(variants[0].id, "USD");
4155
+ if (price) startingPrice = price;
4156
+ }
4157
+ var inv = await deps.catalog.inventory.get(variants[vi].sku);
4158
+ if (inv && (Number(inv.stock_on_hand) - Number(inv.stock_held)) > 0) inStock = true;
4159
+ }
4160
+ var media = await deps.catalog.media.listForProduct(p.id);
4161
+ var heroMedia = media.length ? media[0] : null;
4162
+ var collectionSlugs = [];
4163
+ if (deps.collections && typeof deps.collections.collectionsForProduct === "function") {
4164
+ var cols = await deps.collections.collectionsForProduct(p.id);
4165
+ for (var ci = 0; ci < cols.length; ci += 1) {
4166
+ if (cols[ci] && cols[ci].slug) collectionSlugs.push(cols[ci].slug);
4167
+ }
4168
+ }
4169
+ return Object.assign({}, p, {
4170
+ starting_price_minor: startingPrice ? startingPrice.amount_minor : null,
4171
+ starting_price_currency: startingPrice ? startingPrice.currency : "USD",
4172
+ hero_media: heroMedia,
4173
+ collection: collectionSlugs,
4174
+ price_minor: startingPrice ? startingPrice.amount_minor : null,
4175
+ in_stock: inStock,
4176
+ });
4177
+ }
4178
+
4179
+ // Pull the facetable universe for a set of search terms: every
4180
+ // active product matching ANY term (canonical query + synonym
4181
+ // expansions) on title / description, decorated once. The
4182
+ // searchFacets primitive consumes this through a `catalog.list`
4183
+ // adapter and walks the rows in-memory for counts; the route reuses
4184
+ // the same rows for the narrowed result grid (one decoration pass,
4185
+ // no double round trip).
4186
+ async function _facetableUniverse(terms) {
4187
+ var byId = {};
4188
+ var rows = [];
4189
+ for (var ti = 0; ti < terms.length; ti += 1) {
4190
+ var term = typeof terms[ti] === "string" ? terms[ti].trim() : "";
4191
+ if (!term.length) continue;
4192
+ var page = await deps.catalog.products.search({ q: term, status: "active", limit: 100 });
4193
+ for (var i = 0; i < page.rows.length; i += 1) {
4194
+ var p = page.rows[i];
4195
+ if (byId[p.id]) continue;
4196
+ byId[p.id] = true;
4197
+ rows.push(await _decorateForSearch(p));
4198
+ }
4199
+ }
4200
+ return rows;
4201
+ }
4202
+
3642
4203
  router.get("/search", async function (req, res) {
3643
4204
  var url = req.url ? new URL(req.url, "http://localhost") : null;
3644
4205
  var qRaw = url && url.searchParams.get("q");
@@ -3647,26 +4208,53 @@ function mount(router, deps) {
3647
4208
  // primitive — defends against a 10 MiB `?q=...` mass that would
3648
4209
  // otherwise round-trip through the LIKE escape function.
3649
4210
  if (q.length > 200) q = q.slice(0, 200);
3650
- var products = [];
4211
+
4212
+ var products = [];
4213
+ var facetGroups = [];
4214
+ var filters = {};
4215
+ var correctedQ = "";
4216
+
3651
4217
  if (q.trim().length > 0) {
3652
- var page = await deps.catalog.products.search({ q: q, status: "active", limit: 24 });
3653
- for (var i = 0; i < page.rows.length; i += 1) {
3654
- var p = page.rows[i];
3655
- var variants = await deps.catalog.variants.listForProduct(p.id);
3656
- var startingPrice = null;
3657
- if (variants.length) {
3658
- var price = await deps.catalog.prices.current(variants[0].id, "USD");
3659
- if (price) startingPrice = price;
3660
- }
3661
- var media = await deps.catalog.media.listForProduct(p.id);
3662
- var heroMedia = media.length ? media[0] : null;
3663
- products.push(Object.assign({}, p, {
3664
- starting_price_minor: startingPrice ? startingPrice.amount_minor : null,
3665
- starting_price_currency: startingPrice ? startingPrice.currency : "USD",
3666
- hero_media: heroMedia,
3667
- }));
4218
+ // Synonym + typo rewrite expands the typed query into the
4219
+ // canonical term plus operator-curated expansions BEFORE the
4220
+ // product query runs. Without the dep, the raw query is the
4221
+ // only term.
4222
+ var terms = [];
4223
+ if (deps.searchSynonyms) {
4224
+ var rewrite = await deps.searchSynonyms.rewrite(q);
4225
+ correctedQ = rewrite.canonical;
4226
+ if (rewrite.canonical.length) terms.push(rewrite.canonical);
4227
+ for (var e = 0; e < rewrite.expansions.length; e += 1) terms.push(rewrite.expansions[e]);
4228
+ }
4229
+ if (!terms.length) terms.push(q.trim());
4230
+
4231
+ if (deps.searchFacets) {
4232
+ // The universe is decorated once; the searchFacets catalog
4233
+ // adapter and the narrowed result grid share it. The facet
4234
+ // instance is created per-request bound to this request's
4235
+ // universe so concurrent searches never share a catalog
4236
+ // snapshot. `deps.searchFacets(catalog)` is the factory wired
4237
+ // in server.js (the searchFacets primitive's `create`, with
4238
+ // the DB query handle pre-bound).
4239
+ var universe = await _facetableUniverse(terms);
4240
+ var facetCatalog = {
4241
+ // The primitive narrows in-memory and drops the focal facet
4242
+ // per option, so the adapter ignores applied_filters and
4243
+ // returns the full matched universe every call.
4244
+ list: function () { return Promise.resolve({ rows: universe }); },
4245
+ };
4246
+ var sfInstance = deps.searchFacets(facetCatalog);
4247
+ var facetDefs = await sfInstance.listFacets({});
4248
+ filters = _parseSearchFilters(url, facetDefs);
4249
+ facetGroups = await sfInstance.getFacets({ query: q, applied_filters: filters });
4250
+ var preview = await sfInstance.previewQuery({ query: q, filters: filters, sample: 24 });
4251
+ products = preview.sample;
4252
+ } else {
4253
+ // No facet dep — flat search over the expanded terms.
4254
+ products = (await _facetableUniverse(terms)).slice(0, 24);
3668
4255
  }
3669
4256
  }
4257
+
3670
4258
  var sid = _readSidCookie(req);
3671
4259
  var cartCount = 0;
3672
4260
  if (sid) {
@@ -3677,10 +4265,13 @@ function mount(router, deps) {
3677
4265
  }
3678
4266
  }
3679
4267
  _send(res, 200, renderSearch({
3680
- q: q,
3681
- products: products,
3682
- shop_name: shopName,
3683
- cart_count: cartCount,
4268
+ q: q,
4269
+ products: products,
4270
+ facets: facetGroups,
4271
+ filters: filters,
4272
+ corrected_query: correctedQ,
4273
+ shop_name: shopName,
4274
+ cart_count: cartCount,
3684
4275
  }));
3685
4276
  });
3686
4277
 
@@ -3768,6 +4359,19 @@ function mount(router, deps) {
3768
4359
  catch (_e) { /* drop-silent — recently-viewed is supplementary to the buy path */ }
3769
4360
  }
3770
4361
  }
4362
+ // Bundle offers + quantity-break table. Both compose the
4363
+ // server-authoritative pricing primitives (the customer never sends
4364
+ // a price); both degrade to empty on a missing-table / read failure
4365
+ // so the buy path renders regardless. The quantity-break table is
4366
+ // built against the first variant's list price — the band a shopper
4367
+ // sees on the PDP matches what the cart charges at that quantity.
4368
+ var variantSkus = variants.map(function (v) { return v.sku; });
4369
+ var bundleOffers = await _resolveBundleOffers(variantSkus, "USD");
4370
+ var firstVariant = variants[0] || null;
4371
+ var firstPrice = firstVariant ? prices[firstVariant.id] : null;
4372
+ var qtyBreaks = firstVariant && firstPrice
4373
+ ? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency)
4374
+ : [];
3771
4375
  var html = renderProduct({
3772
4376
  product: product,
3773
4377
  variants: variants,
@@ -3778,6 +4382,8 @@ function mount(router, deps) {
3778
4382
  review_cta: reviewCta,
3779
4383
  qa_questions: qaQuestions,
3780
4384
  qa_cta: qaCta,
4385
+ bundle_offers: bundleOffers,
4386
+ qty_breaks: qtyBreaks,
3781
4387
  wishlist_count: wishlistCount,
3782
4388
  shop_name: shopName,
3783
4389
  cart_count: cartCount,
@@ -4111,7 +4717,13 @@ function mount(router, deps) {
4111
4717
  shop_name: shopName, theme: theme,
4112
4718
  }));
4113
4719
  }
4114
- var lines = await deps.cart.listLines(c.id);
4720
+ var rawLines = await deps.cart.listLines(c.id);
4721
+ // Reapply the active quantity-break for each line at its current
4722
+ // quantity before totals — the unit price the customer sees on the
4723
+ // line, the line total, and the cart subtotal all reflect the break.
4724
+ // Recomputed every render (idempotent); the stored snapshot is never
4725
+ // mutated, so changing a line's quantity re-prices it automatically.
4726
+ var lines = await _repriceCartLines(rawLines);
4115
4727
  var totals = pricing.totals(c, lines, {});
4116
4728
  // Build the variant_id → { product, hero_media } lookup the
4117
4729
  // renderer uses to decorate each line with a thumbnail + title.
@@ -6142,6 +6754,141 @@ function mount(router, deps) {
6142
6754
  res.end ? res.end() : res.send("");
6143
6755
  });
6144
6756
 
6757
+ // POST /cart/bundle — add every member of a bundle to the cart at the
6758
+ // bundle price, atomically. Reads `bundle_sku` from the form body.
6759
+ // The price is recomputed server-side from the catalog + the bundle
6760
+ // primitive (the client sends only the SKU, never a price). The
6761
+ // bundle discount is allocated across the member lines proportional
6762
+ // to each member's list contribution, so the cart subtotal equals the
6763
+ // bundle's quoted price; integer-cent remainder lands on the last
6764
+ // line. Mounted only when the bundles primitive is wired.
6765
+ if (deps.bundles) {
6766
+ router.post("/cart/bundle", async function (req, res) {
6767
+ var body = req.body || {};
6768
+ var bundleSku = body.bundle_sku;
6769
+ if (!bundleSku || typeof bundleSku !== "string") {
6770
+ res.status(400);
6771
+ return res.end ? res.end("Invalid request") : res.send("Invalid request");
6772
+ }
6773
+ var currency = "USD";
6774
+ // Resolve + price the bundle. A malformed sku throws TypeError
6775
+ // (→ 400); an unknown sku resolves to no bundle (→ 404).
6776
+ var bundle, leaves, priced;
6777
+ try {
6778
+ bundle = await deps.bundles.getBundle(bundleSku);
6779
+ if (!bundle) {
6780
+ res.status(404);
6781
+ return res.end ? res.end("Bundle not found") : res.send("Bundle not found");
6782
+ }
6783
+ leaves = await deps.bundles.expand({ bundle_sku: bundleSku, quantity: 1 });
6784
+ priced = await deps.bundles.priceBundle({ bundle_sku: bundleSku, pricing: _skuPricer(currency) });
6785
+ } catch (e) {
6786
+ if (e instanceof TypeError) {
6787
+ res.status(400);
6788
+ return res.end ? res.end((e && e.message) || "Invalid bundle") : res.send((e && e.message) || "Invalid bundle");
6789
+ }
6790
+ res.status(500);
6791
+ return res.end ? res.end("Error") : res.send("Error");
6792
+ }
6793
+
6794
+ // Pre-flight: every leaf must resolve to a buyable variant + a
6795
+ // current price. All-or-nothing — if any member is unavailable,
6796
+ // add nothing and bounce back to the PDP with a notice (no
6797
+ // half-filled cart). `leaf.quantity` is the per-bundle demand.
6798
+ var members = [];
6799
+ for (var i = 0; i < leaves.length; i += 1) {
6800
+ var leaf = leaves[i];
6801
+ var variant = await _skuBuyable(leaf.sku);
6802
+ if (!variant) {
6803
+ res.status(303);
6804
+ res.setHeader && res.setHeader("location", "/cart?bundle=unavailable");
6805
+ return res.end ? res.end() : res.send("");
6806
+ }
6807
+ var price = await deps.catalog.prices.current(variant.id, currency);
6808
+ if (!price) {
6809
+ res.status(303);
6810
+ res.setHeader && res.setHeader("location", "/cart?bundle=unavailable");
6811
+ return res.end ? res.end() : res.send("");
6812
+ }
6813
+ members.push({
6814
+ variant_id: variant.id,
6815
+ qty: leaf.quantity,
6816
+ list_each: price.amount_minor,
6817
+ list_line: price.amount_minor * leaf.quantity,
6818
+ });
6819
+ }
6820
+
6821
+ // Allocate the bundle total across members proportional to each
6822
+ // member's list contribution, then express each share as an integer
6823
+ // per-unit price. The cart stores one integer unit price per line and
6824
+ // charges qty*unit, so flooring a share to a per-unit price loses up
6825
+ // to (qty-1) cents on a multi-unit line. Those cents are added back
6826
+ // below so the cart subtotal equals the bundle price exactly — never
6827
+ // undercharging the advertised bundle.
6828
+ var listTotal = priced.list_total_minor;
6829
+ var bundleTotal = priced.amount_minor;
6830
+ var allocated = 0;
6831
+ for (var m = 0; m < members.length; m += 1) {
6832
+ var mem = members[m];
6833
+ var share = listTotal > 0
6834
+ ? Math.floor((bundleTotal * mem.list_line) / listTotal)
6835
+ : Math.floor(bundleTotal / members.length);
6836
+ mem.alloc_line = share;
6837
+ allocated += share;
6838
+ }
6839
+ // The proportional floor leaves a whole-cent remainder; park it on the
6840
+ // last member's line so the per-line allocations sum to the bundle
6841
+ // total exactly.
6842
+ if (members.length > 0) members[members.length - 1].alloc_line += (bundleTotal - allocated);
6843
+
6844
+ // Convert each member's line allocation to a uniform integer per-unit
6845
+ // price (floor) and tally the cents lost to that floor across all
6846
+ // lines. A single-unit line (the usual bundle shape) absorbs the whole
6847
+ // shortfall exactly; failing that, the cents are returned in qty-sized
6848
+ // steps to the smallest-quantity lines, with any final sub-step residual
6849
+ // rounded up onto the smallest line — so the realized subtotal is never
6850
+ // below the quoted bundle price.
6851
+ var lostCents = 0;
6852
+ for (var u = 0; u < members.length; u += 1) {
6853
+ var me = members[u];
6854
+ me.unit = me.qty > 0 ? Math.floor(me.alloc_line / me.qty) : me.alloc_line;
6855
+ lostCents += me.alloc_line - (me.unit * me.qty);
6856
+ }
6857
+ if (lostCents > 0 && members.length > 0) {
6858
+ var byQty = members.slice().sort(function (a, b) { return a.qty - b.qty; });
6859
+ var single = null;
6860
+ for (var s = 0; s < byQty.length; s += 1) { if (byQty[s].qty === 1) { single = byQty[s]; break; } }
6861
+ if (single) {
6862
+ single.unit += lostCents;
6863
+ } else {
6864
+ for (var g = 0; g < byQty.length && lostCents > 0; g += 1) {
6865
+ while (lostCents >= byQty[g].qty) { byQty[g].unit += 1; lostCents -= byQty[g].qty; }
6866
+ }
6867
+ if (lostCents > 0) { byQty[0].unit += 1; lostCents = 0; }
6868
+ }
6869
+ }
6870
+
6871
+ var resolved = await _getOrCreateCart(req, res, currency);
6872
+ try {
6873
+ for (var k = 0; k < members.length; k += 1) {
6874
+ var mk = members[k];
6875
+ await deps.cart.addLine(resolved.cart.id, {
6876
+ variant_id: mk.variant_id,
6877
+ qty: mk.qty,
6878
+ unit_amount_minor: mk.unit,
6879
+ unit_currency: currency,
6880
+ });
6881
+ }
6882
+ } catch (e) {
6883
+ res.status(e instanceof TypeError ? 400 : 500);
6884
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
6885
+ }
6886
+ res.status(303);
6887
+ res.setHeader && res.setHeader("location", "/cart");
6888
+ res.end ? res.end() : res.send("");
6889
+ });
6890
+ }
6891
+
6145
6892
  // POST /cart/lines/:line_id/update — change qty on an existing
6146
6893
  // line. Form value `qty` is the new quantity (1..99). HTML forms
6147
6894
  // only support GET/POST so the verb is in the path.