@blamejs/blamejs-shop 0.0.123 → 0.0.124

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.0.x
10
10
 
11
+ - v0.0.124 (2026-05-24) — **Save for later — move cart items into a holding list and back.** Each cart line now has a "Save for later" control that moves the item out of the cart into a per-customer holding list without losing it. Saved items live on a new account page where they can be moved back to the cart or removed. Moving an item back reprices it to the current catalog price and checks stock first, so a saved item that sold out (and isn't backorderable) can't silently re-enter the cart. Login-required, since the list is scoped to one customer. **Added:** *Save-for-later control on cart lines* — Each editable cart line gets a Save-for-later control. `POST /cart/lines/:line_id/save` moves the line out of the cart into the customer's saved list (`moveFromCart`). Login-gated — a signed-out shopper is redirected to sign in. · *`/account/saved` — the holding list* — A new account page lists saved items with a thumbnail, the saved price for reference, and Move-to-cart / Remove controls. Items whose product was archived render as "no longer available" rather than breaking the list. The account dashboard links to it (alongside Wishlist). · *Move back to cart, repriced + stock-checked* — `POST /saved/:save_id/move-to-cart` returns the item to the session cart at the current catalog price (not the stale snapshot) and refuses if the SKU is out of stock and not backorderable. `POST /saved/:save_id/remove` drops a saved row.
12
+
11
13
  - v0.0.123 (2026-05-24) — **Wishlist — save products to your account, with social-proof counts on the product page.** Signed-in customers can now save products to a wishlist from the product page. A "N shoppers saved this" count surfaces social proof, and saved items live on a new account page where they can be removed or reopened. The save control and count render identically on the edge and container paths; the toggle is idempotent (saving twice is a no-op, toggling again removes) and login-gated, since a wishlist is scoped to one customer. **Added:** *Save to wishlist on the product page* — The PDP renders a "Save to wishlist" control and, once any customer has saved it, a "N shoppers saved this" social-proof count. Both render server-side on the edge and container paths. The count is public; the save action requires sign-in. · *`/account/wishlist` — saved items* — A new account page lists the customer's saved products with a thumbnail, a link back to the product, and a Remove control. Entries whose product was archived render as "no longer available" rather than breaking the list (wishlist rows are orphan-tolerant by design). The account dashboard links to it. · *`POST /wishlist/toggle`* — Login-required endpoint that saves the product if it isn't saved and removes it if it is. Idempotent (`INSERT OR IGNORE`). Redirects back to the product by resolving its canonical slug from the product id, or to a safe same-origin `return_to` (the account page's Remove uses it) — a forged or off-site redirect target is rejected.
12
14
 
13
15
  - v0.0.122 (2026-05-24) — **Product reviews on the storefront — verified-buyer submission, operator moderation, and rich-snippet ratings.** The product page now shows customer reviews: an average rating, a per-star distribution, and the published review list, with `AggregateRating` structured data so star ratings can surface in search results. Submission is gated to signed-in customers who have actually purchased the product — the route confirms a completed order for that product before accepting a review, and re-checks on submit. Reviews land in a pending state; operators publish or reject them through new bearer-token admin endpoints. Display and structured data are identical whether the page is served at the edge or from the container. **Added:** *Reviews on the product page* — The PDP renders an average rating, a per-star distribution bar chart, and the published reviews (newest first, verified-buyer badge, date). Products with no reviews show an invite to be the first. Rendered server-side on both the edge and container paths. · *Verified-buyer review submission* — `GET /products/:slug/review` shows the review form to a signed-in customer who has purchased the product; everyone else is redirected to sign in or told only verified buyers can review. `POST /products/:slug/review` re-checks the purchase before accepting the review, so a direct POST can't bypass the gate. Submitted reviews start pending. · *Operator review moderation* — `GET /admin/reviews?status=pending` lists the moderation queue across all products; `GET /admin/reviews/:id` reads one; `POST /admin/reviews/:id/publish` and `POST /admin/reviews/:id/reject` move it. Bearer-token-gated like the rest of the admin API. Pending and rejected reviews never appear on the storefront. · *`AggregateRating` structured data* — The product page emits Schema.org `AggregateRating` (rating value + review count) nested in the existing `Product` JSON-LD when published reviews exist, so eligible products can show star ratings in search results. Omitted entirely at zero reviews to stay valid. The container render path now emits the full `Product` + `AggregateOffer` + `BreadcrumbList` JSON-LD that the edge already did. · *`order.hasPurchasedProduct(customerId, productId)`* — Existence check — true when the customer has an order line for any variant of the product in an order that reached `paid` or later (excludes `pending` and `cancelled`). Backs the review purchase gate. · *`reviews.listByStatus(status, opts)`* — Lists reviews across all products by status, newest first, with the same opaque tuple cursor as `listForProduct`. Backs the admin moderation queue.
package/README.md CHANGED
@@ -65,6 +65,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
65
65
  | **`lib/customers.js`** | Customer accounts — passkey-only (WebAuthn). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. Account routes (`/account/login`, `/account/register`, `/account`) ship as designed cards on the storefront. |
66
66
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
67
67
  | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
68
+ | **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
68
69
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
69
70
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
70
71
  | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
@@ -84,6 +85,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
84
85
  - `migrations-d1/0010_newsletter_signups.sql` — email signups with hash-based dedup
85
86
  - `migrations-d1/0011_reviews.sql` — operator-moderated product reviews (hash-only author identity)
86
87
  - `migrations-d1/0012_wishlist.sql` — per-customer saved products (unique customer + product + variant)
88
+ - `migrations-d1/0041_save_for_later.sql` — per-customer cart holding list (price snapshot + source line)
87
89
 
88
90
  ### Demo seed
89
91
 
package/lib/storefront.js CHANGED
@@ -967,6 +967,71 @@ function renderWishlist(opts) {
967
967
  });
968
968
  }
969
969
 
970
+ // Account "Saved for later" page. `opts.items` is a resolved list:
971
+ // { save, product, hero_media } for live products, or { save,
972
+ // product: null } when the variant/product behind a saved row was
973
+ // archived (orphan-tolerant — render "no longer available").
974
+ function renderSaved(opts) {
975
+ var esc = _b().template.escapeHtml;
976
+ var items = opts.items || [];
977
+ var prefix = opts.asset_prefix || "/assets/";
978
+ var rowsHtml = "";
979
+ for (var i = 0; i < items.length; i += 1) {
980
+ var it = items[i];
981
+ var save = it.save;
982
+ var moveForm =
983
+ "<form method=\"post\" action=\"/saved/" + esc(save.id) + "/move-to-cart\">" +
984
+ "<button type=\"submit\" class=\"btn-secondary btn-secondary--sm\">Move to cart</button></form>";
985
+ var removeForm =
986
+ "<form method=\"post\" action=\"/saved/" + esc(save.id) + "/remove\">" +
987
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button></form>";
988
+ if (!it.product) {
989
+ // Archived/unavailable product — only Remove. Move-to-cart can't
990
+ // succeed (no current price / stock), so don't offer it.
991
+ rowsHtml +=
992
+ "<li class=\"saved-item saved-item--gone\">" +
993
+ "<span class=\"saved-item__title\">" + esc(save.sku) + " — no longer available</span>" +
994
+ "<div class=\"saved-item__actions\">" + removeForm + "</div>" +
995
+ "</li>";
996
+ continue;
997
+ }
998
+ var actions = "<div class=\"saved-item__actions\">" + moveForm + removeForm + "</div>";
999
+ var slug = esc(it.product.slug);
1000
+ var thumb = it.hero_media
1001
+ ? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || it.product.title) + "\" loading=\"lazy\">"
1002
+ : "<span class=\"saved-item__mark\" aria-hidden=\"true\">" + esc((it.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
1003
+ var priceStr = pricing.format(Number(save.snapshot_price_minor) || 0, "USD");
1004
+ rowsHtml +=
1005
+ "<li class=\"saved-item\">" +
1006
+ "<a class=\"saved-item__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
1007
+ "<div class=\"saved-item__body\">" +
1008
+ "<a class=\"saved-item__title\" href=\"/products/" + slug + "\">" + esc(it.product.title) + "</a>" +
1009
+ "<span class=\"saved-item__meta\">Qty " + (Number(save.quantity) || 1) + " &middot; " + esc(priceStr) + " <span class=\"saved-item__snapshot\">(saved price)</span></span>" +
1010
+ "</div>" +
1011
+ actions +
1012
+ "</li>";
1013
+ }
1014
+ var inner = rowsHtml
1015
+ ? "<ul class=\"saved-list\">" + rowsHtml + "</ul>"
1016
+ : "<p class=\"saved-empty\">Nothing saved for later. Use <strong>Save for later</strong> on a cart item to move it here without losing it.</p>";
1017
+ var body =
1018
+ "<section class=\"account-saved\">" +
1019
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1020
+ "<li><a href=\"/account\">Account</a></li>" +
1021
+ "<li aria-current=\"page\">Saved for later</li>" +
1022
+ "</ol></nav>" +
1023
+ "<h1 class=\"account-saved__title\">Saved for later</h1>" +
1024
+ inner +
1025
+ "</section>";
1026
+ return _wrap({
1027
+ title: "Saved for later",
1028
+ shop_name: opts.shop_name || "blamejs.shop",
1029
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1030
+ theme_css: opts.theme_css,
1031
+ body: body,
1032
+ });
1033
+ }
1034
+
970
1035
  // Product-level "Save to wishlist" control + social-proof count.
971
1036
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
972
1037
  // so both paths emit identical markup. Action-only label — the toggle
@@ -1165,6 +1230,7 @@ var CART_LINE_EDITABLE =
1165
1230
  " <td class=\"price\">{{unit}}</td>\n" +
1166
1231
  " <td class=\"price\">{{total}}</td>\n" +
1167
1232
  " <td class=\"cart-line__remove-cell\">\n" +
1233
+ " RAW_CART_LINE_SAVE" +
1168
1234
  " <form method=\"post\" action=\"/cart/lines/{{line_id}}/remove\">\n" +
1169
1235
  " <button type=\"submit\" class=\"cart-line__btn cart-line__btn--remove\" aria-label=\"Remove line\">Remove</button>\n" +
1170
1236
  " </form>\n" +
@@ -1511,10 +1577,18 @@ function renderCart(opts) {
1511
1577
  if (rendered.length === 0) {
1512
1578
  body = CART_EMPTY_PAGE;
1513
1579
  } else {
1580
+ var canSave = !!opts.can_save;
1514
1581
  var rows = rendered.map(function (l) {
1515
1582
  var thumb = l.image_url
1516
1583
  ? "<span class=\"cart-line__thumb\"><img src=\"" + _escAttr(l.image_url) + "\" alt=\"" + _escAttr(l.image_alt) + "\" loading=\"lazy\"></span>"
1517
1584
  : "<span class=\"cart-line__thumb cart-line__thumb--empty\" aria-hidden=\"true\"></span>";
1585
+ // "Save for later" moves the line into the customer's saved list.
1586
+ // Rendered only when the feature is wired (and account auth is
1587
+ // present); the route itself enforces login, redirecting a guest
1588
+ // to sign in.
1589
+ var saveBtn = canSave
1590
+ ? "<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>"
1591
+ : "";
1518
1592
  return _render(CART_LINE_EDITABLE, {
1519
1593
  sku: l.sku,
1520
1594
  qty: l.qty,
@@ -1523,7 +1597,7 @@ function renderCart(opts) {
1523
1597
  line_id: l.id,
1524
1598
  product_title: l.product_title,
1525
1599
  product_url: l.product_url,
1526
- }).replace("RAW_CART_LINE_THUMB", thumb);
1600
+ }).replace("RAW_CART_LINE_THUMB", thumb).replace("RAW_CART_LINE_SAVE", saveBtn);
1527
1601
  }).join("");
1528
1602
  body = _render(CART_PAGE, {
1529
1603
  line_rows: "RAW_LINES",
@@ -1887,7 +1961,8 @@ var ACCOUNT_DASH_PAGE =
1887
1961
  " <p class=\"section-head__lede\">Your orders + account controls. Every order ships from origin with a Stripe-secured receipt.</p>\n" +
1888
1962
  " </div>\n" +
1889
1963
  " <div class=\"account-dash__actions\">\n" +
1890
- " <a class=\"btn-secondary\" href=\"/account/wishlist\">Saved items</a>\n" +
1964
+ " <a class=\"btn-secondary\" href=\"/account/wishlist\">Wishlist</a>\n" +
1965
+ " <a class=\"btn-secondary\" href=\"/account/saved\">Saved for later</a>\n" +
1891
1966
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
1892
1967
  " </div>\n" +
1893
1968
  " </header>\n" +
@@ -2216,6 +2291,7 @@ function mount(router, deps) {
2216
2291
  lines: lines,
2217
2292
  totals: totals,
2218
2293
  product_lookup: productLookup,
2294
+ can_save: !!(deps.saveForLater && deps.customers),
2219
2295
  shop_name: shopName,
2220
2296
  theme: theme,
2221
2297
  }));
@@ -2785,6 +2861,133 @@ function mount(router, deps) {
2785
2861
  });
2786
2862
  }
2787
2863
 
2864
+ // Save for later — move a cart line into a per-customer holding
2865
+ // list and back. Login required (the list is per-customer).
2866
+ if (deps.saveForLater) {
2867
+ function _savedAuth(req, res) {
2868
+ var auth;
2869
+ try { auth = _currentCustomer(req); }
2870
+ catch (e) {
2871
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
2872
+ throw e;
2873
+ }
2874
+ if (!auth) {
2875
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
2876
+ res.end ? res.end() : res.send("");
2877
+ return null;
2878
+ }
2879
+ return auth;
2880
+ }
2881
+
2882
+ // POST /cart/lines/:line_id/save — move the line out of the cart
2883
+ // into the customer's saved list. Redirects back to /cart.
2884
+ router.post("/cart/lines/:line_id/save", async function (req, res) {
2885
+ var auth = _savedAuth(req, res);
2886
+ if (!auth) return;
2887
+ var sid = _readSidCookie(req);
2888
+ var cart = sid ? await deps.cart.bySession(sid) : null;
2889
+ if (!cart) {
2890
+ res.status(303); res.setHeader && res.setHeader("location", "/cart");
2891
+ return res.end ? res.end() : res.send("");
2892
+ }
2893
+ try {
2894
+ await deps.saveForLater.moveFromCart({
2895
+ customer_id: auth.customer_id,
2896
+ cart_id: cart.id,
2897
+ line_id: req.params && req.params.line_id,
2898
+ });
2899
+ } catch (e) {
2900
+ res.status(e instanceof TypeError ? 400 : 500);
2901
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
2902
+ }
2903
+ res.status(303); res.setHeader && res.setHeader("location", "/cart");
2904
+ return res.end ? res.end() : res.send("");
2905
+ });
2906
+
2907
+ // GET /account/saved — the customer's saved-for-later list.
2908
+ router.get("/account/saved", async function (req, res) {
2909
+ var auth = _savedAuth(req, res);
2910
+ if (!auth) return;
2911
+ var page = await deps.saveForLater.listForCustomer({ customer_id: auth.customer_id, limit: 50 });
2912
+ var items = [];
2913
+ for (var i = 0; i < page.rows.length; i += 1) {
2914
+ var row = page.rows[i];
2915
+ var product = null;
2916
+ if (row.variant_id) {
2917
+ try {
2918
+ var v = await deps.catalog.variants.get(row.variant_id);
2919
+ if (v) product = await deps.catalog.products.get(v.product_id);
2920
+ } catch (_e) { product = null; }
2921
+ }
2922
+ if (!product) { items.push({ save: row, product: null }); continue; }
2923
+ var media = await deps.catalog.media.listForProduct(product.id);
2924
+ items.push({ save: row, product: product, hero_media: media.length ? media[0] : null });
2925
+ }
2926
+ var cartCount = await _cartCountForReq(req);
2927
+ _send(res, 200, renderSaved({
2928
+ items: items,
2929
+ shop_name: shopName,
2930
+ cart_count: cartCount,
2931
+ asset_prefix: deps.asset_prefix || "/assets/",
2932
+ }));
2933
+ });
2934
+
2935
+ // POST /saved/:save_id/move-to-cart — move a saved row back into
2936
+ // the session cart (created if absent). Redirects to /cart.
2937
+ router.post("/saved/:save_id/move-to-cart", async function (req, res) {
2938
+ var auth = _savedAuth(req, res);
2939
+ if (!auth) return;
2940
+ var resolved = await _getOrCreateCart(req, res, "USD");
2941
+ try {
2942
+ await deps.saveForLater.moveToCart({
2943
+ customer_id: auth.customer_id,
2944
+ save_id: req.params && req.params.save_id,
2945
+ cart_id: resolved.cart.id,
2946
+ // Reprice to the live catalog price so the cart never carries
2947
+ // a stale snapshot; the saved page shows the snapshot for
2948
+ // reference only.
2949
+ use_price: "current",
2950
+ });
2951
+ } catch (e) {
2952
+ if (e instanceof TypeError) {
2953
+ res.status(400);
2954
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
2955
+ }
2956
+ // The cart enforces one line per (cart_id, variant_id). If the
2957
+ // shopper re-added this variant before moving the saved copy,
2958
+ // moveToCart's INSERT collides — but the end state they want
2959
+ // (variant in the cart) already holds. Drop the saved row and
2960
+ // treat it as success instead of surfacing a 500. The lib
2961
+ // leaves the save row intact on collision, so removing it here
2962
+ // is what completes the "move".
2963
+ if (/unique|constraint/i.test((e && e.message) || "")) {
2964
+ try {
2965
+ await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
2966
+ } catch (_e) { /* drop-silent — the move is already effectively done */ }
2967
+ res.status(303); res.setHeader && res.setHeader("location", "/cart");
2968
+ return res.end ? res.end() : res.send("");
2969
+ }
2970
+ throw e;
2971
+ }
2972
+ res.status(303); res.setHeader && res.setHeader("location", "/cart");
2973
+ return res.end ? res.end() : res.send("");
2974
+ });
2975
+
2976
+ // POST /saved/:save_id/remove — drop a saved row.
2977
+ router.post("/saved/:save_id/remove", async function (req, res) {
2978
+ var auth = _savedAuth(req, res);
2979
+ if (!auth) return;
2980
+ try {
2981
+ await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
2982
+ } catch (e) {
2983
+ res.status(e instanceof TypeError ? 400 : 500);
2984
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
2985
+ }
2986
+ res.status(303); res.setHeader && res.setHeader("location", "/account/saved");
2987
+ return res.end ? res.end() : res.send("");
2988
+ });
2989
+ }
2990
+
2788
2991
  // Product reviews — submission requires a logged-in customer AND a
2789
2992
  // verified purchase of the product (the gate, not just a badge).
2790
2993
  // Only mounts when both the reviews primitive and an order handle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.0.123",
3
+ "version": "0.0.124",
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": {