@blamejs/blamejs-shop 0.3.68 → 0.3.70
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 +254 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/auto-discount.js +104 -2
- package/lib/cart.js +104 -0
- package/lib/checkout.js +6 -2
- package/lib/storefront.js +225 -14
- 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.70 (2026-06-05) — **An Analytics screen in the admin console: funnel, search terms, and product views.** The admin console gains a read-only Analytics screen at /admin/analytics showing the pre-purchase signal the sales report cannot see: the browse-to-buy funnel (product views to cart adds to checkout starts to completed orders) with a conversion rate, top search terms, most-viewed products, top SKUs ranked by units sold, and a revenue-by-day sparkline. It complements rather than duplicates the existing Reports screen — revenue totals stay there, and the two screens link to each other. The same path answers a bearer-token request with JSON, and three JSON endpoints expose the funnel, search terms, and viewed products individually for tooling. Every aggregate is window-bounded (one year maximum, thirty-day default) and limit-bounded; the screen adds no write path anywhere. **Added:** *Read-only analytics dashboard* — The analytics primitive's aggregates are now wired into the console: a date-windowed view with the conversion funnel, top search terms, most-viewed products, units-ranked top SKUs, recent revenue trend, and cross-links with the Reports screen. The HTML view degrades a malformed date window to the default with a notice; the JSON surfaces reject it with a 400. Limits clamp to the primitive's own bounds, a refund-heavy window renders a signed negative total instead of failing, and every operator- or customer-derived string on the screen is escaped. The screen appears in the navigation only when the primitive is wired.
|
|
12
|
+
|
|
13
|
+
- v0.3.69 (2026-06-05) — **Discount codes on the cart page, and the full shipment timeline on the order page.** Shoppers can now redeem a discount code: an automatic-discount rule may carry an unlock code, leaving it dormant until a shopper enters that code on the cart page — the code persists on the cart, survives signing in, and flows through the exact same evaluation, quoting, and charge path as every other discount rule, so there is one discount system, not two. The cart shows the applied code with a remove control, totals re-render with the savings, and an unknown code gets one uniform message. Separately, the customer order page now shows the full shipment tracking timeline — every recorded carrier event with its status, location, and time, newest first — instead of only the latest event. Two schema migrations apply on deploy. **Added:** *Code-unlocked discount rules with cart-page redemption* — An automatic-discount rule can now carry an unlock code (one active rule per code). The rule stays dormant until a shopper enters its code in the new entry form on the cart page; the code is stored on the cart (capped, idempotent, carried across sign-in by the cart merge), totals re-render with the rule applied, and checkout's quote and confirm honor it on every payment path. The applied code renders as a chip with a remove control; unknown, malformed, or inactive codes all get the same message, so the endpoint reveals nothing about which codes exist. The redemption form is CSRF-tokened like the other cart configuration forms, and the cart page's edge-cached anonymous shell is unaffected — code state renders only on the container. · *Full shipment tracking timeline for customers* — The order page's tracking card previously showed the carrier, tracking number, status, and only the most recent event. It now renders the full event timeline newest-first — status, optional carrier location, optional note, and the event time — bounded to the newest twenty events. Orders without shipment events render exactly as before.
|
|
14
|
+
|
|
11
15
|
- v0.3.68 (2026-06-05) — **Delivery estimates: "Get it by" dates on the product and cart pages.** The product page and cart can now promise a delivery date. Operators configure the inputs at /admin/delivery-estimates — carrier transit times, warehouse cutoff hours, shipping holidays, and postal-prefix zones — and name the origin warehouse via the SHOP_ESTIMATE_ORIGIN environment variable or the shop.estimate_origin config row. A signed-in customer with a saved shipping address then sees "Get it by <date>" computed from the cutoff clock, transit days, and the holiday calendar. Anonymous visitors served from the edge cache deliberately see no date: an estimate is destination-specific, and a date baked into a shared cached page would show one visitor's delivery promise to everyone while going stale in the cache. The product and cart markup builders are byte-identical across the edge and container renderers with a deterministic date formatter, locked by a parity test. A store that has not configured estimates renders exactly what it does today — nothing — and a configuration gap can never produce a customer-facing error. **Added:** *Configurable delivery-date estimates* — The delivery-estimate primitive is wired end to end: an /admin/delivery-estimates console manages carrier transit times, order-cutoff hours per warehouse, shipping holidays, and postal-prefix zone mappings, with each mutation audited; the product and cart pages render "Get it by <date>" for signed-in customers with a saved shipping address once an origin warehouse is configured. Estimates render only where the destination is actually known — never on shared edge-cached pages — and every estimate read is fail-quiet: missing configuration, an unmatched destination, or a computation error renders no estimate rather than an error. Operators enable it by setting the origin (SHOP_ESTIMATE_ORIGIN or the shop.estimate_origin config row) and authoring at least one cutoff, transit time, and postal zone.
|
|
12
16
|
|
|
13
17
|
- v0.3.67 (2026-06-05) — **Digital orders no longer require a shipping address, and search ranking shrugs off malformed weights.** A cart containing only digital goods completes checkout since 0.3.63, but the shipping form still demanded a street address and city the order would never use. For an all-digital cart, the form now marks the address fields optional with an honest note — email stays required for the receipt and country for tax — and the backend enforces exactly the same relaxed set, while any value the shopper does supply is still format-validated. Carts with any shippable item keep the full requirements. Separately, the search ranker now skips a non-numeric weight in a hand-edited weight set instead of letting it poison every score into NaN and garble the result order; the edge and container rankers apply the identical filter, keeping their orders locked together even on malformed configuration. **Fixed:** *Digital-only carts check out without a street address* — When no line in the cart requires shipping, the checkout form renders the address block as optional — the required markers move off street and city, a note explains that a digital order ships nothing and country is kept for tax, and the backend requires exactly email, name, and country. Values the shopper supplies anyway are still format-validated, including the per-field error rendering. A cart with any physical item keeps the full address requirements, and a missing variant record counts as physical, so the relaxation can never trigger on incomplete data. · *Search ranking ignores non-numeric weights instead of corrupting the order* — A weight set carrying a non-numeric value — possible through a hand-edited database row — multiplied into every product's score as NaN, making the result order effectively random on the container while the edge filtered the bad weight and produced a different order. Both rankers now skip non-numeric weights identically, so a malformed entry degrades to "that signal contributes nothing" with edge and container orders staying byte-identical.
|
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, 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); **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 + 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. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, 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, 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); **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. |
|
|
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
|
@@ -573,7 +573,7 @@ function mount(router, deps) {
|
|
|
573
573
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
574
574
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
575
575
|
// notice when the salesReports primitive isn't wired.
|
|
576
|
-
var navAvailable = { 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 };
|
|
576
|
+
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 };
|
|
577
577
|
|
|
578
578
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
579
579
|
|
|
@@ -7896,6 +7896,142 @@ function mount(router, deps) {
|
|
|
7896
7896
|
nav_available: navAvailable,
|
|
7897
7897
|
}));
|
|
7898
7898
|
});
|
|
7899
|
+
|
|
7900
|
+
// Product-performance + behaviour screen at /admin/analytics. Complements
|
|
7901
|
+
// /admin/reports: the report screen owns the orders-derived money view
|
|
7902
|
+
// (gross/net/AOV/refund-rate, the order-FSM status funnel, top products
|
|
7903
|
+
// by revenue, the by-day money table). This screen owns what the report
|
|
7904
|
+
// does NOT show — units-ranked SKU performance, the pre-purchase
|
|
7905
|
+
// browse→buy funnel (PDP→cart→checkout→complete) and conversion rate,
|
|
7906
|
+
// most-viewed products, top search terms, and a revenue-by-day trend
|
|
7907
|
+
// sparkline. The two link to each other.
|
|
7908
|
+
//
|
|
7909
|
+
// Date range mirrors /admin/reports: `from`/`to` epoch-ms OR
|
|
7910
|
+
// `from-date`/`to-date` calendar-date params (the browser date inputs),
|
|
7911
|
+
// default last 30 days. The analytics primitive re-validates each window
|
|
7912
|
+
// (since < until, span ≤ 1 year) and refuses an unbounded scan, so the
|
|
7913
|
+
// worst an operator typo does is a 400-with-correction re-render — never
|
|
7914
|
+
// a 500, never an unbounded query.
|
|
7915
|
+
|
|
7916
|
+
// Resolve the analytics window from the query string into BOTH the
|
|
7917
|
+
// orders-derived `{ since, until }` shape and the event-stream
|
|
7918
|
+
// `{ from, to }` shape (the primitive uses different bound names per
|
|
7919
|
+
// surface). Accepts the raw epoch-ms `from`/`to` pair (machine clients)
|
|
7920
|
+
// or the calendar-date `from-date`/`to-date` pair (browser date inputs);
|
|
7921
|
+
// epoch-ms wins when both are present. Throws TypeError on a malformed
|
|
7922
|
+
// value so the config/entry-tier 400 path catches it; the primitive's own
|
|
7923
|
+
// span/order re-validation is the second gate.
|
|
7924
|
+
function _parseAnalyticsWindow(url) {
|
|
7925
|
+
var from = _parseEpochMs(url && url.searchParams.get("from"), "from");
|
|
7926
|
+
var to = _parseEpochMs(url && url.searchParams.get("to"), "to");
|
|
7927
|
+
if (from == null) from = _parseDateParam(url && url.searchParams.get("from-date"), "from");
|
|
7928
|
+
if (to == null) {
|
|
7929
|
+
var toDate = _parseDateParam(url && url.searchParams.get("to-date"), "to");
|
|
7930
|
+
// `to-date` is the inclusive end day — advance to the next UTC
|
|
7931
|
+
// midnight so the window covers the whole selected day (the primitive
|
|
7932
|
+
// treats `until`/`to` as exclusive).
|
|
7933
|
+
if (toDate != null) to = toDate + b.constants.TIME.days(1);
|
|
7934
|
+
}
|
|
7935
|
+
var now = Date.now();
|
|
7936
|
+
if (to == null) to = now;
|
|
7937
|
+
if (from == null) from = now - b.constants.TIME.days(30);
|
|
7938
|
+
return { from: from, to: to };
|
|
7939
|
+
}
|
|
7940
|
+
|
|
7941
|
+
// Compose the screen payload from the analytics primitive. Every read
|
|
7942
|
+
// carries the primitive's own window/limit bound: `topSKUs` /
|
|
7943
|
+
// `topViewedProducts` / `topSearchTerms` are capped at limit 10 (the
|
|
7944
|
+
// primitive enforces [1,100]); `revenueByDay` and `funnel` are window-
|
|
7945
|
+
// bounded (the primitive enforces span ≤ 1 year). No unbounded read is
|
|
7946
|
+
// exposed by this screen.
|
|
7947
|
+
async function _buildAnalyticsView(win) {
|
|
7948
|
+
var ordersWin = { since: win.from, until: win.to };
|
|
7949
|
+
var eventsWin = { from: win.from, to: win.to };
|
|
7950
|
+
var topSkus = await analytics.topSKUs(Object.assign({}, ordersWin, { limit: 10 }));
|
|
7951
|
+
var byDay = await analytics.revenueByDay(ordersWin);
|
|
7952
|
+
var viewed = await analytics.topViewedProducts(Object.assign({}, eventsWin, { limit: 10 }));
|
|
7953
|
+
var searches = await analytics.topSearchTerms(Object.assign({}, eventsWin, { limit: 10 }));
|
|
7954
|
+
var funnel = await analytics.funnel(eventsWin);
|
|
7955
|
+
// Headline currency for the sparkline + SKU revenue column: the first
|
|
7956
|
+
// currency the by-day series touched (alphabetical), USD when empty.
|
|
7957
|
+
var currency = "USD";
|
|
7958
|
+
for (var i = 0; i < byDay.length; i += 1) {
|
|
7959
|
+
if (byDay[i].currency) { currency = byDay[i].currency; break; }
|
|
7960
|
+
}
|
|
7961
|
+
return {
|
|
7962
|
+
from: win.from,
|
|
7963
|
+
to: win.to,
|
|
7964
|
+
currency: currency,
|
|
7965
|
+
top_skus: topSkus,
|
|
7966
|
+
by_day: byDay,
|
|
7967
|
+
top_viewed_products: viewed,
|
|
7968
|
+
top_search_terms: searches,
|
|
7969
|
+
funnel: funnel,
|
|
7970
|
+
};
|
|
7971
|
+
}
|
|
7972
|
+
|
|
7973
|
+
// JSON-only complements to the existing /admin/analytics/* API — the
|
|
7974
|
+
// pre-purchase aggregates the dashboard/reports JSON never exposed.
|
|
7975
|
+
router.get("/admin/analytics/top-search-terms", R(async function (req, res) {
|
|
7976
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7977
|
+
var w = _parseWindow(url);
|
|
7978
|
+
w.from = w.since; w.to = w.until; delete w.since; delete w.until;
|
|
7979
|
+
w.limit = _parseLimit(url && url.searchParams.get("limit"), "limit", 100, 10);
|
|
7980
|
+
var rows = await analytics.topSearchTerms(w);
|
|
7981
|
+
_json(res, 200, { rows: rows });
|
|
7982
|
+
}));
|
|
7983
|
+
|
|
7984
|
+
router.get("/admin/analytics/top-viewed-products", R(async function (req, res) {
|
|
7985
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7986
|
+
var w = _parseWindow(url);
|
|
7987
|
+
w.from = w.since; w.to = w.until; delete w.since; delete w.until;
|
|
7988
|
+
w.limit = _parseLimit(url && url.searchParams.get("limit"), "limit", 100, 10);
|
|
7989
|
+
var rows = await analytics.topViewedProducts(w);
|
|
7990
|
+
_json(res, 200, { rows: rows });
|
|
7991
|
+
}));
|
|
7992
|
+
|
|
7993
|
+
router.get("/admin/analytics/funnel", R(async function (req, res) {
|
|
7994
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7995
|
+
var w = _parseWindow(url);
|
|
7996
|
+
var f = await analytics.funnel({ from: w.since, to: w.until });
|
|
7997
|
+
_json(res, 200, f);
|
|
7998
|
+
}));
|
|
7999
|
+
|
|
8000
|
+
// The product-performance screen — bearer → JSON contract, signed-in
|
|
8001
|
+
// browser → rendered console page.
|
|
8002
|
+
router.get("/admin/analytics", _pageOrApi(true,
|
|
8003
|
+
R(async function (req, res) {
|
|
8004
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
8005
|
+
var win;
|
|
8006
|
+
try { win = _parseAnalyticsWindow(url); }
|
|
8007
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
8008
|
+
var view;
|
|
8009
|
+
try { view = await _buildAnalyticsView(win); }
|
|
8010
|
+
catch (e2) { if (e2 instanceof TypeError) return _problem(res, 400, "bad-request", e2.message); throw e2; }
|
|
8011
|
+
_json(res, 200, view);
|
|
8012
|
+
}),
|
|
8013
|
+
async function (req, res) {
|
|
8014
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
8015
|
+
var win, notice = null;
|
|
8016
|
+
try { win = _parseAnalyticsWindow(url); }
|
|
8017
|
+
catch (e) {
|
|
8018
|
+
// Bad range → default window + a correction notice (config/entry
|
|
8019
|
+
// tier: the operator fixes the typo, the screen never 500s).
|
|
8020
|
+
win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
|
|
8021
|
+
notice = _safeNotice(e, "analytics.view").message.replace(/^admin:\s*/, "");
|
|
8022
|
+
}
|
|
8023
|
+
var view;
|
|
8024
|
+
try { view = await _buildAnalyticsView(win); }
|
|
8025
|
+
catch (e2) {
|
|
8026
|
+
win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
|
|
8027
|
+
notice = _safeNotice(e2, "analytics.view").message.replace(/^analytics:\s*/, "");
|
|
8028
|
+
view = await _buildAnalyticsView(win);
|
|
8029
|
+
}
|
|
8030
|
+
_sendHtml(res, 200, renderAdminAnalytics({
|
|
8031
|
+
shop_name: deps.shop_name, nav_available: navAvailable, view: view, notice: notice,
|
|
8032
|
+
}));
|
|
8033
|
+
},
|
|
8034
|
+
));
|
|
7899
8035
|
}
|
|
7900
8036
|
|
|
7901
8037
|
// ---- subscriptions --------------------------------------------------
|
|
@@ -10831,6 +10967,121 @@ function _statCard(label, value, accent) {
|
|
|
10831
10967
|
"<div class=\"value" + (accent ? " accent" : "") + "\">" + _htmlEscape(value) + "</div></div>";
|
|
10832
10968
|
}
|
|
10833
10969
|
|
|
10970
|
+
// Format a money amount that may be negative. `pricing.format` (composing
|
|
10971
|
+
// b.money) asserts a non-negative minor-unit; analytics net revenue
|
|
10972
|
+
// subtracts refunds, so a refund-dominated window can go negative. Render
|
|
10973
|
+
// the magnitude through the same formatter and carry the sign by hand so
|
|
10974
|
+
// the screen never 500s on a legitimately-negative aggregate.
|
|
10975
|
+
function _fmtSignedMoney(amountMinor, currency) {
|
|
10976
|
+
var n = Number(amountMinor) || 0;
|
|
10977
|
+
if (n < 0) return "-" + pricing.format(-n, currency);
|
|
10978
|
+
return pricing.format(n, currency);
|
|
10979
|
+
}
|
|
10980
|
+
|
|
10981
|
+
// Product-performance + behaviour screen for /admin/analytics. Surfaces the
|
|
10982
|
+
// aggregates /admin/reports does NOT: units-ranked SKU performance, the
|
|
10983
|
+
// browse→buy funnel + conversion rate, most-viewed products, top search
|
|
10984
|
+
// terms, and a revenue-by-day trend sparkline. `opts.view` is the
|
|
10985
|
+
// `_buildAnalyticsView` payload (never null — the route always builds a
|
|
10986
|
+
// view, falling back to the default window on a bad range). Each table
|
|
10987
|
+
// renders an honest empty state when its aggregate is empty.
|
|
10988
|
+
function renderAdminAnalytics(opts) {
|
|
10989
|
+
opts = opts || {};
|
|
10990
|
+
var view = opts.view || {};
|
|
10991
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
10992
|
+
var cur = view.currency || "USD";
|
|
10993
|
+
|
|
10994
|
+
var fromVal = _dateInputValue(view.from);
|
|
10995
|
+
// `to` is exclusive (next UTC midnight after the operator's chosen end
|
|
10996
|
+
// day) — step back one day to render the inclusive end day into the input.
|
|
10997
|
+
var toVal = _dateInputValue((view.to || Date.now()) - b.constants.TIME.days(1));
|
|
10998
|
+
|
|
10999
|
+
// Date-range form (GET so the window lives in the URL — bookmarkable).
|
|
11000
|
+
var rangeForm =
|
|
11001
|
+
"<form method=\"get\" action=\"/admin/analytics\" class=\"order-filters\">" +
|
|
11002
|
+
"<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" value=\"" + _htmlEscape(fromVal) + "\"></label>" +
|
|
11003
|
+
"<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" value=\"" + _htmlEscape(toVal) + "\"></label>" +
|
|
11004
|
+
"<button class=\"btn\" type=\"submit\">Apply</button>" +
|
|
11005
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/reports\">Open sales report →</a>" +
|
|
11006
|
+
"</form>";
|
|
11007
|
+
|
|
11008
|
+
// ---- conversion funnel + rate (pre-purchase, from the event stream).
|
|
11009
|
+
var fn = view.funnel || { pdp_views: 0, cart_adds: 0, checkout_starts: 0, checkout_completes: 0, conversion_rate: 0 };
|
|
11010
|
+
var ratePct = (Number(fn.conversion_rate) || 0) * 100;
|
|
11011
|
+
var funnelStats =
|
|
11012
|
+
"<section><h2>Browse-to-buy funnel</h2>" +
|
|
11013
|
+
"<p class=\"meta\">Pre-purchase signal from the event stream — the sales report covers post-order status; this covers the path to it.</p>" +
|
|
11014
|
+
"<div class=\"stat-grid\">" +
|
|
11015
|
+
_statCard("Product views", String(fn.pdp_views || 0)) +
|
|
11016
|
+
_statCard("Cart adds", String(fn.cart_adds || 0)) +
|
|
11017
|
+
_statCard("Checkouts started", String(fn.checkout_starts || 0)) +
|
|
11018
|
+
_statCard("Checkouts completed", String(fn.checkout_completes || 0)) +
|
|
11019
|
+
_statCard("Conversion rate", ratePct.toFixed(2) + "%", true) +
|
|
11020
|
+
"</div>" +
|
|
11021
|
+
"</section>";
|
|
11022
|
+
|
|
11023
|
+
// ---- revenue-by-day trend sparkline (server-rendered SVG, same
|
|
11024
|
+
// mechanism the dashboard uses).
|
|
11025
|
+
var sparkBlock =
|
|
11026
|
+
"<section><h2>Revenue trend</h2><div class=\"panel\">" +
|
|
11027
|
+
_sparkSvg(view.by_day || [], cur) +
|
|
11028
|
+
"</div></section>";
|
|
11029
|
+
|
|
11030
|
+
// ---- top SKUs by units sold (reports ranks top products by revenue;
|
|
11031
|
+
// this ranks by units, the complementary view).
|
|
11032
|
+
var topSkus = view.top_skus || [];
|
|
11033
|
+
var skuRows = topSkus.length
|
|
11034
|
+
? topSkus.map(function (r) {
|
|
11035
|
+
return "<tr><td>" + _htmlEscape(r.sku) + "</td>" +
|
|
11036
|
+
"<td class=\"num\">" + _htmlEscape(String(r.units_sold)) + "</td>" +
|
|
11037
|
+
"<td class=\"num\">" + _htmlEscape(_fmtSignedMoney(r.revenue_minor, r.currency)) + "</td></tr>";
|
|
11038
|
+
}).join("")
|
|
11039
|
+
: "<tr><td colspan=\"3\" class=\"empty\">No sales in this window.</td></tr>";
|
|
11040
|
+
var skuBlock =
|
|
11041
|
+
"<section><h2>Top SKUs by units sold</h2><div class=\"panel\">" +
|
|
11042
|
+
_tableWrap("<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Units</th><th scope=\"col\" class=\"num\">Revenue</th></tr></thead><tbody>" + skuRows + "</tbody></table>") +
|
|
11043
|
+
"</div></section>";
|
|
11044
|
+
|
|
11045
|
+
// ---- most-viewed products (event stream, pre-purchase).
|
|
11046
|
+
var viewed = view.top_viewed_products || [];
|
|
11047
|
+
var viewedRows = viewed.length
|
|
11048
|
+
? viewed.map(function (r) {
|
|
11049
|
+
return "<tr><td>" + _htmlEscape(r.product_id) + "</td>" +
|
|
11050
|
+
"<td class=\"num\">" + _htmlEscape(String(r.count)) + "</td></tr>";
|
|
11051
|
+
}).join("")
|
|
11052
|
+
: "<tr><td colspan=\"2\" class=\"empty\">No product views in this window.</td></tr>";
|
|
11053
|
+
|
|
11054
|
+
// ---- top search terms (event stream, pre-purchase).
|
|
11055
|
+
var searches = view.top_search_terms || [];
|
|
11056
|
+
var searchRows = searches.length
|
|
11057
|
+
? searches.map(function (r) {
|
|
11058
|
+
return "<tr><td>" + _htmlEscape(r.search_q) + "</td>" +
|
|
11059
|
+
"<td class=\"num\">" + _htmlEscape(String(r.count)) + "</td></tr>";
|
|
11060
|
+
}).join("")
|
|
11061
|
+
: "<tr><td colspan=\"2\" class=\"empty\">No searches in this window.</td></tr>";
|
|
11062
|
+
|
|
11063
|
+
var twoCol =
|
|
11064
|
+
"<section><h2>Demand signal</h2><div class=\"two-col\">" +
|
|
11065
|
+
"<div class=\"panel\">" +
|
|
11066
|
+
"<h3 class=\"subhead\">Most-viewed products</h3>" +
|
|
11067
|
+
_tableWrap("<table><thead><tr><th scope=\"col\">Product</th><th scope=\"col\" class=\"num\">Views</th></tr></thead><tbody>" + viewedRows + "</tbody></table>") +
|
|
11068
|
+
"</div>" +
|
|
11069
|
+
"<div class=\"panel\">" +
|
|
11070
|
+
"<h3 class=\"subhead\">Top search terms</h3>" +
|
|
11071
|
+
_tableWrap("<table><thead><tr><th scope=\"col\">Query</th><th scope=\"col\" class=\"num\">Searches</th></tr></thead><tbody>" + searchRows + "</tbody></table>") +
|
|
11072
|
+
"</div>" +
|
|
11073
|
+
"</div></section>";
|
|
11074
|
+
|
|
11075
|
+
var body =
|
|
11076
|
+
"<section><h2>Analytics</h2>" + notice +
|
|
11077
|
+
"<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(view.from)) + " → " + _htmlEscape(_fmtDate(view.to)) + "</p>" +
|
|
11078
|
+
"<p class=\"meta\">Product performance and pre-purchase behaviour. For order revenue totals, AOV, and refund rate see the <a href=\"/admin/reports\">sales report</a>.</p>" +
|
|
11079
|
+
rangeForm +
|
|
11080
|
+
"</section>" +
|
|
11081
|
+
funnelStats + sparkBlock + skuBlock + twoCol;
|
|
11082
|
+
return _renderAdminShell(opts.shop_name, "Analytics", body, "analytics", opts.nav_available);
|
|
11083
|
+
}
|
|
11084
|
+
|
|
10834
11085
|
// ---- admin web pages (login / landing / setup wizard) -------------------
|
|
10835
11086
|
|
|
10836
11087
|
// Console nav — one entry per HTML console screen. `active` highlights
|
|
@@ -10847,6 +11098,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
10847
11098
|
{ key: "inventory", href: "/admin/inventory", label: "Inventory" },
|
|
10848
11099
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
10849
11100
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
11101
|
+
{ key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
|
|
10850
11102
|
{ key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
|
|
10851
11103
|
{ key: "errors", href: "/admin/errors", label: "Errors", requires: "errorLog" },
|
|
10852
11104
|
{ key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
|
|
@@ -17662,6 +17914,7 @@ module.exports = {
|
|
|
17662
17914
|
mount: mount,
|
|
17663
17915
|
AUDIT_NAMESPACE: AUDIT_NAMESPACE,
|
|
17664
17916
|
renderDashboard: renderDashboard,
|
|
17917
|
+
renderAdminAnalytics: renderAdminAnalytics,
|
|
17665
17918
|
renderAdminLogin: renderAdminLogin,
|
|
17666
17919
|
renderAdminLanding: renderAdminLanding,
|
|
17667
17920
|
renderAdminSetup: renderAdminSetup,
|
package/lib/asset-manifest.json
CHANGED
package/lib/auto-discount.js
CHANGED
|
@@ -172,9 +172,15 @@ var MAX_LIST_LIMIT = 500;
|
|
|
172
172
|
var MAX_BASIS_POINTS = 10000;
|
|
173
173
|
var MAX_MINOR_VALUE = 1e12;
|
|
174
174
|
var MAX_BOGO_QTY = 1000;
|
|
175
|
+
var MAX_UNLOCK_CODE_LEN = 64;
|
|
175
176
|
|
|
176
177
|
// Slug shape — alnum + dot + hyphen + underscore, alnum leading.
|
|
177
178
|
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
179
|
+
// Unlock-code shape — the string a shopper types on the cart page to
|
|
180
|
+
// unlock a code-gated rule. Same alnum + dot + hyphen + underscore family
|
|
181
|
+
// the couponStacking primitive accepts so the two surfaces agree on what a
|
|
182
|
+
// "code" is; refuses whitespace + control bytes.
|
|
183
|
+
var UNLOCK_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
178
184
|
// Sku shape — same family the catalog primitive uses (alnum + dot +
|
|
179
185
|
// hyphen + underscore + slash + colon), tight enough to refuse
|
|
180
186
|
// whitespace + control bytes.
|
|
@@ -214,6 +220,7 @@ var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
|
214
220
|
"max_redemptions_total",
|
|
215
221
|
"max_redemptions_per_customer",
|
|
216
222
|
"active",
|
|
223
|
+
"unlock_code",
|
|
217
224
|
]);
|
|
218
225
|
|
|
219
226
|
var b = require("./vendor/blamejs");
|
|
@@ -227,6 +234,17 @@ function _slug(s) {
|
|
|
227
234
|
return s;
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
// Optional shopper-typed code that gates a rule. null/"" clears it (the
|
|
238
|
+
// rule reverts to a pure automatic). A non-empty value must match the
|
|
239
|
+
// code shape; stored as authored, matched case-insensitively at evaluate.
|
|
240
|
+
function _unlockCode(s) {
|
|
241
|
+
if (s == null || s === "") return null;
|
|
242
|
+
if (typeof s !== "string" || !UNLOCK_CODE_RE.test(s)) {
|
|
243
|
+
throw new TypeError("autoDiscount: unlock_code must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_UNLOCK_CODE_LEN + " chars)");
|
|
244
|
+
}
|
|
245
|
+
return s;
|
|
246
|
+
}
|
|
247
|
+
|
|
230
248
|
function _title(s) {
|
|
231
249
|
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
232
250
|
throw new TypeError("autoDiscount: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
@@ -493,6 +511,7 @@ function _hydrateRow(r) {
|
|
|
493
511
|
active: r.active === 1 || r.active === true,
|
|
494
512
|
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
495
513
|
redemptions_used: Number(r.redemptions_used) || 0,
|
|
514
|
+
unlock_code: r.unlock_code == null ? null : String(r.unlock_code),
|
|
496
515
|
created_at: Number(r.created_at),
|
|
497
516
|
updated_at: Number(r.updated_at),
|
|
498
517
|
};
|
|
@@ -617,6 +636,7 @@ function create(opts) {
|
|
|
617
636
|
}
|
|
618
637
|
var maxTotal = input.max_redemptions_total == null ? null : _nonNegInt(input.max_redemptions_total, "max_redemptions_total");
|
|
619
638
|
var maxPerCustomer = input.max_redemptions_per_customer == null ? null : _nonNegInt(input.max_redemptions_per_customer, "max_redemptions_per_customer");
|
|
639
|
+
var unlockCode = _unlockCode(input.unlock_code);
|
|
620
640
|
|
|
621
641
|
var existing = (await query(
|
|
622
642
|
"SELECT slug FROM auto_discount_rules WHERE slug = ?1 LIMIT 1",
|
|
@@ -625,14 +645,28 @@ function create(opts) {
|
|
|
625
645
|
if (existing) {
|
|
626
646
|
throw new TypeError("autoDiscount.defineRule: slug " + JSON.stringify(slug) + " already exists -- use updateRule");
|
|
627
647
|
}
|
|
648
|
+
// Reject a duplicate active code up front so the caller gets a clean
|
|
649
|
+
// TypeError rather than a raw UNIQUE-constraint bubble. The partial
|
|
650
|
+
// unique index is the authoritative guard; this is the friendly check.
|
|
651
|
+
if (unlockCode != null) {
|
|
652
|
+
var clash = (await query(
|
|
653
|
+
"SELECT slug FROM auto_discount_rules " +
|
|
654
|
+
"WHERE lower(unlock_code) = lower(?1) AND archived_at IS NULL LIMIT 1",
|
|
655
|
+
[unlockCode],
|
|
656
|
+
)).rows[0];
|
|
657
|
+
if (clash) {
|
|
658
|
+
throw new TypeError("autoDiscount.defineRule: unlock_code " + JSON.stringify(unlockCode) +
|
|
659
|
+
" already claimed by an active rule");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
628
662
|
|
|
629
663
|
var ts = _now();
|
|
630
664
|
await query(
|
|
631
665
|
"INSERT INTO auto_discount_rules (slug, title, trigger_json, value_json, applies_to_json, " +
|
|
632
666
|
"customer_segment_in_json, exclusions_json, priority, starts_at, expires_at, " +
|
|
633
667
|
"max_redemptions_total, max_redemptions_per_customer, active, archived_at, " +
|
|
634
|
-
"redemptions_used, created_at, updated_at) " +
|
|
635
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, NULL, 0, ?13, ?
|
|
668
|
+
"redemptions_used, unlock_code, created_at, updated_at) " +
|
|
669
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, NULL, 0, ?13, ?14, ?14)",
|
|
636
670
|
[
|
|
637
671
|
slug,
|
|
638
672
|
title,
|
|
@@ -646,6 +680,7 @@ function create(opts) {
|
|
|
646
680
|
expiresAt,
|
|
647
681
|
maxTotal,
|
|
648
682
|
maxPerCustomer,
|
|
683
|
+
unlockCode,
|
|
649
684
|
ts,
|
|
650
685
|
],
|
|
651
686
|
);
|
|
@@ -746,6 +781,24 @@ function create(opts) {
|
|
|
746
781
|
} else if (col === "max_redemptions_per_customer") {
|
|
747
782
|
sets.push("max_redemptions_per_customer = ?" + idx);
|
|
748
783
|
params.push(patch[col] == null ? null : _nonNegInt(patch[col], "max_redemptions_per_customer"));
|
|
784
|
+
} else if (col === "unlock_code") {
|
|
785
|
+
var nextCode = _unlockCode(patch[col]);
|
|
786
|
+
if (nextCode != null) {
|
|
787
|
+
// Don't let an update steal another active rule's code. A
|
|
788
|
+
// re-assert of THIS rule's own code is fine (the clash row is
|
|
789
|
+
// itself).
|
|
790
|
+
var codeClash = (await query(
|
|
791
|
+
"SELECT slug FROM auto_discount_rules " +
|
|
792
|
+
"WHERE lower(unlock_code) = lower(?1) AND archived_at IS NULL AND slug != ?2 LIMIT 1",
|
|
793
|
+
[nextCode, slug],
|
|
794
|
+
)).rows[0];
|
|
795
|
+
if (codeClash) {
|
|
796
|
+
throw new TypeError("autoDiscount.updateRule: unlock_code " + JSON.stringify(nextCode) +
|
|
797
|
+
" already claimed by an active rule");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
sets.push("unlock_code = ?" + idx);
|
|
801
|
+
params.push(nextCode);
|
|
749
802
|
} else /* active */ {
|
|
750
803
|
sets.push("active = ?" + idx);
|
|
751
804
|
params.push(_bool(patch[col], "active") ? 1 : 0);
|
|
@@ -789,6 +842,30 @@ function create(opts) {
|
|
|
789
842
|
return await getRule(slug);
|
|
790
843
|
}
|
|
791
844
|
|
|
845
|
+
// ---- ruleForCode ---------------------------------------------------
|
|
846
|
+
|
|
847
|
+
// Resolve a shopper-typed code to its active, code-gated rule (the row
|
|
848
|
+
// whose unlock_code matches case-insensitively, not archived). Returns
|
|
849
|
+
// the hydrated rule or null when no active rule claims the code. The
|
|
850
|
+
// cart-page coupon entry calls this to validate a typed code before
|
|
851
|
+
// persisting it — a non-empty result means "this is a real code"; null
|
|
852
|
+
// is the uniform "unknown code" answer. A malformed code (whitespace /
|
|
853
|
+
// control bytes / over-length) returns null rather than throwing, so the
|
|
854
|
+
// cart route gives one message for "doesn't match a rule" regardless of
|
|
855
|
+
// whether the input was garbage or simply unknown (no code-shape oracle).
|
|
856
|
+
async function ruleForCode(code) {
|
|
857
|
+
if (typeof code !== "string" || !UNLOCK_CODE_RE.test(code)) return null;
|
|
858
|
+
var r = (await query(
|
|
859
|
+
"SELECT * FROM auto_discount_rules " +
|
|
860
|
+
"WHERE lower(unlock_code) = lower(?1) AND active = 1 AND archived_at IS NULL " +
|
|
861
|
+
" AND (starts_at IS NULL OR starts_at <= ?2) " +
|
|
862
|
+
" AND (expires_at IS NULL OR expires_at > ?2) " +
|
|
863
|
+
"LIMIT 1",
|
|
864
|
+
[code, _now()],
|
|
865
|
+
)).rows[0];
|
|
866
|
+
return _hydrateRow(r);
|
|
867
|
+
}
|
|
868
|
+
|
|
792
869
|
// ---- evaluate ------------------------------------------------------
|
|
793
870
|
|
|
794
871
|
async function evaluate(input) {
|
|
@@ -800,6 +877,21 @@ function create(opts) {
|
|
|
800
877
|
if (customerId != null && (typeof customerId !== "string" || !customerId.length)) {
|
|
801
878
|
throw new TypeError("autoDiscount.evaluate: customer_id must be a non-empty string when provided");
|
|
802
879
|
}
|
|
880
|
+
// Shopper-presented codes that unlock code-gated rules. A defensive
|
|
881
|
+
// request-shape reader: any non-string / malformed entry is dropped
|
|
882
|
+
// (never throws on the buy path), each kept entry is lower-cased into a
|
|
883
|
+
// lookup set so the code gate is a case-insensitive membership test. A
|
|
884
|
+
// pure-automatic rule (unlock_code IS NULL) ignores this set entirely.
|
|
885
|
+
var presentedCodes = Object.create(null);
|
|
886
|
+
if (input.codes != null) {
|
|
887
|
+
if (!Array.isArray(input.codes)) {
|
|
888
|
+
throw new TypeError("autoDiscount.evaluate: codes must be an array when provided");
|
|
889
|
+
}
|
|
890
|
+
for (var pc = 0; pc < input.codes.length; pc += 1) {
|
|
891
|
+
var raw = input.codes[pc];
|
|
892
|
+
if (typeof raw === "string" && raw.length) presentedCodes[raw.toLowerCase()] = true;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
803
895
|
|
|
804
896
|
var ts = _now();
|
|
805
897
|
var rows = (await query(
|
|
@@ -839,6 +931,15 @@ function create(opts) {
|
|
|
839
931
|
for (var ri = 0; ri < rows.length; ri += 1) {
|
|
840
932
|
var rule = _hydrateRow(rows[ri]);
|
|
841
933
|
|
|
934
|
+
// 0. Code gate — a rule with an unlock_code is dormant until the
|
|
935
|
+
// shopper presents that code (case-insensitive). A pure-automatic
|
|
936
|
+
// rule (unlock_code IS NULL) skips this gate and fires on shape alone,
|
|
937
|
+
// so the legacy code-less behaviour is unchanged.
|
|
938
|
+
if (rule.unlock_code != null && !presentedCodes[rule.unlock_code.toLowerCase()]) {
|
|
939
|
+
skipped.push({ rule_slug: rule.slug, reason: "unlock_code_not_presented" });
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
842
943
|
// 1. Total-redemption cap
|
|
843
944
|
if (rule.max_redemptions_total != null && rule.redemptions_used >= rule.max_redemptions_total) {
|
|
844
945
|
skipped.push({ rule_slug: rule.slug, reason: "max_redemptions_total_reached" });
|
|
@@ -1118,6 +1219,7 @@ function create(opts) {
|
|
|
1118
1219
|
listRules: listRules,
|
|
1119
1220
|
updateRule: updateRule,
|
|
1120
1221
|
archiveRule: archiveRule,
|
|
1222
|
+
ruleForCode: ruleForCode,
|
|
1121
1223
|
evaluate: evaluate,
|
|
1122
1224
|
recordApplication: recordApplication,
|
|
1123
1225
|
metricsForRule: metricsForRule,
|
package/lib/cart.js
CHANGED
|
@@ -35,6 +35,12 @@ var DEFAULT_TTL_MS = C.TIME.days(30);
|
|
|
35
35
|
var MAX_QTY = 99999;
|
|
36
36
|
var SESSION_ID_RE = /^[A-Za-z0-9_-]{16,64}$/; // shape-only; sealed-cookie origin
|
|
37
37
|
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
38
|
+
// Discount-code shape — the string a shopper types on the cart page. Same
|
|
39
|
+
// alnum + dot + hyphen + underscore family the autoDiscount unlock_code +
|
|
40
|
+
// couponStacking primitives accept, so the three surfaces agree on what a
|
|
41
|
+
// "code" is. Refuses whitespace + control bytes; caps length.
|
|
42
|
+
var DISCOUNT_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
43
|
+
var MAX_CODES_PER_CART = 16;
|
|
38
44
|
|
|
39
45
|
// ---- validators ---------------------------------------------------------
|
|
40
46
|
|
|
@@ -62,6 +68,12 @@ function _status(s) {
|
|
|
62
68
|
throw new TypeError("cart: status must be one of " + CART_STATUSES.join(", "));
|
|
63
69
|
}
|
|
64
70
|
}
|
|
71
|
+
function _discountCode(s) {
|
|
72
|
+
if (typeof s !== "string" || !DISCOUNT_CODE_RE.test(s)) {
|
|
73
|
+
throw new TypeError("cart: discount code must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ 64 chars)");
|
|
74
|
+
}
|
|
75
|
+
return s;
|
|
76
|
+
}
|
|
65
77
|
|
|
66
78
|
function _now() { return Date.now(); }
|
|
67
79
|
|
|
@@ -255,6 +267,27 @@ function create(opts) {
|
|
|
255
267
|
);
|
|
256
268
|
}
|
|
257
269
|
}
|
|
270
|
+
// Carry the anonymous cart's applied discount codes onto the
|
|
271
|
+
// surviving cart so a code typed before sign-in isn't silently
|
|
272
|
+
// dropped on login. A code already on the destination cart wins
|
|
273
|
+
// (INSERT OR IGNORE against the UNIQUE (cart_id, code_lower)).
|
|
274
|
+
// Best-effort: the cart_discount_codes table may not be migrated on a
|
|
275
|
+
// given deploy (the coupon feature is additive), so a missing table
|
|
276
|
+
// degrades to "codes not carried" rather than failing the whole login
|
|
277
|
+
// merge — the line merge is the load-bearing part.
|
|
278
|
+
try {
|
|
279
|
+
var fromCodes = (await query(
|
|
280
|
+
"SELECT * FROM cart_discount_codes WHERE cart_id = ?1", [fromCartId],
|
|
281
|
+
)).rows;
|
|
282
|
+
for (var ci = 0; ci < fromCodes.length; ci += 1) {
|
|
283
|
+
var fc = fromCodes[ci];
|
|
284
|
+
await query(
|
|
285
|
+
"INSERT OR IGNORE INTO cart_discount_codes (id, cart_id, code, code_lower, rule_slug, applied_at) " +
|
|
286
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
287
|
+
[b.uuid.v7(), toCartId, fc.code, fc.code_lower, fc.rule_slug, ts],
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
} catch (_e) { /* cart_discount_codes unmigrated — codes simply don't carry */ }
|
|
258
291
|
await query("UPDATE carts SET status = 'abandoned', updated_at = ?1 WHERE id = ?2", [ts, fromCartId]);
|
|
259
292
|
await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, toCartId]);
|
|
260
293
|
return (await query("SELECT * FROM carts WHERE id = ?1", [toCartId])).rows[0];
|
|
@@ -283,6 +316,77 @@ function create(opts) {
|
|
|
283
316
|
if (r.rowCount === 0) return null;
|
|
284
317
|
return (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0];
|
|
285
318
|
},
|
|
319
|
+
|
|
320
|
+
// ---- applied discount codes ---------------------------------------
|
|
321
|
+
//
|
|
322
|
+
// A cart carries the discount codes a shopper applied on the cart page.
|
|
323
|
+
// The codes are stored as typed; `code_lower` is the case-insensitive
|
|
324
|
+
// key the UNIQUE (cart_id, code_lower) constraint + the rule lookup
|
|
325
|
+
// both use, so applying the same code twice is idempotent rather than
|
|
326
|
+
// stacking duplicate rows. These methods own ONLY the storage; the
|
|
327
|
+
// caller validates a code against the discount engine before applying
|
|
328
|
+
// (the cart never decides a code is valid — it persists what the
|
|
329
|
+
// storefront route accepted).
|
|
330
|
+
|
|
331
|
+
// Persist an accepted code on the cart. `rule_slug` snapshots which
|
|
332
|
+
// discount rule the code resolved to at apply time (audit convenience).
|
|
333
|
+
// Idempotent on (cart_id, code_lower): re-applying refreshes the
|
|
334
|
+
// snapshot + timestamp rather than erroring. Returns the stored row.
|
|
335
|
+
addDiscountCode: async function (cartId, code, ruleSlug) {
|
|
336
|
+
_uuid(cartId, "cart_id");
|
|
337
|
+
_discountCode(code);
|
|
338
|
+
var lower = code.toLowerCase();
|
|
339
|
+
var ts = _now();
|
|
340
|
+
var existing = (await query(
|
|
341
|
+
"SELECT id FROM cart_discount_codes WHERE cart_id = ?1 AND code_lower = ?2 LIMIT 1",
|
|
342
|
+
[cartId, lower],
|
|
343
|
+
)).rows[0];
|
|
344
|
+
if (existing) {
|
|
345
|
+
await query(
|
|
346
|
+
"UPDATE cart_discount_codes SET code = ?1, rule_slug = ?2, applied_at = ?3 WHERE id = ?4",
|
|
347
|
+
[code, ruleSlug == null ? null : String(ruleSlug), ts, existing.id],
|
|
348
|
+
);
|
|
349
|
+
return { id: existing.id, cart_id: cartId, code: code, code_lower: lower, rule_slug: ruleSlug == null ? null : String(ruleSlug), applied_at: ts };
|
|
350
|
+
}
|
|
351
|
+
// Cap the codes a single cart can carry so a scripted apply loop can't
|
|
352
|
+
// grow the row set unbounded.
|
|
353
|
+
var countRow = (await query(
|
|
354
|
+
"SELECT COUNT(*) AS n FROM cart_discount_codes WHERE cart_id = ?1",
|
|
355
|
+
[cartId],
|
|
356
|
+
)).rows[0] || {};
|
|
357
|
+
if ((Number(countRow.n) || 0) >= MAX_CODES_PER_CART) {
|
|
358
|
+
throw new TypeError("cart.addDiscountCode: cart already carries the maximum of " + MAX_CODES_PER_CART + " codes");
|
|
359
|
+
}
|
|
360
|
+
var id = b.uuid.v7();
|
|
361
|
+
await query(
|
|
362
|
+
"INSERT INTO cart_discount_codes (id, cart_id, code, code_lower, rule_slug, applied_at) " +
|
|
363
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
364
|
+
[id, cartId, code, lower, ruleSlug == null ? null : String(ruleSlug), ts],
|
|
365
|
+
);
|
|
366
|
+
return { id: id, cart_id: cartId, code: code, code_lower: lower, rule_slug: ruleSlug == null ? null : String(ruleSlug), applied_at: ts };
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
// The cart's applied codes, apply order. Returns the stored rows.
|
|
370
|
+
listDiscountCodes: async function (cartId) {
|
|
371
|
+
_uuid(cartId, "cart_id");
|
|
372
|
+
var r = await query(
|
|
373
|
+
"SELECT * FROM cart_discount_codes WHERE cart_id = ?1 ORDER BY applied_at ASC, id ASC",
|
|
374
|
+
[cartId],
|
|
375
|
+
);
|
|
376
|
+
return r.rows;
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
// Remove one applied code (case-insensitive). Returns true when a row
|
|
380
|
+
// was removed, false when the code wasn't on the cart.
|
|
381
|
+
removeDiscountCode: async function (cartId, code) {
|
|
382
|
+
_uuid(cartId, "cart_id");
|
|
383
|
+
_discountCode(code);
|
|
384
|
+
var r = await query(
|
|
385
|
+
"DELETE FROM cart_discount_codes WHERE cart_id = ?1 AND code_lower = ?2",
|
|
386
|
+
[cartId, code.toLowerCase()],
|
|
387
|
+
);
|
|
388
|
+
return Number(r.rowCount || 0) > 0;
|
|
389
|
+
},
|
|
286
390
|
};
|
|
287
391
|
}
|
|
288
392
|
|
package/lib/checkout.js
CHANGED
|
@@ -284,7 +284,7 @@ function create(deps) {
|
|
|
284
284
|
// the subtotal — the order total can therefore never go negative,
|
|
285
285
|
// and a customer can never be charged less than zero or credited
|
|
286
286
|
// more than the cart is worth.
|
|
287
|
-
async function _resolveAutoDiscount(lines, subtotalMinor, customerId) {
|
|
287
|
+
async function _resolveAutoDiscount(lines, subtotalMinor, customerId, codes) {
|
|
288
288
|
if (!autoDiscount || typeof autoDiscount.evaluate !== "function") {
|
|
289
289
|
return { discount_minor: 0, applied: [] };
|
|
290
290
|
}
|
|
@@ -304,6 +304,10 @@ function create(deps) {
|
|
|
304
304
|
lines: evalLines,
|
|
305
305
|
},
|
|
306
306
|
customer_id: customerId || undefined,
|
|
307
|
+
// Shopper-presented coupon codes unlock code-gated rules. Absent /
|
|
308
|
+
// empty leaves only the pure-automatic rules in play, so a quote
|
|
309
|
+
// with no codes is byte-identical to the pre-code behaviour.
|
|
310
|
+
codes: Array.isArray(codes) && codes.length ? codes : undefined,
|
|
307
311
|
});
|
|
308
312
|
var appliedRaw = res && Array.isArray(res.applied) ? res.applied : [];
|
|
309
313
|
var total = 0;
|
|
@@ -660,7 +664,7 @@ function create(deps) {
|
|
|
660
664
|
// resolver clamps the result to [0, subtotal] and falls back to 0
|
|
661
665
|
// on any failure, so the total math below is identical to the
|
|
662
666
|
// un-wired flow whenever no rule applies.
|
|
663
|
-
var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null);
|
|
667
|
+
var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null, input.codes);
|
|
664
668
|
|
|
665
669
|
var totals = pricing.totals(c, lines, {
|
|
666
670
|
tax_minor: taxRow.tax_minor,
|
package/lib/storefront.js
CHANGED
|
@@ -7186,11 +7186,51 @@ function _orderTimelineBlock(status) {
|
|
|
7186
7186
|
"<ol class=\"order-timeline__steps\">" + steps + "</ol></div>";
|
|
7187
7187
|
}
|
|
7188
7188
|
|
|
7189
|
+
// Cap on how many carrier events the per-shipment timeline renders. A
|
|
7190
|
+
// long-haul international parcel can accumulate dozens of scans; the
|
|
7191
|
+
// customer-facing panel shows the most recent MAX_SHIPMENT_TIMELINE
|
|
7192
|
+
// (newest-first) so the page stays bounded regardless of stream length.
|
|
7193
|
+
// getShipment hydrates the FULL event list (it has no LIMIT of its own),
|
|
7194
|
+
// so the bound is applied here at the render sink.
|
|
7195
|
+
var MAX_SHIPMENT_TIMELINE = 20;
|
|
7196
|
+
|
|
7197
|
+
// Render one shipment's carrier-event timeline, newest-first. Events
|
|
7198
|
+
// arrive oldest-first from getShipment (occurred_at ASC, recorded_at ASC),
|
|
7199
|
+
// so the list is reversed, then capped at MAX_SHIPMENT_TIMELINE. Each row
|
|
7200
|
+
// shows the status, an optional carrier location, an optional operator-
|
|
7201
|
+
// recorded detail note, and the event time. status / location / detail are
|
|
7202
|
+
// carrier- or operator-supplied free text → escaped at the sink. Returns ""
|
|
7203
|
+
// for a shipment with no events so the panel falls back to the carrier line
|
|
7204
|
+
// alone (exactly what the pre-timeline block rendered).
|
|
7205
|
+
function _shipmentTimeline(events) {
|
|
7206
|
+
if (!Array.isArray(events) || !events.length) return "";
|
|
7207
|
+
var esc = b.template.escapeHtml;
|
|
7208
|
+
// Newest-first: copy + reverse (never mutate the hydrated row's array),
|
|
7209
|
+
// then bound the render.
|
|
7210
|
+
var ordered = events.slice().reverse().slice(0, MAX_SHIPMENT_TIMELINE);
|
|
7211
|
+
var rows = ordered.map(function (ev) {
|
|
7212
|
+
var when = ev && ev.occurred_at != null
|
|
7213
|
+
? new Date(Number(ev.occurred_at)).toISOString().slice(0, 16).replace("T", " ")
|
|
7214
|
+
: "";
|
|
7215
|
+
var loc = ev && ev.location ? String(ev.location) : "";
|
|
7216
|
+
var detail = ev && ev.detail ? String(ev.detail) : "";
|
|
7217
|
+
return "<li class=\"order-shipment__event\">" +
|
|
7218
|
+
"<span class=\"order-shipment__event-status\">" + esc(String(ev && ev.status)) + "</span>" +
|
|
7219
|
+
(loc ? " <span class=\"order-shipment__event-loc\">" + esc(loc) + "</span>" : "") +
|
|
7220
|
+
(detail ? " <span class=\"order-shipment__event-detail\">" + esc(detail) + "</span>" : "") +
|
|
7221
|
+
(when ? " <time class=\"order-shipment__event-when\" datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") +
|
|
7222
|
+
"</li>";
|
|
7223
|
+
}).join("");
|
|
7224
|
+
return "<ol class=\"order-shipment__timeline\">" + rows + "</ol>";
|
|
7225
|
+
}
|
|
7226
|
+
|
|
7189
7227
|
// Render the shipment + carrier-tracking panel from order-tracking's
|
|
7190
7228
|
// listForOrder() rows. Each shipment shows its carrier, status, the
|
|
7191
7229
|
// tracking number (linked to the carrier's public tracking URL when one
|
|
7192
|
-
// is known), and the
|
|
7193
|
-
// render nothing so a digital
|
|
7230
|
+
// is known), and the FULL carrier-event timeline newest-first (bounded by
|
|
7231
|
+
// MAX_SHIPMENT_TIMELINE). Empty/absent shipments render nothing so a digital
|
|
7232
|
+
// or not-yet-shipped order shows no panel; a shipment with no events yet
|
|
7233
|
+
// shows just the carrier + status line (the pre-timeline shape).
|
|
7194
7234
|
function _orderTrackingBlock(shipments) {
|
|
7195
7235
|
if (!Array.isArray(shipments) || !shipments.length) return "";
|
|
7196
7236
|
var esc = b.template.escapeHtml;
|
|
@@ -7205,24 +7245,20 @@ function _orderTrackingBlock(shipments) {
|
|
|
7205
7245
|
"\" rel=\"noopener nofollow\" target=\"_blank\">" + esc(String(s.tracking_number)) + " ↗</a>"
|
|
7206
7246
|
: "<span class=\"order-shipment__track\">" + esc(String(s.tracking_number)) + "</span>";
|
|
7207
7247
|
}
|
|
7208
|
-
//
|
|
7209
|
-
//
|
|
7210
|
-
//
|
|
7248
|
+
// Full carrier-event timeline (newest-first, bounded). getShipment
|
|
7249
|
+
// hydrates the per-shipment events array oldest-first; the timeline
|
|
7250
|
+
// builder reverses + caps it. An eventless shipment renders no timeline,
|
|
7251
|
+
// so the card is the carrier + status line alone — identical to the
|
|
7252
|
+
// block before the timeline shipped.
|
|
7211
7253
|
var events = Array.isArray(s.events) ? s.events : [];
|
|
7212
|
-
var
|
|
7213
|
-
var latestHtml = latest
|
|
7214
|
-
? "<p class=\"order-shipment__event\">" +
|
|
7215
|
-
esc(String(latest.status)) +
|
|
7216
|
-
(latest.location ? " · " + esc(String(latest.location)) : "") +
|
|
7217
|
-
"</p>"
|
|
7218
|
-
: "";
|
|
7254
|
+
var timelineHtml = _shipmentTimeline(events);
|
|
7219
7255
|
return "<li class=\"order-shipment\">" +
|
|
7220
7256
|
"<div class=\"order-shipment__head\">" +
|
|
7221
7257
|
"<span class=\"order-shipment__carrier\">" + esc(String(carrier)) + "</span>" +
|
|
7222
7258
|
"<span class=\"pdp__badge\">" + esc(String(s.status)) + "</span>" +
|
|
7223
7259
|
"</div>" +
|
|
7224
7260
|
(trackingHtml ? "<p class=\"order-shipment__tracking\">Tracking: " + trackingHtml + "</p>" : "") +
|
|
7225
|
-
|
|
7261
|
+
timelineHtml +
|
|
7226
7262
|
"</li>";
|
|
7227
7263
|
}).join("");
|
|
7228
7264
|
return "<div class=\"order-tracking-panel\">" +
|
|
@@ -7783,6 +7819,62 @@ function _cartGiftBlock(opts) {
|
|
|
7783
7819
|
"</section>";
|
|
7784
7820
|
}
|
|
7785
7821
|
|
|
7822
|
+
// The cart-page coupon-code entry (CONTAINER-ONLY — like the gift block, it
|
|
7823
|
+
// is only reached for a cart WITH lines, which the edge never renders; the
|
|
7824
|
+
// edge cart is the cookie-less empty shell, see [[storefront-dual-render]]).
|
|
7825
|
+
// The form posts to /cart/coupon, which is NOT an EDGE_POST_PATHS prefix, so
|
|
7826
|
+
// _injectCsrfFields tokens it automatically — same posture as /cart/gift
|
|
7827
|
+
// (the closest sibling). It offers a single code input + Apply, lists each
|
|
7828
|
+
// already-applied code with a Remove control, and shows an inline PRG notice
|
|
7829
|
+
// (applied / removed / uniform error). The code echo is operator/shopper
|
|
7830
|
+
// free text → escaped at the sink. Returns "" when the coupon surface isn't
|
|
7831
|
+
// wired (no discount engine), so a store without discounts shows nothing.
|
|
7832
|
+
function _cartCouponBlock(opts) {
|
|
7833
|
+
if (!opts.coupon_enabled) return "";
|
|
7834
|
+
var esc = b.template.escapeHtml;
|
|
7835
|
+
var applied = Array.isArray(opts.applied_codes) ? opts.applied_codes : [];
|
|
7836
|
+
// Inline PRG notice — applied / removed succeed with a confirmation; an
|
|
7837
|
+
// error is the uniform "couldn't apply" (no oracle on why).
|
|
7838
|
+
var notice = "";
|
|
7839
|
+
if (opts.code_applied) {
|
|
7840
|
+
notice = "<p class=\"cart-coupon__notice cart-coupon__notice--ok\" role=\"status\">Discount code applied.</p>";
|
|
7841
|
+
} else if (opts.code_removed) {
|
|
7842
|
+
notice = "<p class=\"cart-coupon__notice cart-coupon__notice--ok\" role=\"status\">Discount code removed.</p>";
|
|
7843
|
+
} else if (opts.code_err) {
|
|
7844
|
+
notice = "<p class=\"cart-coupon__notice cart-coupon__notice--err\" role=\"status\">That code can't be applied to this cart.</p>";
|
|
7845
|
+
}
|
|
7846
|
+
// Already-applied codes, each with a Remove form (the code rides in a
|
|
7847
|
+
// hidden field; the value is escaped). Shopper-typed free text → esc().
|
|
7848
|
+
var appliedHtml = "";
|
|
7849
|
+
if (applied.length) {
|
|
7850
|
+
var items = applied.map(function (r) {
|
|
7851
|
+
var codeStr = String(r.code);
|
|
7852
|
+
return "<li class=\"cart-coupon__applied-item\">" +
|
|
7853
|
+
"<code class=\"cart-coupon__code\">" + esc(codeStr) + "</code>" +
|
|
7854
|
+
"<form method=\"post\" action=\"/cart/coupon/remove\" class=\"cart-coupon__remove\">" +
|
|
7855
|
+
"<input type=\"hidden\" name=\"code\" value=\"" + esc(codeStr) + "\">" +
|
|
7856
|
+
"<button type=\"submit\" class=\"cart-line__btn\">Remove</button>" +
|
|
7857
|
+
"</form>" +
|
|
7858
|
+
"</li>";
|
|
7859
|
+
}).join("");
|
|
7860
|
+
appliedHtml = "<ul class=\"cart-coupon__applied\">" + items + "</ul>";
|
|
7861
|
+
}
|
|
7862
|
+
return "<section class=\"cart-coupon\">" +
|
|
7863
|
+
"<details class=\"cart-coupon__details\"" + (applied.length || opts.code_err ? " open" : "") + ">" +
|
|
7864
|
+
"<summary class=\"cart-coupon__summary\">Have a discount code?</summary>" +
|
|
7865
|
+
notice +
|
|
7866
|
+
appliedHtml +
|
|
7867
|
+
"<form method=\"post\" action=\"/cart/coupon\" class=\"cart-coupon__form\">" +
|
|
7868
|
+
"<label class=\"form-field\"><span>Discount code</span>" +
|
|
7869
|
+
"<input type=\"text\" name=\"code\" autocomplete=\"off\" autocapitalize=\"characters\" " +
|
|
7870
|
+
"spellcheck=\"false\" maxlength=\"64\" placeholder=\"Enter code\">" +
|
|
7871
|
+
"</label>" +
|
|
7872
|
+
"<button type=\"submit\" class=\"btn-secondary\">Apply code</button>" +
|
|
7873
|
+
"</form>" +
|
|
7874
|
+
"</details>" +
|
|
7875
|
+
"</section>";
|
|
7876
|
+
}
|
|
7877
|
+
|
|
7786
7878
|
function renderCart(opts) {
|
|
7787
7879
|
if (!opts) throw new TypeError("storefront.renderCart: opts required");
|
|
7788
7880
|
var lines = opts.lines || [];
|
|
@@ -7932,6 +8024,11 @@ function renderCart(opts) {
|
|
|
7932
8024
|
// free text already escaped; appending — not String.replace — so a `$`
|
|
7933
8025
|
// in a wrap title can't trip dollar substitution).
|
|
7934
8026
|
body = body + _cartGiftBlock(opts);
|
|
8027
|
+
// CONTAINER-ONLY coupon-code entry, same placement + edge-safety
|
|
8028
|
+
// reasoning as the gift block (reached only for a cart with lines; the
|
|
8029
|
+
// code echo is escaped at the sink; appended, not String.replace'd, so a
|
|
8030
|
+
// `$` in a typed code can't trip dollar substitution).
|
|
8031
|
+
body = body + _cartCouponBlock(opts);
|
|
7935
8032
|
}
|
|
7936
8033
|
return _wrap(Object.assign({
|
|
7937
8034
|
title: "Cart",
|
|
@@ -11274,6 +11371,18 @@ function mount(router, deps) {
|
|
|
11274
11371
|
// subtotal-only breakdown with tax/shipping flagged unresolved — the
|
|
11275
11372
|
// subtotal is always honest, and the renderer labels the rest
|
|
11276
11373
|
// "calculated at checkout" rather than fabricating a number.
|
|
11374
|
+
// The discount codes a cart carries, as plain strings, for threading into
|
|
11375
|
+
// checkout.confirm / checkout.quote. Drop-silent: an unmigrated
|
|
11376
|
+
// cart_discount_codes table / un-wired method → [] (no codes, no
|
|
11377
|
+
// code-gated discount), never a throw on the buy path.
|
|
11378
|
+
async function _cartAppliedCodeStrings(cartId) {
|
|
11379
|
+
if (typeof deps.cart.listDiscountCodes !== "function") return [];
|
|
11380
|
+
try {
|
|
11381
|
+
var rows = await deps.cart.listDiscountCodes(cartId);
|
|
11382
|
+
return rows.map(function (r) { return r.code; });
|
|
11383
|
+
} catch (_e) { return []; }
|
|
11384
|
+
}
|
|
11385
|
+
|
|
11277
11386
|
async function _estimateCartTotals(req, c, lines, opts) {
|
|
11278
11387
|
opts = opts || {};
|
|
11279
11388
|
var base = pricing.totals(c, lines, {}); // subtotal-only, always valid
|
|
@@ -11299,6 +11408,10 @@ function mount(router, deps) {
|
|
|
11299
11408
|
var quote = await deps.checkout.quote({
|
|
11300
11409
|
cart_id: c.id,
|
|
11301
11410
|
ship_to: dest.ship_to,
|
|
11411
|
+
// Shopper-applied coupon codes so the estimate reflects a code-
|
|
11412
|
+
// gated discount (the same codes checkout.confirm honours). Absent
|
|
11413
|
+
// / empty → only pure-automatic rules, byte-identical to before.
|
|
11414
|
+
codes: Array.isArray(opts.codes) && opts.codes.length ? opts.codes : undefined,
|
|
11302
11415
|
});
|
|
11303
11416
|
var taxMinor = quote.totals.tax_minor;
|
|
11304
11417
|
result.tax_resolved = quote.tax_rate_bps > 0 ||
|
|
@@ -12133,6 +12246,17 @@ function mount(router, deps) {
|
|
|
12133
12246
|
var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
12134
12247
|
var added = (req.query && req.query.added === "1") ||
|
|
12135
12248
|
(cartUrl && cartUrl.searchParams.get("added") === "1") || false;
|
|
12249
|
+
// Coupon-entry PRG outcomes (set by POST /cart/coupon[/remove]). One of
|
|
12250
|
+
// applied / removed / err so the cart shows an inline notice. `?code_err`
|
|
12251
|
+
// carries no detail beyond "couldn't apply" — a uniform message, no
|
|
12252
|
+
// code-existence oracle.
|
|
12253
|
+
function _cartQp(name) {
|
|
12254
|
+
return (req.query && req.query[name] === "1") ||
|
|
12255
|
+
(cartUrl && cartUrl.searchParams.get(name) === "1") || false;
|
|
12256
|
+
}
|
|
12257
|
+
var codeApplied = _cartQp("code_applied");
|
|
12258
|
+
var codeRemoved = _cartQp("code_removed");
|
|
12259
|
+
var codeErr = _cartQp("code_err");
|
|
12136
12260
|
if (!sid) {
|
|
12137
12261
|
return _send(res, 200, renderCart(Object.assign({
|
|
12138
12262
|
lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
|
|
@@ -12153,12 +12277,23 @@ function mount(router, deps) {
|
|
|
12153
12277
|
// Recomputed every render (idempotent); the stored snapshot is never
|
|
12154
12278
|
// mutated, so changing a line's quantity re-prices it automatically.
|
|
12155
12279
|
var lines = await _repriceCartLines(rawLines);
|
|
12280
|
+
// Applied coupon codes — the strings the shopper entered on the cart
|
|
12281
|
+
// page, persisted on the cart. Threaded into the totals estimate so a
|
|
12282
|
+
// code-gated discount shows in the breakdown, and echoed in the coupon
|
|
12283
|
+
// block (with a remove control). Drop-silent: an unmigrated
|
|
12284
|
+
// cart_discount_codes table → no applied codes, no coupon discount.
|
|
12285
|
+
var appliedCodes = [];
|
|
12286
|
+
if (typeof deps.cart.listDiscountCodes === "function") {
|
|
12287
|
+
try { appliedCodes = await deps.cart.listDiscountCodes(c.id); }
|
|
12288
|
+
catch (_e) { appliedCodes = []; }
|
|
12289
|
+
}
|
|
12290
|
+
var appliedCodeStrings = appliedCodes.map(function (r) { return r.code; });
|
|
12156
12291
|
// Real total before pay: compose the same tax + shipping primitives the
|
|
12157
12292
|
// charge runs through (estimated against the shopper's saved/default
|
|
12158
12293
|
// destination until they confirm an address at checkout). Falls back to
|
|
12159
12294
|
// a subtotal-only breakdown — with tax/shipping labelled "calculated at
|
|
12160
12295
|
// checkout" — when checkout isn't wired or no zone matches.
|
|
12161
|
-
var totalsDetail = await _estimateCartTotals(req, c, lines, {});
|
|
12296
|
+
var totalsDetail = await _estimateCartTotals(req, c, lines, { codes: appliedCodeStrings });
|
|
12162
12297
|
var totals = totalsDetail.totals;
|
|
12163
12298
|
// Truthful per-line stock state (out / low / ok) so the cart never
|
|
12164
12299
|
// implies a sold-out line is buyable.
|
|
@@ -12218,6 +12353,15 @@ function mount(router, deps) {
|
|
|
12218
12353
|
gift_wraps: giftWraps,
|
|
12219
12354
|
gift_wrap_in_cart: giftWrapInCart,
|
|
12220
12355
|
added: added,
|
|
12356
|
+
// Coupon entry: surfaced only when the discount engine is wired (the
|
|
12357
|
+
// POST routes mount on the same condition). `applied_codes` echoes the
|
|
12358
|
+
// typed codes so each gets a remove control; the *_notice flags drive
|
|
12359
|
+
// the inline PRG banner.
|
|
12360
|
+
coupon_enabled: !!(deps.autoDiscount && typeof deps.cart.listDiscountCodes === "function"),
|
|
12361
|
+
applied_codes: appliedCodes,
|
|
12362
|
+
code_applied: codeApplied,
|
|
12363
|
+
code_removed: codeRemoved,
|
|
12364
|
+
code_err: codeErr,
|
|
12221
12365
|
shop_name: shopName,
|
|
12222
12366
|
theme: theme,
|
|
12223
12367
|
}, ccy)));
|
|
@@ -12590,12 +12734,16 @@ function mount(router, deps) {
|
|
|
12590
12734
|
} else {
|
|
12591
12735
|
defaultShipId = deps.default_shipping_id;
|
|
12592
12736
|
}
|
|
12737
|
+
var coCodes = await _cartAppliedCodeStrings(c.id);
|
|
12593
12738
|
var result = await deps.checkout.confirm({
|
|
12594
12739
|
cart_id: c.id,
|
|
12595
12740
|
ship_to: shipTo,
|
|
12596
12741
|
selected_shipping_id: defaultShipId || "std",
|
|
12597
12742
|
customer: { email: body.email, name: body.name },
|
|
12598
12743
|
gift_card_code: body.gift_card_code || undefined,
|
|
12744
|
+
// Shopper-applied coupon codes — honoured at charge time so the
|
|
12745
|
+
// order total matches the cart-page estimate.
|
|
12746
|
+
codes: coCodes.length ? coCodes : undefined,
|
|
12599
12747
|
loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
|
|
12600
12748
|
idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
|
|
12601
12749
|
});
|
|
@@ -12740,12 +12888,14 @@ function mount(router, deps) {
|
|
|
12740
12888
|
try {
|
|
12741
12889
|
var defaultShipId = typeof deps.default_shipping_id === "function"
|
|
12742
12890
|
? await deps.default_shipping_id() : deps.default_shipping_id;
|
|
12891
|
+
var ppCodes = await _cartAppliedCodeStrings(c.id);
|
|
12743
12892
|
var created = await deps.checkout.createPaypalOrder({
|
|
12744
12893
|
cart_id: c.id,
|
|
12745
12894
|
ship_to: shipTo,
|
|
12746
12895
|
selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
|
|
12747
12896
|
customer: { email: body.email, name: body.name },
|
|
12748
12897
|
gift_card_code: body.gift_card_code || undefined,
|
|
12898
|
+
codes: ppCodes.length ? ppCodes : undefined,
|
|
12749
12899
|
idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
|
|
12750
12900
|
return_url: body.return_url || undefined,
|
|
12751
12901
|
cancel_url: body.cancel_url || undefined,
|
|
@@ -17714,6 +17864,67 @@ function mount(router, deps) {
|
|
|
17714
17864
|
});
|
|
17715
17865
|
}
|
|
17716
17866
|
|
|
17867
|
+
// POST /cart/coupon — apply a shopper-typed discount code to the cart.
|
|
17868
|
+
// The code is validated server-side against the discount engine
|
|
17869
|
+
// (autoDiscount.ruleForCode): it must resolve to an active, code-gated
|
|
17870
|
+
// rule. An accepted code is persisted on the cart (cart.addDiscountCode)
|
|
17871
|
+
// so the cart totals re-render with the rule's savings and
|
|
17872
|
+
// checkout.confirm honours it. An unknown / malformed / un-applicable
|
|
17873
|
+
// code bounces back with ?code_err=1 and a UNIFORM message — no oracle on
|
|
17874
|
+
// whether the code exists. PRG: always a 303 to /cart. CSRF: /cart/coupon
|
|
17875
|
+
// is NOT an EDGE_POST_PATHS prefix, so _injectCsrfFields tokens the form +
|
|
17876
|
+
// the csrf gate checks it (same posture as /cart/gift). Mounts only when
|
|
17877
|
+
// the discount engine is wired.
|
|
17878
|
+
if (deps.autoDiscount && typeof deps.cart.addDiscountCode === "function") {
|
|
17879
|
+
router.post("/cart/coupon", async function (req, res) {
|
|
17880
|
+
var body = req.body || {};
|
|
17881
|
+
var typed = typeof body.code === "string" ? body.code.trim() : "";
|
|
17882
|
+
var dest = "/cart?code_err=1";
|
|
17883
|
+
try {
|
|
17884
|
+
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
17885
|
+
var cartId = resolved.cart.id;
|
|
17886
|
+
// ruleForCode returns null for anything that isn't an active, code-
|
|
17887
|
+
// gated rule (including a malformed code) — one uniform "no" answer.
|
|
17888
|
+
var rule = typed ? await deps.autoDiscount.ruleForCode(typed) : null;
|
|
17889
|
+
if (rule && rule.unlock_code) {
|
|
17890
|
+
// Persist the code as the operator authored it (canonical casing),
|
|
17891
|
+
// not as the shopper typed it, so the applied chip + the engine
|
|
17892
|
+
// lookup agree.
|
|
17893
|
+
await deps.cart.addDiscountCode(cartId, rule.unlock_code, rule.slug);
|
|
17894
|
+
dest = "/cart?code_applied=1";
|
|
17895
|
+
}
|
|
17896
|
+
} catch (e) {
|
|
17897
|
+
// A malformed code (TypeError from addDiscountCode's validator) or a
|
|
17898
|
+
// bad cart shape is the same uniform error — never a 500 on the cart.
|
|
17899
|
+
if (!(e instanceof TypeError)) throw e;
|
|
17900
|
+
dest = "/cart?code_err=1";
|
|
17901
|
+
}
|
|
17902
|
+
res.status(303);
|
|
17903
|
+
res.setHeader && res.setHeader("location", dest);
|
|
17904
|
+
return res.end ? res.end() : res.send("");
|
|
17905
|
+
});
|
|
17906
|
+
|
|
17907
|
+
// POST /cart/coupon/remove — drop one applied code. Idempotent: removing
|
|
17908
|
+
// a code that isn't on the cart still lands on the removed notice (no
|
|
17909
|
+
// oracle). Same CSRF posture as /cart/coupon.
|
|
17910
|
+
router.post("/cart/coupon/remove", async function (req, res) {
|
|
17911
|
+
var body = req.body || {};
|
|
17912
|
+
var typed = typeof body.code === "string" ? body.code.trim() : "";
|
|
17913
|
+
var dest = "/cart?code_removed=1";
|
|
17914
|
+
try {
|
|
17915
|
+
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
17916
|
+
if (typed) await deps.cart.removeDiscountCode(resolved.cart.id, typed);
|
|
17917
|
+
} catch (e) {
|
|
17918
|
+
if (!(e instanceof TypeError)) throw e;
|
|
17919
|
+
// A malformed code can't be on the cart anyway — treat as removed.
|
|
17920
|
+
dest = "/cart?code_removed=1";
|
|
17921
|
+
}
|
|
17922
|
+
res.status(303);
|
|
17923
|
+
res.setHeader && res.setHeader("location", dest);
|
|
17924
|
+
return res.end ? res.end() : res.send("");
|
|
17925
|
+
});
|
|
17926
|
+
}
|
|
17927
|
+
|
|
17717
17928
|
// POST /cart/bundle — add every member of a bundle to the cart at the
|
|
17718
17929
|
// bundle price, atomically. Reads `bundle_sku` from the form body.
|
|
17719
17930
|
// The price is recomputed server-side from the catalog + the bundle
|
package/package.json
CHANGED