@blamejs/blamejs-shop 0.3.74 → 0.3.75

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.3.x
10
10
 
11
+ - v0.3.75 (2026-06-05) — **The analytics funnel records real shopper events — only with analytics consent.** The admin Analytics screen's browse-to-buy funnel, top search terms, and most-viewed products read an event stream that nothing previously wrote, so those tables stayed empty. The storefront now records five events — product view, search, add to cart, checkout started, order completed — strictly gated on the visitor's analytics consent: no consent decision means no recording, Do Not Track and Global Privacy Control suppress recording even after an accept-all, session identifiers are stored only as keyed hashes, and no name or email ever rides an event. Recording is fire-and-forget and can never affect a request. Edge-cached anonymous pages are deliberately not recorded — the cache stays shared and fast — so the funnel reflects consented, container-served traffic, and the dashboard's own copy now says exactly that. **Added:** *Consent-gated shopper event recording* — Product views, searches, cart adds, checkout starts, and completed orders record into the analytics event stream when — and only when — the session's consent grants the analytics category. The gate is the consent primitive's own server-side read: default-deny without a decision, policy-version-aware, and honoring Do Not Track and Sec-GPC over a stored acceptance. Events carry a hashed session key and bounded payloads, never personal data. The Analytics screen's funnel now states that its counts cover consented, container-served traffic; anonymous edge-cached page views are not tracked by design.
12
+
11
13
  - v0.3.74 (2026-06-05) — **The payment form matches the shop's dark theme.** The Stripe Payment Element and the express wallet buttons on the pay page previously rendered in Stripe's default light style — a white card floating on the shop's near-black page. The elements now use Stripe's night appearance recolored with the shop's own design tokens: violet accent on focus and selected tabs, the shop's charcoal input surfaces, soft ink text, and matching corner radii. The Apple Pay, Google Pay, and PayPal express buttons switch to their white and outline variants, which keep each brand's contrast rules legible on the dark card. No payment behavior changes; this is purely the appearance configuration on the existing elements. **Changed:** *Dark-themed payment elements* — The pay page's Stripe elements adopt the night appearance with the shop's design tokens — violet primary, charcoal surfaces, soft ink, ten-pixel radii, violet focus rings — and the express wallet buttons render in their white and outline variants sized to the page's controls. Inside Stripe's cross-origin frame the typeface falls back to the system stack; everything else mirrors the storefront's stylesheet tokens.
12
14
 
13
15
  - v0.3.73 (2026-06-05) — **Unlock codes are manageable from the Discounts screen, and coupon guessing is rate-capped.** Code-unlocked discount rules shipped with an API-only gap: the Discounts console had no field for the unlock code, so creating a code-gated rule required a raw API call. The create and edit forms now carry an optional Unlock code input — clearing it on edit reverts the rule to purely automatic — and the rule list and detail show which rules are code-gated; the screen's description covers both kinds. The cart's code-apply endpoint joins the tight per-address rate budget that already guards gift-card balance lookups, capping coupon-namespace guessing at the same rate. Also fixed: a failed confirmation-resend in the browser console now lands in the error log with a clean notice instead of an unrecorded failure, and the signed-in cart page resolves the shopper's destination once instead of twice per view. **Fixed:** *Unlock codes editable in the Discounts console* — The create and edit forms gain an optional Unlock code field, threaded through the same validation as the API. On edit, submitting the field blank explicitly clears the code (the rule becomes purely automatic again), while the inline quick-edit leaves it untouched. The rule list and the detail view display each rule's code, escaped, so code-gated rules are visible at a glance, and the screen copy now describes both automatic and code-unlocked rules. · *Coupon-code guessing joins the tight rate budget* — POST /cart/coupon now sits in the per-address, per-path rate budget alongside gift-card balance lookups — both accept guessable secrets and answer uniformly, so both deserve the same throttle. The pinned integration test sprays the endpoint and asserts the cap engages. · *Failed confirmation resends are captured and surfaced* — A mailer fault during a browser-initiated confirmation resend previously escaped both the error log and the screen. It now records to the error log and redirects back to the order with an honest failure notice; the API path captures identically. The signed-in cart view also drops a duplicated destination lookup per render.
package/lib/admin.js CHANGED
@@ -11247,7 +11247,7 @@ function renderAdminAnalytics(opts) {
11247
11247
  var ratePct = (Number(fn.conversion_rate) || 0) * 100;
11248
11248
  var funnelStats =
11249
11249
  "<section><h2>Browse-to-buy funnel</h2>" +
11250
- "<p class=\"meta\">Pre-purchase signal from the event stream — the sales report covers post-order status; this covers the path to it.</p>" +
11250
+ "<p class=\"meta\">Pre-purchase signal from the event stream — the sales report covers post-order status; this covers the path to it. Counts cover visitors who opted into analytics cookies and reflect container-served traffic: anonymous product and search pages are served from the edge cache and aren't recorded here.</p>" +
11251
11251
  "<div class=\"stat-grid\">" +
11252
11252
  _statCard("Product views", String(fn.pdp_views || 0)) +
11253
11253
  _statCard("Cart adds", String(fn.cart_adds || 0)) +
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.74",
2
+ "version": "0.3.75",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/storefront.js CHANGED
@@ -10114,6 +10114,53 @@ function mount(router, deps) {
10114
10114
  return lines.length;
10115
10115
  }
10116
10116
 
10117
+ // Consent-gated browse→buy event recording (lib/analytics.js's event
10118
+ // stream — the data behind /admin/analytics's funnel, top-search-terms,
10119
+ // and most-viewed tables). Mounts only when deps.analytics is wired.
10120
+ //
10121
+ // GATE: a single byte is written ONLY when the visitor's stored decision
10122
+ // opts the `analytics` category in. The gate is the SAME server-side read
10123
+ // every other consent-gated behaviour uses — `_consentAllows(req,
10124
+ // "analytics", _liveConsentPolicy())` — which reads the sealed
10125
+ // `shop_consent` cookie, default-denies when there's no decision, and
10126
+ // honours DNT / Sec-GPC as an implicit deny. No consent → nothing is
10127
+ // recorded (no anonymised fallback row).
10128
+ //
10129
+ // PRIVACY: the only join key is the per-session `shop_sid` value, which
10130
+ // recordEvent namespace-hashes before it touches the table (raw id never
10131
+ // persists). No emails / names / customer ids are passed — session-scoped
10132
+ // only. recordEvent itself refuses anything that looks like a raw email
10133
+ // or IP, so a stray PII value loud-fails rather than leaking.
10134
+ //
10135
+ // EDGE NOTE: PDP + search GETs are edge-cached for anonymous visitors,
10136
+ // and the edge cannot record per-visitor events (it has no DB write path
10137
+ // and must stay cacheable) — it is deliberately NOT changed to. These
10138
+ // events therefore fire on CONTAINER-served requests only, so the funnel
10139
+ // reflects container-served traffic (a session-cookie-carrying visitor
10140
+ // skips the edge cache, which is exactly the cohort whose consent we can
10141
+ // read). The /admin/analytics screen copy states this honestly.
10142
+ //
10143
+ // HOT PATH: every call is fire-and-forget + drop-silent. We never await
10144
+ // the write into the response and the whole body is wrapped so a recording
10145
+ // failure (unmigrated table, write contention, validator throw) can never
10146
+ // affect the request that triggered it.
10147
+ function _recordAnalyticsEvent(req, fields, sidOverride) {
10148
+ if (!deps.analytics) return;
10149
+ try {
10150
+ if (!_consentAllows(req, "analytics", _liveConsentPolicy())) return;
10151
+ // `sidOverride` carries a session id the route just resolved/minted
10152
+ // (e.g. cart-add creating a fresh session) — the request cookie can
10153
+ // be stale or absent at that point.
10154
+ var sid = sidOverride || null;
10155
+ if (!sid) { try { sid = _readSidCookie(req); } catch (_e) { sid = null; } }
10156
+ if (!sid) return; // recordEvent needs a join key; no session → skip
10157
+ var input = Object.assign({ session_id: sid }, fields);
10158
+ // Fire-and-forget: kick the async write off and swallow any rejection
10159
+ // so a slow / failing DB hop never delays or breaks the response.
10160
+ Promise.resolve(deps.analytics.recordEvent(input)).catch(function () {});
10161
+ } catch (_e) { /* drop-silent — analytics is supplementary to every request */ }
10162
+ }
10163
+
10117
10164
  // Resolve the active trust badges for a container-only placement and
10118
10165
  // concatenate each one's sanitized renderHtml. Fires an impression per
10119
10166
  // rendered badge (fire-and-forget — the counter is drop-silent on the hot
@@ -11717,6 +11764,13 @@ function mount(router, deps) {
11717
11764
  shop_name: shopName,
11718
11765
  cart_count: cartCount,
11719
11766
  }, _requestUrls(req), ccy)));
11767
+ // Consent-gated funnel event — feeds the top-search-terms aggregate.
11768
+ // Only a real (non-empty) query counts; the typed term is bounded to
11769
+ // 200 chars above (recordEvent caps search_q at 256). Container-served
11770
+ // only; fire-and-forget + drop-silent.
11771
+ if (q.trim().length > 0) {
11772
+ _recordAnalyticsEvent(req, { event_type: "search_query", search_q: q.trim() });
11773
+ }
11720
11774
  });
11721
11775
 
11722
11776
  router.get("/products/:slug", async function (req, res) {
@@ -11886,6 +11940,10 @@ function mount(router, deps) {
11886
11940
  cart_count: cartCount,
11887
11941
  theme: theme,
11888
11942
  }, _requestUrls(req), ccy));
11943
+ // Consent-gated funnel event — the top of the browse→buy funnel +
11944
+ // most-viewed-products aggregate. Container-served only (anonymous PDPs
11945
+ // are edge-cached and never reach here); fire-and-forget + drop-silent.
11946
+ _recordAnalyticsEvent(req, { event_type: "pdp_view", product_id: product.id });
11889
11947
  _send(res, 200, html);
11890
11948
  });
11891
11949
 
@@ -12681,6 +12739,10 @@ function mount(router, deps) {
12681
12739
  var lines = await _repriceCartLines(rawLines);
12682
12740
  _setCheckoutCsp(res);
12683
12741
  _send(res, 200, renderCheckoutForm(await _checkoutRenderOpts(req, c, lines, null)));
12742
+ // Consent-gated funnel event — the checkout_start step (the visitor
12743
+ // reached the checkout form with a non-empty cart). Container-served
12744
+ // (checkout is never edge-cached); fire-and-forget + drop-silent.
12745
+ _recordAnalyticsEvent(req, { event_type: "checkout_start" });
12684
12746
  });
12685
12747
 
12686
12748
  router.post("/checkout", async function (req, res) {
@@ -12768,6 +12830,20 @@ function mount(router, deps) {
12768
12830
  // schedule land here. A failure must never roll back a paid order, so
12769
12831
  // each is its own try/catch (mirrors _recordAutoDiscounts).
12770
12832
  await _persistGiftAndPickup(c, result.order, body);
12833
+ // Consent-gated funnel event — checkout_complete (an order was
12834
+ // placed: the cart converted via checkout.confirm). Fires for both
12835
+ // the gift-card-fully-paid path and the Stripe-intent path (the
12836
+ // async pending→paid webhook carries no visitor session/consent, so
12837
+ // it is deliberately NOT a recording point — the order placed here
12838
+ // is the conversion the funnel counts). Container-served; fire-and-
12839
+ // forget + drop-silent.
12840
+ // A fully-covered order (gift card / loyalty pays it all) settles AT
12841
+ // confirm; a Stripe-path order is only PENDING here — its completion
12842
+ // records on the post-payment return (/orders/:id) instead, so the
12843
+ // funnel never counts a payment the shopper abandoned at /pay.
12844
+ if (!result.payment_intent) {
12845
+ _recordAnalyticsEvent(req, { event_type: "checkout_complete", payload: { order_id: result.order.id } });
12846
+ }
12771
12847
  // When a gift card fully covered the order there's no Stripe
12772
12848
  // intent — the order is already paid. Skip the pay-cookie +
12773
12849
  // pay page and land the customer straight on the confirmation.
@@ -13060,6 +13136,19 @@ function mount(router, deps) {
13060
13136
  if (o.customer_id && (!orderAuth || o.customer_id !== orderAuth.customer_id)) {
13061
13137
  return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13062
13138
  }
13139
+ // Stripe's post-payment return lands here with ?redirect_status=
13140
+ // succeeded — the moment the payment actually settled client-side,
13141
+ // with the shopper's session + consent readable. Record the funnel's
13142
+ // checkout_complete HERE (the confirm POST only records fully-covered
13143
+ // orders), then 303 to the param-less URL: the redirect both cleans
13144
+ // the confirmation link and acts as the dedupe — a refresh or revisit
13145
+ // hits the bare URL and records nothing.
13146
+ if (req.query && req.query.redirect_status === "succeeded") {
13147
+ _recordAnalyticsEvent(req, { event_type: "checkout_complete", payload: { order_id: o.id } });
13148
+ res.status(303);
13149
+ if (res.setHeader) res.setHeader("location", "/orders/" + o.id);
13150
+ return res.end ? res.end() : res.send("");
13151
+ }
13063
13152
  // Same variant_id → {product, hero_media} lookup pattern as the
13064
13153
  // cart route, applied to the order's frozen line items so the
13065
13154
  // post-checkout page shows what the customer bought visually.
@@ -17823,6 +17912,12 @@ function mount(router, deps) {
17823
17912
  back_href: "/cart", back_label: "Back to cart",
17824
17913
  }));
17825
17914
  }
17915
+ // Consent-gated funnel event — the cart_add step of the browse→buy
17916
+ // funnel. The added variant rides in the payload (the funnel counts by
17917
+ // event_type; product_id is reserved for pdp_view's most-viewed
17918
+ // aggregate). Fire-and-forget + drop-silent.
17919
+ _recordAnalyticsEvent(req, { event_type: "cart_add", payload: { variant_id: variantId, qty: qty } },
17920
+ resolved && resolved.cart && resolved.cart.session_id);
17826
17921
  // `?added=1` so the cart page can confirm the item landed (the page
17827
17922
  // surfaces an "Added to cart" status banner). Still a 303 so a refresh
17828
17923
  // re-issues the GET, not the POST.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.74",
3
+ "version": "0.3.75",
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": {