@blamejs/blamejs-shop 0.3.27 → 0.3.30

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.30 (2026-05-31) — **An unrecognized automatic-discount type is now rejected instead of silently becoming free shipping.** When defining or editing an automatic discount over the API, an unrecognized discount type was silently turned into a free-shipping rule — the most generous kind — and accepted without any error. A discount sent over the API with a type outside the supported set (percent off, amount off the total, amount off each item, buy-X-get-Y, or free shipping) is now rejected with a clear error, so a typo can no longer create a store-wide free-shipping rule by accident. The admin form was never affected, because its type selector only offers the supported values. **Fixed:** *Automatic-discount type is validated* — Defining or editing an automatic discount with a discount type outside the supported set now returns a clear error instead of quietly defaulting to free shipping. Previously an unrecognized type sent over the JSON API was coerced into a free-shipping rule and saved with no warning, which could apply free shipping store-wide from a single typo. Free shipping remains a valid, explicitly chosen type; only unrecognized values are rejected. The admin console form was unaffected, since its selector is limited to the supported types.
12
+
13
+ - v0.3.28 (2026-05-31) — **Customers can now see their store-credit balance and history.** Operators could grant or deduct a customer's store credit, but the customer had no way to see it — the balance only ever surfaced as a silent discount at checkout. A new Store credit page in the account area shows the current balance, a callout for any credit about to expire, and the full ledger of credits, deductions, and expirations with amounts, reasons, and dates. The page is read-only and shows only the signed-in customer's own balance. **Added:** *Store-credit wallet in the account area* — A Store credit page lists a customer's current balance, any credit expiring soon, and the full history of credits, deductions, and expirations — each with its amount, the reason, and the date. It is reachable from the account dashboard and reflects the store credit an operator grants from the customer screen, so a shopper can finally see credit they have been given instead of only discovering it at checkout. The page is read-only and scoped to the signed-in customer.
14
+
11
15
  - v0.3.27 (2026-05-31) — **Shared product and page links now show a preview image, and category pages get breadcrumb rich results.** Social-share previews and search rich results were emitting relative image paths, which Facebook, Slack, Twitter, iMessage, and Google all drop — so a shared product or page link showed no preview image, and product rich results had no image. Every og:image, twitter:image, and Product/Article structured-data image is now a fully-qualified URL built from the page's own origin. Collection and category pages, which already showed an on-page breadcrumb, now also emit BreadcrumbList structured data so the trail can appear in search results, matching what product pages already did. And operator-supplied page keywords or announcement text containing a dollar-sign sequence now render literally instead of being garbled. **Fixed:** *Share and rich-result images are now absolute URLs* — The og:image, twitter:image, and the image in Product and blog-article structured data were relative paths, so social platforms and Google could not load them — a shared link showed no preview image. They are now fully-qualified against the page's origin on every page, in both the edge and container renderers, so shares unfurl with the right image and product rich results carry one. Images an operator already hosts at an absolute URL pass through unchanged. · *Breadcrumb structured data on collection and category pages* — Collection and category pages showed a breadcrumb trail on the page but did not expose it as BreadcrumbList structured data, so search engines could not render the trail in results. They now emit it with absolute URLs, the same way product pages already do. · *Dollar-sign sequences in page metadata render literally* — Page meta-keywords and announcement-bar text containing a dollar-sign sequence (for example a price written with a special character) could be garbled in the page head because of how the value was substituted into the template. The substitution now treats the value as literal text, so it renders exactly as entered.
12
16
 
13
17
  - v0.3.26 (2026-05-31) — **Customers can raise support tickets and operators work them from a queue.** The support-ticket system had a complete backend — threaded messages, assignment, tags, and a status workflow — but no way in or out: a shopper could not open a ticket and an operator could not see one. Both sides are now wired. A logged-in customer can raise a ticket from their account (with an optional link to one of their own orders), see the thread, and reply while it is open. Operators get a Support queue in the console with an unassigned-triage view, the full thread for each ticket, and controls to reply (with an internal-note option that the customer never sees), assign, tag, and move the ticket through its status workflow. A customer only ever sees their own tickets, and replying to a closed ticket is refused. **Added:** *Customer support tickets* — A Support section in the account area lets a signed-in customer open a ticket (subject, message, category, and an optional reference to one of their own orders), list their tickets with status, read the full message thread, and reply while the ticket is open. Internal operator notes are never shown to the customer, a customer can only see and reply to their own tickets, and a reply to a closed ticket is refused. · *Support queue in the admin console* — A Support screen lists inbound tickets by status with an unassigned-triage view. Opening a ticket shows the full thread, the requester, the current status, the assignee, and tags. From there an operator can reply (optionally as an internal note), assign the ticket, tag it, and move it through its status workflow — in progress, waiting on the customer, resolved, closed, or reopened.
package/lib/admin.js CHANGED
@@ -7713,7 +7713,18 @@ function mount(router, deps) {
7713
7713
  get_qty: _strictMinorInt(body.value_get_qty, "autoDiscount", "value get_qty"),
7714
7714
  };
7715
7715
  }
7716
- return { kind: "free_shipping" };
7716
+ if (kind === "free_shipping") {
7717
+ return { kind: "free_shipping" };
7718
+ }
7719
+ // An unrecognized value_kind must NOT silently become free_shipping —
7720
+ // free shipping is the most generous kind, so a typo'd value_kind (the
7721
+ // browser select is constrained to the five valid kinds, but a JSON API
7722
+ // client is not) would otherwise create a store-wide free-shipping rule
7723
+ // with no operator signal. Throw so the route maps it to a clean 400,
7724
+ // the same way a bad numeric field already does.
7725
+ throw new TypeError(
7726
+ "autoDiscount: value_kind must be one of percent_off, amount_off_total, amount_off_each, bogo, free_shipping"
7727
+ );
7717
7728
  }
7718
7729
 
7719
7730
  // Translate the rule edit form into an updateRule patch. The inline
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.27",
2
+ "version": "0.3.30",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/storefront.js CHANGED
@@ -3625,6 +3625,147 @@ function renderLoyalty(opts) {
3625
3625
  });
3626
3626
  }
3627
3627
 
3628
+ // Store credit is account-bound and carries no per-row currency — the
3629
+ // ledger stores integer minor units in the shop's single display
3630
+ // currency. The storefront's other account surfaces (loyalty "worth",
3631
+ // the order-history total fallback) format in USD, so the wallet does
3632
+ // too; a future multi-currency store threads the resolved code in here.
3633
+ var STORE_CREDIT_CURRENCY = "USD";
3634
+
3635
+ // A human label for the credit-row provenance enum
3636
+ // (store_credit_ledger.source). Falls back to the raw code so a future
3637
+ // source the renderer hasn't learned still shows something sensible.
3638
+ var STORE_CREDIT_SOURCE_LABELS = {
3639
+ refund: "Refund",
3640
+ goodwill: "Goodwill",
3641
+ promotional: "Promotion",
3642
+ manual: "Manual adjustment",
3643
+ loyalty_redemption: "Loyalty redemption",
3644
+ };
3645
+
3646
+ // A ledger row's amount, signed by its kind: credit adds (+), debit and
3647
+ // expire subtract (−). The amount is always stored positive, so the
3648
+ // sign comes from `kind`. The minor-unit amount is formatted through the
3649
+ // shared `pricing.format` money helper (Intl-backed, ISO 4217 exponent),
3650
+ // never hand-rolled.
3651
+ function _storeCreditSignedAmount(row) {
3652
+ var minor = Number(row.amount_minor) || 0;
3653
+ var money = pricing.format(minor, STORE_CREDIT_CURRENCY);
3654
+ if (row.kind === "credit") return "+" + money;
3655
+ return "−" + money; // U+2212 MINUS SIGN
3656
+ }
3657
+
3658
+ // A ledger row's reason text. Credit rows carry an operator-authored
3659
+ // free-form `source_ref` plus the provenance enum; expire rows carry the
3660
+ // operator's justification in `source_ref` (the primitive writes the
3661
+ // `reason` there). Debit rows settle an order and carry neither — they
3662
+ // read as a plain "Spent". The caller escapes on output.
3663
+ function _storeCreditReasonText(row) {
3664
+ if (row.kind === "credit") {
3665
+ var label = STORE_CREDIT_SOURCE_LABELS[row.source] || String(row.source || "Credit");
3666
+ return row.source_ref ? (label + " — " + String(row.source_ref)) : label;
3667
+ }
3668
+ if (row.kind === "expire") {
3669
+ return row.source_ref ? ("Expired — " + String(row.source_ref)) : "Expired";
3670
+ }
3671
+ return "Spent"; // debit — settles an order
3672
+ }
3673
+
3674
+ // The signed-in customer's store-credit wallet: the current balance, an
3675
+ // expiring-soon callout when any credit is within the warning window,
3676
+ // and the credit/debit/expire ledger (paginated, newest first). A wallet
3677
+ // with zero balance + no history renders a clean empty state, not an
3678
+ // error. Read-only — granting/deducting is operator-only on the admin
3679
+ // customer-detail screen. Every amount, reason, and date is escaped on
3680
+ // output. Reuses the account/returns + loyalty layout classes — no new
3681
+ // CSS.
3682
+ function renderAccountStoreCredit(opts) {
3683
+ opts = opts || {};
3684
+ var esc = b.template.escapeHtml;
3685
+
3686
+ var balanceMinor = Number(opts.balance_minor) || 0;
3687
+ var balanceStr = pricing.format(balanceMinor, STORE_CREDIT_CURRENCY);
3688
+
3689
+ // Balance stat strip.
3690
+ var stats =
3691
+ "<dl class=\"account-dash__stats\">" +
3692
+ "<div><dt>Store credit</dt><dd>" + esc(balanceStr) + "</dd></div>" +
3693
+ "</dl>";
3694
+
3695
+ // Expiring-soon callout — only when a credit row is inside the warning
3696
+ // window. Sums the exposed (still-spendable) amounts and names the
3697
+ // soonest deadline.
3698
+ var expiring = opts.expiring || [];
3699
+ var expiringSection = "";
3700
+ if (expiring.length) {
3701
+ var expMinor = 0;
3702
+ var soonest = null;
3703
+ for (var e = 0; e < expiring.length; e += 1) {
3704
+ expMinor += Number(expiring[e].amount_minor) || 0;
3705
+ var ea = Number(expiring[e].expires_at);
3706
+ if (isFinite(ea) && (soonest == null || ea < soonest)) soonest = ea;
3707
+ }
3708
+ var expStr = pricing.format(expMinor, STORE_CREDIT_CURRENCY);
3709
+ var expDate = soonest != null ? new Date(soonest).toISOString().slice(0, 10) : "";
3710
+ expiringSection =
3711
+ "<p class=\"form-notice\" role=\"status\">" +
3712
+ esc(expStr) + " of your store credit expires soon" +
3713
+ (expDate ? " &mdash; use it by <time datetime=\"" + esc(expDate) + "\">" + esc(expDate) + "</time>" : "") +
3714
+ "</p>";
3715
+ }
3716
+
3717
+ // The credit/debit/expire ledger, newest first.
3718
+ var hist = opts.history || [];
3719
+ var histInner = "";
3720
+ for (var h = 0; h < hist.length; h += 1) {
3721
+ var row = hist[h];
3722
+ var tdate = row.occurred_at ? new Date(Number(row.occurred_at)).toISOString().slice(0, 10) : "";
3723
+ var rowExpiry = (row.kind === "credit" && row.expires_at != null)
3724
+ ? new Date(Number(row.expires_at)).toISOString().slice(0, 10)
3725
+ : "";
3726
+ histInner +=
3727
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
3728
+ "<span class=\"pdp__badge store-credit-tx--" + esc(String(row.kind)) + "\">" + esc(String(row.kind)) + "</span>" +
3729
+ "<span class=\"return-card__rma\">" + esc(_storeCreditSignedAmount(row)) + "</span>" +
3730
+ "</div>" +
3731
+ "<p class=\"return-card__meta\">" + esc(_storeCreditReasonText(row)) +
3732
+ (tdate ? " &middot; <time datetime=\"" + esc(tdate) + "\">" + esc(tdate) + "</time>" : "") +
3733
+ (rowExpiry ? " &middot; expires <time datetime=\"" + esc(rowExpiry) + "\">" + esc(rowExpiry) + "</time>" : "") +
3734
+ "</p></li>";
3735
+ }
3736
+ var historySection;
3737
+ if (histInner) {
3738
+ var more = opts.history_next_cursor != null
3739
+ ? "<p class=\"loyalty-more\"><a class=\"btn-secondary\" href=\"/account/credit?cursor=" +
3740
+ esc(String(opts.history_next_cursor)) + "\">Older activity</a></p>"
3741
+ : "";
3742
+ historySection = "<h2 class=\"pdp__variants-title\">Activity</h2><ul class=\"return-list\">" + histInner + "</ul>" + more;
3743
+ } else {
3744
+ historySection = "<h2 class=\"pdp__variants-title\">Activity</h2>" +
3745
+ "<p class=\"return-empty\">No store credit yet. Refunds and goodwill credit will show up here.</p>";
3746
+ }
3747
+
3748
+ var body =
3749
+ "<section class=\"account-returns\">" +
3750
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3751
+ "<li><a href=\"/account\">Account</a></li>" +
3752
+ "<li aria-current=\"page\">Store credit</li>" +
3753
+ "</ol></nav>" +
3754
+ "<h1 class=\"account-returns__title\">Store credit</h1>" +
3755
+ stats +
3756
+ expiringSection +
3757
+ historySection +
3758
+ "</section>";
3759
+
3760
+ return _wrap({
3761
+ title: "Store credit",
3762
+ shop_name: opts.shop_name || "blamejs.shop",
3763
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3764
+ theme_css: opts.theme_css,
3765
+ body: body,
3766
+ });
3767
+ }
3768
+
3628
3769
  // Human-readable funnel stage for a referred friend. The referred
3629
3770
  // email is stored hash-only, so a row never carries an address — the
3630
3771
  // surface shows the stage + dates only.
@@ -6276,6 +6417,7 @@ var ACCOUNT_DASH_PAGE =
6276
6417
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
6277
6418
  " <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
6278
6419
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
6420
+ " <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
6279
6421
  " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
6280
6422
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
6281
6423
  // begin: profile + passkey management actions
@@ -11152,6 +11294,99 @@ function mount(router, deps) {
11152
11294
  }
11153
11295
  }
11154
11296
 
11297
+ // Store-credit wallet — the signed-in customer's READ-ONLY view of
11298
+ // their account-bound store credit. One surface:
11299
+ // * GET /account/credit — the current balance, an expiring-soon
11300
+ // callout, and the credit/debit/expire ledger (paginated). A
11301
+ // browser gets the server-rendered page; an API client (Accept:
11302
+ // application/json) gets the balance + ledger payload.
11303
+ //
11304
+ // SESSION-SCOPED, no IDOR surface: the route reads the customer id
11305
+ // from the signed-in session (`auth.customer_id`) and passes ONLY
11306
+ // that id to storeCredit.balance / .history / .expiringWithin. There
11307
+ // is no `:id` path param and the route never reads a customer id from
11308
+ // the query string or body, so a signed-in shopper can only ever see
11309
+ // their OWN wallet. Granting / deducting credit is operator-only on
11310
+ // the admin customer-detail screen — this surface writes nothing.
11311
+ if (deps.storeCredit) {
11312
+ function _storeCreditAuth(req, res) {
11313
+ var auth;
11314
+ try { auth = _currentCustomer(req); }
11315
+ catch (e) {
11316
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
11317
+ throw e;
11318
+ }
11319
+ if (!auth) {
11320
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
11321
+ res.end ? res.end() : res.send("");
11322
+ return null;
11323
+ }
11324
+ return auth;
11325
+ }
11326
+
11327
+ // Build the /account/credit render context for the SESSION
11328
+ // customer. `opts2.cursor` is the optional history-page cursor
11329
+ // (epoch-ms). The expiring-soon read is best-effort so an
11330
+ // unmigrated table or a read error degrades that callout rather
11331
+ // than the page; balance + history are the core reads.
11332
+ async function _storeCreditView(req, auth, opts2) {
11333
+ opts2 = opts2 || {};
11334
+ var bal = await deps.storeCredit.balance(auth.customer_id);
11335
+ var hist = await deps.storeCredit.history({
11336
+ customer_id: auth.customer_id,
11337
+ limit: 20,
11338
+ cursor: opts2.cursor,
11339
+ });
11340
+ var expiring = [];
11341
+ try {
11342
+ expiring = await deps.storeCredit.expiringWithin({
11343
+ customer_id: auth.customer_id,
11344
+ days: 30,
11345
+ });
11346
+ } catch (_e) { expiring = []; }
11347
+ var cartCount = await _cartCountForReq(req);
11348
+ return {
11349
+ balance_minor: bal.balance_minor,
11350
+ history: hist.rows,
11351
+ history_next_cursor: hist.next_cursor,
11352
+ expiring: expiring,
11353
+ shop_name: shopName,
11354
+ cart_count: cartCount,
11355
+ };
11356
+ }
11357
+
11358
+ router.get("/account/credit", async function (req, res) {
11359
+ var auth = _storeCreditAuth(req, res); if (!auth) return;
11360
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
11361
+ var cursorRaw = url && url.searchParams.get("cursor");
11362
+ var cursor;
11363
+ if (cursorRaw != null && cursorRaw !== "") {
11364
+ var n = parseInt(cursorRaw, 10);
11365
+ // A malformed cursor degrades to the first page (the lib would
11366
+ // TypeError on a non-integer) — never 500 the page.
11367
+ cursor = Number.isFinite(n) && n >= 0 ? n : undefined;
11368
+ }
11369
+ var view = await _storeCreditView(req, auth, { cursor: cursor });
11370
+ // Content negotiation: an API client (Accept: application/json)
11371
+ // gets the balance + ledger payload; a browser gets the rendered
11372
+ // wallet page. Both read the SAME session customer id.
11373
+ var accept = (req.headers && (req.headers.accept || req.headers.Accept)) || "";
11374
+ if (/\bapplication\/json\b/.test(String(accept))) {
11375
+ res.status(200);
11376
+ res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
11377
+ var payload = JSON.stringify({
11378
+ balance_minor: view.balance_minor,
11379
+ currency: "USD",
11380
+ expiring: view.expiring,
11381
+ ledger: view.history,
11382
+ history_next_cursor: view.history_next_cursor,
11383
+ });
11384
+ return res.end ? res.end(payload) : res.send(payload);
11385
+ }
11386
+ _send(res, 200, renderAccountStoreCredit(view));
11387
+ });
11388
+ }
11389
+
11155
11390
  // Referrals — refer-a-friend. Three surfaces:
11156
11391
  // * GET /r/:code — the attribution landing. Sets a
11157
11392
  // short-lived sealed first-party cookie naming the code, records
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.27",
3
+ "version": "0.3.30",
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": {