@blamejs/blamejs-shop 0.3.67 → 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 +4 -0
- package/README.md +2 -1
- package/lib/admin.js +298 -1
- package/lib/asset-manifest.json +3 -3
- package/lib/auto-discount.js +104 -2
- package/lib/cart.js +104 -0
- package/lib/checkout.js +6 -2
- package/lib/storefront.js +382 -14
- package/package.json +1 -1
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,
|