@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.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -0
- package/lib/admin.js +18 -10
- package/lib/asset-manifest.json +17 -5
- package/lib/bundles.js +28 -0
- package/lib/checkout.js +48 -2
- package/lib/storefront.js +792 -45
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +8 -0
- package/lib/vendor/blamejs/README.md +3 -1
- package/lib/vendor/blamejs/api-snapshot.json +99 -2
- package/lib/vendor/blamejs/index.js +3 -0
- package/lib/vendor/blamejs/lib/crypto-oprf.js +110 -0
- package/lib/vendor/blamejs/lib/network-tsig.js +404 -0
- package/lib/vendor/blamejs/lib/network.js +1 -0
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +44 -9
- package/lib/vendor/blamejs/lib/vendor/noble-curves.cjs +19 -0
- package/lib/vendor/blamejs/lib/worm.js +246 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.x.json +1844 -0
- package/lib/vendor/blamejs/release-notes/v0.13.0.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.1.json +18 -0
- package/lib/vendor/blamejs/scripts/vendor-update.sh +11 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +3 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-oprf.test.js +101 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-tsig.test.js +149 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sandbox.test.js +2 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/testing.test.js +3 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/worm.test.js +126 -0
- package/package.json +2 -2
- package/lib/vendor/blamejs/release-notes/v0.12.0.json +0 -64
- package/lib/vendor/blamejs/release-notes/v0.12.1.json +0 -32
- package/lib/vendor/blamejs/release-notes/v0.12.10.json +0 -65
- package/lib/vendor/blamejs/release-notes/v0.12.11.json +0 -39
- package/lib/vendor/blamejs/release-notes/v0.12.12.json +0 -48
- package/lib/vendor/blamejs/release-notes/v0.12.13.json +0 -31
- package/lib/vendor/blamejs/release-notes/v0.12.14.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.15.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.16.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.17.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.18.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.19.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.2.json +0 -45
- package/lib/vendor/blamejs/release-notes/v0.12.20.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.21.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.22.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.23.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.24.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.25.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.26.json +0 -30
- package/lib/vendor/blamejs/release-notes/v0.12.27.json +0 -26
- package/lib/vendor/blamejs/release-notes/v0.12.28.json +0 -26
- package/lib/vendor/blamejs/release-notes/v0.12.29.json +0 -31
- package/lib/vendor/blamejs/release-notes/v0.12.3.json +0 -23
- package/lib/vendor/blamejs/release-notes/v0.12.30.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.31.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.32.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.33.json +0 -31
- package/lib/vendor/blamejs/release-notes/v0.12.34.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.35.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.36.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.37.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.38.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.39.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.4.json +0 -19
- package/lib/vendor/blamejs/release-notes/v0.12.40.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.41.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.42.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.43.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.44.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.45.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.46.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.47.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.48.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.49.json +0 -31
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +0 -40
- package/lib/vendor/blamejs/release-notes/v0.12.50.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.51.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.52.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.53.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.54.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.55.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.56.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.57.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.58.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.6.json +0 -45
- package/lib/vendor/blamejs/release-notes/v0.12.60.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.61.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.62.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.63.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.64.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.65.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.66.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.68.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.69.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.7.json +0 -86
- package/lib/vendor/blamejs/release-notes/v0.12.8.json +0 -81
- 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
|
|
209
|
-
//
|
|
210
|
-
// committed. Read here instead of
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
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
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
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
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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
|
-
|
|
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=\"
|
|
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
|
|
851
|
+
var resultsInner;
|
|
674
852
|
if (products.length === 0) {
|
|
675
|
-
|
|
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
|
-
|
|
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)) + "×</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 & 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
|
-
|
|
4211
|
+
|
|
4212
|
+
var products = [];
|
|
4213
|
+
var facetGroups = [];
|
|
4214
|
+
var filters = {};
|
|
4215
|
+
var correctedQ = "";
|
|
4216
|
+
|
|
3651
4217
|
if (q.trim().length > 0) {
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
var
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
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:
|
|
3681
|
-
products:
|
|
3682
|
-
|
|
3683
|
-
|
|
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
|
|
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.
|