@blamejs/blamejs-shop 0.1.25 → 0.1.27

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
@@ -1413,6 +1413,186 @@ function renderReturns(opts) {
1413
1413
  });
1414
1414
  }
1415
1415
 
1416
+ // Loyalty transaction-type pill — reuses the `pdp__badge` class the
1417
+ // theme already styles. The type is one of the ledger's closed enum
1418
+ // (earn / redeem / expire / adjust / tier-bonus).
1419
+ function _loyaltyTxBadge(type) {
1420
+ var esc = b.template.escapeHtml;
1421
+ return "<span class=\"pdp__badge loyalty-tx--" + esc(String(type)) + "\">" + esc(String(type)) + "</span>";
1422
+ }
1423
+
1424
+ // Human label for a reward `kind` + its value payload. Keeps the
1425
+ // catalog row readable without leaking the raw value_json shape.
1426
+ function _loyaltyRewardValue(reward) {
1427
+ var v = reward.value_json || {};
1428
+ if (reward.kind === "discount_percent") return (Number(v.percent) || 0) + "% off";
1429
+ if (reward.kind === "discount_amount") return pricing.format(Number(v.amount_minor) || 0, "USD") + " off";
1430
+ if (reward.kind === "free_shipping") return "Free shipping";
1431
+ if (reward.kind === "free_product") return "Free product";
1432
+ return reward.kind;
1433
+ }
1434
+
1435
+ // Earn-rule trigger → customer-facing phrase. Operators see slugs;
1436
+ // customers see plain language. Unknown triggers fall back to the raw
1437
+ // trigger so a future enum addition still renders something.
1438
+ var LOYALTY_TRIGGER_LABELS = {
1439
+ per_dollar_spent: "per $1 spent",
1440
+ per_purchase: "per order",
1441
+ per_review: "per review you write",
1442
+ per_referral_redeemed: "per friend you refer",
1443
+ birthday: "on your birthday",
1444
+ signup_bonus: "when you sign up",
1445
+ first_purchase: "on your first order",
1446
+ abandoned_cart_recovered: "when you complete a saved cart",
1447
+ };
1448
+
1449
+ // The signed-in customer's loyalty surface: balance + tier, how points
1450
+ // are earned (active earn rules), the reward catalog (redeem control
1451
+ // when wired), the earn/redeem ledger (paginated), and past
1452
+ // redemptions. Reuses the account/returns layout classes — no new CSS.
1453
+ function renderLoyalty(opts) {
1454
+ opts = opts || {};
1455
+ var esc = b.template.escapeHtml;
1456
+ var bal = opts.balance || { balance: 0, lifetime: 0, tier: "bronze" };
1457
+ var ratio = Number(opts.redemption_points_per_usd) || 100;
1458
+
1459
+ var notice = opts.notice
1460
+ ? "<p class=\"form-notice" + (opts.notice_kind === "error" ? " form-notice--error" : "") + "\" role=\"alert\">" +
1461
+ esc(String(opts.notice)) + "</p>"
1462
+ : "";
1463
+
1464
+ // Stats strip — balance / tier / lifetime / spendable value.
1465
+ var spendableValue = pricing.format(Math.floor((Number(bal.balance) || 0) * 100 / ratio), "USD");
1466
+ var stats =
1467
+ "<dl class=\"account-dash__stats\">" +
1468
+ "<div><dt>Points balance</dt><dd>" + esc(String(Number(bal.balance) || 0)) + "</dd></div>" +
1469
+ "<div><dt>Tier</dt><dd>" + esc(String(bal.tier || "bronze")) + "</dd></div>" +
1470
+ "<div><dt>Lifetime points</dt><dd>" + esc(String(Number(bal.lifetime) || 0)) + "</dd></div>" +
1471
+ "<div><dt>Worth</dt><dd>" + esc(spendableValue) + "</dd></div>" +
1472
+ "</dl>";
1473
+
1474
+ // How points are earned.
1475
+ var rules = opts.earn_rules || [];
1476
+ var earnInner = "";
1477
+ for (var i = 0; i < rules.length; i += 1) {
1478
+ var rule = rules[i];
1479
+ var label = LOYALTY_TRIGGER_LABELS[rule.trigger] || rule.trigger;
1480
+ earnInner +=
1481
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1482
+ "<span>" + esc(String(rule.points_per_unit)) + " points " + esc(label) + "</span>" +
1483
+ "</div></li>";
1484
+ }
1485
+ var earnSection = earnInner
1486
+ ? "<h2 class=\"pdp__variants-title\">How you earn points</h2><ul class=\"return-list\">" + earnInner + "</ul>"
1487
+ : "<h2 class=\"pdp__variants-title\">How you earn points</h2>" +
1488
+ "<p class=\"return-empty\">Earn points on every order — your balance grows as you shop.</p>";
1489
+
1490
+ // Reward catalog + redeem control.
1491
+ var rewards = opts.rewards || [];
1492
+ var rewardSection = "";
1493
+ if (rewards.length) {
1494
+ var rewardItems = "";
1495
+ for (var r = 0; r < rewards.length; r += 1) {
1496
+ var rw = rewards[r];
1497
+ var affordable = (Number(bal.balance) || 0) >= Number(rw.point_cost);
1498
+ var action;
1499
+ if (opts.can_redeem) {
1500
+ action = "<form method=\"post\" action=\"/account/loyalty/redeem\">" +
1501
+ "<input type=\"hidden\" name=\"reward_slug\" value=\"" + esc(rw.slug) + "\">" +
1502
+ "<button type=\"submit\" class=\"btn-primary\"" + (affordable ? "" : " disabled") + ">" +
1503
+ (affordable ? "Redeem" : "Not enough points") + "</button></form>";
1504
+ } else {
1505
+ action = "";
1506
+ }
1507
+ rewardItems +=
1508
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1509
+ "<span class=\"return-card__rma\">" + esc(rw.title) + "</span>" +
1510
+ "<span class=\"pdp__badge\">" + esc(_loyaltyRewardValue(rw)) + "</span>" +
1511
+ "</div>" +
1512
+ "<p class=\"return-card__meta\">" + esc(String(rw.point_cost)) + " points</p>" +
1513
+ action +
1514
+ "</li>";
1515
+ }
1516
+ rewardSection =
1517
+ "<h2 class=\"pdp__variants-title\">Redeem your points</h2>" +
1518
+ "<ul class=\"return-list\">" + rewardItems + "</ul>";
1519
+ }
1520
+
1521
+ // Past redemptions.
1522
+ var reds = opts.redemptions || [];
1523
+ var redSection = "";
1524
+ if (reds.length) {
1525
+ var redItems = "";
1526
+ for (var d = 0; d < reds.length; d += 1) {
1527
+ var red = reds[d];
1528
+ var rdate = red.redeemed_at ? new Date(Number(red.redeemed_at)).toISOString().slice(0, 10) : "";
1529
+ redItems +=
1530
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1531
+ "<span class=\"return-card__rma\">" + esc(red.reward_slug) + "</span>" +
1532
+ "<span class=\"pdp__badge loyalty-tx--" + esc(String(red.status)) + "\">" + esc(String(red.status)) + "</span>" +
1533
+ "</div>" +
1534
+ "<p class=\"return-card__meta\">" + esc(String(red.points_debited)) + " points" +
1535
+ (rdate ? " &middot; <time datetime=\"" + esc(rdate) + "\">" + esc(rdate) + "</time>" : "") +
1536
+ (red.coupon_code ? " &middot; code <code>" + esc(red.coupon_code) + "</code>" : "") +
1537
+ "</p></li>";
1538
+ }
1539
+ redSection = "<h2 class=\"pdp__variants-title\">Your redemptions</h2><ul class=\"return-list\">" + redItems + "</ul>";
1540
+ }
1541
+
1542
+ // Earn/redeem ledger (paginated).
1543
+ var hist = opts.history || [];
1544
+ var histInner = "";
1545
+ for (var h = 0; h < hist.length; h += 1) {
1546
+ var tx = hist[h];
1547
+ var tdate = tx.occurred_at ? new Date(Number(tx.occurred_at)).toISOString().slice(0, 10) : "";
1548
+ var pts = Number(tx.points) || 0;
1549
+ var ptsStr = (pts > 0 ? "+" : "") + pts;
1550
+ histInner +=
1551
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
1552
+ _loyaltyTxBadge(tx.transaction_type) +
1553
+ "<span class=\"return-card__rma\">" + esc(ptsStr) + " points</span>" +
1554
+ "</div>" +
1555
+ "<p class=\"return-card__meta\">" + esc(String(tx.source || "")) +
1556
+ (tdate ? " &middot; <time datetime=\"" + esc(tdate) + "\">" + esc(tdate) + "</time>" : "") +
1557
+ (tx.notes ? " &middot; " + esc(String(tx.notes)) : "") +
1558
+ "</p></li>";
1559
+ }
1560
+ var historySection;
1561
+ if (histInner) {
1562
+ var more = opts.history_next_cursor != null
1563
+ ? "<p class=\"loyalty-more\"><a class=\"btn-secondary\" href=\"/account/loyalty?cursor=" +
1564
+ esc(String(opts.history_next_cursor)) + "\">Older activity</a></p>"
1565
+ : "";
1566
+ historySection = "<h2 class=\"pdp__variants-title\">Activity</h2><ul class=\"return-list\">" + histInner + "</ul>" + more;
1567
+ } else {
1568
+ historySection = "<h2 class=\"pdp__variants-title\">Activity</h2>" +
1569
+ "<p class=\"return-empty\">No points activity yet. Place an order to start earning.</p>";
1570
+ }
1571
+
1572
+ var body =
1573
+ "<section class=\"account-returns\">" +
1574
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1575
+ "<li><a href=\"/account\">Account</a></li>" +
1576
+ "<li aria-current=\"page\">Rewards</li>" +
1577
+ "</ol></nav>" +
1578
+ "<h1 class=\"account-returns__title\">Rewards</h1>" +
1579
+ notice +
1580
+ stats +
1581
+ rewardSection +
1582
+ earnSection +
1583
+ redSection +
1584
+ historySection +
1585
+ "</section>";
1586
+
1587
+ return _wrap({
1588
+ title: "Rewards",
1589
+ shop_name: opts.shop_name || "blamejs.shop",
1590
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1591
+ theme_css: opts.theme_css,
1592
+ body: body,
1593
+ });
1594
+ }
1595
+
1416
1596
  // Subscription status pill — mirrors `_returnStatusBadge`. The status
1417
1597
  // string is one of Stripe's enum values (active, trialing, past_due,
1418
1598
  // canceled, …), surfaced as a CSS-classed badge the theme can style.
@@ -1748,6 +1928,28 @@ var CHECKOUT_PAGE =
1748
1928
  " </form>\n" +
1749
1929
  "</section>\n";
1750
1930
 
1931
+ // Redeem-points-at-checkout field — appended to the checkout form for a
1932
+ // signed-in customer with a spendable balance. The customer types how
1933
+ // many points to spend; the server caps the credit at the order total
1934
+ // and at the balance (the `loyalty_redeem_points` field rides the same
1935
+ // POST as the gift-card code). The max attribute is advisory client-
1936
+ // side polish; the backend is authoritative.
1937
+ function _loyaltyCheckoutField(bal, perUsd) {
1938
+ var esc = b.template.escapeHtml;
1939
+ var points = Number(bal.balance) || 0;
1940
+ var ratio = Number(perUsd) || 100;
1941
+ // Minor-unit value of the full balance (points / ratio dollars), for
1942
+ // the helper line. Floored to a whole point's worth.
1943
+ var worth = pricing.format(Math.floor((points * 100) / ratio), "USD");
1944
+ return "<div class=\"form-row\"><label class=\"form-field\">" +
1945
+ "<span class=\"form-field__label\">Redeem loyalty points <span class=\"small\">(optional)</span></span>" +
1946
+ "<input type=\"number\" name=\"loyalty_redeem_points\" min=\"0\" step=\"1\" max=\"" + points + "\" " +
1947
+ "inputmode=\"numeric\" autocomplete=\"off\" placeholder=\"0\">" +
1948
+ "<span class=\"form-field__req\">You have " + esc(String(points)) + " points (worth " + esc(worth) +
1949
+ ") &middot; " + esc(String(ratio)) + " points = $1</span>" +
1950
+ "</label></div>";
1951
+ }
1952
+
1751
1953
  function renderCheckoutForm(opts) {
1752
1954
  if (!opts) throw new TypeError("storefront.renderCheckoutForm: opts required");
1753
1955
  var lines = opts.lines || [];
@@ -1764,6 +1966,19 @@ function renderCheckoutForm(opts) {
1764
1966
  });
1765
1967
  }
1766
1968
  var body = _render(CHECKOUT_PAGE, { subtotal: subtotal });
1969
+ // Signed-in customer with a spendable points balance — surface a
1970
+ // redeem-at-checkout field. The block is appended as raw HTML (the
1971
+ // balance + value are numbers we control, the conversion ratio is the
1972
+ // ledger's own constant) so it slots into the existing form via a
1973
+ // small client island that copies the field into the POST. Rendered
1974
+ // only when there's a balance to spend; absent that the checkout is
1975
+ // unchanged for guests + zero-balance customers.
1976
+ if (opts.loyalty_balance && opts.loyalty_balance.balance > 0) {
1977
+ body = body.replace(
1978
+ "</form>",
1979
+ _loyaltyCheckoutField(opts.loyalty_balance, opts.loyalty_points_per_usd) + "</form>",
1980
+ );
1981
+ }
1767
1982
  // When PayPal is configured, append its button below the card form. The
1768
1983
  // block is built as raw HTML (appended after the strict render) so the SDK
1769
1984
  // script + handlers survive; the client-id is the only interpolation and is
@@ -2569,6 +2784,7 @@ var ACCOUNT_DASH_PAGE =
2569
2784
  " <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
2570
2785
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
2571
2786
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
2787
+ " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
2572
2788
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
2573
2789
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
2574
2790
  " </div>\n" +
@@ -2694,6 +2910,20 @@ function mount(router, deps) {
2694
2910
  res.end ? res.end(html) : res.send(html);
2695
2911
  }
2696
2912
 
2913
+ // Defensive request-shape reader for the optional loyalty-points
2914
+ // redeem field on the checkout form. Returns undefined for missing /
2915
+ // empty / zero / non-numeric input (checkout treats undefined as "no
2916
+ // redemption"); a positive integer otherwise. The backend's
2917
+ // _resolveLoyaltyCredit is authoritative on balance + cap — this only
2918
+ // shapes the wire value so a blank field doesn't become a "0 points"
2919
+ // validation error.
2920
+ function _parseRedeemPoints(raw) {
2921
+ if (raw == null || raw === "") return undefined;
2922
+ var n = parseInt(String(raw), 10);
2923
+ if (!Number.isFinite(n) || n <= 0) return undefined;
2924
+ return n;
2925
+ }
2926
+
2697
2927
  // Cart-count read shared across every handler that wraps a page in
2698
2928
  // the layout — the header's cart pill renders the count. Returns 0
2699
2929
  // for visitors with no session cookie. Defined at the top of mount
@@ -3021,7 +3251,23 @@ function mount(router, deps) {
3021
3251
  return res.end ? res.end() : res.send("");
3022
3252
  }
3023
3253
  var totals = pricing.totals(c, lines, {});
3024
- _send(res, 200, renderCheckoutForm({ lines: lines, totals: totals, shop_name: shopName, theme: theme, paypal_client_id: deps.paypal ? deps.paypal_client_id : null }));
3254
+ // Loyalty balance only for a signed-in customer with loyalty
3255
+ // wired. A read failure (table not migrated) degrades to no
3256
+ // redeem field rather than 500-ing checkout.
3257
+ var loyaltyBalance = null;
3258
+ if (deps.loyalty) {
3259
+ var loyAuth = _currentCustomerEnv(req);
3260
+ if (loyAuth) {
3261
+ try { loyaltyBalance = await deps.loyalty.balance(loyAuth.customer_id); }
3262
+ catch (_e) { loyaltyBalance = null; }
3263
+ }
3264
+ }
3265
+ _send(res, 200, renderCheckoutForm({
3266
+ lines: lines, totals: totals, shop_name: shopName, theme: theme,
3267
+ paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
3268
+ loyalty_balance: loyaltyBalance,
3269
+ loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
3270
+ }));
3025
3271
  });
3026
3272
 
3027
3273
  router.post("/checkout", async function (req, res) {
@@ -3063,6 +3309,7 @@ function mount(router, deps) {
3063
3309
  selected_shipping_id: defaultShipId || "std",
3064
3310
  customer: { email: body.email, name: body.name },
3065
3311
  gift_card_code: body.gift_card_code || undefined,
3312
+ loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
3066
3313
  idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
3067
3314
  });
3068
3315
  // When a gift card fully covered the order there's no Stripe
@@ -3083,11 +3330,13 @@ function mount(router, deps) {
3083
3330
  res.setHeader && res.setHeader("location", "/pay/" + result.order.id);
3084
3331
  return res.end ? res.end() : res.send("");
3085
3332
  } catch (e) {
3086
- // A bad customer input (malformed shape OR a gift-card code the
3087
- // customer typed that can't be applied) is a 400, not a 500 —
3088
- // the gift-card errors carry a GIFTCARD_* code so a fat-fingered
3089
- // code re-prompts rather than 500-ing the checkout.
3090
- var clientErr = (e instanceof TypeError) || (e && typeof e.code === "string" && e.code.indexOf("GIFTCARD_") === 0);
3333
+ // A bad customer input (malformed shape, a gift-card code, or a
3334
+ // loyalty-points request the customer entered that can't be
3335
+ // applied) is a 400, not a 500 — the gift-card errors carry a
3336
+ // GIFTCARD_* code and the loyalty errors a LOYALTY_* code so a
3337
+ // fat-fingered value re-prompts rather than 500-ing checkout.
3338
+ var code = (e && typeof e.code === "string") ? e.code : "";
3339
+ var clientErr = (e instanceof TypeError) || code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0;
3091
3340
  res.status(clientErr ? 400 : 500);
3092
3341
  var msg = (e && e.message) || "checkout failed";
3093
3342
  return res.end ? res.end(msg) : res.send(msg);
@@ -4414,6 +4663,124 @@ function mount(router, deps) {
4414
4663
  });
4415
4664
  }
4416
4665
 
4666
+ // Loyalty — the signed-in customer's points balance + tier, the
4667
+ // earn/redeem ledger, how points are earned, and (when a reward
4668
+ // catalog + redemption primitive are wired) a redeem-a-reward
4669
+ // control. Login-gated; a read failure on any optional sub-read
4670
+ // degrades that section to empty rather than 500-ing the page.
4671
+ if (deps.loyalty) {
4672
+ function _loyaltyAuth(req, res) {
4673
+ var auth;
4674
+ try { auth = _currentCustomer(req); }
4675
+ catch (e) {
4676
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
4677
+ throw e;
4678
+ }
4679
+ if (!auth) {
4680
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
4681
+ res.end ? res.end() : res.send("");
4682
+ return null;
4683
+ }
4684
+ return auth;
4685
+ }
4686
+
4687
+ // Build the full /account/loyalty render context for a customer.
4688
+ // `opts2.cursor` is the optional history-page cursor (epoch-ms).
4689
+ // Each optional read (earn rules, reward catalog, redemptions) is
4690
+ // best-effort so a not-migrated table degrades that panel rather
4691
+ // than the page.
4692
+ async function _loyaltyView(req, auth, opts2) {
4693
+ opts2 = opts2 || {};
4694
+ var bal = await deps.loyalty.balance(auth.customer_id);
4695
+ var hist = await deps.loyalty.history(auth.customer_id, { limit: 20, cursor: opts2.cursor });
4696
+ var rules = [];
4697
+ if (deps.loyaltyEarnRules) {
4698
+ try { rules = await deps.loyaltyEarnRules.listRules({ active_only: true, limit: 50 }); }
4699
+ catch (_e) { rules = []; }
4700
+ }
4701
+ var rewards = [];
4702
+ var redemptions = [];
4703
+ if (deps.loyaltyRedemption) {
4704
+ try { rewards = await deps.loyaltyRedemption.listRewards({ active_only: true, limit: 50 }); }
4705
+ catch (_e) { rewards = []; }
4706
+ try {
4707
+ var rpage = await deps.loyaltyRedemption.redemptionsForCustomer(auth.customer_id, { limit: 20 });
4708
+ redemptions = rpage.rows;
4709
+ } catch (_e) { redemptions = []; }
4710
+ }
4711
+ var cartCount = await _cartCountForReq(req);
4712
+ return {
4713
+ balance: bal,
4714
+ tiers: deps.loyalty.TIERS,
4715
+ tier_thresholds: deps.loyalty.TIER_THRESHOLDS,
4716
+ redemption_points_per_usd: deps.loyalty.REDEMPTION_POINTS_PER_USD,
4717
+ history: hist.rows,
4718
+ history_next_cursor: hist.next_cursor,
4719
+ earn_rules: rules,
4720
+ rewards: rewards,
4721
+ redemptions: redemptions,
4722
+ can_redeem: !!deps.loyaltyRedemption,
4723
+ notice: opts2.notice || null,
4724
+ notice_kind: opts2.notice_kind || null,
4725
+ shop_name: shopName,
4726
+ cart_count: cartCount,
4727
+ };
4728
+ }
4729
+
4730
+ router.get("/account/loyalty", async function (req, res) {
4731
+ var auth = _loyaltyAuth(req, res); if (!auth) return;
4732
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4733
+ var cursorRaw = url && url.searchParams.get("cursor");
4734
+ var cursor;
4735
+ if (cursorRaw != null && cursorRaw !== "") {
4736
+ var n = parseInt(cursorRaw, 10);
4737
+ // A malformed cursor degrades to the first page (the lib
4738
+ // would TypeError on a non-integer) — never 500 the page.
4739
+ cursor = Number.isFinite(n) && n >= 0 ? n : undefined;
4740
+ }
4741
+ var view = await _loyaltyView(req, auth, { cursor: cursor });
4742
+ _send(res, 200, renderLoyalty(view));
4743
+ });
4744
+
4745
+ // Redeem a reward from the catalog. Mounts only when the
4746
+ // redemption primitive is wired. The reward debits points via the
4747
+ // composed loyalty ledger; insufficient balance / cap reached /
4748
+ // not-redeemable surface as a 400 re-render, never a 500.
4749
+ if (deps.loyaltyRedemption) {
4750
+ router.post("/account/loyalty/redeem", async function (req, res) {
4751
+ var auth = _loyaltyAuth(req, res); if (!auth) return;
4752
+ var body = req.body || {};
4753
+ var rewardSlug = body.reward_slug;
4754
+ try {
4755
+ await deps.loyaltyRedemption.redeemForCustomer({
4756
+ customer_id: auth.customer_id,
4757
+ reward_slug: rewardSlug,
4758
+ });
4759
+ } catch (e) {
4760
+ // Customer-facing refusals (TypeError on a bad slug,
4761
+ // LOYALTY_INSUFFICIENT_BALANCE, REWARD_NOT_REDEEMABLE,
4762
+ // REDEMPTION_CAP_REACHED) re-render the page with the
4763
+ // reason; anything else propagates as a 500.
4764
+ var code = (e && typeof e.code === "string") ? e.code : "";
4765
+ var clientErr = (e instanceof TypeError)
4766
+ || code === "LOYALTY_INSUFFICIENT_BALANCE"
4767
+ || code === "REWARD_NOT_REDEEMABLE"
4768
+ || code === "REDEMPTION_CAP_REACHED";
4769
+ if (!clientErr) throw e;
4770
+ var msg;
4771
+ if (code === "LOYALTY_INSUFFICIENT_BALANCE") msg = "You don't have enough points for that reward yet.";
4772
+ else if (code === "REWARD_NOT_REDEEMABLE") msg = "That reward isn't available right now.";
4773
+ else if (code === "REDEMPTION_CAP_REACHED") msg = "You've reached the redemption limit for that reward.";
4774
+ else msg = "We couldn't redeem that reward — please pick one from the list.";
4775
+ var failView = await _loyaltyView(req, auth, { notice: msg, notice_kind: "error" });
4776
+ return _send(res, 400, renderLoyalty(failView));
4777
+ }
4778
+ res.status(303); res.setHeader && res.setHeader("location", "/account/loyalty");
4779
+ return res.end ? res.end() : res.send("");
4780
+ });
4781
+ }
4782
+ }
4783
+
4417
4784
  // Recently viewed — the signed-in customer's newest-first browse
4418
4785
  // history. Views are recorded server-side on the (container-rendered)
4419
4786
  // PDP; this surface lets the customer review + clear that history.
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.12.62",
7
- "tag": "v0.12.62",
6
+ "version": "0.12.63",
7
+ "tag": "v0.12.63",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.63 (2026-05-25) — **`b.cloudEvents` gains the JSON event format, batch, and the HTTP binding.** b.cloudEvents grows beyond wrap / parse into a full CloudEvents 1.0.2 surface. b.cloudEvents.validate / isValid check an envelope against the spec without throwing (the non-throwing companion to parse). toJSON / fromJSON serialize and parse the JSON event format, and toJSONBatch / fromJSONBatch handle the JSON batch format; untrusted bodies parse through the framework's bounded, prototype-pollution-safe JSON reader. The new http.* binding speaks both content modes the spec defines — binary mode spreads context attributes across percent-encoded ce-* headers with the data in the body, structured mode carries the whole event as application/cloudevents+json — plus the batch mode, and http.decode auto-detects the incoming mode from Content-Type exactly as a conformant receiver does. Verified against the spec's normative example events. **Added:** *`b.cloudEvents` JSON event format, batch, and HTTP binding* — `validate` / `isValid` report spec violations without throwing (the non-throwing companion to `parse`). `toJSON` / `fromJSON` and `toJSONBatch` / `fromJSONBatch` serialize and parse the JSON event and batch formats over the existing envelope shape. `http.encodeBinary` / `http.encodeStructured` / `http.encodeBatch` render the three HTTP content modes — binary spreads attributes across percent-encoded `ce-*` headers, structured and batched carry the event(s) as `application/cloudevents+json` / `application/cloudevents-batch+json` — and `http.decode` parses a request back into an envelope (or array) by auto-detecting the mode from `Content-Type`. `b.jtd` or `b.safeSchema` still validate the event's `data` payload. **Fixed:** *`b.csp.build` accepts `fenced-frame-src` and `webrtc`* — The CSP3 `fenced-frame-src` directive — which the default security-headers policy emits to block `<fencedframe>` embeds — was missing from the builder's recognized-directive set, so the default policy could not round-trip through `b.csp.build` (it threw `csp/unknown-directive`). Both `fenced-frame-src` and the CSP3 `webrtc` directive are now recognized.
12
+
11
13
  - v0.12.62 (2026-05-26) — **`b.jtd` — JSON Type Definition validation (RFC 8927).** Validate JSON against a JSON Type Definition schema (RFC 8927) — a small, portable, cross-implementation schema language, the interop-friendly companion to the framework's fluent b.safeSchema builder. b.jtd.validate(schema, instance) returns an array of { instancePath, schemaPath } errors (empty = valid); b.jtd.isValid is the boolean form. All eight schema forms are supported — empty, type, enum, elements, properties (with optional / additional properties and nullable), values, discriminator (with mapping), and ref (with definitions) — including the integer-range and RFC 3339 timestamp types. A malformed schema is rejected at compile time with jtd/bad-schema rather than silently mis-validating. Verified against the official json-typedef-spec suites: all 316 validation cases and all 49 invalid-schema cases. **Added:** *`b.jtd.validate(schema, instance)` / `b.jtd.isValid(schema, instance)`* — `validate` returns the RFC 8927 error list — each `{ instancePath, schemaPath }` naming the offending value and the broken schema rule — and `isValid` is the boolean convenience form. Supports every JTD form and type: the numeric types enforce their exact ranges (int8 … uint32, float32 / float64), `timestamp` requires an RFC 3339 date-time, `properties` honours `optionalProperties` / `additionalProperties` / `nullable`, and `discriminator` selects a `mapping` schema by a tag property. The schema is checked for well-formedness before validation, so unknown keywords, multiple forms, bad refs, or a discriminator over a non-properties mapping all throw `jtd/bad-schema`. Use JTD for schemas you share across implementations or generate code from; use `b.safeSchema` for in-process fluent validation.
12
14
 
13
15
  - v0.12.61 (2026-05-26) — **`b.jsonPath` — JSONPath query (RFC 9535).** A full RFC 9535 JSONPath query evaluator, complementing the framework's JSONPath guards (which screen path strings). b.jsonPath.query(doc, path) compiles a path and returns the matched node values; b.jsonPath.paths returns their normalized locations. The complete surface is implemented: name / wildcard / index / slice selectors, descendant segments (..), filter selectors (?) with comparison and logical operators, relative (@) and absolute ($) embedded queries, and the five standard functions length / count / match / search / value — with the spec's well-typedness rules enforced at compile time so a malformed or ill-typed query is rejected rather than silently mis-evaluated. Descendant walks are node-capped to bound work on hostile input. Verified against all 703 cases of the official jsonpath-compliance-test-suite. **Added:** *`b.jsonPath.query(doc, path)` / `b.jsonPath.paths(doc, path)`* — `query` returns the array of node values selected by an RFC 9535 JSONPath; `paths` returns the normalized-path string of each match (e.g. `$['a'][1]['p']`). Supports every selector (name, wildcard `*`, index incl. negative, slice `start:end:step` incl. negative step, comma-separated selections), child and descendant (`..`) segments, and filter expressions with `==` / `!=` / `<` / `<=` / `>` / `>=`, `&&` / `||` / `!`, existence tests, and the standard functions. The well-typedness rules are checked when the path is compiled — a non-singular query used as a comparison operand, an ill-typed function argument, or a value-typed function used as a test all throw `json-path/invalid`. Pairs with `b.guardJsonPath`, which screens operator-supplied path strings before they reach the evaluator.
@@ -158,7 +158,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
158
158
  - **WebSockets (server)** — channel/room fan-out across cluster replicas; RFC 6455 §5.5 control-frame size + FIN enforcement on inbound (defends 1 MiB-PING-as-PONG amplification) (`b.websocket`, `b.websocketChannels`)
159
159
  - **WebSockets (client)** — `b.wsClient` with PQC-TLS handshake, permessage-deflate negotiation with decompression-bomb cap, fatal UTF-8 validation, permanent-error classifier (skips reconnect on 4xx / accept mismatch / bad-subprotocol), exponential-backoff with full jitter
160
160
  - **Pub/sub + events** — distributed pub/sub with cluster-table / Redis PUB/SUB / custom backends (`b.pubsub`); framework-emitted signal bus for breach / integrity events (`b.events`)
161
- - **CloudEvents + SSE** — CloudEvents 1.0 envelope for AWS EventBridge / Knative / Azure Event Grid / Google Eventarc / CNCF (`b.cloudEvents`); Server-Sent Events with newline-injection refusal in `event:` / `id:` / `data:` / `Last-Event-ID` (CVE-2026-33128 / 29085 / 44217 class) (`b.sse`, `b.middleware.sse`)
161
+ - **CloudEvents + SSE** — CloudEvents 1.0.2 for AWS EventBridge / Knative / Azure Event Grid / Google Eventarc / CNCF: `wrap` / `parse` envelopes, non-throwing `validate` / `isValid`, the JSON event + batch formats (`toJSON` / `fromJSON` / `toJSONBatch` / `fromJSONBatch`), and the HTTP binding in both binary and structured content modes with auto-detecting `http.decode` (`b.cloudEvents`); Server-Sent Events with newline-injection refusal in `event:` / `id:` / `data:` / `Last-Event-ID` (CVE-2026-33128 / 29085 / 44217 class) (`b.sse`, `b.middleware.sse`)
162
162
  - **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
163
163
  - **Mail (outbound delivery)** — turnkey MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
164
164
  - **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.12.62",
4
- "createdAt": "2026-05-26T02:49:26.197Z",
3
+ "frameworkVersion": "0.12.63",
4
+ "createdAt": "2026-05-26T04:30:08.809Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -6233,10 +6233,59 @@
6233
6233
  "type": "primitive",
6234
6234
  "valueType": "string"
6235
6235
  },
6236
+ "fromJSON": {
6237
+ "type": "function",
6238
+ "arity": 2
6239
+ },
6240
+ "fromJSONBatch": {
6241
+ "type": "function",
6242
+ "arity": 2
6243
+ },
6244
+ "http": {
6245
+ "type": "object",
6246
+ "members": {
6247
+ "decode": {
6248
+ "type": "function",
6249
+ "arity": 3
6250
+ },
6251
+ "decodeBinary": {
6252
+ "type": "function",
6253
+ "arity": 3
6254
+ },
6255
+ "encodeBatch": {
6256
+ "type": "function",
6257
+ "arity": 1
6258
+ },
6259
+ "encodeBinary": {
6260
+ "type": "function",
6261
+ "arity": 1
6262
+ },
6263
+ "encodeStructured": {
6264
+ "type": "function",
6265
+ "arity": 1
6266
+ }
6267
+ }
6268
+ },
6269
+ "isValid": {
6270
+ "type": "function",
6271
+ "arity": 1
6272
+ },
6236
6273
  "parse": {
6237
6274
  "type": "function",
6238
6275
  "arity": 1
6239
6276
  },
6277
+ "toJSON": {
6278
+ "type": "function",
6279
+ "arity": 2
6280
+ },
6281
+ "toJSONBatch": {
6282
+ "type": "function",
6283
+ "arity": 2
6284
+ },
6285
+ "validate": {
6286
+ "type": "function",
6287
+ "arity": 1
6288
+ },
6240
6289
  "wrap": {
6241
6290
  "type": "function",
6242
6291
  "arity": 1
@@ -46100,6 +46149,10 @@
46100
46149
  "type": "instance",
46101
46150
  "ctorName": "RegExp"
46102
46151
  },
46152
+ "BASE64_RE": {
46153
+ "type": "instance",
46154
+ "ctorName": "RegExp"
46155
+ },
46103
46156
  "CRLF_RE": {
46104
46157
  "type": "instance",
46105
46158
  "ctorName": "RegExp"
@@ -95,7 +95,7 @@ var VALID_KINDS = { "raw": 1, "vault-sealed": 1, "plaintext": 1 };
95
95
  // in the manifest, so the hex string is 128 chars long.
96
96
  var SHA3_512_HEX_LENGTH = 128;
97
97
  var HEX_RE = safeBuffer.HEX_RE;
98
- var BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
98
+ var BASE64_RE = safeBuffer.BASE64_RE;
99
99
 
100
100
  function _isHex(s, evenLength) {
101
101
  if (typeof s !== "string" || s.length === 0) return false;