@blamejs/blamejs-shop 0.2.28 → 0.2.29

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.2.x
10
10
 
11
+ - v0.2.29 (2026-05-29) — **Show the real total before checkout and truthful stock on every product.** Two storefront truthfulness fixes. The cart and checkout now show the real total before payment — subtotal plus estimated tax, shipping, and any discount, all totalled — computed by the same engine that runs the actual charge, so the figure shown matches what will be charged (labelled estimated until an address narrows the tax and shipping, then exact; a destination with no matching rule reads 'calculated at checkout' rather than a wrong number). Cart lines also surface real stock state. On product pages, the buy box now reflects real per-variant inventory: an out-of-stock variant disables Add to cart with an honest message, and a low-stock variant shows 'Only N left'. **Fixed:** *The cart and checkout show your real total before you pay* — Previously the cart total was just the subtotal and checkout showed no tax/shipping until the payment step. Both now render subtotal + estimated tax + estimated shipping + discount + total, computed from the same tax/shipping primitives as the Stripe charge — the displayed estimate equals the confirmed order total to the cent. Figures are labelled 'estimated' until a shipping address is entered (then exact); a destination with no matching tax or shipping rule reads 'calculated at checkout', and the subtotal stays honest. Cart lines now also show out-of-stock / low-stock state. · *Product availability reflects real inventory* — The product buy box now reads real per-variant stock: an out-of-stock variant disables 'Add to cart' and shows an out-of-stock message (in the badge and the JSON-LD), and a variant at or below the configured low-stock threshold shows 'Only N left'. The add-to-cart control is no longer enabled for items that can't be bought.
12
+
11
13
  - v0.2.28 (2026-05-29) — **Sales reports and printable receipts in the admin console.** The admin console gains a Reports screen and printable order documents. Reports aggregates a sales and revenue summary over a date range you choose — order count, gross and net revenue, refunds, average order value, an order-status funnel, a by-day revenue table, and top products — with a CSV export of the daily series. Every order detail page now offers a printable receipt and a printable packing slip (print-optimized, carrying your shop name and contact), so you can pull a revenue report or print an order without querying the database directly. **Added:** *Reports screen* — `/admin/reports` aggregates a sales/revenue summary over a selectable date range: order count, gross and net revenue, refunds and refund rate, average order value, an order-status funnel, a by-day revenue table, and top products. The daily series exports to CSV. The screen reads only — it never writes. · *Printable receipts and packing slips* — `GET /admin/orders/:id/receipt` and `GET /admin/orders/:id/packing-slip` render self-contained, print-optimized order documents (lines, totals, ship-to, tracking barcode) with your shop name and contact in the masthead. Both are linked from a Documents panel on the admin order detail screen.
12
14
 
13
15
  - v0.2.27 (2026-05-29) — **Upload product images directly from the admin console.** The admin product media manager can now take an image file uploaded straight from your device, in addition to attaching one by URL. On the product detail screen, pick a PNG, JPEG, WebP, GIF, AVIF, or SVG and it streams to object storage and attaches to the product (or a specific variant) in one step. Every upload is validated by both its declared content type and its actual magic bytes — a mismatched or disguised file is refused — and capped at 10 MiB. The upload surface is only present when object storage is configured, so a store without it sees no change. **Added:** *Direct image-file upload in the admin product media manager* — A file picker on the admin product detail screen accepts a local image (`multipart/form-data`) alongside the existing attach-by-URL field. The file is content-type- and magic-byte-validated (png/jpeg/webp/gif/avif/svg), size-capped at 10 MiB, streamed to the object-storage bridge, and attached to the product or variant. JSON API: `POST /admin/products/:id/media/upload-file` and `POST /admin/media/upload-file`. The routes mount only when the object-storage bridge is configured.
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.2.28",
2
+ "version": "0.2.29",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
6
6
  "fingerprinted": "css/admin.72c808274ebcefd9.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-E56XcvNC5nbTWyHJJcepF6sVA1KjRNo/MWFL5F5w2PDN10JNaHdzIAP9a46m2Md8",
10
- "fingerprinted": "css/main.a76d36ac566703d6.css"
9
+ "integrity": "sha384-fnCp5krUbHLkDXg+zRbl1QWTkjKB4VJuu+gyfkn+7t/fsWqiKildlVjrs00p/bno",
10
+ "fingerprinted": "css/main.f9ddd7f75f5f7d09.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
package/lib/storefront.js CHANGED
@@ -1409,10 +1409,15 @@ var VARIANT_ROW =
1409
1409
  // keeps a per-row add form, so the same endpoint contract holds.
1410
1410
  // `variants` is the pre-formatted array [{ id, sku, title, price }]
1411
1411
  // the renderers already build; `escAttr` is the path's HTML escaper.
1412
- // Mirrored byte-for-byte by worker/render/product.js#_buildBuyBox.
1412
+ // `availability` is the resolved `{ in_stock }` shape — when it reports
1413
+ // out of stock the add-to-cart control renders disabled with an honest
1414
+ // message instead of an active button, so the storefront never invites a
1415
+ // purchase the cart-hold path would reject. Mirrored byte-for-byte by
1416
+ // worker/render/product.js#_buildBuyBox.
1413
1417
  var BUYBOX_CHIP_LIMIT = 12;
1414
1418
 
1415
- function _buildBuyBox(variants, escAttr) {
1419
+ function _buildBuyBox(variants, escAttr, availability) {
1420
+ var inStock = !availability || availability.in_stock !== false;
1416
1421
  if (!variants || variants.length === 0) {
1417
1422
  return "<div class=\"pdp__variants\">\n" +
1418
1423
  " <h2 class=\"pdp__variants-title\">Choose a variant</h2>\n" +
@@ -1430,10 +1435,27 @@ function _buildBuyBox(variants, escAttr) {
1430
1435
  " <span class=\"pdp__badge\"><img class=\"pdp__badge-mark\" src=\"/assets/brand/favicon.svg\" alt=\"\" aria-hidden=\"true\" width=\"22\" height=\"22\"> Post-quantum secured checkout · ML-KEM-1024 key agreement · ML-DSA-65 receipt signature.</span>\n" +
1431
1436
  " </div>";
1432
1437
 
1438
+ // Out-of-stock add control: a disabled button + honest message,
1439
+ // reused across all three buy-box shapes so a sold-out product never
1440
+ // offers an active "add to cart" anywhere on the PDP.
1441
+ var soldOutBtn =
1442
+ "<button type=\"submit\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Out of stock</button>\n" +
1443
+ " <p class=\"pdp__soldout-note\" role=\"status\">This item is currently out of stock.</p>";
1444
+ var soldOutRowBtn =
1445
+ "<button type=\"submit\" class=\"btn-primary btn-primary--sm\" disabled aria-disabled=\"true\">Out of stock</button>";
1446
+
1433
1447
  // Many variants → keep the compact table (still a per-row add form).
1434
1448
  if (variants.length > BUYBOX_CHIP_LIMIT) {
1435
1449
  var rows = variants.map(function (v) {
1436
- return _render(VARIANT_ROW, { title: v.title, sku: v.sku, price: v.price, variant_id: v.id });
1450
+ var row = _render(VARIANT_ROW, { title: v.title, sku: v.sku, price: v.price, variant_id: v.id });
1451
+ // When the product is out of stock, swap each per-row add button for
1452
+ // the disabled control so no row offers an active purchase.
1453
+ if (!inStock) {
1454
+ row = row.replace(
1455
+ "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Add to cart</button>",
1456
+ soldOutRowBtn);
1457
+ }
1458
+ return row;
1437
1459
  }).join("");
1438
1460
  return "<div class=\"pdp__variants\">\n" +
1439
1461
  " <h2 class=\"pdp__variants-title\">Choose a variant</h2>\n" +
@@ -1471,13 +1493,17 @@ function _buildBuyBox(variants, escAttr) {
1471
1493
  " <div class=\"pdp__meta\">" + chips + "</div>\n" +
1472
1494
  " </fieldset>";
1473
1495
 
1496
+ var addControl = inStock
1497
+ ? "<button type=\"submit\" class=\"btn-primary cart-page__checkout\">$ add to cart</button>"
1498
+ : soldOutBtn;
1499
+
1474
1500
  return "<div class=\"pdp__buybox\">\n" +
1475
1501
  " <p class=\"featured-product__price\">" + escAttr(lead.price) + "</p>\n" +
1476
1502
  " <form method=\"post\" action=\"/cart/lines\">\n" +
1477
1503
  " " + variantBlock + "\n" +
1478
1504
  " <label class=\"pdp__variants-title\" for=\"buybox-qty\">Quantity</label>\n" +
1479
1505
  " <input id=\"buybox-qty\" type=\"number\" name=\"qty\" value=\"1\" min=\"1\" max=\"99\" class=\"variant-row__qty\" aria-label=\"Quantity\">\n" +
1480
- " <button type=\"submit\" class=\"btn-primary cart-page__checkout\">$ add to cart</button>\n" +
1506
+ " " + addControl + "\n" +
1481
1507
  " </form>\n" +
1482
1508
  " </div>\n" +
1483
1509
  " " + trustLine;
@@ -1489,16 +1515,22 @@ function _buildBuyBox(variants, escAttr) {
1489
1515
  // (the never-block-on-missing-inventory stance the cart-hold path already
1490
1516
  // takes). `requires_shipping` is true if ANY variant ships physically —
1491
1517
  // an all-digital product (`requires_shipping = 0` on every variant)
1492
- // suppresses the "Ships in 1–2 business days" line. Returns a normalised
1493
- // `{ in_stock, requires_shipping }` so the two render paths drive the
1494
- // badge + JSON-LD from the same shape. Defensive request-shape reader:
1495
- // missing/garbage inputs resolve to the available + physical default.
1518
+ // suppresses the "Ships in 1–2 business days" line. `low_stock` is the
1519
+ // smallest still-buyable count across the tracked variants when that count
1520
+ // sits at or below the variant's operator-configured `low_stock_threshold`
1521
+ // (null when no tracked variant is low, or no threshold is set) — drives
1522
+ // the honest "Only N left" nudge without hardcoding a global threshold.
1523
+ // Returns a normalised `{ in_stock, requires_shipping, low_stock }` so the
1524
+ // two render paths drive the badge + CTA + JSON-LD from the same shape.
1525
+ // Defensive request-shape reader: missing/garbage inputs resolve to the
1526
+ // available + physical default.
1496
1527
  function _resolveAvailability(variants, inventoryBySku) {
1497
1528
  variants = Array.isArray(variants) ? variants : [];
1498
1529
  var inv = (inventoryBySku && typeof inventoryBySku === "object") ? inventoryBySku : null;
1499
1530
  var anyTracked = false;
1500
1531
  var anyInStock = false;
1501
1532
  var requiresShipping = false;
1533
+ var lowStock = null;
1502
1534
  for (var i = 0; i < variants.length; i += 1) {
1503
1535
  var v = variants[i] || {};
1504
1536
  // requires_shipping defaults to physical (true) unless the column is
@@ -1512,6 +1544,14 @@ function _resolveAvailability(variants, inventoryBySku) {
1512
1544
  var row = inv[v.sku];
1513
1545
  var available = row ? (Number(row.stock_on_hand) - Number(row.stock_held)) : 0;
1514
1546
  if (available > 0) anyInStock = true;
1547
+ // Low-stock nudge: only when this variant is still buyable AND the
1548
+ // operator set a threshold AND the count is at or below it. Track the
1549
+ // smallest such count so a multi-variant product nudges on its
1550
+ // scarcest in-stock variant.
1551
+ var threshold = row ? Number(row.low_stock_threshold) : NaN;
1552
+ if (available > 0 && Number.isFinite(threshold) && available <= threshold) {
1553
+ if (lowStock === null || available < lowStock) lowStock = available;
1554
+ }
1515
1555
  }
1516
1556
  }
1517
1557
  return {
@@ -1519,6 +1559,9 @@ function _resolveAvailability(variants, inventoryBySku) {
1519
1559
  // so the product reads as in stock (never-block stance).
1520
1560
  in_stock: anyTracked ? anyInStock : true,
1521
1561
  requires_shipping: variants.length === 0 ? true : requiresShipping,
1562
+ // Only surface a low-stock count when the product is actually in stock
1563
+ // — an out-of-stock product never shows "Only N left".
1564
+ low_stock: (anyTracked ? anyInStock : true) ? lowStock : null,
1522
1565
  };
1523
1566
  }
1524
1567
 
@@ -1527,9 +1570,17 @@ function _resolveAvailability(variants, inventoryBySku) {
1527
1570
  // for-byte by worker/render/product.js#_buildAvailability.
1528
1571
  function _buildAvailability(availability) {
1529
1572
  var a = availability || { in_stock: true, requires_shipping: true };
1530
- var stockBadge = a.in_stock
1531
- ? "<span class=\"pdp__badge pdp__badge--ok\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> In stock</span>"
1532
- : "<span class=\"pdp__badge pdp__badge--out\">Out of stock</span>";
1573
+ var low = Number(a.low_stock);
1574
+ var stockBadge;
1575
+ if (!a.in_stock) {
1576
+ stockBadge = "<span class=\"pdp__badge pdp__badge--out\">Out of stock</span>";
1577
+ } else if (Number.isFinite(low) && low > 0) {
1578
+ // In stock but running low — an honest scarcity nudge driven by the
1579
+ // operator's configured threshold, not a hardcoded number.
1580
+ stockBadge = "<span class=\"pdp__badge pdp__badge--low\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> Only " + low + " left</span>";
1581
+ } else {
1582
+ stockBadge = "<span class=\"pdp__badge pdp__badge--ok\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> In stock</span>";
1583
+ }
1533
1584
  // The "Ships in 1–2 business days" line only applies to a physical good;
1534
1585
  // an all-digital product suppresses it (nothing ships).
1535
1586
  var shipBadge = a.requires_shipping
@@ -3668,7 +3719,7 @@ function renderProduct(opts) {
3668
3719
  // loads ({ sku: { stock_on_hand, stock_held } }); absent it, the
3669
3720
  // product reads as in stock (never-block-on-missing-inventory stance).
3670
3721
  var availability = _resolveAvailability(variants, opts.inventory);
3671
- var buyboxHtml = _buildBuyBox(rendered, b.template.escapeHtml);
3722
+ var buyboxHtml = _buildBuyBox(rendered, b.template.escapeHtml, availability);
3672
3723
  var availabilityHtml = _buildAvailability(availability);
3673
3724
  var shippingNoteHtml = _pdpShippingNote(availability);
3674
3725
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
@@ -3814,6 +3865,7 @@ var CART_LINE_EDITABLE =
3814
3865
  " <span class=\"cart-line__product-meta\">\n" +
3815
3866
  " <span class=\"cart-line__product-title\">{{product_title}}</span>\n" +
3816
3867
  " <code class=\"cart-line__sku-chip\">{{sku}}</code>\n" +
3868
+ "RAW_CART_LINE_STOCK" +
3817
3869
  " </span>\n" +
3818
3870
  " </a>\n" +
3819
3871
  " </td>\n" +
@@ -3885,10 +3937,12 @@ function _shipToFromBody(body) {
3885
3937
 
3886
3938
  // Checkout mirrors the cart's two-column shell: the shipping form on the
3887
3939
  // left, a sticky order-summary rail on the right. The summary lists the
3888
- // cart line items + the Subtotal, then the cart's exact honest microcopy
3889
- // tax + shipping can't be computed until the address is entered, so no
3890
- // fabricated "Total" is shown. An "Edit cart" link near the summary lets
3891
- // the shopper change quantities without losing form data.
3940
+ // cart line items + a full Subtotal tax shipping discount → Total
3941
+ // breakdown, computed from the same tax/shipping primitives the charge
3942
+ // runs through (estimated against the destination until the shopper
3943
+ // submits an address; exact on the POST re-render of an entered address).
3944
+ // An "Edit cart" link near the summary lets the shopper change quantities
3945
+ // without losing form data.
3892
3946
  var CHECKOUT_PAGE =
3893
3947
  "<section class=\"checkout-page\">\n" +
3894
3948
  " <header class=\"section-head\">\n" +
@@ -3912,9 +3966,9 @@ var CHECKOUT_PAGE =
3912
3966
  " </div>\n" +
3913
3967
  " <ul class=\"checkout-page__lines\">RAW_SUMMARY_LINES</ul>\n" +
3914
3968
  " <dl class=\"totals-list\">\n" +
3915
- " <div class=\"totals-list__grand\"><dt>Subtotal</dt><dd>{{subtotal}}</dd></div>\n" +
3969
+ "RAW_TOTALS_ROWS" +
3916
3970
  " </dl>\n" +
3917
- " <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n" +
3971
+ "RAW_SUMMARY_NOTE" +
3918
3972
  " </aside>\n" +
3919
3973
  " </div>\n" +
3920
3974
  "</section>\n";
@@ -3973,13 +4027,25 @@ function renderCheckoutForm(opts) {
3973
4027
  var shopName = opts.shop_name || "blamejs.shop";
3974
4028
  var assetPrefix = opts.asset_prefix || "/assets/";
3975
4029
  var lookup = opts.product_lookup || {};
3976
- var subtotal = pricing.format(totals.subtotal_minor, totals.currency);
4030
+ // Full totals breakdown when the route bundled one (`totals_detail` from
4031
+ // _estimateCartTotals); else a subtotal-only fallback so an un-wired
4032
+ // checkout still renders an honest Subtotal. Checkout formats in the
4033
+ // order's own currency (no display-currency conversion at the pay step).
4034
+ var detail = opts.totals_detail || {
4035
+ totals: totals, estimated: true, tax_resolved: false, shipping_resolved: false, shipping_label: null,
4036
+ };
4037
+ var dTotals = detail.totals || totals;
4038
+ var subtotal = pricing.format(dTotals.subtotal_minor, dTotals.currency);
4039
+ var grandTotal = pricing.format(
4040
+ dTotals.grand_total_minor == null ? dTotals.subtotal_minor : dTotals.grand_total_minor,
4041
+ dTotals.currency);
3977
4042
  if (opts.theme) {
3978
4043
  return opts.theme.render("checkout", {
3979
4044
  title: "Checkout",
3980
4045
  shop_name: shopName,
3981
4046
  cart_count: lines.length,
3982
4047
  subtotal: subtotal,
4048
+ total: grandTotal,
3983
4049
  asset_css_main: opts.theme.assetUrl("css/main.css"),
3984
4050
  });
3985
4051
  }
@@ -3990,13 +4056,21 @@ function renderCheckoutForm(opts) {
3990
4056
  var summaryLines = lines.map(function (l) {
3991
4057
  return _checkoutSummaryLine(l, lookup, assetPrefix, pricing.format);
3992
4058
  }).join("");
4059
+ var totalsRows = _buildCartTotalsRows(detail, pricing.format, subtotal, grandTotal);
4060
+ // Honest summary microcopy: an estimate finalizes at the address step;
4061
+ // an entered address reads as the exact total. Mirrors the cart CTA note.
4062
+ var summaryNote = detail.estimated
4063
+ ? " <p class=\"cart-page__note\">Estimated tax and shipping for your destination; the exact total is confirmed when you continue. Payment runs through Stripe.</p>\n"
4064
+ : " <p class=\"cart-page__note\">Total includes tax and shipping for the address above. Payment runs through Stripe.</p>\n";
3993
4065
  // A coded gift-card / loyalty error from a rejected POST re-renders the
3994
4066
  // form with the message inline (role="alert") rather than dead-ending on
3995
4067
  // a separate error page — the shopper fixes the bad code in place.
3996
4068
  var inlineError = opts.inline_error
3997
4069
  ? "<p class=\"auth-form__message auth-form__message--err\" role=\"alert\">" + b.template.escapeHtml(String(opts.inline_error)) + "</p>"
3998
4070
  : "";
3999
- var body = _render(CHECKOUT_PAGE, { subtotal: subtotal })
4071
+ var body = _render(CHECKOUT_PAGE, {})
4072
+ .replace("RAW_TOTALS_ROWS", totalsRows)
4073
+ .replace("RAW_SUMMARY_NOTE", summaryNote)
4000
4074
  .replace("RAW_INLINE_ERROR", inlineError)
4001
4075
  .replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill))
4002
4076
  .replace("RAW_SUMMARY_LINES", summaryLines);
@@ -4544,8 +4618,7 @@ var CART_PAGE =
4544
4618
  " <aside class=\"cart-page__summary\">\n" +
4545
4619
  " <h2 class=\"pdp__variants-title\">Order summary</h2>\n" +
4546
4620
  " <dl class=\"totals-list\">\n" +
4547
- " <div><dt>Subtotal</dt><dd>{{subtotal}}</dd></div>\n" +
4548
- " <div class=\"totals-list__grand\"><dt>Total</dt><dd>{{total}}</dd></div>\n" +
4621
+ "RAW_TOTALS_ROWS" +
4549
4622
  " </dl>\n" +
4550
4623
  "RAW_CHECKOUT_CTA" +
4551
4624
  " </aside>\n" +
@@ -4618,6 +4691,57 @@ function renderCheckoutError(opts) {
4618
4691
  });
4619
4692
  }
4620
4693
 
4694
+ // Build the order-summary `<dl>` rows shared by the cart + checkout
4695
+ // totals lists. Renders Subtotal, an optional discount, tax, shipping,
4696
+ // and the grand Total — composing the real tax/shipping/discount figures
4697
+ // the pricing primitive computed for the destination. Each of tax +
4698
+ // shipping is shown in one of two honest states:
4699
+ //
4700
+ // - a real amount, under an "Estimated tax" / "Estimated shipping" /
4701
+ // "Estimated total" label while the destination isn't yet confirmed
4702
+ // by the shopper (so the figure is never read as the final charge);
4703
+ // - "Calculated at checkout" when no rule/zone matched the destination
4704
+ // (a labelled deferral, never a fabricated 0).
4705
+ //
4706
+ // `fmt` is the per-request price formatter (display-currency aware). When
4707
+ // `t.estimated` is false (the checkout POST re-render of an entered
4708
+ // address) the labels drop the "Estimated" prefix and the figures read
4709
+ // as exact. `subtotalStr`/`totalStr` are pre-formatted so the caller
4710
+ // controls display-currency conversion.
4711
+ function _buildCartTotalsRows(t, fmt, subtotalStr, totalStr) {
4712
+ var totals = t.totals;
4713
+ var estimated = !!t.estimated;
4714
+ var rows = " <div><dt>Subtotal</dt><dd>" + subtotalStr + "</dd></div>\n";
4715
+ if (totals.discount_minor > 0) {
4716
+ rows += " <div class=\"totals-list__discount\"><dt>Discount</dt><dd>−" +
4717
+ fmt(totals.discount_minor, totals.currency) + "</dd></div>\n";
4718
+ }
4719
+ // Tax row.
4720
+ var taxLabel = estimated ? "Estimated tax" : "Tax";
4721
+ if (t.tax_resolved) {
4722
+ rows += " <div><dt>" + taxLabel + "</dt><dd>" + fmt(totals.tax_minor, totals.currency) + "</dd></div>\n";
4723
+ } else if (totals.tax_minor > 0) {
4724
+ // A non-zero tax with no jurisdiction match still reflects a real
4725
+ // computed figure — show it under the estimate label.
4726
+ rows += " <div><dt>" + taxLabel + "</dt><dd>" + fmt(totals.tax_minor, totals.currency) + "</dd></div>\n";
4727
+ } else {
4728
+ rows += " <div class=\"totals-list__pending\"><dt>Tax</dt><dd>Calculated at checkout</dd></div>\n";
4729
+ }
4730
+ // Shipping row.
4731
+ var shipLabel = estimated ? "Estimated shipping" : "Shipping";
4732
+ if (t.shipping_resolved) {
4733
+ var shipValue = totals.shipping_minor === 0 ? "Free" : fmt(totals.shipping_minor, totals.currency);
4734
+ rows += " <div><dt>" + shipLabel + "</dt><dd>" + shipValue + "</dd></div>\n";
4735
+ } else {
4736
+ rows += " <div class=\"totals-list__pending\"><dt>Shipping</dt><dd>Calculated at checkout</dd></div>\n";
4737
+ }
4738
+ // Grand total. On an estimate, label it so the figure is never read as
4739
+ // the committed charge before the address step.
4740
+ var totalLabel = estimated ? "Estimated total" : "Total";
4741
+ rows += " <div class=\"totals-list__grand\"><dt>" + totalLabel + "</dt><dd>" + totalStr + "</dd></div>\n";
4742
+ return rows;
4743
+ }
4744
+
4621
4745
  function renderCart(opts) {
4622
4746
  if (!opts) throw new TypeError("storefront.renderCart: opts required");
4623
4747
  var lines = opts.lines || [];
@@ -4628,6 +4752,10 @@ function renderCart(opts) {
4628
4752
  // route handler bundles it in. Lines without an entry render with
4629
4753
  // a dashed-placeholder tile + the SKU as the fallback title.
4630
4754
  var lookup = opts.product_lookup || {};
4755
+ // Per-line stock state map (variant_id → "out" | "low" | "ok"), bundled
4756
+ // by the route handler. Absent for back-compat callers / the edge empty
4757
+ // cart — those lines render with no badge (the never-block stance).
4758
+ var lineStock = opts.line_stock || {};
4631
4759
  var fmt = _priceFormatter(opts);
4632
4760
  var rendered = lines.map(function (l) {
4633
4761
  var match = lookup[l.variant_id] || null;
@@ -4645,6 +4773,7 @@ function renderCart(opts) {
4645
4773
  product_url: prod ? ("/products/" + prod.slug) : "#",
4646
4774
  image_url: imageUrl,
4647
4775
  image_alt: imageAlt,
4776
+ stock: lineStock[l.variant_id] || "ok",
4648
4777
  };
4649
4778
  });
4650
4779
  var subtotal = fmt(totals.subtotal_minor, totals.currency);
@@ -4678,6 +4807,16 @@ function renderCart(opts) {
4678
4807
  var saveBtn = canSave
4679
4808
  ? "<form method=\"post\" action=\"/cart/lines/" + _escAttr(l.id) + "/save\"><button type=\"submit\" class=\"cart-line__btn cart-line__btn--save\">Save for later</button></form>"
4680
4809
  : "";
4810
+ // Real per-line availability badge — never a hardcoded "in stock".
4811
+ // Out-of-stock + low-stock surface a status pill so the shopper sees
4812
+ // the truth before they commit; an "ok" line shows nothing (the
4813
+ // implied default). `role="status"` so a screen reader announces it.
4814
+ var stockBadge = "";
4815
+ if (l.stock === "out") {
4816
+ stockBadge = " <span class=\"cart-line__stock cart-line__stock--out\" role=\"status\">Out of stock</span>\n";
4817
+ } else if (l.stock === "low") {
4818
+ stockBadge = " <span class=\"cart-line__stock cart-line__stock--low\" role=\"status\">Low stock</span>\n";
4819
+ }
4681
4820
  return _render(CART_LINE_EDITABLE, {
4682
4821
  sku: l.sku,
4683
4822
  qty: l.qty,
@@ -4686,27 +4825,55 @@ function renderCart(opts) {
4686
4825
  line_id: l.id,
4687
4826
  product_title: l.product_title,
4688
4827
  product_url: l.product_url,
4689
- }).replace("RAW_CART_LINE_THUMB", thumb).replace("RAW_CART_LINE_SAVE", saveBtn);
4828
+ }).replace("RAW_CART_LINE_THUMB", thumb).replace("RAW_CART_LINE_STOCK", stockBadge).replace("RAW_CART_LINE_SAVE", saveBtn);
4690
4829
  }).join("");
4691
4830
  // Checkout CTA — only a live button when checkout is actually wired
4692
4831
  // (Stripe configured). Absent that, render a clear, disabled "not set
4693
4832
  // up" notice instead of a link that 404s. Backward-compatible: callers
4694
4833
  // that don't pass the flag (older tests) keep the button.
4695
4834
  var checkoutAvailable = opts.checkout_available !== false;
4835
+ // Truthful CTA note. When the route bundled an estimated totals
4836
+ // breakdown, say the figures are an estimate that finalizes once the
4837
+ // shipping address is entered — never the stale "calculated on the next
4838
+ // step" (which implied the cart total was just the subtotal). Without a
4839
+ // breakdown (back-compat caller), keep the prior note.
4840
+ var ctaNote;
4841
+ if (opts.totals_detail && opts.totals_detail.estimated) {
4842
+ ctaNote = " <p class=\"cart-page__note\">Estimated tax and shipping shown above; the exact total is confirmed once you enter your shipping address. Payment runs through Stripe.</p>\n";
4843
+ } else if (opts.totals_detail) {
4844
+ ctaNote = " <p class=\"cart-page__note\">Total includes tax and shipping for your address. Payment runs through Stripe.</p>\n";
4845
+ } else {
4846
+ ctaNote = " <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n";
4847
+ }
4696
4848
  var checkoutCta = checkoutAvailable
4697
4849
  ? " <a href=\"/checkout\" class=\"btn-primary cart-page__checkout\">Continue to checkout <span aria-hidden=\"true\">→</span></a>\n" +
4698
- " <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n"
4850
+ ctaNote
4699
4851
  : " <button type=\"button\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Checkout unavailable</button>\n" +
4700
4852
  " <p class=\"cart-page__note cart-page__note--warn\" role=\"status\">Online checkout isn't set up for this store yet — payments aren't configured. Your cart is saved; please check back soon.</p>\n";
4701
4853
  // Post-add confirmation banner — rendered only on the `?added=1`
4702
4854
  // redirect from POST /cart/lines so the shopper gets explicit feedback
4703
4855
  // their item landed (the audit found the silent 303 left no cue).
4704
4856
  var notice = opts.added ? CART_ADDED_NOTICE : "";
4857
+ // The order-summary rows. When the route bundled a totals breakdown
4858
+ // (`totals_detail` from _estimateCartTotals), render the full Subtotal
4859
+ // → tax → shipping → discount → Total list with the destination's real
4860
+ // computed figures (estimate-labelled until the address step). Absent
4861
+ // that — a back-compat caller passing only `opts.totals` — fall back to
4862
+ // the bare Subtotal + Total list (byte-identical to the prior shape).
4863
+ var totalsRows;
4864
+ if (opts.totals_detail) {
4865
+ totalsRows = _buildCartTotalsRows(opts.totals_detail, fmt, subtotal, total);
4866
+ } else {
4867
+ totalsRows =
4868
+ " <div><dt>Subtotal</dt><dd>" + subtotal + "</dd></div>\n" +
4869
+ " <div class=\"totals-list__grand\"><dt>Total</dt><dd>" + total + "</dd></div>\n";
4870
+ }
4705
4871
  body = _render(CART_PAGE, {
4706
4872
  line_rows: "RAW_LINES",
4707
- subtotal: subtotal,
4708
- total: total,
4709
- }).replace("RAW_LINES", rows).replace("RAW_CHECKOUT_CTA", checkoutCta).replace("RAW_CART_NOTICE", notice);
4873
+ }).replace("RAW_LINES", rows)
4874
+ .replace("RAW_TOTALS_ROWS", totalsRows)
4875
+ .replace("RAW_CHECKOUT_CTA", checkoutCta)
4876
+ .replace("RAW_CART_NOTICE", notice);
4710
4877
  }
4711
4878
  return _wrap(Object.assign({
4712
4879
  title: "Cart",
@@ -6834,6 +7001,177 @@ function mount(router, deps) {
6834
7001
  return out;
6835
7002
  }
6836
7003
 
7004
+ // Resolve the destination the cart/checkout totals estimate against,
7005
+ // most-specific first: (1) the signed-in customer's default shipping
7006
+ // address, (2) the operator's `shop.estimate_destination` config, (3) a
7007
+ // bare `{ country: "US" }`. Every read is best-effort — a missing
7008
+ // address table, an unmigrated config row, or a malformed saved value
7009
+ // degrades to the next fallback rather than 500-ing the cart. The
7010
+ // returned shape is the `ship_to` the tax/shipping primitives consume,
7011
+ // plus `from_saved` so the renderer can say "estimated for your saved
7012
+ // address" vs "estimated for <country>". A garbage country code from
7013
+ // any source is dropped (→ the US default) so the estimate never throws
7014
+ // inside the primitive's strict validators.
7015
+ async function _estimateDestination(req) {
7016
+ function _normalize(d, fromSaved) {
7017
+ if (!d || typeof d !== "object") return null;
7018
+ var country = typeof d.country === "string" ? d.country.toUpperCase() : "";
7019
+ if (!/^[A-Z]{2}$/.test(country)) return null;
7020
+ var state = (typeof d.state === "string" && /^[A-Za-z0-9]{1,5}$/.test(d.state))
7021
+ ? d.state.toUpperCase() : undefined;
7022
+ var postal = (typeof d.postal === "string" && /^[A-Za-z0-9 -]{1,16}$/.test(d.postal))
7023
+ ? d.postal : undefined;
7024
+ return { ship_to: { country: country, state: state, postal: postal }, from_saved: !!fromSaved };
7025
+ }
7026
+ // (1) Signed-in customer's default shipping address.
7027
+ var coAuth = _currentCustomerEnv(req);
7028
+ if (deps.addresses && coAuth) {
7029
+ try {
7030
+ var rows = await deps.addresses.listForCustomer(coAuth.customer_id, { limit: 50 });
7031
+ var pick = null;
7032
+ for (var ai = 0; ai < rows.length; ai += 1) {
7033
+ if (Number(rows[ai].is_default_shipping) === 1) { pick = rows[ai]; break; }
7034
+ }
7035
+ if (!pick && rows.length) pick = rows[0];
7036
+ if (pick) {
7037
+ var n = _normalize({
7038
+ country: pick.country,
7039
+ // Saved `region` is free-text ("California"); _normalize only
7040
+ // carries it when it already matches the subdivision-code shape.
7041
+ state: pick.region,
7042
+ postal: pick.postal_code,
7043
+ }, true);
7044
+ if (n) return n;
7045
+ }
7046
+ } catch (_e) { /* fall through to config / default */ }
7047
+ }
7048
+ // (2) Operator-configured default destination.
7049
+ if (deps.config && typeof deps.config.get === "function") {
7050
+ try {
7051
+ var cfg = await deps.config.get("shop.estimate_destination", null);
7052
+ var c2 = _normalize(cfg, false);
7053
+ if (c2) return c2;
7054
+ } catch (_e) { /* fall through to the US default */ }
7055
+ }
7056
+ // (3) Bare US default — the storefront's documented estimate locale.
7057
+ return { ship_to: { country: "US" }, from_saved: false };
7058
+ }
7059
+
7060
+ // Compute the cart/checkout totals the shopper sees BEFORE paying.
7061
+ // Composes the SAME tax + shipping primitives the charge runs through
7062
+ // (via checkout.quote, which prices tax against the post-discount
7063
+ // subtotal + picks shipping rates for the destination), so the
7064
+ // displayed grand total agrees with what Stripe is later asked to
7065
+ // charge for that destination. Returns:
7066
+ //
7067
+ // {
7068
+ // totals, // pricing.totals() breakdown (always present)
7069
+ // estimated, // true when tax/shipping are an estimate
7070
+ // // (destination not yet confirmed by the shopper)
7071
+ // tax_resolved, // a tax rule matched the destination
7072
+ // shipping_resolved, // a shipping service priced the destination
7073
+ // shipping_label, // the chosen estimate service's label, or null
7074
+ // destination, // { ship_to, from_saved } the estimate used
7075
+ // }
7076
+ //
7077
+ // `opts.confirmed` marks a destination the shopper actually entered
7078
+ // (the checkout POST re-render) so the figures read as exact, not an
7079
+ // estimate. Degrades gracefully at every step: no checkout dep, a
7080
+ // quote failure, or no shipping zone match all fall back to a
7081
+ // subtotal-only breakdown with tax/shipping flagged unresolved — the
7082
+ // subtotal is always honest, and the renderer labels the rest
7083
+ // "calculated at checkout" rather than fabricating a number.
7084
+ async function _estimateCartTotals(req, c, lines, opts) {
7085
+ opts = opts || {};
7086
+ var base = pricing.totals(c, lines, {}); // subtotal-only, always valid
7087
+ var result = {
7088
+ totals: base,
7089
+ estimated: !opts.confirmed,
7090
+ tax_resolved: false,
7091
+ shipping_resolved: false,
7092
+ shipping_label: null,
7093
+ destination: null,
7094
+ };
7095
+ if (!deps.checkout || typeof deps.checkout.quote !== "function") return result;
7096
+ var dest = opts.ship_to
7097
+ ? { ship_to: opts.ship_to, from_saved: false }
7098
+ : await _estimateDestination(req);
7099
+ result.destination = dest;
7100
+ try {
7101
+ // quote() without a selected_shipping_id returns the tax row + ALL
7102
+ // available shipping services without throwing on selection; we pick
7103
+ // the cheapest as the estimate so the shopper sees the lowest real
7104
+ // shipping figure for the destination (they choose the exact service
7105
+ // at the address step).
7106
+ var quote = await deps.checkout.quote({
7107
+ cart_id: c.id,
7108
+ ship_to: dest.ship_to,
7109
+ });
7110
+ var taxMinor = quote.totals.tax_minor;
7111
+ result.tax_resolved = quote.tax_rate_bps > 0 ||
7112
+ (quote.tax_jurisdiction && quote.tax_jurisdiction !== "fallback");
7113
+ var rates = Array.isArray(quote.shipping_rates) ? quote.shipping_rates : [];
7114
+ var cheapest = null;
7115
+ for (var i = 0; i < rates.length; i += 1) {
7116
+ if (cheapest === null || rates[i].amount_minor < cheapest.amount_minor) cheapest = rates[i];
7117
+ }
7118
+ var shippingMinor = 0;
7119
+ if (cheapest) {
7120
+ shippingMinor = cheapest.amount_minor;
7121
+ result.shipping_resolved = true;
7122
+ result.shipping_label = cheapest.label;
7123
+ }
7124
+ result.totals = pricing.totals(c, lines, {
7125
+ tax_minor: taxMinor,
7126
+ shipping_minor: shippingMinor,
7127
+ discount_minor: quote.totals.discount_minor || 0,
7128
+ });
7129
+ } catch (_e) {
7130
+ // Quote failed (cart not active, primitive error) — keep the honest
7131
+ // subtotal-only breakdown; the renderer shows "calculated at
7132
+ // checkout" for the unresolved lines.
7133
+ return result;
7134
+ }
7135
+ return result;
7136
+ }
7137
+
7138
+ // Per-line stock truth for the cart: maps each line's variant SKU to its
7139
+ // availability state so the cart can say "Low stock" / "Out of stock"
7140
+ // instead of an implied always-buyable. Best-effort — a SKU with no
7141
+ // inventory row (or a read failure) is treated as available, matching the
7142
+ // storefront's never-block-on-missing-inventory stance. Returns a map
7143
+ // keyed by variant_id → "out" | "low" | "ok". Low is the operator's
7144
+ // configured low-stock threshold (`shop.low_stock_threshold`, default 5)
7145
+ // applied to available-on-hand (stock_on_hand − stock_held).
7146
+ async function _cartLineStock(lines) {
7147
+ var out = {};
7148
+ if (!deps.catalog || !deps.catalog.inventory || typeof deps.catalog.inventory.get !== "function") {
7149
+ return out;
7150
+ }
7151
+ var threshold = 5;
7152
+ if (deps.config && typeof deps.config.get === "function") {
7153
+ try {
7154
+ var t = await deps.config.get("shop.low_stock_threshold", 5);
7155
+ if (Number.isInteger(t) && t >= 0) threshold = t;
7156
+ } catch (_e) { /* keep the default */ }
7157
+ }
7158
+ for (var i = 0; i < lines.length; i += 1) {
7159
+ var vId = lines[i].variant_id;
7160
+ if (Object.prototype.hasOwnProperty.call(out, vId)) continue;
7161
+ var sku = lines[i].sku;
7162
+ if (!sku) { out[vId] = "ok"; continue; }
7163
+ try {
7164
+ var inv = await deps.catalog.inventory.get(sku);
7165
+ if (!inv) { out[vId] = "ok"; continue; }
7166
+ var avail = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
7167
+ out[vId] = avail <= 0 ? "out" : (avail <= threshold ? "low" : "ok");
7168
+ } catch (_e) {
7169
+ out[vId] = "ok"; // drop-silent — a read failure never blocks the cart
7170
+ }
7171
+ }
7172
+ return out;
7173
+ }
7174
+
6837
7175
  // POST /currency — set (or clear) the visitor's display-currency choice.
6838
7176
  // Registered only when multi-currency display is wired. Display-only:
6839
7177
  // this NEVER touches the cart / order / payment currency — it sets the
@@ -7506,7 +7844,16 @@ function mount(router, deps) {
7506
7844
  // Recomputed every render (idempotent); the stored snapshot is never
7507
7845
  // mutated, so changing a line's quantity re-prices it automatically.
7508
7846
  var lines = await _repriceCartLines(rawLines);
7509
- var totals = pricing.totals(c, lines, {});
7847
+ // Real total before pay: compose the same tax + shipping primitives the
7848
+ // charge runs through (estimated against the shopper's saved/default
7849
+ // destination until they confirm an address at checkout). Falls back to
7850
+ // a subtotal-only breakdown — with tax/shipping labelled "calculated at
7851
+ // checkout" — when checkout isn't wired or no zone matches.
7852
+ var totalsDetail = await _estimateCartTotals(req, c, lines, {});
7853
+ var totals = totalsDetail.totals;
7854
+ // Truthful per-line stock state (out / low / ok) so the cart never
7855
+ // implies a sold-out line is buyable.
7856
+ var lineStock = await _cartLineStock(lines);
7510
7857
  // Build the variant_id → { product, hero_media } lookup the
7511
7858
  // renderer uses to decorate each line with a thumbnail + title.
7512
7859
  // Cache by variant_id so a cart with the same variant twice
@@ -7527,6 +7874,8 @@ function mount(router, deps) {
7527
7874
  _send(res, 200, renderCart(Object.assign({
7528
7875
  lines: lines,
7529
7876
  totals: totals,
7877
+ totals_detail: totalsDetail,
7878
+ line_stock: lineStock,
7530
7879
  product_lookup: productLookup,
7531
7880
  can_save: !!(deps.saveForLater && deps.customers),
7532
7881
  checkout_available: !!(deps.checkout && deps.order),
@@ -7578,8 +7927,18 @@ function mount(router, deps) {
7578
7927
  // catch (so a rejected gift-card / loyalty code re-renders the same
7579
7928
  // form inline instead of dead-ending). `inlineError` is the optional
7580
7929
  // message to surface at the top of the form.
7581
- async function _checkoutRenderOpts(req, c, lines, inlineError) {
7582
- var totals = pricing.totals(c, lines, {});
7930
+ async function _checkoutRenderOpts(req, c, lines, inlineError, confirmedShipTo) {
7931
+ // Full totals breakdown the summary renders. When the shopper has
7932
+ // entered an address (the POST re-render passes `confirmedShipTo`),
7933
+ // compute the EXACT total against it (estimated:false); otherwise
7934
+ // estimate against the saved/default destination so the summary
7935
+ // still shows a real Subtotal → tax → shipping → Total rather than a
7936
+ // bare subtotal. Composes the same tax/shipping primitives the charge
7937
+ // runs through (via checkout.quote inside _estimateCartTotals).
7938
+ var totalsDetail = await _estimateCartTotals(req, c, lines, confirmedShipTo
7939
+ ? { ship_to: confirmedShipTo, confirmed: true }
7940
+ : {});
7941
+ var totals = totalsDetail.totals;
7583
7942
  // variant_id → { product, hero_media } lookup for the summary
7584
7943
  // thumbnails + titles — same shape (and caching) the cart route uses.
7585
7944
  var checkoutLookup = {};
@@ -7630,7 +7989,8 @@ function mount(router, deps) {
7630
7989
  } catch (_e) { prefill = null; }
7631
7990
  }
7632
7991
  return {
7633
- lines: lines, totals: totals, shop_name: shopName, theme: theme,
7992
+ lines: lines, totals: totals, totals_detail: totalsDetail,
7993
+ shop_name: shopName, theme: theme,
7634
7994
  product_lookup: checkoutLookup,
7635
7995
  paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
7636
7996
  loyalty_balance: loyaltyBalance,
@@ -7739,7 +8099,11 @@ function mount(router, deps) {
7739
8099
  try {
7740
8100
  var coLines = await _repriceCartLines(await deps.cart.listLines(c.id));
7741
8101
  if (coLines.length) {
7742
- return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, coLines, msg)));
8102
+ // The shopper already entered an address on this POST — re-price
8103
+ // the summary against it (exact, not estimated) so the inline
8104
+ // re-render shows the same total the confirm path computed.
8105
+ var confirmedTo = (shipTo && /^[A-Z]{2}$/.test(shipTo.country)) ? shipTo : null;
8106
+ return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, coLines, msg, confirmedTo)));
7743
8107
  }
7744
8108
  } catch (_re) { /* fall through to the styled error page */ }
7745
8109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.28",
3
+ "version": "0.2.29",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {