@blamejs/blamejs-shop 0.1.26 → 0.1.27
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 +2 -0
- package/README.md +4 -0
- package/SECURITY.md +14 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +125 -12
- package/lib/order.js +42 -0
- package/lib/storefront.js +373 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.27 (2026-05-25) — **Loyalty rewards go live — points earned on purchase, viewed in the account, and redeemed at checkout.** The loyalty points ledger now has a customer-facing surface. A new /account/loyalty page shows the signed-in customer's balance, tier, and earn/redeem activity, explains how points are earned, and lists the reward catalog with a one-click redeem. Paid orders award points automatically: the order primitive fans its paid transition into the earn rules (per-dollar-spent + per-order), fire-and-forget and deduplicated so a re-delivered payment webhook never double-credits. At checkout, a signed-in customer can spend points for a storefront credit against the order total — capped at the order's value and the balance, recorded once, with the same atomic double-spend guard the gift-card credit uses. **Added:** *Customer rewards page at /account/loyalty* — A login-gated page showing the points balance, current tier, lifetime points, and the cash value of the balance; how points are earned (the active earn rules in plain language); the reward catalog with a Redeem control per reward (disabled when the balance is short); the customer's past redemptions; and the earn/redeem ledger, paginated newest-first. Linked from the account dashboard as "Rewards". A redeem that the customer can't complete (not enough points, reward unavailable, per-customer limit reached) re-renders the page with the reason rather than erroring; a malformed history cursor degrades to the first page. · *Points earned automatically on paid orders* — When an order reaches paid, the configured earn rules award points to the customer's balance — per dollar of order subtotal and a flat per-order bonus, whichever rules the operator has defined. The award rides the order's paid transition (so every paid path — card, PayPal, gift-card-covered, admin — earns), runs fire-and-forget off the request path, and is keyed to the order so a re-delivered payment webhook collapses onto a single award instead of double-crediting. Guest orders (no customer account) earn nothing, by design. · *Redeem points at checkout as a credit* — A signed-in customer with a balance sees a "Redeem loyalty points" field on the checkout form. The points are valued via the ledger's redemption ratio (100 points = $1 by default), capped at the order total so the credit never exceeds what the order is worth, and the credit reduces the amount due. The order still records the full grand total it owed; the points are debited once, after the order row exists, behind the same balance-guarded SQL decrement that prevents a double-spend. Points worth more than the order spend only what the granted credit is worth — the surplus stays in the balance. A request for more points than the balance, or from a guest cart, is refused with a clean re-prompt. A points credit stacks with a gift-card credit; together they can pay an order in full with no card charge.
|
|
12
|
+
|
|
11
13
|
- v0.1.26 (2026-05-25) — **Fix the container build gate that rejected a manifest absent from the image.** The asset-integrity drift gate added last release checked both the container and edge copies of the manifest. The container image excludes the edge worker directory from its build context, so when the gate runs inside the image build it can't see the worker copy and read it as empty — failing the build. The gate now skips a manifest that isn't present in the current build context; its drift is still caught wherever the file exists (the full-tree CI run checks both). **Fixed:** *Asset-manifest drift gate no longer fails the container build* — `generate-asset-manifest.js --check` compared both manifest copies (`lib/` for the container, `worker/` for the edge) against a fresh rebuild. The container image's build context excludes the `worker/` tree, so inside the image the worker copy is absent and the gate read it as empty — reporting false drift and failing `node test/smoke.js`, which is a build gate, so the container image never built. The check now treats a manifest that isn't present in the current context as not-applicable and skips it (requiring at least one copy to be present), so the same drift detection still runs in CI and at the edge build where both files exist, without breaking the container image build.
|
|
12
14
|
|
|
13
15
|
- v0.1.25 (2026-05-25) — **Asset integrity and the cache-buster are correct in production now.** The renderers stamped a Subresource Integrity digest and a `?v=` cache-buster onto the theme stylesheet and island scripts by hashing the files at render time — which silently produced nothing in production, where the container image doesn't ship the theme files and the edge has no filesystem to read them. So the integrity attribute was absent live and edge-rendered pages pinned a `0.0.0` version that never changed between releases (a stylesheet update would render against a browser-cached copy). Both now come from a manifest built at release time and read directly by both runtimes. **Fixed:** *Subresource Integrity now applied on server-rendered pages* — The `integrity="sha384-…"` attribute on the default theme's stylesheet and island scripts was computed by reading the asset off disk during the render. That read fails in the deployed container (its image carries the code, not the `themes/` tree) and at the edge (no filesystem), so the attribute was silently dropped — the tamper-evidence it's meant to provide wasn't active. The digests are now computed once at release time into `lib/asset-manifest.json` (bundled for both the container and the edge worker) and read from there, so the browser actually verifies the served bytes against the pinned hash. · *Edge-rendered pages no longer pin a 0.0.0 cache-buster* — Pages rendered at the edge (home, product, search, the empty cart, blog) appended `?v=0.0.0` to the stylesheet URL because the worker had no release version to stamp — so the cache-buster never changed across deploys and a browser could keep serving an old stylesheet after a CSS update (markup against stale styling). The version now comes from the release manifest, so each release invalidates the cached asset as intended. The drift between the manifest and the on-disk assets is caught by a smoke gate (`generate-asset-manifest.js --check`).
|
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
68
68
|
| **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
|
|
69
69
|
| **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
|
|
70
70
|
| **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
|
|
71
|
+
| **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
|
|
71
72
|
| **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
|
|
72
73
|
| **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
|
|
73
74
|
| **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
|
|
@@ -102,6 +103,9 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
102
103
|
- `migrations-d1/0043_collections.sql` — manual + smart product collections (members + rules + sort strategy)
|
|
103
104
|
- `migrations-d1/0050_recently_viewed.sql` — per-customer / per-session product browse history (dedup + per-subject cap)
|
|
104
105
|
- `migrations-d1/0105_recommendations.sql` — operator-curated recommendation overrides (kind-scoped, weight-ordered)
|
|
106
|
+
- `migrations-d1/0022_loyalty.sql` — loyalty accounts (balance + lifetime + tier) + audited points transactions
|
|
107
|
+
- `migrations-d1/0085_loyalty_redemptions.sql` — reward catalog + per-redemption records (FSM: active → consumed / expired / cancelled)
|
|
108
|
+
- `migrations-d1/0163_loyalty_earn_rules.sql` — per-event earn rules + dedup-keyed award log
|
|
105
109
|
|
|
106
110
|
### Demo seed
|
|
107
111
|
|
package/SECURITY.md
CHANGED
|
@@ -160,3 +160,17 @@ node -e "
|
|
|
160
160
|
Redemption decrements with an atomic `balance >= amount` SQL guard
|
|
161
161
|
keyed on the order id, so concurrent or replayed checkouts can never
|
|
162
162
|
overdraw a card or apply more than the remaining balance.
|
|
163
|
+
- **Loyalty points are account-scoped money — earned and spent under
|
|
164
|
+
the same double-spend discipline.** The `/account/loyalty` page and
|
|
165
|
+
the redeem actions are login-gated and read the customer id from the
|
|
166
|
+
sealed session cookie, never from request input, so a customer only
|
|
167
|
+
ever sees and spends their own balance. Points earned on a paid order
|
|
168
|
+
are awarded fire-and-forget off the request path and deduped on the
|
|
169
|
+
order id (`loyalty_earn_log`'s `(rule, customer, event-ref)` UNIQUE),
|
|
170
|
+
so a re-delivered payment webhook can't double-credit. Spending points
|
|
171
|
+
— redeeming a reward or applying a checkout credit — debits through
|
|
172
|
+
the same atomic `balance >= points` SQL guard, so concurrent or
|
|
173
|
+
replayed requests can't overdraw. A checkout credit is capped at both
|
|
174
|
+
the order total and the live balance (a request for more than either
|
|
175
|
+
is refused, not silently clamped to a windfall), and a guest cart
|
|
176
|
+
with no account can't redeem at all.
|
package/lib/asset-manifest.json
CHANGED
package/lib/checkout.js
CHANGED
|
@@ -112,6 +112,15 @@ function create(deps) {
|
|
|
112
112
|
// either is absent — the rest of the flow is unchanged.
|
|
113
113
|
var giftcards = deps.giftcards || null;
|
|
114
114
|
var giftCardLedger = deps.giftCardLedger || null;
|
|
115
|
+
// Optional loyalty points credit. When wired, a signed-in customer
|
|
116
|
+
// can spend points at checkout for a storefront credit against the
|
|
117
|
+
// grand total. `loyalty.balance` reads the spendable balance;
|
|
118
|
+
// `loyalty.redeem` is the atomic debit (its `balance >= ?` SQL guard
|
|
119
|
+
// is the authoritative double-spend defense). The redemption value is
|
|
120
|
+
// derived from the ledger's own `REDEMPTION_POINTS_PER_USD` ratio so
|
|
121
|
+
// checkout never re-derives the conversion constant. Disabled when
|
|
122
|
+
// the handle is absent — the rest of the flow is unchanged.
|
|
123
|
+
var loyalty = deps.loyalty || null;
|
|
115
124
|
|
|
116
125
|
// Validate a gift-card code against a priced quote: the card exists,
|
|
117
126
|
// is active, not expired, and matches the order currency. Returns
|
|
@@ -194,6 +203,82 @@ function create(deps) {
|
|
|
194
203
|
return redemption;
|
|
195
204
|
}
|
|
196
205
|
|
|
206
|
+
// Resolve an optional loyalty points credit against a priced quote.
|
|
207
|
+
// `points` is the number of points the customer asked to spend;
|
|
208
|
+
// `customerId` is the signed-in customer's UUID (a guest cart has
|
|
209
|
+
// none — loyalty redemption needs an account, so a points request on
|
|
210
|
+
// a guest order is refused). Returns the credit to apply (the points
|
|
211
|
+
// converted to minor units via the ledger's redemption ratio, then
|
|
212
|
+
// capped at the grand total so a customer can't redeem more value
|
|
213
|
+
// than the order is worth) plus the integer points that credit
|
|
214
|
+
// actually consumes. Throws a structured (non-TypeError) error on a
|
|
215
|
+
// request that can't be honored so callers surface a clean re-prompt;
|
|
216
|
+
// returns null when no points were requested or loyalty isn't wired.
|
|
217
|
+
//
|
|
218
|
+
// Capping in points-space (not minor-units) matters: redeeming 250
|
|
219
|
+
// points worth $2.50 against a $1.00 order should debit only the 100
|
|
220
|
+
// points the $1.00 credit consumes, leaving the remaining 150 points
|
|
221
|
+
// in the balance — never silently burning the surplus.
|
|
222
|
+
async function _resolveLoyaltyCredit(points, customerId, quote) {
|
|
223
|
+
if (points == null) return null;
|
|
224
|
+
if (!Number.isInteger(points) || points <= 0) {
|
|
225
|
+
var bad = new Error("checkout: loyalty_redeem_points must be a positive integer");
|
|
226
|
+
bad.code = "LOYALTY_REDEEM_INVALID";
|
|
227
|
+
throw bad;
|
|
228
|
+
}
|
|
229
|
+
if (!loyalty) {
|
|
230
|
+
var unwired = new Error("checkout: loyalty redemption is not configured");
|
|
231
|
+
unwired.code = "LOYALTY_NOT_CONFIGURED";
|
|
232
|
+
throw unwired;
|
|
233
|
+
}
|
|
234
|
+
if (!customerId) {
|
|
235
|
+
var guest = new Error("checkout: sign in to redeem loyalty points");
|
|
236
|
+
guest.code = "LOYALTY_REQUIRES_ACCOUNT";
|
|
237
|
+
throw guest;
|
|
238
|
+
}
|
|
239
|
+
var perUsd = loyalty.REDEMPTION_POINTS_PER_USD;
|
|
240
|
+
var bal = await loyalty.balance(customerId);
|
|
241
|
+
if (bal.balance < points) {
|
|
242
|
+
var insuff = new Error("checkout: loyalty balance is " + bal.balance + ", cannot redeem " + points);
|
|
243
|
+
insuff.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
244
|
+
throw insuff;
|
|
245
|
+
}
|
|
246
|
+
// Value the requested points in minor units, then cap at the grand
|
|
247
|
+
// total. The applied minor-units are floored to a whole point's
|
|
248
|
+
// worth so the points actually debited map exactly to the credit
|
|
249
|
+
// granted (no fractional-point credit).
|
|
250
|
+
var grand = quote.totals.grand_total_minor;
|
|
251
|
+
var requestedMinor = Math.floor((points * 100) / perUsd);
|
|
252
|
+
var appliedMinor = requestedMinor < grand ? requestedMinor : grand;
|
|
253
|
+
if (appliedMinor <= 0) {
|
|
254
|
+
var tooFew = new Error("checkout: " + points + " points is worth less than the minimum redeemable credit");
|
|
255
|
+
tooFew.code = "LOYALTY_REDEEM_TOO_SMALL";
|
|
256
|
+
throw tooFew;
|
|
257
|
+
}
|
|
258
|
+
// Points the applied credit actually consumes — re-derived from the
|
|
259
|
+
// capped minor-units so a partial cap only spends the points the
|
|
260
|
+
// granted credit is worth.
|
|
261
|
+
var spentPoints = Math.ceil((appliedMinor * perUsd) / 100);
|
|
262
|
+
if (spentPoints > points) spentPoints = points; // floor/ceil guard — never spend more than asked
|
|
263
|
+
return { points: spentPoints, applied_minor: appliedMinor };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Burn the resolved loyalty credit against the created order. The
|
|
267
|
+
// `loyalty.redeem` decrement is the authoritative double-spend guard
|
|
268
|
+
// (its `balance_points >= ?` SQL predicate refuses an overdraw), and
|
|
269
|
+
// the `order_id` link records the redemption against the order in the
|
|
270
|
+
// loyalty audit trail. Called once per order, after the order row
|
|
271
|
+
// exists, so points are never spent for an order that failed to
|
|
272
|
+
// create.
|
|
273
|
+
async function _redeemLoyalty(resolved, customerId, orderId) {
|
|
274
|
+
await loyalty.redeem({
|
|
275
|
+
customer_id: customerId,
|
|
276
|
+
points: resolved.points,
|
|
277
|
+
order_id: orderId,
|
|
278
|
+
notes: "checkout-credit:" + orderId,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
197
282
|
// Compose a quote from a cart + ship-to + (optional) selected
|
|
198
283
|
// shipping service. Pure read — no DB writes.
|
|
199
284
|
async function _buildQuote(input) {
|
|
@@ -292,7 +377,27 @@ function create(deps) {
|
|
|
292
377
|
// credit reduces the amount due; the order still records the
|
|
293
378
|
// full grand total it owed.
|
|
294
379
|
var gc = await _resolveGiftCard(input.gift_card_code, quote);
|
|
295
|
-
|
|
380
|
+
// Loyalty points credit stacks on top of any gift-card credit —
|
|
381
|
+
// both reduce the same amount due. The loyalty credit is capped
|
|
382
|
+
// at the grand total inside the resolver, but since the gift card
|
|
383
|
+
// may already cover part of the order, cap the loyalty credit
|
|
384
|
+
// again at what's still due so the two credits together never
|
|
385
|
+
// drive the amount due below zero.
|
|
386
|
+
var cartRowForCustomer = await cart.get(quote.cart_id);
|
|
387
|
+
var loyaltyCustomerId = cartRowForCustomer ? (cartRowForCustomer.customer_id || null) : null;
|
|
388
|
+
var loy = await _resolveLoyaltyCredit(input.loyalty_redeem_points, loyaltyCustomerId, quote);
|
|
389
|
+
var afterGiftCard = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
|
|
390
|
+
if (loy && loy.applied_minor > afterGiftCard) {
|
|
391
|
+
// The gift card already covers more of the order than the
|
|
392
|
+
// points were valued against — re-cap the points credit to the
|
|
393
|
+
// residual so the two never overlap. Re-derive the spent points
|
|
394
|
+
// from the smaller credit so surplus points stay in the balance.
|
|
395
|
+
loy.applied_minor = afterGiftCard < 0 ? 0 : afterGiftCard;
|
|
396
|
+
var perUsd = loyalty.REDEMPTION_POINTS_PER_USD;
|
|
397
|
+
loy.points = Math.ceil((loy.applied_minor * perUsd) / 100);
|
|
398
|
+
if (loy.applied_minor <= 0) loy = null;
|
|
399
|
+
}
|
|
400
|
+
var amountDue = afterGiftCard - (loy ? loy.applied_minor : 0);
|
|
296
401
|
|
|
297
402
|
var orderLines = quote.lines.map(function (l) {
|
|
298
403
|
return {
|
|
@@ -303,15 +408,17 @@ function create(deps) {
|
|
|
303
408
|
unit_currency: l.unit_currency,
|
|
304
409
|
};
|
|
305
410
|
});
|
|
306
|
-
|
|
411
|
+
// Reuse the cart row already fetched for the loyalty
|
|
412
|
+
// customer-id lookup above — one read, not two.
|
|
413
|
+
var cartRow = cartRowForCustomer;
|
|
307
414
|
// Hash of the buyer email (same key as the customers table) so a
|
|
308
415
|
// later verified-email sign-in can claim this guest order.
|
|
309
416
|
var emailHash = customers ? customers.hashEmail(email) : null;
|
|
310
417
|
|
|
311
|
-
// Zero amount due —
|
|
312
|
-
// PaymentIntent (Stripe refuses a zero-amount intent);
|
|
313
|
-
// the order, burn the
|
|
314
|
-
// to paid via the FSM.
|
|
418
|
+
// Zero amount due — gift-card + loyalty credit fully cover the
|
|
419
|
+
// order. No PaymentIntent (Stripe refuses a zero-amount intent);
|
|
420
|
+
// create the order, burn the credits against it, and advance it
|
|
421
|
+
// straight to paid via the FSM.
|
|
315
422
|
if (amountDue === 0) {
|
|
316
423
|
var paidOrder = await order.createFromCart({
|
|
317
424
|
cart_id: quote.cart_id,
|
|
@@ -328,15 +435,17 @@ function create(deps) {
|
|
|
328
435
|
customer_email_hash: emailHash,
|
|
329
436
|
lines: orderLines,
|
|
330
437
|
});
|
|
331
|
-
await _redeemGiftCard(gc, paidOrder.id);
|
|
438
|
+
if (gc) await _redeemGiftCard(gc, paidOrder.id);
|
|
439
|
+
if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id);
|
|
332
440
|
await cart.setStatus(quote.cart_id, "converted");
|
|
333
441
|
var settled = await order.transition(paidOrder.id, "mark_paid", {
|
|
334
|
-
reason: "gift_card:full",
|
|
442
|
+
reason: loy && !gc ? "loyalty:full" : "gift_card:full",
|
|
335
443
|
});
|
|
336
444
|
return {
|
|
337
445
|
order: settled,
|
|
338
446
|
payment_intent: null,
|
|
339
|
-
gift_card: { applied_minor: gc.applied_minor, amount_due_minor: 0 },
|
|
447
|
+
gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: 0 } : null,
|
|
448
|
+
loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: 0 } : null,
|
|
340
449
|
};
|
|
341
450
|
}
|
|
342
451
|
|
|
@@ -354,6 +463,7 @@ function create(deps) {
|
|
|
354
463
|
tax_minor: String(quote.totals.tax_minor),
|
|
355
464
|
shipping_minor: String(quote.totals.shipping_minor),
|
|
356
465
|
gift_card_applied_minor: String(gc ? gc.applied_minor : 0),
|
|
466
|
+
loyalty_applied_minor: String(loy ? loy.applied_minor : 0),
|
|
357
467
|
},
|
|
358
468
|
}, idempotencyKey);
|
|
359
469
|
|
|
@@ -374,10 +484,12 @@ function create(deps) {
|
|
|
374
484
|
lines: orderLines,
|
|
375
485
|
});
|
|
376
486
|
|
|
377
|
-
// Burn the gift-card
|
|
378
|
-
// after the order row exists so a failed order never
|
|
379
|
-
//
|
|
487
|
+
// Burn the gift-card + loyalty credits against the created order.
|
|
488
|
+
// Runs after the order row exists so a failed order never spends
|
|
489
|
+
// either credit; each redeem decrement is its own double-spend
|
|
490
|
+
// guard.
|
|
380
491
|
if (gc) await _redeemGiftCard(gc, createdOrder.id);
|
|
492
|
+
if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id);
|
|
381
493
|
|
|
382
494
|
// Mark the cart converted so a refresh of the storefront
|
|
383
495
|
// doesn't accidentally re-quote the same cart.
|
|
@@ -391,6 +503,7 @@ function create(deps) {
|
|
|
391
503
|
status: pi.status,
|
|
392
504
|
},
|
|
393
505
|
gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null,
|
|
506
|
+
loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: amountDue } : null,
|
|
394
507
|
};
|
|
395
508
|
},
|
|
396
509
|
|
package/lib/order.js
CHANGED
|
@@ -141,6 +141,15 @@ function create(opts) {
|
|
|
141
141
|
// dependency is opt-in so existing callers (and the test suite)
|
|
142
142
|
// can run without standing up a webhooks primitive.
|
|
143
143
|
var webhooks = opts.webhooks || null;
|
|
144
|
+
// Optional loyalty earn dispatcher — when present, an order reaching
|
|
145
|
+
// `paid` fans out to the customer's loyalty balance via the earn
|
|
146
|
+
// rules (per_dollar_spent / per_purchase / first_purchase). Opt-in
|
|
147
|
+
// like `webhooks` so callers and the test suite can run without
|
|
148
|
+
// standing up a loyalty primitive. The award runs fire-and-forget on
|
|
149
|
+
// the same detached-promise discipline as the webhook fan-out: the
|
|
150
|
+
// transition has already persisted, so a loyalty read/write must not
|
|
151
|
+
// add latency to (or fail) the order transition.
|
|
152
|
+
var loyaltyEarnRules = opts.loyaltyEarnRules || null;
|
|
144
153
|
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
145
154
|
// b.pagination so an operator can't hand-craft one to skip past a
|
|
146
155
|
// hidden order or replay across deployments. The secret defaults
|
|
@@ -306,6 +315,39 @@ function create(opts) {
|
|
|
306
315
|
return webhooks.send("order." + event, _whPayload);
|
|
307
316
|
}).catch(function () { /* drop-silent — delivery rows hold the failure */ });
|
|
308
317
|
}
|
|
318
|
+
// Loyalty earn-on-purchase fan-out — fire-and-forget, same
|
|
319
|
+
// discipline as the webhook block. Only on the paid transition
|
|
320
|
+
// and only for an order that carries a customer_id (guest orders
|
|
321
|
+
// have no loyalty account to credit). The per-event dedup on
|
|
322
|
+
// `loyalty_earn_log` (rule_slug, customer_id, trigger_event_ref)
|
|
323
|
+
// collapses a re-delivered mark_paid (Stripe + PayPal webhook
|
|
324
|
+
// backstops both fire it) onto one award, so a retried transition
|
|
325
|
+
// never double-credits. dollars_spent is derived from the order
|
|
326
|
+
// subtotal (the revenue the customer actually spent on goods,
|
|
327
|
+
// excluding tax + shipping); per_purchase / first_purchase rules
|
|
328
|
+
// ignore it. The award is detached so a loyalty failure lives in
|
|
329
|
+
// the loyalty ledger's own audit trail, never as an
|
|
330
|
+
// unhandledRejection and never on the transition's latency.
|
|
331
|
+
if (loyaltyEarnRules && typeof loyaltyEarnRules.awardForEvent === "function"
|
|
332
|
+
&& result.to === "paid" && refreshed && refreshed.customer_id) {
|
|
333
|
+
var _loyCustomer = refreshed.customer_id;
|
|
334
|
+
var _loyOrderId = refreshed.id;
|
|
335
|
+
var _loyDollars = Math.floor((Number(refreshed.subtotal_minor) || 0) / 100);
|
|
336
|
+
Promise.resolve().then(function () {
|
|
337
|
+
return loyaltyEarnRules.awardForEvent({
|
|
338
|
+
trigger: "per_dollar_spent",
|
|
339
|
+
customer_id: _loyCustomer,
|
|
340
|
+
dollars_spent: _loyDollars,
|
|
341
|
+
trigger_event_ref: "order:" + _loyOrderId,
|
|
342
|
+
});
|
|
343
|
+
}).then(function () {
|
|
344
|
+
return loyaltyEarnRules.awardForEvent({
|
|
345
|
+
trigger: "per_purchase",
|
|
346
|
+
customer_id: _loyCustomer,
|
|
347
|
+
trigger_event_ref: "order:" + _loyOrderId,
|
|
348
|
+
});
|
|
349
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
350
|
+
}
|
|
309
351
|
return refreshed;
|
|
310
352
|
},
|
|
311
353
|
|
package/lib/storefront.js
CHANGED
|
@@ -1413,6 +1413,186 @@ function renderReturns(opts) {
|
|
|
1413
1413
|
});
|
|
1414
1414
|
}
|
|
1415
1415
|
|
|
1416
|
+
// Loyalty transaction-type pill — reuses the `pdp__badge` class the
|
|
1417
|
+
// theme already styles. The type is one of the ledger's closed enum
|
|
1418
|
+
// (earn / redeem / expire / adjust / tier-bonus).
|
|
1419
|
+
function _loyaltyTxBadge(type) {
|
|
1420
|
+
var esc = b.template.escapeHtml;
|
|
1421
|
+
return "<span class=\"pdp__badge loyalty-tx--" + esc(String(type)) + "\">" + esc(String(type)) + "</span>";
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Human label for a reward `kind` + its value payload. Keeps the
|
|
1425
|
+
// catalog row readable without leaking the raw value_json shape.
|
|
1426
|
+
function _loyaltyRewardValue(reward) {
|
|
1427
|
+
var v = reward.value_json || {};
|
|
1428
|
+
if (reward.kind === "discount_percent") return (Number(v.percent) || 0) + "% off";
|
|
1429
|
+
if (reward.kind === "discount_amount") return pricing.format(Number(v.amount_minor) || 0, "USD") + " off";
|
|
1430
|
+
if (reward.kind === "free_shipping") return "Free shipping";
|
|
1431
|
+
if (reward.kind === "free_product") return "Free product";
|
|
1432
|
+
return reward.kind;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Earn-rule trigger → customer-facing phrase. Operators see slugs;
|
|
1436
|
+
// customers see plain language. Unknown triggers fall back to the raw
|
|
1437
|
+
// trigger so a future enum addition still renders something.
|
|
1438
|
+
var LOYALTY_TRIGGER_LABELS = {
|
|
1439
|
+
per_dollar_spent: "per $1 spent",
|
|
1440
|
+
per_purchase: "per order",
|
|
1441
|
+
per_review: "per review you write",
|
|
1442
|
+
per_referral_redeemed: "per friend you refer",
|
|
1443
|
+
birthday: "on your birthday",
|
|
1444
|
+
signup_bonus: "when you sign up",
|
|
1445
|
+
first_purchase: "on your first order",
|
|
1446
|
+
abandoned_cart_recovered: "when you complete a saved cart",
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
// The signed-in customer's loyalty surface: balance + tier, how points
|
|
1450
|
+
// are earned (active earn rules), the reward catalog (redeem control
|
|
1451
|
+
// when wired), the earn/redeem ledger (paginated), and past
|
|
1452
|
+
// redemptions. Reuses the account/returns layout classes — no new CSS.
|
|
1453
|
+
function renderLoyalty(opts) {
|
|
1454
|
+
opts = opts || {};
|
|
1455
|
+
var esc = b.template.escapeHtml;
|
|
1456
|
+
var bal = opts.balance || { balance: 0, lifetime: 0, tier: "bronze" };
|
|
1457
|
+
var ratio = Number(opts.redemption_points_per_usd) || 100;
|
|
1458
|
+
|
|
1459
|
+
var notice = opts.notice
|
|
1460
|
+
? "<p class=\"form-notice" + (opts.notice_kind === "error" ? " form-notice--error" : "") + "\" role=\"alert\">" +
|
|
1461
|
+
esc(String(opts.notice)) + "</p>"
|
|
1462
|
+
: "";
|
|
1463
|
+
|
|
1464
|
+
// Stats strip — balance / tier / lifetime / spendable value.
|
|
1465
|
+
var spendableValue = pricing.format(Math.floor((Number(bal.balance) || 0) * 100 / ratio), "USD");
|
|
1466
|
+
var stats =
|
|
1467
|
+
"<dl class=\"account-dash__stats\">" +
|
|
1468
|
+
"<div><dt>Points balance</dt><dd>" + esc(String(Number(bal.balance) || 0)) + "</dd></div>" +
|
|
1469
|
+
"<div><dt>Tier</dt><dd>" + esc(String(bal.tier || "bronze")) + "</dd></div>" +
|
|
1470
|
+
"<div><dt>Lifetime points</dt><dd>" + esc(String(Number(bal.lifetime) || 0)) + "</dd></div>" +
|
|
1471
|
+
"<div><dt>Worth</dt><dd>" + esc(spendableValue) + "</dd></div>" +
|
|
1472
|
+
"</dl>";
|
|
1473
|
+
|
|
1474
|
+
// How points are earned.
|
|
1475
|
+
var rules = opts.earn_rules || [];
|
|
1476
|
+
var earnInner = "";
|
|
1477
|
+
for (var i = 0; i < rules.length; i += 1) {
|
|
1478
|
+
var rule = rules[i];
|
|
1479
|
+
var label = LOYALTY_TRIGGER_LABELS[rule.trigger] || rule.trigger;
|
|
1480
|
+
earnInner +=
|
|
1481
|
+
"<li class=\"return-card\"><div class=\"return-card__head\">" +
|
|
1482
|
+
"<span>" + esc(String(rule.points_per_unit)) + " points " + esc(label) + "</span>" +
|
|
1483
|
+
"</div></li>";
|
|
1484
|
+
}
|
|
1485
|
+
var earnSection = earnInner
|
|
1486
|
+
? "<h2 class=\"pdp__variants-title\">How you earn points</h2><ul class=\"return-list\">" + earnInner + "</ul>"
|
|
1487
|
+
: "<h2 class=\"pdp__variants-title\">How you earn points</h2>" +
|
|
1488
|
+
"<p class=\"return-empty\">Earn points on every order — your balance grows as you shop.</p>";
|
|
1489
|
+
|
|
1490
|
+
// Reward catalog + redeem control.
|
|
1491
|
+
var rewards = opts.rewards || [];
|
|
1492
|
+
var rewardSection = "";
|
|
1493
|
+
if (rewards.length) {
|
|
1494
|
+
var rewardItems = "";
|
|
1495
|
+
for (var r = 0; r < rewards.length; r += 1) {
|
|
1496
|
+
var rw = rewards[r];
|
|
1497
|
+
var affordable = (Number(bal.balance) || 0) >= Number(rw.point_cost);
|
|
1498
|
+
var action;
|
|
1499
|
+
if (opts.can_redeem) {
|
|
1500
|
+
action = "<form method=\"post\" action=\"/account/loyalty/redeem\">" +
|
|
1501
|
+
"<input type=\"hidden\" name=\"reward_slug\" value=\"" + esc(rw.slug) + "\">" +
|
|
1502
|
+
"<button type=\"submit\" class=\"btn-primary\"" + (affordable ? "" : " disabled") + ">" +
|
|
1503
|
+
(affordable ? "Redeem" : "Not enough points") + "</button></form>";
|
|
1504
|
+
} else {
|
|
1505
|
+
action = "";
|
|
1506
|
+
}
|
|
1507
|
+
rewardItems +=
|
|
1508
|
+
"<li class=\"return-card\"><div class=\"return-card__head\">" +
|
|
1509
|
+
"<span class=\"return-card__rma\">" + esc(rw.title) + "</span>" +
|
|
1510
|
+
"<span class=\"pdp__badge\">" + esc(_loyaltyRewardValue(rw)) + "</span>" +
|
|
1511
|
+
"</div>" +
|
|
1512
|
+
"<p class=\"return-card__meta\">" + esc(String(rw.point_cost)) + " points</p>" +
|
|
1513
|
+
action +
|
|
1514
|
+
"</li>";
|
|
1515
|
+
}
|
|
1516
|
+
rewardSection =
|
|
1517
|
+
"<h2 class=\"pdp__variants-title\">Redeem your points</h2>" +
|
|
1518
|
+
"<ul class=\"return-list\">" + rewardItems + "</ul>";
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Past redemptions.
|
|
1522
|
+
var reds = opts.redemptions || [];
|
|
1523
|
+
var redSection = "";
|
|
1524
|
+
if (reds.length) {
|
|
1525
|
+
var redItems = "";
|
|
1526
|
+
for (var d = 0; d < reds.length; d += 1) {
|
|
1527
|
+
var red = reds[d];
|
|
1528
|
+
var rdate = red.redeemed_at ? new Date(Number(red.redeemed_at)).toISOString().slice(0, 10) : "";
|
|
1529
|
+
redItems +=
|
|
1530
|
+
"<li class=\"return-card\"><div class=\"return-card__head\">" +
|
|
1531
|
+
"<span class=\"return-card__rma\">" + esc(red.reward_slug) + "</span>" +
|
|
1532
|
+
"<span class=\"pdp__badge loyalty-tx--" + esc(String(red.status)) + "\">" + esc(String(red.status)) + "</span>" +
|
|
1533
|
+
"</div>" +
|
|
1534
|
+
"<p class=\"return-card__meta\">" + esc(String(red.points_debited)) + " points" +
|
|
1535
|
+
(rdate ? " · <time datetime=\"" + esc(rdate) + "\">" + esc(rdate) + "</time>" : "") +
|
|
1536
|
+
(red.coupon_code ? " · code <code>" + esc(red.coupon_code) + "</code>" : "") +
|
|
1537
|
+
"</p></li>";
|
|
1538
|
+
}
|
|
1539
|
+
redSection = "<h2 class=\"pdp__variants-title\">Your redemptions</h2><ul class=\"return-list\">" + redItems + "</ul>";
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Earn/redeem ledger (paginated).
|
|
1543
|
+
var hist = opts.history || [];
|
|
1544
|
+
var histInner = "";
|
|
1545
|
+
for (var h = 0; h < hist.length; h += 1) {
|
|
1546
|
+
var tx = hist[h];
|
|
1547
|
+
var tdate = tx.occurred_at ? new Date(Number(tx.occurred_at)).toISOString().slice(0, 10) : "";
|
|
1548
|
+
var pts = Number(tx.points) || 0;
|
|
1549
|
+
var ptsStr = (pts > 0 ? "+" : "") + pts;
|
|
1550
|
+
histInner +=
|
|
1551
|
+
"<li class=\"return-card\"><div class=\"return-card__head\">" +
|
|
1552
|
+
_loyaltyTxBadge(tx.transaction_type) +
|
|
1553
|
+
"<span class=\"return-card__rma\">" + esc(ptsStr) + " points</span>" +
|
|
1554
|
+
"</div>" +
|
|
1555
|
+
"<p class=\"return-card__meta\">" + esc(String(tx.source || "")) +
|
|
1556
|
+
(tdate ? " · <time datetime=\"" + esc(tdate) + "\">" + esc(tdate) + "</time>" : "") +
|
|
1557
|
+
(tx.notes ? " · " + esc(String(tx.notes)) : "") +
|
|
1558
|
+
"</p></li>";
|
|
1559
|
+
}
|
|
1560
|
+
var historySection;
|
|
1561
|
+
if (histInner) {
|
|
1562
|
+
var more = opts.history_next_cursor != null
|
|
1563
|
+
? "<p class=\"loyalty-more\"><a class=\"btn-secondary\" href=\"/account/loyalty?cursor=" +
|
|
1564
|
+
esc(String(opts.history_next_cursor)) + "\">Older activity</a></p>"
|
|
1565
|
+
: "";
|
|
1566
|
+
historySection = "<h2 class=\"pdp__variants-title\">Activity</h2><ul class=\"return-list\">" + histInner + "</ul>" + more;
|
|
1567
|
+
} else {
|
|
1568
|
+
historySection = "<h2 class=\"pdp__variants-title\">Activity</h2>" +
|
|
1569
|
+
"<p class=\"return-empty\">No points activity yet. Place an order to start earning.</p>";
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
var body =
|
|
1573
|
+
"<section class=\"account-returns\">" +
|
|
1574
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1575
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
1576
|
+
"<li aria-current=\"page\">Rewards</li>" +
|
|
1577
|
+
"</ol></nav>" +
|
|
1578
|
+
"<h1 class=\"account-returns__title\">Rewards</h1>" +
|
|
1579
|
+
notice +
|
|
1580
|
+
stats +
|
|
1581
|
+
rewardSection +
|
|
1582
|
+
earnSection +
|
|
1583
|
+
redSection +
|
|
1584
|
+
historySection +
|
|
1585
|
+
"</section>";
|
|
1586
|
+
|
|
1587
|
+
return _wrap({
|
|
1588
|
+
title: "Rewards",
|
|
1589
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1590
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1591
|
+
theme_css: opts.theme_css,
|
|
1592
|
+
body: body,
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1416
1596
|
// Subscription status pill — mirrors `_returnStatusBadge`. The status
|
|
1417
1597
|
// string is one of Stripe's enum values (active, trialing, past_due,
|
|
1418
1598
|
// canceled, …), surfaced as a CSS-classed badge the theme can style.
|
|
@@ -1748,6 +1928,28 @@ var CHECKOUT_PAGE =
|
|
|
1748
1928
|
" </form>\n" +
|
|
1749
1929
|
"</section>\n";
|
|
1750
1930
|
|
|
1931
|
+
// Redeem-points-at-checkout field — appended to the checkout form for a
|
|
1932
|
+
// signed-in customer with a spendable balance. The customer types how
|
|
1933
|
+
// many points to spend; the server caps the credit at the order total
|
|
1934
|
+
// and at the balance (the `loyalty_redeem_points` field rides the same
|
|
1935
|
+
// POST as the gift-card code). The max attribute is advisory client-
|
|
1936
|
+
// side polish; the backend is authoritative.
|
|
1937
|
+
function _loyaltyCheckoutField(bal, perUsd) {
|
|
1938
|
+
var esc = b.template.escapeHtml;
|
|
1939
|
+
var points = Number(bal.balance) || 0;
|
|
1940
|
+
var ratio = Number(perUsd) || 100;
|
|
1941
|
+
// Minor-unit value of the full balance (points / ratio dollars), for
|
|
1942
|
+
// the helper line. Floored to a whole point's worth.
|
|
1943
|
+
var worth = pricing.format(Math.floor((points * 100) / ratio), "USD");
|
|
1944
|
+
return "<div class=\"form-row\"><label class=\"form-field\">" +
|
|
1945
|
+
"<span class=\"form-field__label\">Redeem loyalty points <span class=\"small\">(optional)</span></span>" +
|
|
1946
|
+
"<input type=\"number\" name=\"loyalty_redeem_points\" min=\"0\" step=\"1\" max=\"" + points + "\" " +
|
|
1947
|
+
"inputmode=\"numeric\" autocomplete=\"off\" placeholder=\"0\">" +
|
|
1948
|
+
"<span class=\"form-field__req\">You have " + esc(String(points)) + " points (worth " + esc(worth) +
|
|
1949
|
+
") · " + esc(String(ratio)) + " points = $1</span>" +
|
|
1950
|
+
"</label></div>";
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1751
1953
|
function renderCheckoutForm(opts) {
|
|
1752
1954
|
if (!opts) throw new TypeError("storefront.renderCheckoutForm: opts required");
|
|
1753
1955
|
var lines = opts.lines || [];
|
|
@@ -1764,6 +1966,19 @@ function renderCheckoutForm(opts) {
|
|
|
1764
1966
|
});
|
|
1765
1967
|
}
|
|
1766
1968
|
var body = _render(CHECKOUT_PAGE, { subtotal: subtotal });
|
|
1969
|
+
// Signed-in customer with a spendable points balance — surface a
|
|
1970
|
+
// redeem-at-checkout field. The block is appended as raw HTML (the
|
|
1971
|
+
// balance + value are numbers we control, the conversion ratio is the
|
|
1972
|
+
// ledger's own constant) so it slots into the existing form via a
|
|
1973
|
+
// small client island that copies the field into the POST. Rendered
|
|
1974
|
+
// only when there's a balance to spend; absent that the checkout is
|
|
1975
|
+
// unchanged for guests + zero-balance customers.
|
|
1976
|
+
if (opts.loyalty_balance && opts.loyalty_balance.balance > 0) {
|
|
1977
|
+
body = body.replace(
|
|
1978
|
+
"</form>",
|
|
1979
|
+
_loyaltyCheckoutField(opts.loyalty_balance, opts.loyalty_points_per_usd) + "</form>",
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1767
1982
|
// When PayPal is configured, append its button below the card form. The
|
|
1768
1983
|
// block is built as raw HTML (appended after the strict render) so the SDK
|
|
1769
1984
|
// script + handlers survive; the client-id is the only interpolation and is
|
|
@@ -2569,6 +2784,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
2569
2784
|
" <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
|
|
2570
2785
|
" <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
|
|
2571
2786
|
" <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
|
|
2787
|
+
" <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
|
|
2572
2788
|
" <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
|
|
2573
2789
|
" <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
|
|
2574
2790
|
" </div>\n" +
|
|
@@ -2694,6 +2910,20 @@ function mount(router, deps) {
|
|
|
2694
2910
|
res.end ? res.end(html) : res.send(html);
|
|
2695
2911
|
}
|
|
2696
2912
|
|
|
2913
|
+
// Defensive request-shape reader for the optional loyalty-points
|
|
2914
|
+
// redeem field on the checkout form. Returns undefined for missing /
|
|
2915
|
+
// empty / zero / non-numeric input (checkout treats undefined as "no
|
|
2916
|
+
// redemption"); a positive integer otherwise. The backend's
|
|
2917
|
+
// _resolveLoyaltyCredit is authoritative on balance + cap — this only
|
|
2918
|
+
// shapes the wire value so a blank field doesn't become a "0 points"
|
|
2919
|
+
// validation error.
|
|
2920
|
+
function _parseRedeemPoints(raw) {
|
|
2921
|
+
if (raw == null || raw === "") return undefined;
|
|
2922
|
+
var n = parseInt(String(raw), 10);
|
|
2923
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
2924
|
+
return n;
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2697
2927
|
// Cart-count read shared across every handler that wraps a page in
|
|
2698
2928
|
// the layout — the header's cart pill renders the count. Returns 0
|
|
2699
2929
|
// for visitors with no session cookie. Defined at the top of mount
|
|
@@ -3021,7 +3251,23 @@ function mount(router, deps) {
|
|
|
3021
3251
|
return res.end ? res.end() : res.send("");
|
|
3022
3252
|
}
|
|
3023
3253
|
var totals = pricing.totals(c, lines, {});
|
|
3024
|
-
|
|
3254
|
+
// Loyalty balance — only for a signed-in customer with loyalty
|
|
3255
|
+
// wired. A read failure (table not migrated) degrades to no
|
|
3256
|
+
// redeem field rather than 500-ing checkout.
|
|
3257
|
+
var loyaltyBalance = null;
|
|
3258
|
+
if (deps.loyalty) {
|
|
3259
|
+
var loyAuth = _currentCustomerEnv(req);
|
|
3260
|
+
if (loyAuth) {
|
|
3261
|
+
try { loyaltyBalance = await deps.loyalty.balance(loyAuth.customer_id); }
|
|
3262
|
+
catch (_e) { loyaltyBalance = null; }
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
_send(res, 200, renderCheckoutForm({
|
|
3266
|
+
lines: lines, totals: totals, shop_name: shopName, theme: theme,
|
|
3267
|
+
paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
|
|
3268
|
+
loyalty_balance: loyaltyBalance,
|
|
3269
|
+
loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
|
|
3270
|
+
}));
|
|
3025
3271
|
});
|
|
3026
3272
|
|
|
3027
3273
|
router.post("/checkout", async function (req, res) {
|
|
@@ -3063,6 +3309,7 @@ function mount(router, deps) {
|
|
|
3063
3309
|
selected_shipping_id: defaultShipId || "std",
|
|
3064
3310
|
customer: { email: body.email, name: body.name },
|
|
3065
3311
|
gift_card_code: body.gift_card_code || undefined,
|
|
3312
|
+
loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
|
|
3066
3313
|
idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
|
|
3067
3314
|
});
|
|
3068
3315
|
// When a gift card fully covered the order there's no Stripe
|
|
@@ -3083,11 +3330,13 @@ function mount(router, deps) {
|
|
|
3083
3330
|
res.setHeader && res.setHeader("location", "/pay/" + result.order.id);
|
|
3084
3331
|
return res.end ? res.end() : res.send("");
|
|
3085
3332
|
} catch (e) {
|
|
3086
|
-
// A bad customer input (malformed shape
|
|
3087
|
-
// customer
|
|
3088
|
-
// the gift-card errors carry a
|
|
3089
|
-
// code
|
|
3090
|
-
|
|
3333
|
+
// A bad customer input (malformed shape, a gift-card code, or a
|
|
3334
|
+
// loyalty-points request the customer entered that can't be
|
|
3335
|
+
// applied) is a 400, not a 500 — the gift-card errors carry a
|
|
3336
|
+
// GIFTCARD_* code and the loyalty errors a LOYALTY_* code so a
|
|
3337
|
+
// fat-fingered value re-prompts rather than 500-ing checkout.
|
|
3338
|
+
var code = (e && typeof e.code === "string") ? e.code : "";
|
|
3339
|
+
var clientErr = (e instanceof TypeError) || code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0;
|
|
3091
3340
|
res.status(clientErr ? 400 : 500);
|
|
3092
3341
|
var msg = (e && e.message) || "checkout failed";
|
|
3093
3342
|
return res.end ? res.end(msg) : res.send(msg);
|
|
@@ -4414,6 +4663,124 @@ function mount(router, deps) {
|
|
|
4414
4663
|
});
|
|
4415
4664
|
}
|
|
4416
4665
|
|
|
4666
|
+
// Loyalty — the signed-in customer's points balance + tier, the
|
|
4667
|
+
// earn/redeem ledger, how points are earned, and (when a reward
|
|
4668
|
+
// catalog + redemption primitive are wired) a redeem-a-reward
|
|
4669
|
+
// control. Login-gated; a read failure on any optional sub-read
|
|
4670
|
+
// degrades that section to empty rather than 500-ing the page.
|
|
4671
|
+
if (deps.loyalty) {
|
|
4672
|
+
function _loyaltyAuth(req, res) {
|
|
4673
|
+
var auth;
|
|
4674
|
+
try { auth = _currentCustomer(req); }
|
|
4675
|
+
catch (e) {
|
|
4676
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
4677
|
+
throw e;
|
|
4678
|
+
}
|
|
4679
|
+
if (!auth) {
|
|
4680
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
4681
|
+
res.end ? res.end() : res.send("");
|
|
4682
|
+
return null;
|
|
4683
|
+
}
|
|
4684
|
+
return auth;
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
// Build the full /account/loyalty render context for a customer.
|
|
4688
|
+
// `opts2.cursor` is the optional history-page cursor (epoch-ms).
|
|
4689
|
+
// Each optional read (earn rules, reward catalog, redemptions) is
|
|
4690
|
+
// best-effort so a not-migrated table degrades that panel rather
|
|
4691
|
+
// than the page.
|
|
4692
|
+
async function _loyaltyView(req, auth, opts2) {
|
|
4693
|
+
opts2 = opts2 || {};
|
|
4694
|
+
var bal = await deps.loyalty.balance(auth.customer_id);
|
|
4695
|
+
var hist = await deps.loyalty.history(auth.customer_id, { limit: 20, cursor: opts2.cursor });
|
|
4696
|
+
var rules = [];
|
|
4697
|
+
if (deps.loyaltyEarnRules) {
|
|
4698
|
+
try { rules = await deps.loyaltyEarnRules.listRules({ active_only: true, limit: 50 }); }
|
|
4699
|
+
catch (_e) { rules = []; }
|
|
4700
|
+
}
|
|
4701
|
+
var rewards = [];
|
|
4702
|
+
var redemptions = [];
|
|
4703
|
+
if (deps.loyaltyRedemption) {
|
|
4704
|
+
try { rewards = await deps.loyaltyRedemption.listRewards({ active_only: true, limit: 50 }); }
|
|
4705
|
+
catch (_e) { rewards = []; }
|
|
4706
|
+
try {
|
|
4707
|
+
var rpage = await deps.loyaltyRedemption.redemptionsForCustomer(auth.customer_id, { limit: 20 });
|
|
4708
|
+
redemptions = rpage.rows;
|
|
4709
|
+
} catch (_e) { redemptions = []; }
|
|
4710
|
+
}
|
|
4711
|
+
var cartCount = await _cartCountForReq(req);
|
|
4712
|
+
return {
|
|
4713
|
+
balance: bal,
|
|
4714
|
+
tiers: deps.loyalty.TIERS,
|
|
4715
|
+
tier_thresholds: deps.loyalty.TIER_THRESHOLDS,
|
|
4716
|
+
redemption_points_per_usd: deps.loyalty.REDEMPTION_POINTS_PER_USD,
|
|
4717
|
+
history: hist.rows,
|
|
4718
|
+
history_next_cursor: hist.next_cursor,
|
|
4719
|
+
earn_rules: rules,
|
|
4720
|
+
rewards: rewards,
|
|
4721
|
+
redemptions: redemptions,
|
|
4722
|
+
can_redeem: !!deps.loyaltyRedemption,
|
|
4723
|
+
notice: opts2.notice || null,
|
|
4724
|
+
notice_kind: opts2.notice_kind || null,
|
|
4725
|
+
shop_name: shopName,
|
|
4726
|
+
cart_count: cartCount,
|
|
4727
|
+
};
|
|
4728
|
+
}
|
|
4729
|
+
|
|
4730
|
+
router.get("/account/loyalty", async function (req, res) {
|
|
4731
|
+
var auth = _loyaltyAuth(req, res); if (!auth) return;
|
|
4732
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4733
|
+
var cursorRaw = url && url.searchParams.get("cursor");
|
|
4734
|
+
var cursor;
|
|
4735
|
+
if (cursorRaw != null && cursorRaw !== "") {
|
|
4736
|
+
var n = parseInt(cursorRaw, 10);
|
|
4737
|
+
// A malformed cursor degrades to the first page (the lib
|
|
4738
|
+
// would TypeError on a non-integer) — never 500 the page.
|
|
4739
|
+
cursor = Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
4740
|
+
}
|
|
4741
|
+
var view = await _loyaltyView(req, auth, { cursor: cursor });
|
|
4742
|
+
_send(res, 200, renderLoyalty(view));
|
|
4743
|
+
});
|
|
4744
|
+
|
|
4745
|
+
// Redeem a reward from the catalog. Mounts only when the
|
|
4746
|
+
// redemption primitive is wired. The reward debits points via the
|
|
4747
|
+
// composed loyalty ledger; insufficient balance / cap reached /
|
|
4748
|
+
// not-redeemable surface as a 400 re-render, never a 500.
|
|
4749
|
+
if (deps.loyaltyRedemption) {
|
|
4750
|
+
router.post("/account/loyalty/redeem", async function (req, res) {
|
|
4751
|
+
var auth = _loyaltyAuth(req, res); if (!auth) return;
|
|
4752
|
+
var body = req.body || {};
|
|
4753
|
+
var rewardSlug = body.reward_slug;
|
|
4754
|
+
try {
|
|
4755
|
+
await deps.loyaltyRedemption.redeemForCustomer({
|
|
4756
|
+
customer_id: auth.customer_id,
|
|
4757
|
+
reward_slug: rewardSlug,
|
|
4758
|
+
});
|
|
4759
|
+
} catch (e) {
|
|
4760
|
+
// Customer-facing refusals (TypeError on a bad slug,
|
|
4761
|
+
// LOYALTY_INSUFFICIENT_BALANCE, REWARD_NOT_REDEEMABLE,
|
|
4762
|
+
// REDEMPTION_CAP_REACHED) re-render the page with the
|
|
4763
|
+
// reason; anything else propagates as a 500.
|
|
4764
|
+
var code = (e && typeof e.code === "string") ? e.code : "";
|
|
4765
|
+
var clientErr = (e instanceof TypeError)
|
|
4766
|
+
|| code === "LOYALTY_INSUFFICIENT_BALANCE"
|
|
4767
|
+
|| code === "REWARD_NOT_REDEEMABLE"
|
|
4768
|
+
|| code === "REDEMPTION_CAP_REACHED";
|
|
4769
|
+
if (!clientErr) throw e;
|
|
4770
|
+
var msg;
|
|
4771
|
+
if (code === "LOYALTY_INSUFFICIENT_BALANCE") msg = "You don't have enough points for that reward yet.";
|
|
4772
|
+
else if (code === "REWARD_NOT_REDEEMABLE") msg = "That reward isn't available right now.";
|
|
4773
|
+
else if (code === "REDEMPTION_CAP_REACHED") msg = "You've reached the redemption limit for that reward.";
|
|
4774
|
+
else msg = "We couldn't redeem that reward — please pick one from the list.";
|
|
4775
|
+
var failView = await _loyaltyView(req, auth, { notice: msg, notice_kind: "error" });
|
|
4776
|
+
return _send(res, 400, renderLoyalty(failView));
|
|
4777
|
+
}
|
|
4778
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/loyalty");
|
|
4779
|
+
return res.end ? res.end() : res.send("");
|
|
4780
|
+
});
|
|
4781
|
+
}
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4417
4784
|
// Recently viewed — the signed-in customer's newest-first browse
|
|
4418
4785
|
// history. Views are recorded server-side on the (container-rendered)
|
|
4419
4786
|
// PDP; this surface lets the customer review + clear that history.
|
package/package.json
CHANGED