@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/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)) + "&times;</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 &amp; 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 lines = await deps.cart.listLines(c.id);
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.