@blamejs/blamejs-shop 0.3.68 → 0.3.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.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.
12
+
11
13
  - 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
14
 
13
15
  - 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. 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. |
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
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.68",
2
+ "version": "0.3.69",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -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, ?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 most-recent carrier event. Empty/absent shipments
7193
- // render nothing so a digital or not-yet-shipped order shows no panel.
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
- // Latest carrier event (events arrive oldest-first from listForOrder's
7209
- // getShipment ordering; the panel's per-shipment events array, when
7210
- // hydrated, is the same order take the last).
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 latest = events.length ? events[events.length - 1] : null;
7213
- var latestHtml = latest
7214
- ? "<p class=\"order-shipment__event\">" +
7215
- esc(String(latest.status)) +
7216
- (latest.location ? " &middot; " + 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
- latestHtml +
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.68",
3
+ "version": "0.3.69",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {