@blamejs/blamejs-shop 0.3.74 → 0.3.76
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 +4 -0
- package/README.md +1 -1
- package/lib/admin.js +293 -2
- package/lib/asset-manifest.json +1 -1
- package/lib/cart.js +144 -4
- package/lib/storefront.js +95 -0
- package/package.json +1 -1
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.76 (2026-06-05) — **Abandoned-cart visibility in the admin console, with honest recovery.** The admin console gains a Carts screen showing abandoned carts: active carts with items whose last activity is older than a tunable window (twenty-four hours by default), listed freshest-first with line counts, subtotals, guest or signed-in attribution, and a value-at-risk summary. Recovery is built around what the privacy stance actually permits: buyer email addresses are stored only as one-way hashes, so a recovery email is impossible by design and the screen says so plainly. Instead, a per-cart action mints a single-use, code-gated percent-off discount — composing the existing unlock-code rules — and shows the code once for the operator to share through whatever channel they have. Fresh, empty, and converted carts are excluded; queries are window- and limit-bounded; the screen and its JSON shape are bearer-gated like every other console surface. **Added:** *Abandoned-carts screen with single-use recovery codes* — GET /admin/carts lists abandoned carts — active, containing items, idle past the window (tunable via the hours parameter, clamped to sane bounds) — with per-cart line counts, subtotal, last activity, and the customer link for signed-in carts, plus a count and value-at-risk summary. The per-cart Issue-code action creates a single-use unlock-code discount rule at the operator's chosen percentage and reveals the code once; the action is audited and appears only when the discounts primitive is wired. Session identifiers never appear in the JSON shape, and the screen states why no email recovery exists: addresses are one-way hashed.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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/README.md
CHANGED
|
@@ -97,7 +97,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
97
97
|
| **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
|
|
98
98
|
| **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
|
|
99
99
|
| **`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. |
|
|
100
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
100
|
+
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Carts, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
101
101
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
|
102
102
|
| **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
|
|
103
103
|
|
package/lib/admin.js
CHANGED
|
@@ -36,10 +36,19 @@ var quantityDiscountsModule = require("./quantity-discounts");
|
|
|
36
36
|
var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
|
|
37
37
|
var loyaltyRedemptionModule = require("./loyalty-redemption");
|
|
38
38
|
var trustBadgesModule = require("./trust-badges");
|
|
39
|
+
var cartModule = require("./cart"); // ABANDONED_* window/limit constants for the /admin/carts console
|
|
39
40
|
var textGuard = require("./text-guard");
|
|
40
41
|
var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
|
|
41
42
|
|
|
42
43
|
var b = require("./vendor/blamejs");
|
|
44
|
+
// Framework duration constants (C.TIME.hours(n) etc.). The index entry
|
|
45
|
+
// point exposes `framework` before the require cascade, so resolving this
|
|
46
|
+
// at module-eval is safe — same pattern lib/cart.js uses.
|
|
47
|
+
var C = b.constants;
|
|
48
|
+
|
|
49
|
+
// One hour in ms, named once so the hours<->ms conversions on the
|
|
50
|
+
// abandoned-cart window read in named units rather than a bare literal.
|
|
51
|
+
var MS_PER_HOUR = C.TIME.hours(1);
|
|
43
52
|
|
|
44
53
|
var AUDIT_NAMESPACE = "shop_admin";
|
|
45
54
|
|
|
@@ -523,6 +532,7 @@ function mount(router, deps) {
|
|
|
523
532
|
}
|
|
524
533
|
var catalog = deps.catalog;
|
|
525
534
|
var order = deps.order;
|
|
535
|
+
var cart = deps.cart || null; // abandoned-cart visibility console (/admin/carts) disabled when absent
|
|
526
536
|
var payment = deps.payment || null; // refund endpoints disabled when absent
|
|
527
537
|
var mailer = deps.mailer || null; // transactional email factory (lib/email.js) — resend-confirmation disabled when absent
|
|
528
538
|
var _checkout = deps.checkout || null; // reserved — future webhook handler wiring
|
|
@@ -574,7 +584,7 @@ function mount(router, deps) {
|
|
|
574
584
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
575
585
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
576
586
|
// notice when the salesReports primitive isn't wired.
|
|
577
|
-
var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog };
|
|
587
|
+
var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart };
|
|
578
588
|
|
|
579
589
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
580
590
|
|
|
@@ -9431,6 +9441,154 @@ function mount(router, deps) {
|
|
|
9431
9441
|
return patch;
|
|
9432
9442
|
}
|
|
9433
9443
|
|
|
9444
|
+
// ---- abandoned carts (visibility + honest recovery) -----------------
|
|
9445
|
+
//
|
|
9446
|
+
// Operators have zero abandoned-cart visibility without this screen — no
|
|
9447
|
+
// list, no value-at-risk total, no recovery surface. This reads the live
|
|
9448
|
+
// `carts` table directly (active cart + has lines + untouched for >N
|
|
9449
|
+
// hours), so it works WITHOUT the cart-abandonment scanner cron having
|
|
9450
|
+
// ever run. The window is operator-tunable via `?hours=` (default 24).
|
|
9451
|
+
//
|
|
9452
|
+
// Recovery, honest version: a buyer's email is one-way hashed at every
|
|
9453
|
+
// layer (the `carts` row carries only a sealed-cookie `session_id`, the
|
|
9454
|
+
// customer record stores only an `email_hash`), so the framework cannot
|
|
9455
|
+
// send a recovery EMAIL to ANY cart — guest or signed-in — on its own.
|
|
9456
|
+
// The screen states this plainly. What it CAN do is compose the existing
|
|
9457
|
+
// `autoDiscount` unlock-code surface: the "issue recovery code" action
|
|
9458
|
+
// mints a single-use, code-gated percent-off rule (`max_redemptions_total
|
|
9459
|
+
// = 1`) and shows the code ONCE for the operator to share through their
|
|
9460
|
+
// own channel (the order-confirmation reply, an SMS, a printed receipt).
|
|
9461
|
+
// No email is claimed where none is possible. When `autoDiscount` isn't
|
|
9462
|
+
// wired the action is absent and the screen documents it as the next step.
|
|
9463
|
+
|
|
9464
|
+
// The recovery-code action composes deps.autoDiscount. Read it through a
|
|
9465
|
+
// helper rather than the `autoDiscount` local (which is declared further
|
|
9466
|
+
// down, inside the discounts block) so this block doesn't depend on
|
|
9467
|
+
// var-hoist ordering.
|
|
9468
|
+
function autoDiscountForCarts() { return deps.autoDiscount || null; }
|
|
9469
|
+
|
|
9470
|
+
// Defensive request-shape readers for the abandoned-cart window. The
|
|
9471
|
+
// console accepts `?hours=` (whole hours) + `?limit=`; the cart primitive
|
|
9472
|
+
// clamps the ms window + limit again, but converting here keeps the
|
|
9473
|
+
// operator-facing unit (hours) at the route boundary. Garbage falls back
|
|
9474
|
+
// to the default rather than 500-ing the screen.
|
|
9475
|
+
function _cartHoursToMs(hoursRaw) {
|
|
9476
|
+
if (hoursRaw == null || hoursRaw === "") return cartModule.ABANDONED_DEFAULT_IDLE_MS;
|
|
9477
|
+
var h = Number(hoursRaw);
|
|
9478
|
+
if (!isFinite(h) || h <= 0) return cartModule.ABANDONED_DEFAULT_IDLE_MS;
|
|
9479
|
+
return Math.floor(h * MS_PER_HOUR);
|
|
9480
|
+
}
|
|
9481
|
+
function _cartListLimit(limitRaw) {
|
|
9482
|
+
if (limitRaw == null || limitRaw === "") return undefined;
|
|
9483
|
+
var n = parseInt(limitRaw, 10);
|
|
9484
|
+
if (!isFinite(n) || n <= 0) return undefined;
|
|
9485
|
+
return n;
|
|
9486
|
+
}
|
|
9487
|
+
|
|
9488
|
+
// Mint a single-use, code-gated percent-off rule for one abandoned cart.
|
|
9489
|
+
// Returns `{ rule_slug, unlock_code, percent_off }` on success, or null
|
|
9490
|
+
// when the cart id doesn't resolve to an abandonable cart (so the route
|
|
9491
|
+
// 404s rather than minting a code for nothing). Throws TypeError on bad
|
|
9492
|
+
// input (a non-1..99 percent, a bad cart id) → clean 400 at the route.
|
|
9493
|
+
async function _issueCartRecoveryCode(cartId, body) {
|
|
9494
|
+
var ad = autoDiscountForCarts();
|
|
9495
|
+
if (!ad) return null; // defensive — the route only mounts when wired
|
|
9496
|
+
// Resolve the cart so we don't mint a code for a non-existent / already
|
|
9497
|
+
// converted cart. cart.get validates the id shape (throws TypeError on
|
|
9498
|
+
// garbage → 400).
|
|
9499
|
+
var row = await cart.get(cartId);
|
|
9500
|
+
if (!row || row.status !== "active") return null;
|
|
9501
|
+
// Percent off — operator-chosen, 1..99. Default 10% when blank.
|
|
9502
|
+
var pctRaw = body.percent_off;
|
|
9503
|
+
var pct = (pctRaw == null || pctRaw === "") ? 10 : Number(pctRaw);
|
|
9504
|
+
if (!isFinite(pct) || !Number.isInteger(pct) || pct < 1 || pct > 99) {
|
|
9505
|
+
throw new TypeError("percent_off must be a whole number between 1 and 99");
|
|
9506
|
+
}
|
|
9507
|
+
// Mint a short, human-shareable code. The unlock_code grammar is
|
|
9508
|
+
// alnum + . _ - ; an uppercased hex slice of random bytes keeps it
|
|
9509
|
+
// typeable. generateToken(n) returns n random bytes as 2n hex chars.
|
|
9510
|
+
var code = "SAVE" + b.crypto.generateToken(4).slice(0, 6).toUpperCase();
|
|
9511
|
+
var slug = "cart-recovery-" + String(cartId).slice(0, 8).toLowerCase() + "-" + Date.now().toString(36);
|
|
9512
|
+
// Single-use, code-gated, no automatic trigger (cart_total_min: 0 fires
|
|
9513
|
+
// on any cart once the code is presented). The lib rejects a code clash
|
|
9514
|
+
// with another active rule (→ TypeError → 400), so a duplicate is safe.
|
|
9515
|
+
await ad.defineRule({
|
|
9516
|
+
slug: slug,
|
|
9517
|
+
title: "Cart recovery — " + pct + "% off",
|
|
9518
|
+
trigger: { kind: "cart_total_min", min_minor: 0 },
|
|
9519
|
+
value: { kind: "percent_off", basis_points: pct * 100 },
|
|
9520
|
+
unlock_code: code,
|
|
9521
|
+
max_redemptions_total: 1,
|
|
9522
|
+
});
|
|
9523
|
+
return { rule_slug: slug, unlock_code: code, percent_off: pct };
|
|
9524
|
+
}
|
|
9525
|
+
|
|
9526
|
+
if (cart) {
|
|
9527
|
+
var cartRecoveryCodeEnabled = !!autoDiscountForCarts();
|
|
9528
|
+
|
|
9529
|
+
router.get("/admin/carts", _pageOrApi(true,
|
|
9530
|
+
R(async function (req, res) {
|
|
9531
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
9532
|
+
var hours = url && url.searchParams.get("hours");
|
|
9533
|
+
var idleMs = _cartHoursToMs(hours);
|
|
9534
|
+
var limit = _cartListLimit(url && url.searchParams.get("limit"));
|
|
9535
|
+
var carts = await cart.listAbandoned({ idle_threshold_ms: idleMs, limit: limit });
|
|
9536
|
+
var summary = await cart.abandonedSummary({ idle_threshold_ms: idleMs });
|
|
9537
|
+
_json(res, 200, { carts: carts, summary: summary });
|
|
9538
|
+
}),
|
|
9539
|
+
async function (req, res) {
|
|
9540
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
9541
|
+
var hoursRaw = url && url.searchParams.get("hours");
|
|
9542
|
+
var idleMs = _cartHoursToMs(hoursRaw);
|
|
9543
|
+
var hours = Math.round(idleMs / MS_PER_HOUR);
|
|
9544
|
+
var carts = await cart.listAbandoned({ idle_threshold_ms: idleMs, limit: cartModule.ABANDONED_MAX_LIMIT });
|
|
9545
|
+
var summary = await cart.abandonedSummary({ idle_threshold_ms: idleMs });
|
|
9546
|
+
_sendHtml(res, 200, renderAdminCarts({
|
|
9547
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
9548
|
+
carts: carts, summary: summary, hours: hours,
|
|
9549
|
+
recovery_enabled: cartRecoveryCodeEnabled,
|
|
9550
|
+
issued: url && url.searchParams.get("issued"),
|
|
9551
|
+
issued_code: url && url.searchParams.get("code"),
|
|
9552
|
+
issued_cart: url && url.searchParams.get("cart"),
|
|
9553
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
9554
|
+
}));
|
|
9555
|
+
},
|
|
9556
|
+
));
|
|
9557
|
+
|
|
9558
|
+
// Issue a single-use recovery code for one cart. Composes the existing
|
|
9559
|
+
// autoDiscount unlock-code surface — NO new primitive. Only mounted
|
|
9560
|
+
// when autoDiscount is wired; absent it, the screen never renders the
|
|
9561
|
+
// form (so this route stays an honest 404 for the unwired deploy).
|
|
9562
|
+
if (cartRecoveryCodeEnabled) {
|
|
9563
|
+
router.post("/admin/carts/:id/recovery-code", _pageOrApi(false,
|
|
9564
|
+
W("cart_recovery_code.issue", async function (req, res) {
|
|
9565
|
+
var out;
|
|
9566
|
+
try { out = await _issueCartRecoveryCode(req.params.id, req.body || {}); }
|
|
9567
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
9568
|
+
if (!out) return _problem(res, 404, "cart-not-found");
|
|
9569
|
+
_json(res, 201, out);
|
|
9570
|
+
return { id: out.rule_slug };
|
|
9571
|
+
}),
|
|
9572
|
+
async function (req, res) {
|
|
9573
|
+
var out;
|
|
9574
|
+
try { out = await _issueCartRecoveryCode(req.params.id, req.body || {}); }
|
|
9575
|
+
catch (e) {
|
|
9576
|
+
if (!(e instanceof TypeError)) throw e;
|
|
9577
|
+
return _redirect(res, "/admin/carts?err=1");
|
|
9578
|
+
}
|
|
9579
|
+
if (!out) return _redirect(res, "/admin/carts?err=1");
|
|
9580
|
+
b.audit.safeEmit({
|
|
9581
|
+
action: AUDIT_NAMESPACE + ".cart_recovery_code.issue",
|
|
9582
|
+
outcome: "success",
|
|
9583
|
+
metadata: { rule_slug: out.rule_slug },
|
|
9584
|
+
});
|
|
9585
|
+
_redirect(res, "/admin/carts?issued=1&code=" + encodeURIComponent(out.unlock_code) +
|
|
9586
|
+
"&cart=" + encodeURIComponent(String(req.params.id).slice(0, 8)));
|
|
9587
|
+
},
|
|
9588
|
+
));
|
|
9589
|
+
}
|
|
9590
|
+
}
|
|
9591
|
+
|
|
9434
9592
|
// ---- automatic discounts + stacking policies ------------------------
|
|
9435
9593
|
//
|
|
9436
9594
|
// Two related concerns on one console screen. `autoDiscount` rules are
|
|
@@ -11247,7 +11405,7 @@ function renderAdminAnalytics(opts) {
|
|
|
11247
11405
|
var ratePct = (Number(fn.conversion_rate) || 0) * 100;
|
|
11248
11406
|
var funnelStats =
|
|
11249
11407
|
"<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>" +
|
|
11408
|
+
"<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
11409
|
"<div class=\"stat-grid\">" +
|
|
11252
11410
|
_statCard("Product views", String(fn.pdp_views || 0)) +
|
|
11253
11411
|
_statCard("Cart adds", String(fn.cart_adds || 0)) +
|
|
@@ -11334,6 +11492,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
11334
11492
|
{ key: "products", href: "/admin/products", label: "Products" },
|
|
11335
11493
|
{ key: "inventory", href: "/admin/inventory", label: "Inventory" },
|
|
11336
11494
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
11495
|
+
{ key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
|
|
11337
11496
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
11338
11497
|
{ key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
|
|
11339
11498
|
{ key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
|
|
@@ -11655,6 +11814,138 @@ function renderAdminOrders(opts) {
|
|
|
11655
11814
|
return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
|
|
11656
11815
|
}
|
|
11657
11816
|
|
|
11817
|
+
// Format an idle duration (ms) as a coarse human string for the abandoned-
|
|
11818
|
+
// cart "last activity" column: "3h", "2d", "<1h". Coarse on purpose — the
|
|
11819
|
+
// operator wants the order of magnitude, not a precise stopwatch.
|
|
11820
|
+
function _fmtIdle(ms) {
|
|
11821
|
+
if (typeof ms !== "number" || !isFinite(ms) || ms < 0) return "—";
|
|
11822
|
+
var hours = ms / MS_PER_HOUR;
|
|
11823
|
+
if (hours < 1) return "<1h";
|
|
11824
|
+
if (hours < 48) return Math.floor(hours) + "h";
|
|
11825
|
+
return Math.floor(hours / 24) + "d";
|
|
11826
|
+
}
|
|
11827
|
+
|
|
11828
|
+
// Abandoned-cart visibility. Lists active carts with at least one line that
|
|
11829
|
+
// haven't been touched for the selected window (default 24h, tunable via the
|
|
11830
|
+
// `?hours=` filter), freshest-abandonment first, with a value-at-risk summary.
|
|
11831
|
+
//
|
|
11832
|
+
// Recovery is honest about the framework's hash-only stance: buyer emails are
|
|
11833
|
+
// one-way hashed everywhere (the cart row carries only a sealed-cookie
|
|
11834
|
+
// session_id; the customer record stores only an email_hash), so NO recovery
|
|
11835
|
+
// EMAIL is possible — for guest OR signed-in carts. The screen says so. When
|
|
11836
|
+
// `autoDiscount` is wired it offers the one recovery that IS possible: minting
|
|
11837
|
+
// a single-use code-gated discount whose code the operator shares through
|
|
11838
|
+
// their own channel.
|
|
11839
|
+
//
|
|
11840
|
+
// XSS note: customer_id + cart id are framework-issued UUIDs, but every value
|
|
11841
|
+
// still flows through _htmlEscape (escape-by-default) — the discipline holds
|
|
11842
|
+
// regardless of the field's provenance.
|
|
11843
|
+
function renderAdminCarts(opts) {
|
|
11844
|
+
opts = opts || {};
|
|
11845
|
+
var carts = opts.carts || [];
|
|
11846
|
+
var summary = opts.summary || { cart_count: 0, by_currency: [] };
|
|
11847
|
+
var hours = opts.hours || 24;
|
|
11848
|
+
var recoveryEnabled = !!opts.recovery_enabled;
|
|
11849
|
+
|
|
11850
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11851
|
+
|
|
11852
|
+
// PRG success banner after issuing a recovery code — shows the code ONCE
|
|
11853
|
+
// (the operator copies it to share through their own channel; it isn't
|
|
11854
|
+
// re-displayed on reload).
|
|
11855
|
+
var issued = "";
|
|
11856
|
+
if (opts.issued && opts.issued_code) {
|
|
11857
|
+
issued = "<div class=\"banner banner--ok\">" +
|
|
11858
|
+
"Recovery code <code class=\"order-id\">" + _htmlEscape(opts.issued_code) + "</code> issued" +
|
|
11859
|
+
(opts.issued_cart ? " for cart " + _htmlEscape(opts.issued_cart) : "") + ". " +
|
|
11860
|
+
"Share it through your own channel — it's single-use and won't be shown again." +
|
|
11861
|
+
"</div>";
|
|
11862
|
+
}
|
|
11863
|
+
|
|
11864
|
+
// Value-at-risk summary line(s) — one per currency present in the window.
|
|
11865
|
+
var valueLine;
|
|
11866
|
+
if (summary.cart_count === 0) {
|
|
11867
|
+
valueLine = "<span class=\"meta\">No carts have been idle longer than " + _htmlEscape(String(hours)) + "h.</span>";
|
|
11868
|
+
} else {
|
|
11869
|
+
var perCur = (summary.by_currency || []).map(function (c) {
|
|
11870
|
+
return _htmlEscape(pricing.format(c.value_minor, c.currency)) +
|
|
11871
|
+
" across " + _htmlEscape(String(c.cart_count)) + " cart" + (c.cart_count === 1 ? "" : "s");
|
|
11872
|
+
}).join("; ");
|
|
11873
|
+
valueLine = "<strong>" + _htmlEscape(String(summary.cart_count)) + "</strong> abandoned cart" +
|
|
11874
|
+
(summary.cart_count === 1 ? "" : "s") + " — " + perCur + " at risk.";
|
|
11875
|
+
}
|
|
11876
|
+
|
|
11877
|
+
// Window filter chips — common windows plus the current one highlighted.
|
|
11878
|
+
var WINDOWS = [1, 6, 24, 72, 168];
|
|
11879
|
+
var chips = "<div class=\"order-filters\">" +
|
|
11880
|
+
WINDOWS.map(function (h) {
|
|
11881
|
+
var label = h < 24 ? h + "h" : (h / 24) + "d";
|
|
11882
|
+
return "<a class=\"chip" + (hours === h ? " chip--on" : "") +
|
|
11883
|
+
"\" href=\"/admin/carts?hours=" + h + "\">> " + _htmlEscape(label) + "</a>";
|
|
11884
|
+
}).join("") +
|
|
11885
|
+
"</div>";
|
|
11886
|
+
|
|
11887
|
+
var rows = carts.map(function (c) {
|
|
11888
|
+
var isGuest = !c.customer_id;
|
|
11889
|
+
var who = isGuest
|
|
11890
|
+
? "<span class=\"chip\">Guest</span>"
|
|
11891
|
+
: "<a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(c.customer_id)) +
|
|
11892
|
+
"\"><span class=\"chip\">Signed in</span></a>";
|
|
11893
|
+
// The recovery-code form (only when autoDiscount is wired). A guest cart
|
|
11894
|
+
// gets the same code-mint affordance — the code is shared out-of-band, so
|
|
11895
|
+
// it doesn't depend on a deliverable address either way.
|
|
11896
|
+
var action = recoveryEnabled
|
|
11897
|
+
? "<form method=\"post\" action=\"/admin/carts/" + _htmlEscape(encodeURIComponent(c.id)) +
|
|
11898
|
+
"/recovery-code\" class=\"form-inline\">" +
|
|
11899
|
+
"<input type=\"number\" name=\"percent_off\" min=\"1\" max=\"99\" step=\"1\" value=\"10\" " +
|
|
11900
|
+
"class=\"input-code\" aria-label=\"Percent off\"> " +
|
|
11901
|
+
"<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Issue code</button>" +
|
|
11902
|
+
"</form>"
|
|
11903
|
+
: "<span class=\"meta\">—</span>";
|
|
11904
|
+
return "<tr>" +
|
|
11905
|
+
"<td><code class=\"order-id\">" + _htmlEscape(String(c.id).slice(0, 8)) + "</code></td>" +
|
|
11906
|
+
"<td class=\"num\">" + _htmlEscape(String(c.line_count)) + "</td>" +
|
|
11907
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(c.subtotal_minor, c.currency)) + "</td>" +
|
|
11908
|
+
"<td>" + _htmlEscape(_fmtIdle(c.idle_ms)) + " ago <span class=\"meta\">(" + _htmlEscape(_fmtDate(c.updated_at)) + ")</span></td>" +
|
|
11909
|
+
"<td>" + who + "</td>" +
|
|
11910
|
+
"<td>" + action + "</td>" +
|
|
11911
|
+
"</tr>";
|
|
11912
|
+
}).join("");
|
|
11913
|
+
|
|
11914
|
+
var table = carts.length
|
|
11915
|
+
? "<div class=\"panel\">" + _tableWrap("<table><thead><tr>" +
|
|
11916
|
+
"<th scope=\"col\">Cart</th>" +
|
|
11917
|
+
"<th scope=\"col\" class=\"num\">Items</th>" +
|
|
11918
|
+
"<th scope=\"col\" class=\"num\">Subtotal</th>" +
|
|
11919
|
+
"<th scope=\"col\">Last activity</th>" +
|
|
11920
|
+
"<th scope=\"col\">Shopper</th>" +
|
|
11921
|
+
"<th scope=\"col\">" + (recoveryEnabled ? "Recovery" : "<span class=\"sr-only\">Recovery</span>") + "</th>" +
|
|
11922
|
+
"</tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
|
|
11923
|
+
: "<p class=\"empty\">No carts have been abandoned longer than " + _htmlEscape(String(hours)) + "h.</p>";
|
|
11924
|
+
|
|
11925
|
+
// The honest-recovery explainer. Always shown — it's the load-bearing
|
|
11926
|
+
// caveat that distinguishes this screen from a store that emails idle
|
|
11927
|
+
// shoppers (which this framework structurally cannot do).
|
|
11928
|
+
var recoveryNote = recoveryEnabled
|
|
11929
|
+
? "<div class=\"panel mt\"><h3 class=\"subhead\">Recovering a cart</h3>" +
|
|
11930
|
+
"<p class=\"meta\">Buyer emails are one-way hashed everywhere in this shop, so the " +
|
|
11931
|
+
"framework can't send an abandoned-cart email — for guest or signed-in carts. " +
|
|
11932
|
+
"Instead, issue a single-use discount code for a cart and share it through your own " +
|
|
11933
|
+
"channel (an order-confirmation reply, SMS, or printed receipt). Each code works once " +
|
|
11934
|
+
"and appears on your <a href=\"/admin/discounts\">discounts</a> list.</p></div>"
|
|
11935
|
+
: "<div class=\"panel mt\"><h3 class=\"subhead\">Recovering a cart</h3>" +
|
|
11936
|
+
"<p class=\"meta\">Buyer emails are one-way hashed everywhere in this shop, so the " +
|
|
11937
|
+
"framework can't send an abandoned-cart email. The recovery path here is to issue a " +
|
|
11938
|
+
"single-use discount code per cart and share it through your own channel — wire the " +
|
|
11939
|
+
"discounts feature (the <code>autoDiscount</code> dependency) to enable it.</p></div>";
|
|
11940
|
+
|
|
11941
|
+
var body = "<section><h2>Abandoned carts</h2>" + notice + issued +
|
|
11942
|
+
"<p class=\"meta\">Active carts with items that haven't been touched in a while. " +
|
|
11943
|
+
"These are reads of live carts — no scanner or cron required.</p>" +
|
|
11944
|
+
"<p class=\"meta\">" + valueLine + "</p>" +
|
|
11945
|
+
chips + table + recoveryNote + "</section>";
|
|
11946
|
+
return _renderAdminShell(opts.shop_name, "Abandoned carts", body, "carts", opts.nav_available);
|
|
11947
|
+
}
|
|
11948
|
+
|
|
11658
11949
|
// The outcomes an operator can filter the audit log by — drives the chips.
|
|
11659
11950
|
var AUDIT_OUTCOME_FILTERS = ["success", "failure", "denied"];
|
|
11660
11951
|
|
package/lib/asset-manifest.json
CHANGED
package/lib/cart.js
CHANGED
|
@@ -42,6 +42,18 @@ var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
|
42
42
|
var DISCOUNT_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
43
43
|
var MAX_CODES_PER_CART = 16;
|
|
44
44
|
|
|
45
|
+
// Abandoned-cart visibility defaults. A cart counts as "abandoned" for the
|
|
46
|
+
// operator dashboard when it is still `active`, carries at least one line,
|
|
47
|
+
// and has not been touched (no `updated_at` bump) for at least
|
|
48
|
+
// `idle_threshold_ms`. This reads the live `carts` table directly — it does
|
|
49
|
+
// NOT require the cart-abandonment scanner's detection rows to exist, so an
|
|
50
|
+
// operator who never wired the recovery cron still sees their idle carts.
|
|
51
|
+
var ABANDONED_DEFAULT_IDLE_MS = C.TIME.hours(24);
|
|
52
|
+
var ABANDONED_MIN_IDLE_MS = C.TIME.minutes(1); // floor so a 0h window can't list live carts
|
|
53
|
+
var ABANDONED_MAX_IDLE_MS = C.TIME.days(90); // ceiling — past this the TTL has abandoned it anyway
|
|
54
|
+
var ABANDONED_DEFAULT_LIMIT = 50;
|
|
55
|
+
var ABANDONED_MAX_LIMIT = 200;
|
|
56
|
+
|
|
45
57
|
// ---- validators ---------------------------------------------------------
|
|
46
58
|
|
|
47
59
|
function _uuid(s, label) {
|
|
@@ -77,6 +89,31 @@ function _discountCode(s) {
|
|
|
77
89
|
|
|
78
90
|
function _now() { return Date.now(); }
|
|
79
91
|
|
|
92
|
+
// Defensive request-shape readers for the abandoned-cart dashboard window.
|
|
93
|
+
// A garbage / out-of-range value (a hand-typed `?hours=` query param) clamps
|
|
94
|
+
// to a sane default rather than throwing — the console must never 500 on a
|
|
95
|
+
// fat-fingered filter. The bounds also stop a caller widening the scan past
|
|
96
|
+
// the ceiling (idle floor keeps live carts out; limit ceiling keeps the
|
|
97
|
+
// payload bounded).
|
|
98
|
+
function _abandonedIdleMs(v) {
|
|
99
|
+
if (v == null) return ABANDONED_DEFAULT_IDLE_MS;
|
|
100
|
+
var n = typeof v === "string" ? Number(v) : v;
|
|
101
|
+
if (typeof n !== "number" || !isFinite(n)) return ABANDONED_DEFAULT_IDLE_MS;
|
|
102
|
+
n = Math.floor(n);
|
|
103
|
+
if (n < ABANDONED_MIN_IDLE_MS) return ABANDONED_MIN_IDLE_MS;
|
|
104
|
+
if (n > ABANDONED_MAX_IDLE_MS) return ABANDONED_MAX_IDLE_MS;
|
|
105
|
+
return n;
|
|
106
|
+
}
|
|
107
|
+
function _abandonedLimit(v) {
|
|
108
|
+
if (v == null) return ABANDONED_DEFAULT_LIMIT;
|
|
109
|
+
var n = typeof v === "string" ? Number(v) : v;
|
|
110
|
+
if (typeof n !== "number" || !isFinite(n)) return ABANDONED_DEFAULT_LIMIT;
|
|
111
|
+
n = Math.floor(n);
|
|
112
|
+
if (n < 1) return ABANDONED_DEFAULT_LIMIT;
|
|
113
|
+
if (n > ABANDONED_MAX_LIMIT) return ABANDONED_MAX_LIMIT;
|
|
114
|
+
return n;
|
|
115
|
+
}
|
|
116
|
+
|
|
80
117
|
// ---- factory ------------------------------------------------------------
|
|
81
118
|
|
|
82
119
|
function create(opts) {
|
|
@@ -317,6 +354,107 @@ function create(opts) {
|
|
|
317
354
|
return (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0];
|
|
318
355
|
},
|
|
319
356
|
|
|
357
|
+
// ---- abandoned-cart visibility (operator dashboard) ---------------
|
|
358
|
+
//
|
|
359
|
+
// A cart is "abandoned" for dashboard purposes when it is still
|
|
360
|
+
// `active`, has at least one line, and hasn't been touched for
|
|
361
|
+
// `idle_threshold_ms` (default 24h, operator-tunable). This reads the
|
|
362
|
+
// live `carts` / `cart_lines` tables — it needs NO scanner detection
|
|
363
|
+
// rows and NO schema change, so an operator who never wired the
|
|
364
|
+
// recovery cron still sees their idle carts.
|
|
365
|
+
//
|
|
366
|
+
// Defensive request-shape reader tier: a missing / garbage option
|
|
367
|
+
// falls back to a sane default rather than throwing, so a hand-typed
|
|
368
|
+
// `?hours=` query param can't 500 the console. The window is clamped
|
|
369
|
+
// to [1min, 90d] and the limit to [1, 200] — a caller can't widen the
|
|
370
|
+
// scan past the bounded ceiling.
|
|
371
|
+
|
|
372
|
+
// List abandoned carts, most-recent-activity-first, each enriched with
|
|
373
|
+
// its line count + subtotal-in-minor-units + age. Bounded by `limit`
|
|
374
|
+
// (default 50, max 200). The `idle_threshold_ms` option sets the
|
|
375
|
+
// untouched-for window; carts updated more recently than that are still
|
|
376
|
+
// "live" and excluded. Carts with zero lines are excluded (an empty
|
|
377
|
+
// cart has nothing to recover).
|
|
378
|
+
listAbandoned: async function (listOpts) {
|
|
379
|
+
listOpts = listOpts || {};
|
|
380
|
+
var idleMs = _abandonedIdleMs(listOpts.idle_threshold_ms);
|
|
381
|
+
var limit = _abandonedLimit(listOpts.limit);
|
|
382
|
+
var now = listOpts.now == null ? _now() : listOpts.now;
|
|
383
|
+
var cutoff = now - idleMs;
|
|
384
|
+
// INNER JOIN onto an aggregate of cart_lines so the zero-line carts
|
|
385
|
+
// drop out (a cart with no lines produces no group row) and we read
|
|
386
|
+
// the line count + subtotal in one pass. ORDER BY updated_at DESC =
|
|
387
|
+
// freshest-abandonment first (the operator's most-actionable carts).
|
|
388
|
+
var rows = (await query(
|
|
389
|
+
"SELECT c.id AS id, c.session_id AS session_id, c.customer_id AS customer_id, " +
|
|
390
|
+
" c.currency AS currency, c.created_at AS created_at, c.updated_at AS updated_at, " +
|
|
391
|
+
" agg.line_count AS line_count, agg.subtotal_minor AS subtotal_minor " +
|
|
392
|
+
"FROM carts c " +
|
|
393
|
+
"JOIN (SELECT cart_id, COUNT(*) AS line_count, " +
|
|
394
|
+
" SUM(qty * unit_amount_minor) AS subtotal_minor " +
|
|
395
|
+
" FROM cart_lines GROUP BY cart_id) agg ON agg.cart_id = c.id " +
|
|
396
|
+
"WHERE c.status = 'active' AND c.updated_at <= ?1 " +
|
|
397
|
+
"ORDER BY c.updated_at DESC, c.id DESC LIMIT ?2",
|
|
398
|
+
[cutoff, limit],
|
|
399
|
+
)).rows;
|
|
400
|
+
var out = [];
|
|
401
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
402
|
+
var r = rows[i];
|
|
403
|
+
out.push({
|
|
404
|
+
id: r.id,
|
|
405
|
+
// session_id is sealed-cookie material — never surface it raw to
|
|
406
|
+
// the operator screen; a short opaque tag of the cart id is the
|
|
407
|
+
// dashboard handle. customer_id passes through so the console can
|
|
408
|
+
// link signed-in carts to /admin/customers/<id>.
|
|
409
|
+
customer_id: r.customer_id || null,
|
|
410
|
+
currency: r.currency,
|
|
411
|
+
line_count: Number(r.line_count || 0),
|
|
412
|
+
subtotal_minor: Number(r.subtotal_minor || 0),
|
|
413
|
+
created_at: Number(r.created_at),
|
|
414
|
+
updated_at: Number(r.updated_at),
|
|
415
|
+
idle_ms: Math.max(0, now - Number(r.updated_at)),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
// Summary stats for the same abandoned-cart window: how many carts are
|
|
422
|
+
// at risk + the total value (sum of subtotals) across them, per
|
|
423
|
+
// currency. The console renders this as the headline "N carts, X at
|
|
424
|
+
// risk" line. Unbounded COUNT/SUM (cheap aggregate, no row payload).
|
|
425
|
+
abandonedSummary: async function (sumOpts) {
|
|
426
|
+
sumOpts = sumOpts || {};
|
|
427
|
+
var idleMs = _abandonedIdleMs(sumOpts.idle_threshold_ms);
|
|
428
|
+
var now = sumOpts.now == null ? _now() : sumOpts.now;
|
|
429
|
+
var cutoff = now - idleMs;
|
|
430
|
+
var rows = (await query(
|
|
431
|
+
"SELECT c.currency AS currency, COUNT(*) AS cart_count, " +
|
|
432
|
+
" SUM(agg.subtotal_minor) AS value_minor " +
|
|
433
|
+
"FROM carts c " +
|
|
434
|
+
"JOIN (SELECT cart_id, SUM(qty * unit_amount_minor) AS subtotal_minor " +
|
|
435
|
+
" FROM cart_lines GROUP BY cart_id) agg ON agg.cart_id = c.id " +
|
|
436
|
+
"WHERE c.status = 'active' AND c.updated_at <= ?1 " +
|
|
437
|
+
"GROUP BY c.currency",
|
|
438
|
+
[cutoff],
|
|
439
|
+
)).rows;
|
|
440
|
+
var byCurrency = [];
|
|
441
|
+
var totalCarts = 0;
|
|
442
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
443
|
+
var n = Number(rows[i].cart_count || 0);
|
|
444
|
+
totalCarts += n;
|
|
445
|
+
byCurrency.push({
|
|
446
|
+
currency: rows[i].currency,
|
|
447
|
+
cart_count: n,
|
|
448
|
+
value_minor: Number(rows[i].value_minor || 0),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
idle_threshold_ms: idleMs,
|
|
453
|
+
cart_count: totalCarts,
|
|
454
|
+
by_currency: byCurrency,
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
|
|
320
458
|
// ---- applied discount codes ---------------------------------------
|
|
321
459
|
//
|
|
322
460
|
// A cart carries the discount codes a shopper applied on the cart page.
|
|
@@ -391,8 +529,10 @@ function create(opts) {
|
|
|
391
529
|
}
|
|
392
530
|
|
|
393
531
|
module.exports = {
|
|
394
|
-
create:
|
|
395
|
-
DEFAULT_TTL_MS:
|
|
396
|
-
MAX_QTY:
|
|
397
|
-
CART_STATUSES:
|
|
532
|
+
create: create,
|
|
533
|
+
DEFAULT_TTL_MS: DEFAULT_TTL_MS,
|
|
534
|
+
MAX_QTY: MAX_QTY,
|
|
535
|
+
CART_STATUSES: CART_STATUSES,
|
|
536
|
+
ABANDONED_DEFAULT_IDLE_MS: ABANDONED_DEFAULT_IDLE_MS,
|
|
537
|
+
ABANDONED_MAX_LIMIT: ABANDONED_MAX_LIMIT,
|
|
398
538
|
};
|
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