@blamejs/blamejs-shop 0.1.30 → 0.1.32
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 +3 -0
- package/lib/asset-manifest.json +2 -2
- package/lib/bundles.js +28 -0
- package/lib/checkout.js +48 -2
- package/lib/storefront.js +881 -1
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +44 -2
- package/lib/vendor/blamejs/index.js +2 -0
- package/lib/vendor/blamejs/lib/acme.js +2 -2
- package/lib/vendor/blamejs/lib/auth/dpop.js +14 -44
- package/lib/vendor/blamejs/lib/dbsc.js +5 -18
- package/lib/vendor/blamejs/lib/jwk.js +127 -0
- package/lib/vendor/blamejs/lib/middleware/bot-guard.js +43 -6
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.68.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.12.69.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bot-guard.test.js +102 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +4 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/jwk.test.js +72 -0
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -165,6 +165,7 @@ var LAYOUT =
|
|
|
165
165
|
" <li><a href=\"/categories\">Categories</a></li>\n" +
|
|
166
166
|
" <li><a href=\"/?sort=new\">New arrivals</a></li>\n" +
|
|
167
167
|
" <li><a href=\"/?sort=sale\">On sale</a></li>\n" +
|
|
168
|
+
" <li><a href=\"/compare\">Compare</a></li>\n" +
|
|
168
169
|
" <li><a href=\"/cart\">Cart</a></li>\n" +
|
|
169
170
|
" </ul>\n" +
|
|
170
171
|
" </div>\n" +
|
|
@@ -744,9 +745,12 @@ var PRODUCT_PAGE =
|
|
|
744
745
|
" </table>\n" +
|
|
745
746
|
" </div>\n" +
|
|
746
747
|
" </div>\n" +
|
|
748
|
+
" RAW_QTYBREAK_PLACEHOLDER\n" +
|
|
747
749
|
" RAW_WISHLIST_PLACEHOLDER\n" +
|
|
750
|
+
" RAW_COMPARE_PLACEHOLDER\n" +
|
|
748
751
|
" </div>\n" +
|
|
749
752
|
" </div>\n" +
|
|
753
|
+
" RAW_BUNDLES_PLACEHOLDER\n" +
|
|
750
754
|
" RAW_REVIEWS_PLACEHOLDER\n" +
|
|
751
755
|
" RAW_QA_PLACEHOLDER\n" +
|
|
752
756
|
"</section>\n";
|
|
@@ -888,6 +892,109 @@ function _buildReviews(summary, reviews, ctaHtml) {
|
|
|
888
892
|
"</section>";
|
|
889
893
|
}
|
|
890
894
|
|
|
895
|
+
// Builds the PDP "Bundle & save" rail from the offers the route
|
|
896
|
+
// resolved. Each offer carries its component list (with display
|
|
897
|
+
// titles + per-member quantity), the sum-of-parts list price, the
|
|
898
|
+
// bundle price, and the saving — every figure already formatted to a
|
|
899
|
+
// currency string by the route (price math stays server-side; the
|
|
900
|
+
// builder is pure string assembly so the container + edge renderers
|
|
901
|
+
// emit byte-identical markup). An unavailable offer (a member is
|
|
902
|
+
// archived / out of stock) renders disabled with a reason instead of
|
|
903
|
+
// the add form, so a customer never hits a broken atomic add. Returns
|
|
904
|
+
// "" when there are no offers so the PDP shows no empty rail. Mirrors
|
|
905
|
+
// the edge renderer (`worker/render/product.js#_buildBundles`).
|
|
906
|
+
function _buildBundles(offers) {
|
|
907
|
+
return _renderBundles(offers, b.template.escapeHtml);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Shared pure-string assembler. `esc` is the caller's HTML-escaper
|
|
911
|
+
// (b.template.escapeHtml in the container, escapeHtml at the edge) —
|
|
912
|
+
// both produce the same output, so the markup is identical across
|
|
913
|
+
// render paths.
|
|
914
|
+
function _renderBundles(offers, esc) {
|
|
915
|
+
offers = offers || [];
|
|
916
|
+
if (offers.length === 0) return "";
|
|
917
|
+
var cards = "";
|
|
918
|
+
for (var i = 0; i < offers.length; i += 1) {
|
|
919
|
+
var o = offers[i];
|
|
920
|
+
var members = "";
|
|
921
|
+
for (var j = 0; j < o.components.length; j += 1) {
|
|
922
|
+
var c = o.components[j];
|
|
923
|
+
members +=
|
|
924
|
+
"<li class=\"bundle-card__member\">" +
|
|
925
|
+
"<span class=\"bundle-card__member-qty\">" + esc(String(c.quantity)) + "×</span> " +
|
|
926
|
+
"<span class=\"bundle-card__member-title\">" + esc(String(c.title)) + "</span> " +
|
|
927
|
+
"<code class=\"bundle-card__member-sku\">" + esc(String(c.sku)) + "</code>" +
|
|
928
|
+
"</li>";
|
|
929
|
+
}
|
|
930
|
+
var pricing =
|
|
931
|
+
"<div class=\"bundle-card__pricing\">" +
|
|
932
|
+
"<span class=\"bundle-card__list\">Buy separately " + esc(o.list_total_str) + "</span>" +
|
|
933
|
+
"<span class=\"bundle-card__price price\">Bundle price " + esc(o.amount_str) + "</span>" +
|
|
934
|
+
(o.discount_str ? "<span class=\"bundle-card__save\">You save " + esc(o.discount_str) + "</span>" : "") +
|
|
935
|
+
"</div>";
|
|
936
|
+
var action;
|
|
937
|
+
if (o.available) {
|
|
938
|
+
action =
|
|
939
|
+
"<form method=\"post\" action=\"/cart/bundle\" class=\"bundle-card__form\">" +
|
|
940
|
+
"<input type=\"hidden\" name=\"bundle_sku\" value=\"" + esc(o.bundle_sku) + "\">" +
|
|
941
|
+
"<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Add bundle to cart</button>" +
|
|
942
|
+
"</form>";
|
|
943
|
+
} else {
|
|
944
|
+
action =
|
|
945
|
+
"<p class=\"bundle-card__unavailable\">" +
|
|
946
|
+
esc(o.unavailable_reason || "This bundle is currently unavailable.") +
|
|
947
|
+
"</p>";
|
|
948
|
+
}
|
|
949
|
+
cards +=
|
|
950
|
+
"<article class=\"bundle-card" + (o.available ? "" : " bundle-card--unavailable") + "\">" +
|
|
951
|
+
"<h3 class=\"bundle-card__title\">" + esc(String(o.title)) + "</h3>" +
|
|
952
|
+
"<ul class=\"bundle-card__members\">" + members + "</ul>" +
|
|
953
|
+
pricing +
|
|
954
|
+
action +
|
|
955
|
+
"</article>";
|
|
956
|
+
}
|
|
957
|
+
return "<section class=\"bundles\" aria-labelledby=\"bundles-title\">" +
|
|
958
|
+
"<h2 id=\"bundles-title\" class=\"bundles__heading\">Bundle & save</h2>" +
|
|
959
|
+
"<div class=\"bundles__grid\">" + cards + "</div>" +
|
|
960
|
+
"</section>";
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Builds the PDP quantity-break table for the displayed variant. Each
|
|
964
|
+
// row is a (range label, unit price) pair the route already resolved +
|
|
965
|
+
// formatted (the unit price comes from the quantity-discount
|
|
966
|
+
// primitive's tierBreakdown against the variant's list price — the math
|
|
967
|
+
// is server-side; the builder only lays out strings). Returns "" when
|
|
968
|
+
// the product has no active breaks so the PDP shows no empty table.
|
|
969
|
+
// Mirrors the edge renderer (`worker/render/product.js#_buildQtyBreaks`).
|
|
970
|
+
function _buildQtyBreaks(breaks) {
|
|
971
|
+
return _renderQtyBreaks(breaks, b.template.escapeHtml);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function _renderQtyBreaks(breaks, esc) {
|
|
975
|
+
breaks = breaks || [];
|
|
976
|
+
if (breaks.length === 0) return "";
|
|
977
|
+
var rows = "";
|
|
978
|
+
for (var i = 0; i < breaks.length; i += 1) {
|
|
979
|
+
var br = breaks[i];
|
|
980
|
+
rows +=
|
|
981
|
+
"<tr>" +
|
|
982
|
+
"<td class=\"qty-break__range\">" + esc(String(br.label)) + "</td>" +
|
|
983
|
+
"<td class=\"qty-break__unit price\">" + esc(String(br.unit_str)) + "</td>" +
|
|
984
|
+
"</tr>";
|
|
985
|
+
}
|
|
986
|
+
return "<div class=\"qty-breaks\">" +
|
|
987
|
+
"<h2 class=\"qty-breaks__title\">Buy more, save more</h2>" +
|
|
988
|
+
"<div class=\"table-scroll\">" +
|
|
989
|
+
"<table class=\"qty-break-table\">" +
|
|
990
|
+
"<thead><tr><th>Quantity</th><th>Price each</th></tr></thead>" +
|
|
991
|
+
"<tbody>" + rows + "</tbody>" +
|
|
992
|
+
"</table>" +
|
|
993
|
+
"</div>" +
|
|
994
|
+
"<p class=\"qty-breaks__note\">Discount applies automatically in your cart.</p>" +
|
|
995
|
+
"</div>";
|
|
996
|
+
}
|
|
997
|
+
|
|
891
998
|
var REVIEW_FORM_PAGE =
|
|
892
999
|
"<section class=\"review-form-page\">\n" +
|
|
893
1000
|
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
|
|
@@ -1162,6 +1269,177 @@ function renderWishlist(opts) {
|
|
|
1162
1269
|
});
|
|
1163
1270
|
}
|
|
1164
1271
|
|
|
1272
|
+
// Compare page notice banner — surfaced after a toggle / a full-basket
|
|
1273
|
+
// refusal. The route passes a short message key; unknown keys render
|
|
1274
|
+
// nothing so a forged `?notice=` query can't inject arbitrary copy.
|
|
1275
|
+
var COMPARE_NOTICES = {
|
|
1276
|
+
added: "Added to your comparison.",
|
|
1277
|
+
removed: "Removed from your comparison.",
|
|
1278
|
+
full: "Your comparison is full (4 products max). Remove one to add another.",
|
|
1279
|
+
cleared: "Comparison cleared.",
|
|
1280
|
+
};
|
|
1281
|
+
function _compareNotice(key) {
|
|
1282
|
+
var msg = COMPARE_NOTICES[key];
|
|
1283
|
+
if (!msg) return "";
|
|
1284
|
+
return "<p class=\"compare-page__notice\" role=\"status\">" + b.template.escapeHtml(msg) + "</p>";
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Side-by-side comparison table. `opts.columns` is the resolved per-
|
|
1288
|
+
// product column list in basket order:
|
|
1289
|
+
// { product_id, product, hero_media, price, available }
|
|
1290
|
+
// where `product` is null for a product archived / deleted between the
|
|
1291
|
+
// add and this render (orphan-tolerant — the column renders "no longer
|
|
1292
|
+
// available" and a remove control, never crashes the table). `opts.rows`
|
|
1293
|
+
// is the attribute matrix from product-compare's `compareTable`:
|
|
1294
|
+
// [ { attribute: { slug, label, format }, values_per_product: [...] } ]
|
|
1295
|
+
// each `values_per_product` entry positionally aligned with `columns`.
|
|
1296
|
+
// The currency/number/boolean formatting happens here (the display
|
|
1297
|
+
// layer), not in the primitive.
|
|
1298
|
+
function _compareCellValue(value, format) {
|
|
1299
|
+
var esc = b.template.escapeHtml;
|
|
1300
|
+
if (value == null) return "<span class=\"compare-cell--empty\" aria-hidden=\"true\">—</span><span class=\"sr-only\">Not specified</span>";
|
|
1301
|
+
if (format === "currency") {
|
|
1302
|
+
var minor = Number(value);
|
|
1303
|
+
if (!Number.isFinite(minor)) return esc(String(value));
|
|
1304
|
+
return esc(pricing.format(Math.round(minor), "USD"));
|
|
1305
|
+
}
|
|
1306
|
+
if (format === "boolean") {
|
|
1307
|
+
return value ? "Yes" : "No";
|
|
1308
|
+
}
|
|
1309
|
+
return esc(String(value));
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function renderCompare(opts) {
|
|
1313
|
+
var esc = b.template.escapeHtml;
|
|
1314
|
+
var columns = opts.columns || [];
|
|
1315
|
+
var attrRows = opts.rows || [];
|
|
1316
|
+
var prefix = opts.asset_prefix || "/assets/";
|
|
1317
|
+
var notice = _compareNotice(opts.notice);
|
|
1318
|
+
|
|
1319
|
+
if (!columns.length) {
|
|
1320
|
+
var empty =
|
|
1321
|
+
"<section class=\"compare-page\">" +
|
|
1322
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1323
|
+
"<li><a href=\"/\">Shop</a></li>" +
|
|
1324
|
+
"<li aria-current=\"page\">Compare</li>" +
|
|
1325
|
+
"</ol></nav>" +
|
|
1326
|
+
"<h1 class=\"compare-page__title\">Compare products</h1>" +
|
|
1327
|
+
notice +
|
|
1328
|
+
"<p class=\"compare-page__empty\">You haven't added anything to compare yet. Browse the shop and tap " +
|
|
1329
|
+
"<strong>Add to compare</strong> on up to four products to line them up side by side.</p>" +
|
|
1330
|
+
"<p><a class=\"btn-secondary\" href=\"/\">Browse the shop →</a></p>" +
|
|
1331
|
+
"</section>";
|
|
1332
|
+
return _wrap({
|
|
1333
|
+
title: "Compare products",
|
|
1334
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1335
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1336
|
+
theme_css: opts.theme_css,
|
|
1337
|
+
body: empty,
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Per-column header cell — image + title + remove form. A column whose
|
|
1342
|
+
// product resolved out (archived / deleted) renders the gone-state
|
|
1343
|
+
// header and still offers the remove control so the shopper can clear
|
|
1344
|
+
// the dangling entry.
|
|
1345
|
+
function _headerCell(col) {
|
|
1346
|
+
var removeForm =
|
|
1347
|
+
"<form class=\"compare-col__remove\" method=\"post\" action=\"/compare/toggle\">" +
|
|
1348
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + esc(col.product_id) + "\">" +
|
|
1349
|
+
"<input type=\"hidden\" name=\"return_to\" value=\"/compare\">" +
|
|
1350
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button>" +
|
|
1351
|
+
"</form>";
|
|
1352
|
+
if (!col.product) {
|
|
1353
|
+
return "<th scope=\"col\" class=\"compare-col compare-col--gone\">" +
|
|
1354
|
+
"<span class=\"compare-col__title\">No longer available</span>" +
|
|
1355
|
+
removeForm +
|
|
1356
|
+
"</th>";
|
|
1357
|
+
}
|
|
1358
|
+
var slug = esc(col.product.slug);
|
|
1359
|
+
var thumb = col.hero_media
|
|
1360
|
+
? "<img src=\"" + esc(prefix + col.hero_media.r2_key) + "\" alt=\"" + esc(col.hero_media.alt_text || col.product.title) + "\" loading=\"lazy\">"
|
|
1361
|
+
: "<span class=\"compare-col__mark\" aria-hidden=\"true\">" + esc((col.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
|
|
1362
|
+
return "<th scope=\"col\" class=\"compare-col\">" +
|
|
1363
|
+
"<a class=\"compare-col__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
|
|
1364
|
+
"<a class=\"compare-col__title\" href=\"/products/" + slug + "\">" + esc(col.product.title) + "</a>" +
|
|
1365
|
+
removeForm +
|
|
1366
|
+
"</th>";
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
var headerCells = "";
|
|
1370
|
+
for (var h = 0; h < columns.length; h += 1) headerCells += _headerCell(columns[h]);
|
|
1371
|
+
|
|
1372
|
+
// Fixed display rows resolved directly (richer than the attribute
|
|
1373
|
+
// matrix): price + availability. Price for a gone column renders "—".
|
|
1374
|
+
function _priceCell(col) {
|
|
1375
|
+
if (!col.product) return "<td class=\"compare-cell compare-cell--gone\">—</td>";
|
|
1376
|
+
return "<td class=\"compare-cell\">" + esc(col.price || "—") + "</td>";
|
|
1377
|
+
}
|
|
1378
|
+
function _availCell(col) {
|
|
1379
|
+
if (!col.product) return "<td class=\"compare-cell compare-cell--gone\">—</td>";
|
|
1380
|
+
return col.available
|
|
1381
|
+
? "<td class=\"compare-cell\"><span class=\"compare-cell__badge compare-cell__badge--ok\">In stock</span></td>"
|
|
1382
|
+
: "<td class=\"compare-cell\"><span class=\"compare-cell__badge\">Out of stock</span></td>";
|
|
1383
|
+
}
|
|
1384
|
+
var priceRow = "<tr><th scope=\"row\" class=\"compare-row-label\">Price</th>";
|
|
1385
|
+
var availRow = "<tr><th scope=\"row\" class=\"compare-row-label\">Availability</th>";
|
|
1386
|
+
for (var c = 0; c < columns.length; c += 1) {
|
|
1387
|
+
priceRow += _priceCell(columns[c]);
|
|
1388
|
+
availRow += _availCell(columns[c]);
|
|
1389
|
+
}
|
|
1390
|
+
priceRow += "</tr>";
|
|
1391
|
+
availRow += "</tr>";
|
|
1392
|
+
|
|
1393
|
+
// Attribute matrix rows from the primitive. The `price` attribute is
|
|
1394
|
+
// already surfaced as the fixed Price row above, so skip its duplicate
|
|
1395
|
+
// here; every other attribute renders one row, one cell per column.
|
|
1396
|
+
var attrRowsHtml = "";
|
|
1397
|
+
for (var r = 0; r < attrRows.length; r += 1) {
|
|
1398
|
+
var ar = attrRows[r];
|
|
1399
|
+
if (ar.attribute && ar.attribute.slug === "price") continue;
|
|
1400
|
+
var label = (ar.attribute && ar.attribute.label) || "";
|
|
1401
|
+
var fmt = ar.attribute && ar.attribute.format;
|
|
1402
|
+
var cells = "";
|
|
1403
|
+
var vals = ar.values_per_product || [];
|
|
1404
|
+
for (var v = 0; v < columns.length; v += 1) {
|
|
1405
|
+
cells += "<td class=\"compare-cell\">" + _compareCellValue(vals[v], fmt) + "</td>";
|
|
1406
|
+
}
|
|
1407
|
+
attrRowsHtml += "<tr><th scope=\"row\" class=\"compare-row-label\">" + esc(label) + "</th>" + cells + "</tr>";
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
var clearForm =
|
|
1411
|
+
"<form class=\"compare-page__clear\" method=\"post\" action=\"/compare/clear\">" +
|
|
1412
|
+
"<button type=\"submit\" class=\"btn-ghost\">Clear comparison</button>" +
|
|
1413
|
+
"</form>";
|
|
1414
|
+
|
|
1415
|
+
var body =
|
|
1416
|
+
"<section class=\"compare-page\">" +
|
|
1417
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1418
|
+
"<li><a href=\"/\">Shop</a></li>" +
|
|
1419
|
+
"<li aria-current=\"page\">Compare</li>" +
|
|
1420
|
+
"</ol></nav>" +
|
|
1421
|
+
"<header class=\"compare-page__head\">" +
|
|
1422
|
+
"<h1 class=\"compare-page__title\">Compare products (" + columns.length + ")</h1>" +
|
|
1423
|
+
clearForm +
|
|
1424
|
+
"</header>" +
|
|
1425
|
+
notice +
|
|
1426
|
+
"<div class=\"table-scroll\">" +
|
|
1427
|
+
"<table class=\"compare-table\">" +
|
|
1428
|
+
"<thead><tr><th scope=\"col\" class=\"compare-row-label\"><span class=\"sr-only\">Attribute</span></th>" + headerCells + "</tr></thead>" +
|
|
1429
|
+
"<tbody>" + priceRow + availRow + attrRowsHtml + "</tbody>" +
|
|
1430
|
+
"</table>" +
|
|
1431
|
+
"</div>" +
|
|
1432
|
+
"</section>";
|
|
1433
|
+
|
|
1434
|
+
return _wrap({
|
|
1435
|
+
title: "Compare products",
|
|
1436
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1437
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1438
|
+
theme_css: opts.theme_css,
|
|
1439
|
+
body: body,
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1165
1443
|
// Account "Saved for later" page. `opts.items` is a resolved list:
|
|
1166
1444
|
// { save, product, hero_media } for live products, or { save,
|
|
1167
1445
|
// product: null } when the variant/product behind a saved row was
|
|
@@ -2093,6 +2371,27 @@ function _buildWishlist(productId, count) {
|
|
|
2093
2371
|
"</div>";
|
|
2094
2372
|
}
|
|
2095
2373
|
|
|
2374
|
+
// Product-level "Add to compare" control. Byte-compatible with the
|
|
2375
|
+
// edge renderer (`worker/render/product.js`) so both paths emit
|
|
2376
|
+
// identical markup. Action-only label — the toggle route resolves
|
|
2377
|
+
// add/remove server-side against the sealed session, and a guest who
|
|
2378
|
+
// has never compared anything still sees the same neutral control the
|
|
2379
|
+
// edge cache serves. The link to the compare table sits beside the
|
|
2380
|
+
// toggle so a shopper mid-basket can jump straight to the side-by-side
|
|
2381
|
+
// view.
|
|
2382
|
+
function _buildCompare(productId) {
|
|
2383
|
+
var esc = b.template.escapeHtml;
|
|
2384
|
+
return "<div class=\"compare\">" +
|
|
2385
|
+
"<form class=\"compare__form\" method=\"post\" action=\"/compare/toggle\">" +
|
|
2386
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + esc(productId) + "\">" +
|
|
2387
|
+
"<button type=\"submit\" class=\"btn-secondary compare__btn\">" +
|
|
2388
|
+
"<span class=\"compare__icon\" aria-hidden=\"true\">⇄</span> Add to compare" +
|
|
2389
|
+
"</button>" +
|
|
2390
|
+
"</form>" +
|
|
2391
|
+
"<a class=\"compare__link card-link\" href=\"/compare\">View compare →</a>" +
|
|
2392
|
+
"</div>";
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2096
2395
|
// Schema.org JSON-LD block. JSON.stringify covers the standard escapes;
|
|
2097
2396
|
// the `</` → `<\/` rewrite neutralises any literal `</script>` in a
|
|
2098
2397
|
// value. Mirrors the edge renderer's `jsonLdScript`.
|
|
@@ -2127,7 +2426,10 @@ function renderProduct(opts) {
|
|
|
2127
2426
|
// include the slot; a custom theme opts in by adding it.
|
|
2128
2427
|
reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
|
|
2129
2428
|
qa_html: _buildProductQa(opts.qa_questions, opts.qa_cta),
|
|
2429
|
+
bundles_html: _buildBundles(opts.bundle_offers),
|
|
2430
|
+
qty_breaks_html: _buildQtyBreaks(opts.qty_breaks),
|
|
2130
2431
|
wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
|
|
2432
|
+
compare_html: _buildCompare(opts.product.id),
|
|
2131
2433
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
2132
2434
|
});
|
|
2133
2435
|
}
|
|
@@ -2138,7 +2440,10 @@ function renderProduct(opts) {
|
|
|
2138
2440
|
var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
|
|
2139
2441
|
var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
|
|
2140
2442
|
var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
|
|
2443
|
+
var bundlesHtml = _buildBundles(opts.bundle_offers);
|
|
2444
|
+
var qtyBreaksHtml = _buildQtyBreaks(opts.qty_breaks);
|
|
2141
2445
|
var wishlistHtml = _buildWishlist(opts.product.id, opts.wishlist_count);
|
|
2446
|
+
var compareHtml = _buildCompare(opts.product.id);
|
|
2142
2447
|
var body = _render(PRODUCT_PAGE, {
|
|
2143
2448
|
title: opts.product.title,
|
|
2144
2449
|
description: description,
|
|
@@ -2146,7 +2451,10 @@ function renderProduct(opts) {
|
|
|
2146
2451
|
})
|
|
2147
2452
|
.replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
|
|
2148
2453
|
.replace("RAW_ROWS_PLACEHOLDER", rows)
|
|
2454
|
+
.replace("RAW_QTYBREAK_PLACEHOLDER", qtyBreaksHtml)
|
|
2149
2455
|
.replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
|
|
2456
|
+
.replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
|
|
2457
|
+
.replace("RAW_BUNDLES_PLACEHOLDER", bundlesHtml)
|
|
2150
2458
|
.replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
|
|
2151
2459
|
.replace("RAW_QA_PLACEHOLDER", qaHtml);
|
|
2152
2460
|
// Product-specific OpenGraph + Twitter Card values so shares
|
|
@@ -3413,6 +3721,204 @@ function mount(router, deps) {
|
|
|
3413
3721
|
return { sid: sid, cart: created };
|
|
3414
3722
|
}
|
|
3415
3723
|
|
|
3724
|
+
// ---- bundle + quantity-discount pricing (server-authoritative) --------
|
|
3725
|
+
//
|
|
3726
|
+
// Every price below is recomputed from the catalog + the bundle /
|
|
3727
|
+
// quantity-discount primitives on each render. The client never
|
|
3728
|
+
// sends a price; the cart line's stored snapshot is the add-time
|
|
3729
|
+
// catalog price, and the quantity-break adjustment is reapplied each
|
|
3730
|
+
// time the cart renders (idempotent — the same line quantity always
|
|
3731
|
+
// yields the same adjusted unit), so a stale or forged client value
|
|
3732
|
+
// can't survive a round trip.
|
|
3733
|
+
|
|
3734
|
+
// A pricing.priceFor(sku) adapter over the catalog, for
|
|
3735
|
+
// bundles.priceBundle. Returns the current price row in `currency`
|
|
3736
|
+
// (or null when a member SKU has no price configured, which the
|
|
3737
|
+
// caller treats as "bundle unavailable"). Memoized per call site so
|
|
3738
|
+
// a bundle that repeats a SKU only hits the catalog once.
|
|
3739
|
+
function _skuPricer(currency) {
|
|
3740
|
+
var cache = Object.create(null);
|
|
3741
|
+
return {
|
|
3742
|
+
priceFor: async function (sku) {
|
|
3743
|
+
if (cache[sku] !== undefined) return cache[sku];
|
|
3744
|
+
var variant = await deps.catalog.variants.bySku(sku);
|
|
3745
|
+
if (!variant) { cache[sku] = null; return null; }
|
|
3746
|
+
var price = await deps.catalog.prices.current(variant.id, currency);
|
|
3747
|
+
cache[sku] = price
|
|
3748
|
+
? { amount_minor: price.amount_minor, currency: price.currency }
|
|
3749
|
+
: null;
|
|
3750
|
+
return cache[sku];
|
|
3751
|
+
},
|
|
3752
|
+
};
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
// Is a SKU buyable right now — a real, in-stock catalog variant?
|
|
3756
|
+
// A bundle with any unbuyable member is shown as unavailable so the
|
|
3757
|
+
// atomic add never half-completes. Inventory is optional: a SKU with
|
|
3758
|
+
// no inventory row is treated as available (the operator hasn't opted
|
|
3759
|
+
// into stock tracking for it), matching the rest of the storefront's
|
|
3760
|
+
// never-block-on-missing-inventory stance.
|
|
3761
|
+
async function _skuBuyable(sku) {
|
|
3762
|
+
var variant = await deps.catalog.variants.bySku(sku);
|
|
3763
|
+
if (!variant) return null;
|
|
3764
|
+
var inv = await deps.catalog.inventory.get(sku);
|
|
3765
|
+
if (inv && (inv.stock_on_hand - inv.stock_held) <= 0) return false;
|
|
3766
|
+
return variant;
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
// Resolve the "Bundle & save" offers for a product's variant SKUs.
|
|
3770
|
+
// For each bundle a SKU belongs to: price it (sum-of-parts vs bundle
|
|
3771
|
+
// price via the primitive's stored discount), check every member is
|
|
3772
|
+
// buyable, and shape the display offer the renderer consumes. An
|
|
3773
|
+
// unpriceable or unbuyable-member bundle is surfaced as unavailable
|
|
3774
|
+
// (never hidden silently — the customer sees why). Dedupes bundles
|
|
3775
|
+
// reachable via multiple variant SKUs. Drop-silent on a primitive
|
|
3776
|
+
// error so a bundles read failure can't 500 the PDP — the rail just
|
|
3777
|
+
// doesn't render (mirrors the reviews/Q&A degrade-to-empty stance).
|
|
3778
|
+
async function _resolveBundleOffers(variantSkus, currency) {
|
|
3779
|
+
if (!deps.bundles) return [];
|
|
3780
|
+
var seen = Object.create(null);
|
|
3781
|
+
var offers = [];
|
|
3782
|
+
try {
|
|
3783
|
+
for (var s = 0; s < variantSkus.length; s += 1) {
|
|
3784
|
+
var bundleList = await deps.bundles.bundlesForComponent(variantSkus[s]);
|
|
3785
|
+
for (var i = 0; i < bundleList.length; i += 1) {
|
|
3786
|
+
var bundle = bundleList[i];
|
|
3787
|
+
if (seen[bundle.bundle_sku]) continue;
|
|
3788
|
+
seen[bundle.bundle_sku] = true;
|
|
3789
|
+
|
|
3790
|
+
// Decorate each member with a display title; flag unbuyable.
|
|
3791
|
+
var componentsOut = [];
|
|
3792
|
+
var allBuyable = true;
|
|
3793
|
+
for (var j = 0; j < bundle.components.length; j += 1) {
|
|
3794
|
+
var comp = bundle.components[j];
|
|
3795
|
+
var buyable = await _skuBuyable(comp.sku);
|
|
3796
|
+
if (!buyable) allBuyable = false;
|
|
3797
|
+
var memberVariant = buyable || (await deps.catalog.variants.bySku(comp.sku));
|
|
3798
|
+
var memberTitle = comp.sku;
|
|
3799
|
+
if (memberVariant) {
|
|
3800
|
+
var memberProduct = await deps.catalog.products.get(memberVariant.product_id);
|
|
3801
|
+
memberTitle = (memberProduct && memberProduct.title) || memberVariant.title || comp.sku;
|
|
3802
|
+
}
|
|
3803
|
+
componentsOut.push({ sku: comp.sku, quantity: comp.quantity, title: memberTitle });
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
var priced = null;
|
|
3807
|
+
try {
|
|
3808
|
+
priced = await deps.bundles.priceBundle({ bundle_sku: bundle.bundle_sku, pricing: _skuPricer(currency) });
|
|
3809
|
+
} catch (_e) {
|
|
3810
|
+
// A missing member price / mixed currency makes the bundle
|
|
3811
|
+
// unpriceable — surface it as unavailable rather than 500.
|
|
3812
|
+
priced = null;
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
var available = allBuyable && priced != null;
|
|
3816
|
+
var listMinor = priced ? priced.list_total_minor : 0;
|
|
3817
|
+
var amountMinor = priced ? priced.amount_minor : 0;
|
|
3818
|
+
var discountMinor = priced ? priced.discount_minor : 0;
|
|
3819
|
+
var cur = (priced && priced.currency) || currency;
|
|
3820
|
+
offers.push({
|
|
3821
|
+
bundle_sku: bundle.bundle_sku,
|
|
3822
|
+
title: bundle.title,
|
|
3823
|
+
components: componentsOut,
|
|
3824
|
+
list_total_str: pricing.format(listMinor, cur),
|
|
3825
|
+
amount_str: pricing.format(amountMinor, cur),
|
|
3826
|
+
discount_str: discountMinor > 0 ? pricing.format(discountMinor, cur) : null,
|
|
3827
|
+
available: available,
|
|
3828
|
+
unavailable_reason: available
|
|
3829
|
+
? null
|
|
3830
|
+
: (priced == null
|
|
3831
|
+
? "Pricing for this bundle isn't available right now."
|
|
3832
|
+
: "One or more items in this bundle are out of stock."),
|
|
3833
|
+
});
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
} catch (_e) {
|
|
3837
|
+
// Bundles table not migrated / read failure — degrade to no rail.
|
|
3838
|
+
return [];
|
|
3839
|
+
}
|
|
3840
|
+
return offers;
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
// Resolve the quantity-break table rows for a variant's SKU, against
|
|
3844
|
+
// its list price. Composes the quantity-discount primitive's
|
|
3845
|
+
// tierBreakdown with a sample unit = the variant's current price, so
|
|
3846
|
+
// the displayed "price each" matches what the cart will charge at
|
|
3847
|
+
// that quantity. Rows read as ascending ranges ("1–4", "5–9",
|
|
3848
|
+
// "10+"). Returns [] when no active sku-scoped tier set exists.
|
|
3849
|
+
// Drop-silent on a read failure so the PDP still renders.
|
|
3850
|
+
async function _resolveQtyBreaks(sku, unitMinor, currency) {
|
|
3851
|
+
if (!deps.quantityDiscounts || unitMinor == null) return [];
|
|
3852
|
+
var breakdown;
|
|
3853
|
+
try {
|
|
3854
|
+
breakdown = await deps.quantityDiscounts.tierBreakdown({
|
|
3855
|
+
scope: "sku",
|
|
3856
|
+
scope_id: sku,
|
|
3857
|
+
sample_unit_price_minor: unitMinor,
|
|
3858
|
+
});
|
|
3859
|
+
} catch (_e) {
|
|
3860
|
+
return [];
|
|
3861
|
+
}
|
|
3862
|
+
var rows = breakdown.rows || [];
|
|
3863
|
+
if (rows.length === 0) return [];
|
|
3864
|
+
// Order by min_quantity ascending; build the implicit range each
|
|
3865
|
+
// tier covers (this tier's min up to the next tier's min minus 1,
|
|
3866
|
+
// open-ended on the last). The base price (below the first tier)
|
|
3867
|
+
// rides as the leading "1–(min-1)" row so the table shows the full
|
|
3868
|
+
// ladder including the undiscounted band.
|
|
3869
|
+
var sorted = rows.slice().sort(function (a, b2) { return a.min_quantity - b2.min_quantity; });
|
|
3870
|
+
var out = [];
|
|
3871
|
+
if (sorted[0].min_quantity > 1) {
|
|
3872
|
+
out.push({
|
|
3873
|
+
label: "1–" + (sorted[0].min_quantity - 1),
|
|
3874
|
+
unit_str: pricing.format(unitMinor, currency),
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
for (var i = 0; i < sorted.length; i += 1) {
|
|
3878
|
+
var t = sorted[i];
|
|
3879
|
+
var next = sorted[i + 1];
|
|
3880
|
+
var label = next ? (t.min_quantity + "–" + (next.min_quantity - 1)) : (t.min_quantity + "+");
|
|
3881
|
+
var unit = t.sample_discounted_unit_minor != null ? t.sample_discounted_unit_minor : unitMinor;
|
|
3882
|
+
out.push({ label: label, unit_str: pricing.format(unit, currency) });
|
|
3883
|
+
}
|
|
3884
|
+
return out;
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
// Reprice cart lines for display + totals by reapplying the active
|
|
3888
|
+
// quantity-break for each line's SKU at its current quantity. Returns
|
|
3889
|
+
// a shallow copy of each line with `unit_amount_minor` overwritten by
|
|
3890
|
+
// the discounted unit (the stored snapshot is never mutated — the
|
|
3891
|
+
// adjustment is recomputed every render, so it stays correct as the
|
|
3892
|
+
// quantity changes and as the operator edits tier schedules). When
|
|
3893
|
+
// the quantity-discount primitive isn't wired, the lines pass through
|
|
3894
|
+
// unchanged. Drop-silent per line so one unpriceable SKU can't break
|
|
3895
|
+
// the cart total.
|
|
3896
|
+
async function _repriceCartLines(lines) {
|
|
3897
|
+
if (!deps.quantityDiscounts) return lines;
|
|
3898
|
+
var out = [];
|
|
3899
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
3900
|
+
var l = lines[i];
|
|
3901
|
+
var unit = l.unit_amount_minor;
|
|
3902
|
+
try {
|
|
3903
|
+
var variant = await deps.catalog.variants.get(l.variant_id);
|
|
3904
|
+
var applied = await deps.quantityDiscounts.applyToLine({
|
|
3905
|
+
line: {
|
|
3906
|
+
sku: l.sku,
|
|
3907
|
+
quantity: l.qty,
|
|
3908
|
+
unit_price_minor: l.unit_amount_minor,
|
|
3909
|
+
product_id: variant ? variant.product_id : undefined,
|
|
3910
|
+
},
|
|
3911
|
+
});
|
|
3912
|
+
unit = applied.discounted_unit_minor;
|
|
3913
|
+
} catch (_e) {
|
|
3914
|
+
// Unpriceable line (no tier / bad shape) keeps its snapshot.
|
|
3915
|
+
unit = l.unit_amount_minor;
|
|
3916
|
+
}
|
|
3917
|
+
out.push(Object.assign({}, l, { unit_amount_minor: unit }));
|
|
3918
|
+
}
|
|
3919
|
+
return out;
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3416
3922
|
router.get("/", async function (_req, res) {
|
|
3417
3923
|
var page = await deps.catalog.products.list({ status: "active", limit: 24 });
|
|
3418
3924
|
// Best-effort "starting price" + "hero media" lookup. Each
|
|
@@ -3571,6 +4077,19 @@ function mount(router, deps) {
|
|
|
3571
4077
|
catch (_e) { /* drop-silent — recently-viewed is supplementary to the buy path */ }
|
|
3572
4078
|
}
|
|
3573
4079
|
}
|
|
4080
|
+
// Bundle offers + quantity-break table. Both compose the
|
|
4081
|
+
// server-authoritative pricing primitives (the customer never sends
|
|
4082
|
+
// a price); both degrade to empty on a missing-table / read failure
|
|
4083
|
+
// so the buy path renders regardless. The quantity-break table is
|
|
4084
|
+
// built against the first variant's list price — the band a shopper
|
|
4085
|
+
// sees on the PDP matches what the cart charges at that quantity.
|
|
4086
|
+
var variantSkus = variants.map(function (v) { return v.sku; });
|
|
4087
|
+
var bundleOffers = await _resolveBundleOffers(variantSkus, "USD");
|
|
4088
|
+
var firstVariant = variants[0] || null;
|
|
4089
|
+
var firstPrice = firstVariant ? prices[firstVariant.id] : null;
|
|
4090
|
+
var qtyBreaks = firstVariant && firstPrice
|
|
4091
|
+
? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency)
|
|
4092
|
+
: [];
|
|
3574
4093
|
var html = renderProduct({
|
|
3575
4094
|
product: product,
|
|
3576
4095
|
variants: variants,
|
|
@@ -3581,6 +4100,8 @@ function mount(router, deps) {
|
|
|
3581
4100
|
review_cta: reviewCta,
|
|
3582
4101
|
qa_questions: qaQuestions,
|
|
3583
4102
|
qa_cta: qaCta,
|
|
4103
|
+
bundle_offers: bundleOffers,
|
|
4104
|
+
qty_breaks: qtyBreaks,
|
|
3584
4105
|
wishlist_count: wishlistCount,
|
|
3585
4106
|
shop_name: shopName,
|
|
3586
4107
|
cart_count: cartCount,
|
|
@@ -3589,6 +4110,224 @@ function mount(router, deps) {
|
|
|
3589
4110
|
_send(res, 200, html);
|
|
3590
4111
|
});
|
|
3591
4112
|
|
|
4113
|
+
// Product compare — the guest-or-customer side-by-side basket. Mounts
|
|
4114
|
+
// when the productCompare primitive is wired. The basket is keyed on
|
|
4115
|
+
// the same `shop_sid` session cookie the cart uses (a routing key that
|
|
4116
|
+
// grants zero authority — the primitive namespace-hashes it before it
|
|
4117
|
+
// touches the database), so a shopper compares without authenticating;
|
|
4118
|
+
// a logged-in shopper's customer_id rides alongside so the operator's
|
|
4119
|
+
// account widget could resume the basket on another device. All three
|
|
4120
|
+
// routes (toggle / clear / view) are writes-or-session-reads, so they
|
|
4121
|
+
// live in the container — the edge forwards them (and any request
|
|
4122
|
+
// carrying shop_sid skips the edge cache, so the view always reflects
|
|
4123
|
+
// the live basket).
|
|
4124
|
+
if (deps.productCompare) {
|
|
4125
|
+
// Resolve the compare basket's session id. Reads the cart session
|
|
4126
|
+
// cookie; when `mint` is true and none exists, allocates one + sets
|
|
4127
|
+
// the cookie so the basket has a stable key across requests. A
|
|
4128
|
+
// read-only path (the view) passes mint=false and returns null when
|
|
4129
|
+
// there's no session — that renders the empty state without minting
|
|
4130
|
+
// a cookie on a bare GET.
|
|
4131
|
+
function _compareSid(req, res, mint) {
|
|
4132
|
+
var sid = _readSidCookie(req);
|
|
4133
|
+
if (sid) return sid;
|
|
4134
|
+
if (!mint) return null;
|
|
4135
|
+
sid = b.uuid.v7();
|
|
4136
|
+
_setSidCookie(res, sid);
|
|
4137
|
+
return sid;
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
function _compareRedirect(res, dest, notice) {
|
|
4141
|
+
var sep = dest.indexOf("?") === -1 ? "?" : "&";
|
|
4142
|
+
var to = notice ? (dest + sep + "notice=" + encodeURIComponent(notice)) : dest;
|
|
4143
|
+
res.status(303);
|
|
4144
|
+
res.setHeader && res.setHeader("location", to);
|
|
4145
|
+
return res.end ? res.end() : res.send("");
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
// POST /compare/toggle — add the product if it isn't in the basket,
|
|
4149
|
+
// remove it if it is. Idempotent (the primitive collapses a repeat
|
|
4150
|
+
// add / a remove of an absent id). Redirects to `return_to` when it's
|
|
4151
|
+
// a safe same-origin path (the compare page's per-column Remove uses
|
|
4152
|
+
// it), otherwise back to the product PDP (the canonical slug is
|
|
4153
|
+
// resolved from product_id, so a forged slug can't drive an open
|
|
4154
|
+
// redirect). No auth required — guests compare too.
|
|
4155
|
+
router.post("/compare/toggle", async function (req, res) {
|
|
4156
|
+
var productId = (req.body || {}).product_id;
|
|
4157
|
+
var sid = _compareSid(req, res, true);
|
|
4158
|
+
var custEnv = _currentCustomerEnv(req);
|
|
4159
|
+
var customerId = custEnv ? custEnv.customer_id : null;
|
|
4160
|
+
|
|
4161
|
+
// Resolve the safe redirect destination up front so a validation
|
|
4162
|
+
// failure still lands the shopper somewhere sane.
|
|
4163
|
+
var rt = (req.body || {}).return_to;
|
|
4164
|
+
var dest = null;
|
|
4165
|
+
if (typeof rt === "string" && /^\/[^/]/.test(rt)) dest = rt;
|
|
4166
|
+
|
|
4167
|
+
var notice;
|
|
4168
|
+
try {
|
|
4169
|
+
var list = await deps.productCompare.getCompareList({ session_id: sid });
|
|
4170
|
+
var inBasket = list.product_ids.indexOf(productId) !== -1;
|
|
4171
|
+
if (inBasket) {
|
|
4172
|
+
await deps.productCompare.removeFromCompare({ session_id: sid, product_id: productId });
|
|
4173
|
+
notice = "removed";
|
|
4174
|
+
} else {
|
|
4175
|
+
await deps.productCompare.addToCompare({ session_id: sid, product_id: productId, customer_id: customerId });
|
|
4176
|
+
notice = "added";
|
|
4177
|
+
// Best-effort impression telemetry — drop-silent, never block
|
|
4178
|
+
// the toggle on the merchandising ledger write.
|
|
4179
|
+
try {
|
|
4180
|
+
await deps.productCompare.recordImpression({ session_id: sid, product_id: productId, source_kind: "product_page" });
|
|
4181
|
+
} catch (_e) { /* drop-silent — impression telemetry is supplementary */ }
|
|
4182
|
+
}
|
|
4183
|
+
} catch (e) {
|
|
4184
|
+
if (e && e.code === "COMPARE_FULL") {
|
|
4185
|
+
// Cap reached — refuse the add with a notice rather than a
|
|
4186
|
+
// crash. The basket is unchanged; the shopper removes one to
|
|
4187
|
+
// make room.
|
|
4188
|
+
if (!dest) {
|
|
4189
|
+
var capProduct = null;
|
|
4190
|
+
try { capProduct = await deps.catalog.products.get(productId); } catch (_e2) { capProduct = null; }
|
|
4191
|
+
dest = capProduct ? ("/products/" + encodeURIComponent(capProduct.slug)) : "/compare";
|
|
4192
|
+
}
|
|
4193
|
+
return _compareRedirect(res, dest, "full");
|
|
4194
|
+
}
|
|
4195
|
+
// A malformed product id (or session id) is a client error.
|
|
4196
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
4197
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
if (!dest) {
|
|
4201
|
+
var product = null;
|
|
4202
|
+
try { product = await deps.catalog.products.get(productId); } catch (_e) { product = null; }
|
|
4203
|
+
dest = product ? ("/products/" + encodeURIComponent(product.slug)) : "/compare";
|
|
4204
|
+
}
|
|
4205
|
+
return _compareRedirect(res, dest, notice);
|
|
4206
|
+
});
|
|
4207
|
+
|
|
4208
|
+
// POST /compare/clear — drop the whole basket. Idempotent (clearing
|
|
4209
|
+
// an empty basket is a no-op). Always redirects back to the compare
|
|
4210
|
+
// page so the shopper sees the emptied state.
|
|
4211
|
+
router.post("/compare/clear", async function (req, res) {
|
|
4212
|
+
var sid = _compareSid(req, res, false);
|
|
4213
|
+
if (sid) {
|
|
4214
|
+
try { await deps.productCompare.clearCompareList({ session_id: sid }); }
|
|
4215
|
+
catch (e) {
|
|
4216
|
+
if (!(e instanceof TypeError)) {
|
|
4217
|
+
res.status(500);
|
|
4218
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
4219
|
+
}
|
|
4220
|
+
// A malformed session cookie can't address a basket — treat
|
|
4221
|
+
// the clear as a no-op rather than a 400 (the cookie grants
|
|
4222
|
+
// zero authority; a stale/garbage value just means "no basket").
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
return _compareRedirect(res, "/compare", "cleared");
|
|
4226
|
+
});
|
|
4227
|
+
|
|
4228
|
+
// GET /compare — the side-by-side comparison table for the session's
|
|
4229
|
+
// basket. Empty basket → the friendly empty state. Each product id is
|
|
4230
|
+
// resolved through the catalog; a product archived / deleted between
|
|
4231
|
+
// the add and this render resolves out gracefully (the column renders
|
|
4232
|
+
// "no longer available" with a remove control). The attribute matrix
|
|
4233
|
+
// comes from the primitive's compareTable, fed a getProduct adapter
|
|
4234
|
+
// enriched with the product's first variant + current price so the
|
|
4235
|
+
// baked-in price/sku/weight attributes resolve against this catalog's
|
|
4236
|
+
// schema.
|
|
4237
|
+
router.get("/compare", async function (req, res) {
|
|
4238
|
+
var sid = _compareSid(req, res, false);
|
|
4239
|
+
var cartCount = await _cartCountForReq(req);
|
|
4240
|
+
// Read the post-toggle notice key from the query string the same
|
|
4241
|
+
// way the search route reads `?q=` — via the parsed URL, not
|
|
4242
|
+
// `req.query` (which the router only populates when a route
|
|
4243
|
+
// declares a query validator). renderCompare gates the key
|
|
4244
|
+
// against a fixed allowlist, so a forged value renders nothing.
|
|
4245
|
+
var compareUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4246
|
+
var noticeKey = compareUrl ? compareUrl.searchParams.get("notice") : null;
|
|
4247
|
+
|
|
4248
|
+
if (!sid) {
|
|
4249
|
+
return _send(res, 200, renderCompare({
|
|
4250
|
+
columns: [], rows: [], shop_name: shopName, cart_count: cartCount,
|
|
4251
|
+
asset_prefix: _cardAssetPrefix, notice: noticeKey,
|
|
4252
|
+
}));
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
// Per-request product cache so the header-column decoration and the
|
|
4256
|
+
// attribute-matrix adapter don't double-fetch the same product.
|
|
4257
|
+
var enrichedCache = Object.create(null);
|
|
4258
|
+
async function _enrich(pid) {
|
|
4259
|
+
if (Object.prototype.hasOwnProperty.call(enrichedCache, pid)) return enrichedCache[pid];
|
|
4260
|
+
var result = { product: null, variant: null, price: null, hero_media: null, available: false };
|
|
4261
|
+
var product = null;
|
|
4262
|
+
// get() throws a TypeError on a malformed id; a basket can only
|
|
4263
|
+
// hold strict-UUID ids (the primitive sanitises on add), but stay
|
|
4264
|
+
// defensive — a bad id resolves to the gone-state column.
|
|
4265
|
+
try { product = await deps.catalog.products.get(pid); } catch (_e) { product = null; }
|
|
4266
|
+
if (product && product.status === "active") {
|
|
4267
|
+
result.product = product;
|
|
4268
|
+
var variants = await deps.catalog.variants.listForProduct(pid);
|
|
4269
|
+
if (variants.length) {
|
|
4270
|
+
result.variant = variants[0];
|
|
4271
|
+
var pr = await deps.catalog.prices.current(variants[0].id, "USD");
|
|
4272
|
+
if (pr) result.price = pricing.format(pr.amount_minor, pr.currency);
|
|
4273
|
+
// Availability — best-effort inventory read on the first
|
|
4274
|
+
// variant. A product with no inventory row reads as available
|
|
4275
|
+
// (operators who don't track stock still sell), an explicit
|
|
4276
|
+
// zero-on-hand reads as out of stock.
|
|
4277
|
+
try {
|
|
4278
|
+
var inv = await deps.catalog.inventory.get(variants[0].sku);
|
|
4279
|
+
result.available = !inv || (Number(inv.stock_on_hand) - Number(inv.stock_held)) > 0;
|
|
4280
|
+
} catch (_e) { result.available = true; }
|
|
4281
|
+
} else {
|
|
4282
|
+
result.available = false;
|
|
4283
|
+
}
|
|
4284
|
+
var media = await deps.catalog.media.listForProduct(pid);
|
|
4285
|
+
result.hero_media = media.length ? media[0] : null;
|
|
4286
|
+
}
|
|
4287
|
+
enrichedCache[pid] = result;
|
|
4288
|
+
return result;
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
var list = await deps.productCompare.getCompareList({ session_id: sid });
|
|
4292
|
+
var columns = [];
|
|
4293
|
+
for (var i = 0; i < list.product_ids.length; i += 1) {
|
|
4294
|
+
var pid = list.product_ids[i];
|
|
4295
|
+
var e = await _enrich(pid);
|
|
4296
|
+
columns.push({
|
|
4297
|
+
product_id: pid,
|
|
4298
|
+
product: e.product,
|
|
4299
|
+
hero_media: e.hero_media,
|
|
4300
|
+
price: e.price,
|
|
4301
|
+
available: e.available,
|
|
4302
|
+
});
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
// Attribute matrix from the primitive. compareTable walks the
|
|
4306
|
+
// basket's product ids through the `catalog` adapter wired into the
|
|
4307
|
+
// primitive at create time (server.js) — that adapter enriches each
|
|
4308
|
+
// product with a `variants` array (price as `price_minor`, `weight`
|
|
4309
|
+
// from `weight_grams`) so the baked-in variant-sourced attributes
|
|
4310
|
+
// resolve against this catalog's column shape. compareTable refuses
|
|
4311
|
+
// (throws) when no catalog adapter was wired; a resolution failure
|
|
4312
|
+
// degrades to no attribute rows — the fixed Price/Availability rows
|
|
4313
|
+
// still render the side-by-side view.
|
|
4314
|
+
var attrRows = [];
|
|
4315
|
+
try {
|
|
4316
|
+
var table = await deps.productCompare.compareTable({ session_id: sid });
|
|
4317
|
+
attrRows = table.rows || [];
|
|
4318
|
+
} catch (_e) { attrRows = []; }
|
|
4319
|
+
|
|
4320
|
+
_send(res, 200, renderCompare({
|
|
4321
|
+
columns: columns,
|
|
4322
|
+
rows: attrRows,
|
|
4323
|
+
shop_name: shopName,
|
|
4324
|
+
cart_count: cartCount,
|
|
4325
|
+
asset_prefix: _cardAssetPrefix,
|
|
4326
|
+
notice: noticeKey,
|
|
4327
|
+
}));
|
|
4328
|
+
});
|
|
4329
|
+
}
|
|
4330
|
+
|
|
3592
4331
|
// Collections — operator-curated + smart product lists. Public browse
|
|
3593
4332
|
// pages; mounted when the collections primitive is wired.
|
|
3594
4333
|
if (deps.collections) {
|
|
@@ -3696,7 +4435,13 @@ function mount(router, deps) {
|
|
|
3696
4435
|
shop_name: shopName, theme: theme,
|
|
3697
4436
|
}));
|
|
3698
4437
|
}
|
|
3699
|
-
var
|
|
4438
|
+
var rawLines = await deps.cart.listLines(c.id);
|
|
4439
|
+
// Reapply the active quantity-break for each line at its current
|
|
4440
|
+
// quantity before totals — the unit price the customer sees on the
|
|
4441
|
+
// line, the line total, and the cart subtotal all reflect the break.
|
|
4442
|
+
// Recomputed every render (idempotent); the stored snapshot is never
|
|
4443
|
+
// mutated, so changing a line's quantity re-prices it automatically.
|
|
4444
|
+
var lines = await _repriceCartLines(rawLines);
|
|
3700
4445
|
var totals = pricing.totals(c, lines, {});
|
|
3701
4446
|
// Build the variant_id → { product, hero_media } lookup the
|
|
3702
4447
|
// renderer uses to decorate each line with a thumbnail + title.
|
|
@@ -5727,6 +6472,141 @@ function mount(router, deps) {
|
|
|
5727
6472
|
res.end ? res.end() : res.send("");
|
|
5728
6473
|
});
|
|
5729
6474
|
|
|
6475
|
+
// POST /cart/bundle — add every member of a bundle to the cart at the
|
|
6476
|
+
// bundle price, atomically. Reads `bundle_sku` from the form body.
|
|
6477
|
+
// The price is recomputed server-side from the catalog + the bundle
|
|
6478
|
+
// primitive (the client sends only the SKU, never a price). The
|
|
6479
|
+
// bundle discount is allocated across the member lines proportional
|
|
6480
|
+
// to each member's list contribution, so the cart subtotal equals the
|
|
6481
|
+
// bundle's quoted price; integer-cent remainder lands on the last
|
|
6482
|
+
// line. Mounted only when the bundles primitive is wired.
|
|
6483
|
+
if (deps.bundles) {
|
|
6484
|
+
router.post("/cart/bundle", async function (req, res) {
|
|
6485
|
+
var body = req.body || {};
|
|
6486
|
+
var bundleSku = body.bundle_sku;
|
|
6487
|
+
if (!bundleSku || typeof bundleSku !== "string") {
|
|
6488
|
+
res.status(400);
|
|
6489
|
+
return res.end ? res.end("Invalid request") : res.send("Invalid request");
|
|
6490
|
+
}
|
|
6491
|
+
var currency = "USD";
|
|
6492
|
+
// Resolve + price the bundle. A malformed sku throws TypeError
|
|
6493
|
+
// (→ 400); an unknown sku resolves to no bundle (→ 404).
|
|
6494
|
+
var bundle, leaves, priced;
|
|
6495
|
+
try {
|
|
6496
|
+
bundle = await deps.bundles.getBundle(bundleSku);
|
|
6497
|
+
if (!bundle) {
|
|
6498
|
+
res.status(404);
|
|
6499
|
+
return res.end ? res.end("Bundle not found") : res.send("Bundle not found");
|
|
6500
|
+
}
|
|
6501
|
+
leaves = await deps.bundles.expand({ bundle_sku: bundleSku, quantity: 1 });
|
|
6502
|
+
priced = await deps.bundles.priceBundle({ bundle_sku: bundleSku, pricing: _skuPricer(currency) });
|
|
6503
|
+
} catch (e) {
|
|
6504
|
+
if (e instanceof TypeError) {
|
|
6505
|
+
res.status(400);
|
|
6506
|
+
return res.end ? res.end((e && e.message) || "Invalid bundle") : res.send((e && e.message) || "Invalid bundle");
|
|
6507
|
+
}
|
|
6508
|
+
res.status(500);
|
|
6509
|
+
return res.end ? res.end("Error") : res.send("Error");
|
|
6510
|
+
}
|
|
6511
|
+
|
|
6512
|
+
// Pre-flight: every leaf must resolve to a buyable variant + a
|
|
6513
|
+
// current price. All-or-nothing — if any member is unavailable,
|
|
6514
|
+
// add nothing and bounce back to the PDP with a notice (no
|
|
6515
|
+
// half-filled cart). `leaf.quantity` is the per-bundle demand.
|
|
6516
|
+
var members = [];
|
|
6517
|
+
for (var i = 0; i < leaves.length; i += 1) {
|
|
6518
|
+
var leaf = leaves[i];
|
|
6519
|
+
var variant = await _skuBuyable(leaf.sku);
|
|
6520
|
+
if (!variant) {
|
|
6521
|
+
res.status(303);
|
|
6522
|
+
res.setHeader && res.setHeader("location", "/cart?bundle=unavailable");
|
|
6523
|
+
return res.end ? res.end() : res.send("");
|
|
6524
|
+
}
|
|
6525
|
+
var price = await deps.catalog.prices.current(variant.id, currency);
|
|
6526
|
+
if (!price) {
|
|
6527
|
+
res.status(303);
|
|
6528
|
+
res.setHeader && res.setHeader("location", "/cart?bundle=unavailable");
|
|
6529
|
+
return res.end ? res.end() : res.send("");
|
|
6530
|
+
}
|
|
6531
|
+
members.push({
|
|
6532
|
+
variant_id: variant.id,
|
|
6533
|
+
qty: leaf.quantity,
|
|
6534
|
+
list_each: price.amount_minor,
|
|
6535
|
+
list_line: price.amount_minor * leaf.quantity,
|
|
6536
|
+
});
|
|
6537
|
+
}
|
|
6538
|
+
|
|
6539
|
+
// Allocate the bundle total across members proportional to each
|
|
6540
|
+
// member's list contribution, then express each share as an integer
|
|
6541
|
+
// per-unit price. The cart stores one integer unit price per line and
|
|
6542
|
+
// charges qty*unit, so flooring a share to a per-unit price loses up
|
|
6543
|
+
// to (qty-1) cents on a multi-unit line. Those cents are added back
|
|
6544
|
+
// below so the cart subtotal equals the bundle price exactly — never
|
|
6545
|
+
// undercharging the advertised bundle.
|
|
6546
|
+
var listTotal = priced.list_total_minor;
|
|
6547
|
+
var bundleTotal = priced.amount_minor;
|
|
6548
|
+
var allocated = 0;
|
|
6549
|
+
for (var m = 0; m < members.length; m += 1) {
|
|
6550
|
+
var mem = members[m];
|
|
6551
|
+
var share = listTotal > 0
|
|
6552
|
+
? Math.floor((bundleTotal * mem.list_line) / listTotal)
|
|
6553
|
+
: Math.floor(bundleTotal / members.length);
|
|
6554
|
+
mem.alloc_line = share;
|
|
6555
|
+
allocated += share;
|
|
6556
|
+
}
|
|
6557
|
+
// The proportional floor leaves a whole-cent remainder; park it on the
|
|
6558
|
+
// last member's line so the per-line allocations sum to the bundle
|
|
6559
|
+
// total exactly.
|
|
6560
|
+
if (members.length > 0) members[members.length - 1].alloc_line += (bundleTotal - allocated);
|
|
6561
|
+
|
|
6562
|
+
// Convert each member's line allocation to a uniform integer per-unit
|
|
6563
|
+
// price (floor) and tally the cents lost to that floor across all
|
|
6564
|
+
// lines. A single-unit line (the usual bundle shape) absorbs the whole
|
|
6565
|
+
// shortfall exactly; failing that, the cents are returned in qty-sized
|
|
6566
|
+
// steps to the smallest-quantity lines, with any final sub-step residual
|
|
6567
|
+
// rounded up onto the smallest line — so the realized subtotal is never
|
|
6568
|
+
// below the quoted bundle price.
|
|
6569
|
+
var lostCents = 0;
|
|
6570
|
+
for (var u = 0; u < members.length; u += 1) {
|
|
6571
|
+
var me = members[u];
|
|
6572
|
+
me.unit = me.qty > 0 ? Math.floor(me.alloc_line / me.qty) : me.alloc_line;
|
|
6573
|
+
lostCents += me.alloc_line - (me.unit * me.qty);
|
|
6574
|
+
}
|
|
6575
|
+
if (lostCents > 0 && members.length > 0) {
|
|
6576
|
+
var byQty = members.slice().sort(function (a, b) { return a.qty - b.qty; });
|
|
6577
|
+
var single = null;
|
|
6578
|
+
for (var s = 0; s < byQty.length; s += 1) { if (byQty[s].qty === 1) { single = byQty[s]; break; } }
|
|
6579
|
+
if (single) {
|
|
6580
|
+
single.unit += lostCents;
|
|
6581
|
+
} else {
|
|
6582
|
+
for (var g = 0; g < byQty.length && lostCents > 0; g += 1) {
|
|
6583
|
+
while (lostCents >= byQty[g].qty) { byQty[g].unit += 1; lostCents -= byQty[g].qty; }
|
|
6584
|
+
}
|
|
6585
|
+
if (lostCents > 0) { byQty[0].unit += 1; lostCents = 0; }
|
|
6586
|
+
}
|
|
6587
|
+
}
|
|
6588
|
+
|
|
6589
|
+
var resolved = await _getOrCreateCart(req, res, currency);
|
|
6590
|
+
try {
|
|
6591
|
+
for (var k = 0; k < members.length; k += 1) {
|
|
6592
|
+
var mk = members[k];
|
|
6593
|
+
await deps.cart.addLine(resolved.cart.id, {
|
|
6594
|
+
variant_id: mk.variant_id,
|
|
6595
|
+
qty: mk.qty,
|
|
6596
|
+
unit_amount_minor: mk.unit,
|
|
6597
|
+
unit_currency: currency,
|
|
6598
|
+
});
|
|
6599
|
+
}
|
|
6600
|
+
} catch (e) {
|
|
6601
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
6602
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
6603
|
+
}
|
|
6604
|
+
res.status(303);
|
|
6605
|
+
res.setHeader && res.setHeader("location", "/cart");
|
|
6606
|
+
res.end ? res.end() : res.send("");
|
|
6607
|
+
});
|
|
6608
|
+
}
|
|
6609
|
+
|
|
5730
6610
|
// POST /cart/lines/:line_id/update — change qty on an existing
|
|
5731
6611
|
// line. Form value `qty` is the new quantity (1..99). HTML forms
|
|
5732
6612
|
// only support GET/POST so the verb is in the path.
|