@blamejs/blamejs-shop 0.0.128 → 0.1.0

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -0
  3. package/lib/admin.js +1 -2
  4. package/lib/affiliates.js +4 -3
  5. package/lib/analytics.js +3 -2
  6. package/lib/api-keys.js +1 -1
  7. package/lib/assembly-instructions.js +2 -1
  8. package/lib/auto-replenish.js +4 -3
  9. package/lib/backorder.js +2 -1
  10. package/lib/business-hours.js +8 -1
  11. package/lib/carrier-accounts.js +1 -1
  12. package/lib/carrier-rates.js +1 -1
  13. package/lib/cart-abandonment.js +3 -2
  14. package/lib/cart-bulk-ops.js +2 -1
  15. package/lib/cart-recovery.js +5 -4
  16. package/lib/cart.js +6 -2
  17. package/lib/catalog-drafts.js +1 -1
  18. package/lib/click-and-collect.js +3 -2
  19. package/lib/clickstream.js +4 -3
  20. package/lib/config.js +2 -1
  21. package/lib/cookie-consent.js +2 -1
  22. package/lib/credit-limits.js +2 -1
  23. package/lib/currency-display.js +2 -1
  24. package/lib/customer-activity.js +3 -2
  25. package/lib/customer-impersonation.js +3 -3
  26. package/lib/customer-merge.js +4 -3
  27. package/lib/customer-portal.js +4 -4
  28. package/lib/customer-risk-profile.js +2 -1
  29. package/lib/customer-segments.js +2 -1
  30. package/lib/customer-surveys.js +6 -3
  31. package/lib/delivery-estimate.js +2 -2
  32. package/lib/demand-forecast.js +2 -1
  33. package/lib/discount-analytics.js +2 -2
  34. package/lib/dunning.js +4 -1
  35. package/lib/email-warmup.js +6 -1
  36. package/lib/email.js +1 -8
  37. package/lib/error-log.js +3 -2
  38. package/lib/event-log.js +3 -2
  39. package/lib/fraud-screen.js +3 -1
  40. package/lib/fulfillment-sla.js +3 -1
  41. package/lib/index.js +11 -3
  42. package/lib/inventory-allocations.js +3 -0
  43. package/lib/inventory-snapshots.js +2 -1
  44. package/lib/invoice-renderer.js +2 -1
  45. package/lib/line-gift-wrap.js +6 -1
  46. package/lib/live-chat.js +2 -1
  47. package/lib/loyalty-redemption.js +2 -1
  48. package/lib/newsletter.js +6 -1
  49. package/lib/operator-activity-feed.js +4 -3
  50. package/lib/operator-sessions.js +7 -7
  51. package/lib/order-exchanges.js +1 -0
  52. package/lib/order-timeline.js +2 -1
  53. package/lib/payment-retries.js +2 -1
  54. package/lib/payment.js +5 -4
  55. package/lib/pixel-events.js +6 -5
  56. package/lib/preorder.js +2 -1
  57. package/lib/print-queue.js +2 -1
  58. package/lib/product-compare.js +2 -1
  59. package/lib/product-qa.js +2 -1
  60. package/lib/push-notifications.js +6 -5
  61. package/lib/recently-viewed.js +7 -2
  62. package/lib/recommendations.js +7 -2
  63. package/lib/referral-leaderboard.js +2 -1
  64. package/lib/refund-automation.js +1 -1
  65. package/lib/refund-policy.js +1 -1
  66. package/lib/reorder-reminders.js +2 -1
  67. package/lib/reorder-thresholds.js +2 -1
  68. package/lib/robots-config.js +1 -0
  69. package/lib/sales-reports.js +17 -14
  70. package/lib/sales-tax-filings.js +2 -1
  71. package/lib/save-for-later.js +2 -1
  72. package/lib/search-suggestions.js +1 -1
  73. package/lib/shipping-insurance.js +2 -1
  74. package/lib/shipping-labels.js +3 -2
  75. package/lib/shipping-zones.js +1 -0
  76. package/lib/shrinkage-report.js +9 -8
  77. package/lib/sms-dispatcher.js +6 -5
  78. package/lib/stock-alerts.js +1 -1
  79. package/lib/stock-receipts.js +2 -1
  80. package/lib/store-credit.js +2 -1
  81. package/lib/storefront-forms.js +1 -1
  82. package/lib/storefront.js +223 -141
  83. package/lib/subscription-analytics.js +7 -2
  84. package/lib/subscription-controls.js +9 -8
  85. package/lib/subscription-gifts.js +2 -1
  86. package/lib/subscriptions.js +2 -0
  87. package/lib/support-tickets.js +4 -4
  88. package/lib/tax-cert-renewals.js +2 -1
  89. package/lib/tax-remittance.js +2 -1
  90. package/lib/theme-assets.js +1 -1
  91. package/lib/vendor/MANIFEST.json +2 -2
  92. package/lib/vendor/blamejs/CHANGELOG.md +4 -0
  93. package/lib/vendor/blamejs/README.md +2 -0
  94. package/lib/vendor/blamejs/api-snapshot.json +122 -2
  95. package/lib/vendor/blamejs/index.js +2 -0
  96. package/lib/vendor/blamejs/lib/did.js +367 -0
  97. package/lib/vendor/blamejs/lib/mdoc.js +305 -0
  98. package/lib/vendor/blamejs/package.json +1 -1
  99. package/lib/vendor/blamejs/release-notes/v0.12.40.json +18 -0
  100. package/lib/vendor/blamejs/release-notes/v0.12.41.json +18 -0
  101. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +27 -1
  102. package/lib/vendor/blamejs/test/layer-0-primitives/did.test.js +147 -0
  103. package/lib/vendor/blamejs/test/layer-0-primitives/mdoc.test.js +230 -0
  104. package/lib/vendor-invoices.js +1 -1
  105. package/lib/webhook-receiver.js +8 -2
  106. package/lib/webhook-subscriptions.js +1 -1
  107. package/lib/webhooks.js +6 -5
  108. package/lib/winback-campaigns.js +2 -1
  109. package/lib/wishlist-alerts.js +2 -1
  110. package/lib/wishlist-digest.js +2 -1
  111. package/package.json +1 -1
package/lib/storefront.js CHANGED
@@ -1199,6 +1199,40 @@ function renderCollection(opts) {
1199
1199
  });
1200
1200
  }
1201
1201
 
1202
+ // Account "Recently viewed" page — a newest-first grid of products the
1203
+ // signed-in customer has opened, reusing the standard product card.
1204
+ // `opts.products` is a resolved [{ slug, title, price, image_url,
1205
+ // image_alt }] list (archived products are dropped before render, so
1206
+ // the grid is orphan-tolerant). A "Clear history" control renders only
1207
+ // when the list is non-empty.
1208
+ function renderRecentlyViewed(opts) {
1209
+ var products = opts.products || [];
1210
+ var cards = products.map(function (p) { return _buildProductCard(p); }).join("");
1211
+ var grid = cards
1212
+ ? "<div class=\"catalog-grid recently-viewed-grid\">" + cards + "</div>"
1213
+ : "<p class=\"recently-viewed-empty\">You haven't viewed any products yet. As you browse the shop, the products you open show up here.</p>";
1214
+ var clear = cards
1215
+ ? "<form class=\"recently-viewed__clear\" method=\"post\" action=\"/account/recently-viewed/clear\">" +
1216
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Clear history</button></form>"
1217
+ : "";
1218
+ var body =
1219
+ "<section class=\"account-recently-viewed\">" +
1220
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1221
+ "<li><a href=\"/account\">Account</a></li>" +
1222
+ "<li aria-current=\"page\">Recently viewed</li>" +
1223
+ "</ol></nav>" +
1224
+ "<header class=\"account-recently-viewed__head\">" +
1225
+ "<h1 class=\"account-recently-viewed__title\">Recently viewed</h1>" +
1226
+ clear +
1227
+ "</header>" +
1228
+ grid +
1229
+ "</section>";
1230
+ return _wrap({
1231
+ title: "Recently viewed", shop_name: opts.shop_name || "blamejs.shop",
1232
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
1233
+ });
1234
+ }
1235
+
1202
1236
  var RETURN_REASONS = [
1203
1237
  ["defective", "Defective / doesn't work"],
1204
1238
  ["wrong-item", "Wrong item received"],
@@ -1476,9 +1510,9 @@ var CART_LINE =
1476
1510
  " </span>\n" +
1477
1511
  " </a>\n" +
1478
1512
  " </td>\n" +
1479
- " <td>{{qty}}</td>\n" +
1480
- " <td class=\"price\">{{unit}}</td>\n" +
1481
- " <td class=\"price\">{{total}}</td>\n" +
1513
+ " <td data-label=\"Qty\">{{qty}}</td>\n" +
1514
+ " <td class=\"price\" data-label=\"Unit\">{{unit}}</td>\n" +
1515
+ " <td class=\"price\" data-label=\"Total\">{{total}}</td>\n" +
1482
1516
  "</tr>\n";
1483
1517
 
1484
1518
  // Editable cart line — shown on the /cart page. Includes an inline
@@ -1502,14 +1536,14 @@ var CART_LINE_EDITABLE =
1502
1536
  " </span>\n" +
1503
1537
  " </a>\n" +
1504
1538
  " </td>\n" +
1505
- " <td class=\"cart-line__qty\">\n" +
1539
+ " <td class=\"cart-line__qty\" data-label=\"Qty\">\n" +
1506
1540
  " <form method=\"post\" action=\"/cart/lines/{{line_id}}/update\" class=\"cart-line__update\">\n" +
1507
- " <input type=\"number\" name=\"qty\" value=\"{{qty}}\" min=\"1\" max=\"99\" class=\"cart-line__qty-input\" aria-label=\"Quantity\">\n" +
1508
- " <button type=\"submit\" class=\"cart-line__btn\">Update</button>\n" +
1541
+ " <input type=\"number\" name=\"qty\" value=\"{{qty}}\" min=\"1\" max=\"99999\" class=\"cart-line__qty-input\" aria-label=\"Quantity\">\n" +
1542
+ " <button type=\"submit\" class=\"cart-line__btn cart-line__btn--update\">Update</button>\n" +
1509
1543
  " </form>\n" +
1510
1544
  " </td>\n" +
1511
- " <td class=\"price\">{{unit}}</td>\n" +
1512
- " <td class=\"price\">{{total}}</td>\n" +
1545
+ " <td class=\"price\" data-label=\"Price\">{{unit}}</td>\n" +
1546
+ " <td class=\"price\" data-label=\"Total\">{{total}}</td>\n" +
1513
1547
  " <td class=\"cart-line__remove-cell\">\n" +
1514
1548
  " RAW_CART_LINE_SAVE" +
1515
1549
  " <form method=\"post\" action=\"/cart/lines/{{line_id}}/remove\">\n" +
@@ -1657,7 +1691,7 @@ var ORDER_PAGE =
1657
1691
  " <div class=\"order-page__items\">\n" +
1658
1692
  " <h2 class=\"pdp__variants-title\">Items</h2>\n" +
1659
1693
  " <div class=\"table-scroll\">\n" +
1660
- " <table>\n" +
1694
+ " <table class=\"cart-table\">\n" +
1661
1695
  " <thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Total</th></tr></thead>\n" +
1662
1696
  " <tbody>{{line_rows}}</tbody>\n" +
1663
1697
  " </table>\n" +
@@ -2048,7 +2082,6 @@ function renderNotFound(opts) {
2048
2082
  // — it's a routing key, not an authentication token. The cart itself
2049
2083
  // transitions to `customer_id` on login via cart.setCustomer.
2050
2084
  var SESSION_COOKIE_NAME = "shop_sid";
2051
- var SESSION_COOKIE_MAX = 60 * 60 * 24 * 30; // 30 days
2052
2085
 
2053
2086
  // Authenticated-customer cookie — carries an opaque sealed envelope
2054
2087
  // `{ customer_id, exp }`, AEAD-encrypted via b.vault.seal so the
@@ -2058,79 +2091,85 @@ var SESSION_COOKIE_MAX = 60 * 60 * 24 * 30; // 30 days
2058
2091
  // rotated vault invalidates every outstanding auth cookie (operator-
2059
2092
  // initiated logout-everywhere).
2060
2093
  var AUTH_COOKIE_NAME = "shop_auth";
2061
- var AUTH_COOKIE_MAX = 60 * 60 * 24 * 14; // 14 days
2062
- var AUTH_TTL_MS = 14 * 24 * 60 * 60 * 1000;
2063
2094
 
2064
2095
  // WebAuthn ceremony state cookie — short-lived envelope holding the
2065
2096
  // random challenge + the ceremony-scoped metadata so register-finish /
2066
2097
  // login-finish can verify the same challenge the browser was sent.
2067
2098
  // Path-scoped to /account so it never leaks to other routes.
2068
2099
  var CHALLENGE_COOKIE_NAME = "shop_auth_chal";
2069
- var CHALLENGE_COOKIE_MAX = 5 * 60; // 5 minutes
2070
2100
 
2071
- function _readSidCookie(req) {
2072
- var raw = (req.headers && (req.headers.cookie || req.headers.Cookie)) || "";
2073
- if (!raw) return null;
2074
- var parts = raw.split(";");
2075
- for (var i = 0; i < parts.length; i += 1) {
2076
- var p = parts[i].trim();
2077
- var eq = p.indexOf("=");
2078
- if (eq <= 0) continue;
2079
- if (p.slice(0, eq) === SESSION_COOKIE_NAME) {
2080
- var v = p.slice(eq + 1);
2081
- // Cookie values are URL-encoded.
2082
- try { return decodeURIComponent(v); } catch (_e) { return null; }
2083
- }
2101
+ // Short-lived cookie carrying the Stripe PaymentIntent client_secret
2102
+ // from POST /checkout to GET /pay/:order_id. Path-scoped to /pay/ +
2103
+ // SameSite=Strict so it's only ever sent to the pay route.
2104
+ var PAY_COOKIE_NAME = "shop_pay";
2105
+
2106
+ // Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
2107
+ var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
2108
+
2109
+ // All cookie transport composes the framework's cookie primitive
2110
+ // (`b.cookies`) RFC 6265 parse/serialize, prefix invariants, and the
2111
+ // vault-sealed read/write helpers — rather than hand-built Set-Cookie
2112
+ // strings and manual header splitting. The jar is memoized; it only
2113
+ // captures the vault reference (seal/unseal run lazily per call), so
2114
+ // building it before vault.init() has completed is safe.
2115
+ var _jar = null;
2116
+ function _cookieJar() {
2117
+ if (!_jar) {
2118
+ _jar = _b().cookies.create({
2119
+ vault: _b().vault,
2120
+ defaults: { httpOnly: true, secure: true, sameSite: "Lax", path: "/" },
2121
+ });
2084
2122
  }
2085
- return null;
2086
- }
2087
-
2088
- function _setSidCookie(res, sid) {
2089
- var attrs = "Max-Age=" + SESSION_COOKIE_MAX + "; Path=/; HttpOnly; Secure; SameSite=Lax";
2090
- var header = SESSION_COOKIE_NAME + "=" + encodeURIComponent(sid) + "; " + attrs;
2091
- if (typeof res.appendHeader === "function") res.appendHeader("Set-Cookie", header);
2092
- else if (typeof res.setHeader === "function") res.setHeader("Set-Cookie", header);
2123
+ return _jar;
2093
2124
  }
2094
2125
 
2095
2126
  function _readCookie(req, name) {
2096
- var raw = (req.headers && (req.headers.cookie || req.headers.Cookie)) || "";
2097
- if (!raw) return null;
2098
- var parts = raw.split(";");
2099
- for (var i = 0; i < parts.length; i += 1) {
2100
- var p = parts[i].trim();
2101
- var eq = p.indexOf("=");
2102
- if (eq <= 0) continue;
2103
- if (p.slice(0, eq) === name) {
2104
- try { return decodeURIComponent(p.slice(eq + 1)); }
2105
- catch (_e) { return null; }
2106
- }
2107
- }
2108
- return null;
2127
+ return _cookieJar().read(req, name);
2109
2128
  }
2110
2129
 
2111
- function _appendCookie(res, header) {
2112
- if (typeof res.appendHeader === "function") res.appendHeader("Set-Cookie", header);
2113
- else if (typeof res.setHeader === "function") res.setHeader("Set-Cookie", header);
2130
+ function _readSidCookie(req) {
2131
+ // A cookie carrying anything but a well-shaped session id (a stale
2132
+ // value from an old deploy, a tampered cookie, garbage) reads as "no
2133
+ // session" rather than reaching cart.bySession — which throws on a
2134
+ // malformed id and would turn every page that renders the cart count
2135
+ // into a 500. The cookie grants zero authority, so dropping a
2136
+ // malformed one silently is safe.
2137
+ var v = _cookieJar().read(req, SESSION_COOKIE_NAME);
2138
+ return v && SID_SHAPE_RE.test(v) ? v : null;
2114
2139
  }
2115
2140
 
2116
- function _setAuthCookie(res, sealed) {
2117
- var attrs = "Max-Age=" + AUTH_COOKIE_MAX + "; Path=/; HttpOnly; Secure; SameSite=Lax";
2118
- _appendCookie(res, AUTH_COOKIE_NAME + "=" + encodeURIComponent(sealed) + "; " + attrs);
2141
+ function _setSidCookie(res, sid) {
2142
+ var T = _b().constants.TIME;
2143
+ _cookieJar().write(res, SESSION_COOKIE_NAME, sid, { expires: new Date(Date.now() + T.days(30)) });
2119
2144
  }
2120
2145
 
2146
+ // Auth + WebAuthn-challenge cookies carry a vault-sealed JSON envelope.
2147
+ // writeSealed/readSealed handle the seal + the on-wire prefix; the
2148
+ // caller works in plain objects.
2149
+ function _setAuthCookie(res, env) {
2150
+ var T = _b().constants.TIME;
2151
+ _cookieJar().writeSealed(res, AUTH_COOKIE_NAME, JSON.stringify(env), { expires: new Date(Date.now() + T.days(14)) });
2152
+ }
2121
2153
  function _clearAuthCookie(res) {
2122
- _appendCookie(res,
2123
- AUTH_COOKIE_NAME + "=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax");
2154
+ _cookieJar().clear(res, AUTH_COOKIE_NAME);
2124
2155
  }
2125
-
2126
- function _setChallengeCookie(res, sealed) {
2127
- var attrs = "Max-Age=" + CHALLENGE_COOKIE_MAX + "; Path=/account; HttpOnly; Secure; SameSite=Lax";
2128
- _appendCookie(res, CHALLENGE_COOKIE_NAME + "=" + encodeURIComponent(sealed) + "; " + attrs);
2156
+ function _readAuthEnv(req) {
2157
+ var raw = _cookieJar().readSealed(req, AUTH_COOKIE_NAME);
2158
+ if (raw === null) return null;
2159
+ try { return JSON.parse(raw); } catch (_e) { return null; }
2129
2160
  }
2130
2161
 
2162
+ function _setChallengeCookie(res, env) {
2163
+ var T = _b().constants.TIME;
2164
+ _cookieJar().writeSealed(res, CHALLENGE_COOKIE_NAME, JSON.stringify(env), { expires: new Date(Date.now() + T.minutes(5)), path: "/account" });
2165
+ }
2131
2166
  function _clearChallengeCookie(res) {
2132
- _appendCookie(res,
2133
- CHALLENGE_COOKIE_NAME + "=; Max-Age=0; Path=/account; HttpOnly; Secure; SameSite=Lax");
2167
+ _cookieJar().clear(res, CHALLENGE_COOKIE_NAME, { path: "/account" });
2168
+ }
2169
+ function _readChallengeEnv(req) {
2170
+ var raw = _cookieJar().readSealed(req, CHALLENGE_COOKIE_NAME);
2171
+ if (raw === null) return null;
2172
+ try { return JSON.parse(raw); } catch (_e) { return null; }
2134
2173
  }
2135
2174
 
2136
2175
  // ---- account-page renderers --------------------------------------------
@@ -2244,6 +2283,7 @@ var ACCOUNT_DASH_PAGE =
2244
2283
  " <div class=\"account-dash__actions\">\n" +
2245
2284
  " <a class=\"btn-secondary\" href=\"/account/wishlist\">Wishlist</a>\n" +
2246
2285
  " <a class=\"btn-secondary\" href=\"/account/saved\">Saved for later</a>\n" +
2286
+ " <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
2247
2287
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
2248
2288
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
2249
2289
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
@@ -2268,10 +2308,10 @@ var ACCOUNT_DASH_PAGE =
2268
2308
 
2269
2309
  var ACCOUNT_DASH_ORDER_ROW =
2270
2310
  "<tr>\n" +
2271
- " <td><a href=\"/orders/{{order_id}}\" class=\"account-order__id\"><code>{{order_id_short}}</code></a></td>\n" +
2272
- " <td class=\"account-order__items\">RAW_ACCOUNT_ORDER_THUMBS</td>\n" +
2273
- " <td><span class=\"pdp__badge {{status_class}}\">{{status}}</span></td>\n" +
2274
- " <td class=\"price\">{{total}}</td>\n" +
2311
+ " <td data-label=\"Order\"><a href=\"/orders/{{order_id}}\" class=\"account-order__id\"><code>{{order_id_short}}</code></a></td>\n" +
2312
+ " <td class=\"account-order__items\" data-label=\"Items\">RAW_ACCOUNT_ORDER_THUMBS</td>\n" +
2313
+ " <td data-label=\"Status\"><span class=\"pdp__badge {{status_class}}\">{{status}}</span></td>\n" +
2314
+ " <td class=\"price\" data-label=\"Total\">{{total}}</td>\n" +
2275
2315
  "</tr>\n";
2276
2316
 
2277
2317
  function renderAccount(opts) {
@@ -2372,7 +2412,7 @@ function mount(router, deps) {
2372
2412
  // so the /admin landing + the onNotFound 404 handler (mounted
2373
2413
  // outside the `if (deps.customers)` block below) can reach it.
2374
2414
  async function _cartCountForReq(req) {
2375
- var sid = _readCookie(req, SESSION_COOKIE_NAME);
2415
+ var sid = _readSidCookie(req);
2376
2416
  if (!sid) return 0;
2377
2417
  var c = await deps.cart.bySession(sid);
2378
2418
  if (!c) return 0;
@@ -2380,6 +2420,42 @@ function mount(router, deps) {
2380
2420
  return lines.length;
2381
2421
  }
2382
2422
 
2423
+ // The signed-in customer's sealed-cookie envelope, or null. Shared by
2424
+ // the PDP view recorder (mounted outside the `if (deps.customers)`
2425
+ // block) and the account routes inside it, so there's one auth-cookie
2426
+ // reader rather than a copy per call site. A missing / malformed /
2427
+ // expired cookie returns null — never throws.
2428
+ function _currentCustomerEnv(req) {
2429
+ var env = _readAuthEnv(req);
2430
+ if (!env || !env.customer_id || !env.exp || env.exp < Date.now()) return null;
2431
+ return env;
2432
+ }
2433
+
2434
+ // Resolve a product id into the { slug, title, price, image_url,
2435
+ // image_alt } shape `_buildProductCard` expects. Returns null for an
2436
+ // archived / missing product so it drops out of any grid (collections,
2437
+ // recently-viewed). Shared so the decoration rule lives in one place.
2438
+ var _cardAssetPrefix = deps.asset_prefix || "/assets/";
2439
+ async function _decorateProductCard(pid) {
2440
+ var product = await deps.catalog.products.get(pid);
2441
+ if (!product || product.status !== "active") return null;
2442
+ var priceStr = "—";
2443
+ var variants = await deps.catalog.variants.listForProduct(pid);
2444
+ if (variants.length) {
2445
+ var pr = await deps.catalog.prices.current(variants[0].id, "USD");
2446
+ if (pr) priceStr = pricing.format(pr.amount_minor, pr.currency);
2447
+ }
2448
+ var media = await deps.catalog.media.listForProduct(pid);
2449
+ var hero = media.length ? media[0] : null;
2450
+ return {
2451
+ slug: product.slug,
2452
+ title: product.title,
2453
+ price: priceStr,
2454
+ image_url: hero ? (_cardAssetPrefix + hero.r2_key) : null,
2455
+ image_alt: hero ? (hero.alt_text || product.title) : null,
2456
+ };
2457
+ }
2458
+
2383
2459
  // Resolve the cart for this request — read session_id from the
2384
2460
  // sealed cookie, create one (and the cart) if absent. Returns
2385
2461
  // the cart row OR null when the cart was just created (caller can
@@ -2520,6 +2596,19 @@ function mount(router, deps) {
2520
2596
  try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
2521
2597
  catch (_e) { wishlistCount = 0; }
2522
2598
  }
2599
+ // Log the view for a signed-in customer so it surfaces on their
2600
+ // "Recently viewed" account page. Drop-silent — a recording failure
2601
+ // (table not migrated, write contention) must never break the PDP
2602
+ // render. Guests aren't recorded here: their PDP is edge-cached with
2603
+ // no per-request container hop, so session-scoped guest history is a
2604
+ // separate opt-in (a client beacon) the storefront doesn't ship yet.
2605
+ if (deps.recentlyViewed && deps.customers) {
2606
+ var rvEnv = _currentCustomerEnv(req);
2607
+ if (rvEnv) {
2608
+ try { await deps.recentlyViewed.recordView({ customer_id: rvEnv.customer_id, product_id: product.id }); }
2609
+ catch (_e) { /* drop-silent — recently-viewed is supplementary to the buy path */ }
2610
+ }
2611
+ }
2523
2612
  var html = renderProduct({
2524
2613
  product: product,
2525
2614
  variants: variants,
@@ -2539,36 +2628,11 @@ function mount(router, deps) {
2539
2628
  // Collections — operator-curated + smart product lists. Public browse
2540
2629
  // pages; mounted when the collections primitive is wired.
2541
2630
  if (deps.collections) {
2542
- var _collAssetPrefix = deps.asset_prefix || "/assets/";
2543
-
2544
- // Decorate a product id into the { slug, title, price, image_url }
2545
- // shape _buildProductCard expects. Returns null for an archived /
2546
- // missing product so it drops out of the grid.
2547
- async function _decorateCollectionProduct(pid) {
2548
- var product = await deps.catalog.products.get(pid);
2549
- if (!product || product.status !== "active") return null;
2550
- var priceStr = "—";
2551
- var variants = await deps.catalog.variants.listForProduct(pid);
2552
- if (variants.length) {
2553
- var pr = await deps.catalog.prices.current(variants[0].id, "USD");
2554
- if (pr) priceStr = pricing.format(pr.amount_minor, pr.currency);
2555
- }
2556
- var media = await deps.catalog.media.listForProduct(pid);
2557
- var hero = media.length ? media[0] : null;
2558
- return {
2559
- slug: product.slug,
2560
- title: product.title,
2561
- price: priceStr,
2562
- image_url: hero ? (_collAssetPrefix + hero.r2_key) : null,
2563
- image_alt: hero ? (hero.alt_text || product.title) : null,
2564
- };
2565
- }
2566
-
2567
2631
  router.get("/collections", async function (req, res) {
2568
2632
  var cols = await deps.collections.list({ active_only: true });
2569
2633
  var cartCount = await _cartCountForReq(req);
2570
2634
  _send(res, 200, renderCollectionList({
2571
- collections: cols, shop_name: shopName, cart_count: cartCount, asset_prefix: _collAssetPrefix,
2635
+ collections: cols, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
2572
2636
  }));
2573
2637
  });
2574
2638
 
@@ -2589,7 +2653,7 @@ function mount(router, deps) {
2589
2653
  var products = [];
2590
2654
  for (var i = 0; i < result.rows.length; i += 1) {
2591
2655
  var pid = result.rows[i].product_id || result.rows[i].id;
2592
- var card = await _decorateCollectionProduct(pid);
2656
+ var card = await _decorateProductCard(pid);
2593
2657
  if (card) products.push(card);
2594
2658
  }
2595
2659
  var cartCount = await _cartCountForReq(req);
@@ -2713,11 +2777,11 @@ function mount(router, deps) {
2713
2777
  idempotency_key: "checkout:" + c.id + ":" + _b().uuid.v7(),
2714
2778
  });
2715
2779
  // Set a short-lived pay cookie so /pay/:order_id can serve the
2716
- // client_secret without re-running confirm.
2717
- var payCookie = "shop_pay=" + encodeURIComponent(result.payment_intent.client_secret) +
2718
- "; Max-Age=900; Path=/pay/; HttpOnly; Secure; SameSite=Strict";
2719
- if (res.appendHeader) res.appendHeader("Set-Cookie", payCookie);
2720
- else if (res.setHeader) res.setHeader("Set-Cookie", payCookie);
2780
+ // client_secret without re-running confirm. Scoped to /pay/ +
2781
+ // SameSite=Strict so it's only ever sent to the pay route.
2782
+ _cookieJar().write(res, PAY_COOKIE_NAME, result.payment_intent.client_secret, {
2783
+ expires: new Date(Date.now() + _b().constants.TIME.minutes(15)), path: "/pay/", sameSite: "Strict",
2784
+ });
2721
2785
  res.status(303);
2722
2786
  res.setHeader && res.setHeader("location", "/pay/" + result.order.id);
2723
2787
  return res.end ? res.end() : res.send("");
@@ -2736,14 +2800,7 @@ function mount(router, deps) {
2736
2800
  // Read the client_secret from the shop_pay cookie set on POST
2737
2801
  // /checkout. The cookie is scoped Path=/pay/ + SameSite=Strict
2738
2802
  // so it's only sent to the pay route and never cross-origin.
2739
- var rawCookies = (req.headers && (req.headers.cookie || req.headers.Cookie)) || "";
2740
- var clientSecret = null;
2741
- rawCookies.split(";").forEach(function (p) {
2742
- var t = p.trim();
2743
- if (t.indexOf("shop_pay=") === 0) {
2744
- try { clientSecret = decodeURIComponent(t.slice("shop_pay=".length)); } catch (_e) { /* drop */ }
2745
- }
2746
- });
2803
+ var clientSecret = _readCookie(req, PAY_COOKIE_NAME);
2747
2804
  if (!clientSecret) {
2748
2805
  res.status(303); res.setHeader && res.setHeader("location", "/cart");
2749
2806
  return res.end ? res.end() : res.send("");
@@ -2809,23 +2866,8 @@ function mount(router, deps) {
2809
2866
  return _b().crypto.toBase64Url(buf);
2810
2867
  }
2811
2868
 
2812
- function _sealEnvelope(obj) {
2813
- return _b().vault.seal(JSON.stringify(obj));
2814
- }
2815
- function _unsealEnvelope(s) {
2816
- try {
2817
- var raw = _b().vault.unseal(s);
2818
- return JSON.parse(raw);
2819
- } catch (_e) { return null; }
2820
- }
2821
-
2822
2869
  function _currentCustomer(req) {
2823
- var raw = _readCookie(req, AUTH_COOKIE_NAME);
2824
- if (!raw) return null;
2825
- var env = _unsealEnvelope(raw);
2826
- if (!env || !env.customer_id || !env.exp) return null;
2827
- if (env.exp < Date.now()) return null;
2828
- return env;
2870
+ return _currentCustomerEnv(req);
2829
2871
  }
2830
2872
 
2831
2873
  function _serviceUnavailable(res, msg) {
@@ -2883,13 +2925,12 @@ function mount(router, deps) {
2883
2925
  // Seal the ceremony state (challenge + customer_id) into the
2884
2926
  // shop_auth_chal cookie so register-finish verifies against
2885
2927
  // the same challenge without server-side state.
2886
- var sealed = _sealEnvelope({
2928
+ _setChallengeCookie(res, {
2887
2929
  kind: "register",
2888
2930
  customer_id: customer.id,
2889
2931
  challenge: startOpts.challenge,
2890
2932
  created_at: Date.now(),
2891
2933
  });
2892
- _setChallengeCookie(res, sealed);
2893
2934
  res.status(200);
2894
2935
  res.setHeader && res.setHeader("content-type", "application/json");
2895
2936
  return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
@@ -2902,10 +2943,9 @@ function mount(router, deps) {
2902
2943
 
2903
2944
  router.post("/account/passkey/register-finish", async function (req, res) {
2904
2945
  try {
2905
- var rawCookie = _readCookie(req, CHALLENGE_COOKIE_NAME);
2906
- if (!rawCookie) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
2907
- var env = _unsealEnvelope(rawCookie);
2908
- if (!env || env.kind !== "register") {
2946
+ var env = _readChallengeEnv(req);
2947
+ if (!env) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
2948
+ if (env.kind !== "register") {
2909
2949
  res.status(400); return res.end ? res.end("bad challenge") : res.send("bad challenge");
2910
2950
  }
2911
2951
  var att = _readJsonBody(req);
@@ -2934,10 +2974,10 @@ function mount(router, deps) {
2934
2974
  transports: transports,
2935
2975
  });
2936
2976
  _clearChallengeCookie(res);
2937
- _setAuthCookie(res, _sealEnvelope({
2977
+ _setAuthCookie(res, {
2938
2978
  customer_id: env.customer_id,
2939
- exp: Date.now() + AUTH_TTL_MS,
2940
- }));
2979
+ exp: Date.now() + _b().constants.TIME.days(14),
2980
+ });
2941
2981
  res.status(200);
2942
2982
  return res.end ? res.end("ok") : res.send("ok");
2943
2983
  } catch (e) {
@@ -2972,13 +3012,12 @@ function mount(router, deps) {
2972
3012
  allowCredentials: allow,
2973
3013
  userVerification: "preferred",
2974
3014
  });
2975
- var sealed = _sealEnvelope({
3015
+ _setChallengeCookie(res, {
2976
3016
  kind: "login",
2977
3017
  email_hash: hash,
2978
3018
  challenge: startOpts.challenge,
2979
3019
  created_at: Date.now(),
2980
3020
  });
2981
- _setChallengeCookie(res, sealed);
2982
3021
  res.status(200);
2983
3022
  res.setHeader && res.setHeader("content-type", "application/json");
2984
3023
  return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
@@ -2991,10 +3030,9 @@ function mount(router, deps) {
2991
3030
 
2992
3031
  router.post("/account/passkey/login-finish", async function (req, res) {
2993
3032
  try {
2994
- var rawCookie = _readCookie(req, CHALLENGE_COOKIE_NAME);
2995
- if (!rawCookie) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
2996
- var env = _unsealEnvelope(rawCookie);
2997
- if (!env || env.kind !== "login") { res.status(400); return res.end ? res.end("bad challenge") : res.send("bad challenge"); }
3033
+ var env = _readChallengeEnv(req);
3034
+ if (!env) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
3035
+ if (env.kind !== "login") { res.status(400); return res.end ? res.end("bad challenge") : res.send("bad challenge"); }
2998
3036
  var assertion = _readJsonBody(req);
2999
3037
  var credentialId = assertion.id || assertion.rawId;
3000
3038
  if (!credentialId) { res.status(400); return res.end ? res.end("missing credential id") : res.send("missing credential id"); }
@@ -3033,7 +3071,7 @@ function mount(router, deps) {
3033
3071
  }
3034
3072
  // Merge the anonymous cart into a customer-owned cart so
3035
3073
  // the shopper doesn't lose items on sign-in.
3036
- var sid = _readCookie(req, SESSION_COOKIE_NAME);
3074
+ var sid = _readSidCookie(req);
3037
3075
  if (sid) {
3038
3076
  try {
3039
3077
  var anonCart = await deps.cart.bySession(sid);
@@ -3041,10 +3079,10 @@ function mount(router, deps) {
3041
3079
  } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
3042
3080
  }
3043
3081
  _clearChallengeCookie(res);
3044
- _setAuthCookie(res, _sealEnvelope({
3082
+ _setAuthCookie(res, {
3045
3083
  customer_id: customer.id,
3046
- exp: Date.now() + AUTH_TTL_MS,
3047
- }));
3084
+ exp: Date.now() + _b().constants.TIME.days(14),
3085
+ });
3048
3086
  res.status(200);
3049
3087
  return res.end ? res.end("ok") : res.send("ok");
3050
3088
  } catch (e) {
@@ -3553,6 +3591,50 @@ function mount(router, deps) {
3553
3591
  });
3554
3592
  }
3555
3593
 
3594
+ // Recently viewed — the signed-in customer's newest-first browse
3595
+ // history. Views are recorded server-side on the (container-rendered)
3596
+ // PDP; this surface lets the customer review + clear that history.
3597
+ if (deps.recentlyViewed) {
3598
+ function _rvAuth(req, res) {
3599
+ var auth;
3600
+ try { auth = _currentCustomer(req); }
3601
+ catch (e) {
3602
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
3603
+ throw e;
3604
+ }
3605
+ if (!auth) {
3606
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
3607
+ res.end ? res.end() : res.send("");
3608
+ return null;
3609
+ }
3610
+ return auth;
3611
+ }
3612
+
3613
+ router.get("/account/recently-viewed", async function (req, res) {
3614
+ var auth = _rvAuth(req, res); if (!auth) return;
3615
+ // A read failure (table not migrated) degrades to the empty
3616
+ // state rather than 500-ing the account page.
3617
+ var rows = [];
3618
+ try { rows = await deps.recentlyViewed.forCustomer(auth.customer_id, { limit: 24 }); }
3619
+ catch (_e) { rows = []; }
3620
+ var products = [];
3621
+ for (var i = 0; i < rows.length; i += 1) {
3622
+ var card = await _decorateProductCard(rows[i].product_id);
3623
+ if (card) products.push(card);
3624
+ }
3625
+ var cartCount = await _cartCountForReq(req);
3626
+ _send(res, 200, renderRecentlyViewed({ products: products, shop_name: shopName, cart_count: cartCount }));
3627
+ });
3628
+
3629
+ router.post("/account/recently-viewed/clear", async function (req, res) {
3630
+ var auth = _rvAuth(req, res); if (!auth) return;
3631
+ try { await deps.recentlyViewed.purgeCustomer(auth.customer_id); }
3632
+ catch (_e) { /* drop-silent — a failed clear leaves history intact, no error surface needed */ }
3633
+ res.status(303); res.setHeader && res.setHeader("location", "/account/recently-viewed");
3634
+ return res.end ? res.end() : res.send("");
3635
+ });
3636
+ }
3637
+
3556
3638
  // Product reviews — submission requires a logged-in customer AND a
3557
3639
  // verified purchase of the product (the gate, not just a badge).
3558
3640
  // Only mounts when both the reviews primitive and an order handle
@@ -162,10 +162,15 @@
162
162
 
163
163
  // ---- constants ----------------------------------------------------------
164
164
 
165
+ // `_b()` is a hoisted function declaration (defined below); resolving the
166
+ // framework constants here at module-eval is safe — the index entry point
167
+ // exposes `framework` before the require cascade.
168
+ var C = _b().constants;
169
+
165
170
  var CACHE_NAMESPACE = "subscription-analytics-cache";
166
171
 
167
- var DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
168
- var ONE_DAY_MS = 24 * 60 * 60 * 1000;
172
+ var DEFAULT_TTL_MS = C.TIME.minutes(5); // 5 minutes
173
+ var ONE_DAY_MS = C.TIME.days(1);
169
174
  var ONE_YEAR_MS = 365 * ONE_DAY_MS;
170
175
  var DEFAULT_LTV_WINDOW_MS = 90 * ONE_DAY_MS;
171
176
 
@@ -55,6 +55,7 @@ function _b() {
55
55
  if (!bShop) bShop = require("./index");
56
56
  return bShop.framework;
57
57
  }
58
+ var C = _b().constants;
58
59
 
59
60
  // ---- constants ----------------------------------------------------------
60
61
 
@@ -89,12 +90,12 @@ var FREQUENCIES = [
89
90
  // step skipNext / changeFrequency surface; a calendar-accurate
90
91
  // scheduler is out of scope for v1.
91
92
  var PERIOD_MS = Object.freeze({
92
- weekly: 7 * 24 * 60 * 60 * 1000,
93
- biweekly: 14 * 24 * 60 * 60 * 1000,
94
- monthly: 30 * 24 * 60 * 60 * 1000,
95
- quarterly: 90 * 24 * 60 * 60 * 1000,
96
- semiannual: 182 * 24 * 60 * 60 * 1000,
97
- annual: 365 * 24 * 60 * 60 * 1000,
93
+ weekly: C.TIME.days(7),
94
+ biweekly: C.TIME.days(14),
95
+ monthly: C.TIME.days(30),
96
+ quarterly: C.TIME.days(90),
97
+ semiannual: C.TIME.days(182),
98
+ annual: C.TIME.days(365),
98
99
  });
99
100
 
100
101
  // Plan-interval → frequency fallback. When the row has no `frequency`
@@ -115,7 +116,7 @@ function _frequencyFromPlan(plan) {
115
116
  return "monthly";
116
117
  }
117
118
 
118
- var REACTIVATE_GRACE_MS = 90 * 24 * 60 * 60 * 1000;
119
+ var REACTIVATE_GRACE_MS = C.TIME.days(90);
119
120
  var MAX_REASON_LEN = 280;
120
121
  var MAX_SKIP_COUNT = 12;
121
122
  var MAX_QUANTITY = 1000000;
@@ -599,7 +600,7 @@ function create(opts) {
599
600
  if (now - row.cancelled_at > REACTIVATE_GRACE_MS) {
600
601
  var gErr = new Error(
601
602
  "subscriptionControls.reactivate: refused — cancellation is older than the " +
602
- (REACTIVATE_GRACE_MS / (24 * 60 * 60 * 1000)) + "-day grace window"
603
+ (REACTIVATE_GRACE_MS / C.TIME.days(1)) + "-day grace window"
603
604
  );
604
605
  gErr.code = "SUBSCRIPTION_REACTIVATE_GRACE_EXPIRED";
605
606
  throw gErr;
@@ -72,6 +72,7 @@ function _b() {
72
72
  if (!bShop) bShop = require("./index");
73
73
  return bShop.framework;
74
74
  }
75
+ var C = _b().constants;
75
76
 
76
77
  // ---- constants ----------------------------------------------------------
77
78
 
@@ -94,7 +95,7 @@ var MAX_LIST_LIMIT = 200;
94
95
  var DEFAULT_LIST_LIMIT = 50;
95
96
  // Default redemption window: 365 days. Operators wanting a different
96
97
  // horizon pass `expires_at_ms` (absolute) at purchase time.
97
- var DEFAULT_EXPIRY_MS = 365 * 86400 * 1000;
98
+ var DEFAULT_EXPIRY_MS = C.TIME.days(365);
98
99
 
99
100
  // ---- validators ---------------------------------------------------------
100
101