@ordergroove/offers 2.45.6 → 2.46.1-alpha-PR-1285-2.53

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.
@@ -10,15 +10,19 @@ import {
10
10
  AutoshipByDefaultState,
11
11
  AutoshipEligibleState,
12
12
  ConfigState,
13
+ Incentive,
13
14
  IncentiveObject,
14
15
  IncentivesState,
15
16
  NextUpcomingOrderState,
16
17
  OptedInState,
17
18
  OptedOutState,
18
19
  PrepaidShipmentsSelectedState,
20
+ PriceState,
21
+ ProductPlansState,
19
22
  ReceiveOfferPayload
20
23
  } from './types/reducer';
21
24
  import { EmptyObject } from './types/utility';
25
+ import { IncentiveDisplay, IncentivesDisplayEnhanced } from './types/api';
22
26
 
23
27
  export const optedin = (state: OptedInState = [], action): OptedInState => {
24
28
  switch (action.type) {
@@ -160,11 +164,28 @@ export const eligibilityGroups = (state = {}, action) => {
160
164
  }
161
165
  };
162
166
 
163
- const mapIncentive = (incentive, incentiveDisplay) => {
164
- return incentive.map(i => ({
165
- ...incentiveDisplay[i],
166
- id: [i][0]
167
- }));
167
+ const mapIncentive = (
168
+ incentive: string[],
169
+ incentiveDisplay: IncentiveDisplay,
170
+ incentiveDisplayEnhanced?: IncentivesDisplayEnhanced
171
+ ): Incentive[] => {
172
+ return incentive.map(i => {
173
+ const enhanced = incentiveDisplayEnhanced?.[i];
174
+ return {
175
+ ...incentiveDisplay[i],
176
+ // for standard incentives, include the criteria so we know which kind of incentive (e.g. PSI, prepaid, etc)
177
+ ...(enhanced
178
+ ? {
179
+ criteria: enhanced.criteria
180
+ ? enhanced.criteria
181
+ : // when there is no criteria in the enhanced incentive, it means it's a program wide incentive
182
+ // for ease-of-use, we set use a "PROGRAM_WIDE" pseudo-standard here
183
+ { node_type: 'PREMISE', standard: constants.INCENTIVE_STANDARD_TYPES.PROGRAM_WIDE, premise_value: null }
184
+ }
185
+ : {}),
186
+ id: [i][0]
187
+ };
188
+ });
168
189
  };
169
190
 
170
191
  export const incentives = (
@@ -185,11 +206,19 @@ export const incentives = (
185
206
  ...incentiveObj,
186
207
  initial: [
187
208
  ...(incentiveObj.initial || []),
188
- ...mapIncentive(initial, action.payload.incentives_display)
209
+ ...mapIncentive(
210
+ initial,
211
+ action.payload.incentives_display,
212
+ action.payload.incentives_display_enhanced
213
+ )
189
214
  ],
190
215
  ongoing: [
191
216
  ...(incentiveObj.ongoing || []),
192
- ...mapIncentive(ongoing, action.payload.incentives_display)
217
+ ...mapIncentive(
218
+ ongoing,
219
+ action.payload.incentives_display,
220
+ action.payload.incentives_display_enhanced
221
+ )
193
222
  ]
194
223
  }),
195
224
  {}
@@ -501,7 +530,7 @@ export const templates = (state = [], action) => {
501
530
  }
502
531
  };
503
532
 
504
- export const productPlans = (state = {}, action) => {
533
+ export const productPlans = (state: ProductPlansState = {}, action): ProductPlansState => {
505
534
  switch (action.type) {
506
535
  case constants.RECEIVE_PRODUCT_PLANS:
507
536
  return getObjectStructuredProductPlans(action.payload);
@@ -533,6 +562,8 @@ export const prepaidShipmentsSelected = (
533
562
  }
534
563
  };
535
564
 
565
+ export const price = (state: PriceState = {}, _action) => state;
566
+
536
567
  export default combineReducers({
537
568
  optedin,
538
569
  optedout,
@@ -561,5 +592,6 @@ export default combineReducers({
561
592
  defaultFrequencies,
562
593
  templates,
563
594
  productPlans,
564
- prepaidShipmentsSelected
595
+ prepaidShipmentsSelected,
596
+ price
565
597
  });
@@ -3,7 +3,9 @@ import memoize from 'lodash.memoize';
3
3
  import { stringifyFrequency } from './api';
4
4
  import platform from '../platform';
5
5
  import { mapFrequencyToSellingPlan, safeProductId } from './utils';
6
- import { OfferElement, ProductFrequencyConfig, State } from './types/reducer';
6
+ import { Incentive, OfferElement, ProductFrequencyConfig, State } from './types/reducer';
7
+ import { money, percentage } from '../shopify/utils';
8
+ import { INCENTIVE_STANDARD_TYPES } from './constants';
7
9
 
8
10
  memoize.Cache = Map;
9
11
 
@@ -230,6 +232,59 @@ export const makeFrequencyForPrepaidShipmentsSelector = (product: BaseProduct, p
230
232
  }
231
233
  );
232
234
 
235
+ /** Determine the discounted price of the product, based on the incentives returned from the Offers endpoint. This assumes a pay-as-you-go subscription. */
236
+ export const makeDiscountedProductPriceSelector = memoize((productId: string) =>
237
+ createSelector(
238
+ (state: State) => state.price || {},
239
+ (state: State) => state.incentives || {},
240
+ (state: State) => state.config.storeCurrency,
241
+ (prices, incentives, currency) => {
242
+ const productPriceObj = prices[safeProductId(productId)];
243
+ if (productPriceObj === undefined || productPriceObj === null || !currency) return {};
244
+
245
+ const productPrice = productPriceObj.value;
246
+ let regularPrice = productPrice;
247
+ let subscriptionPrice = productPrice;
248
+
249
+ const productIncentives = incentives[safeProductId(productId)];
250
+ const incentive = productIncentives?.initial.find(findRelevantIncentive);
251
+
252
+ let formatted_discount = '';
253
+
254
+ if (incentive) {
255
+ if (incentive.type === 'Discount Percent') {
256
+ // note: productPrice is in cents ($10 => 1000), so we round to the nearest whole number after applying the discount
257
+ subscriptionPrice = Math.round((productPrice * (100 - incentive.value)) / 100);
258
+ formatted_discount = percentage(incentive.value);
259
+ } else if (incentive.type === 'Discount Amount' && currency === 'USD') {
260
+ // for now, we only support USD for "dollar-off" discounts
261
+ // productPrice is in cents, while the incentive value is in dollars, so we multiply by 100
262
+ subscriptionPrice = Math.max(0, productPrice - Math.round(incentive.value * 100));
263
+ }
264
+ }
265
+ return {
266
+ regularPrice: money(regularPrice, currency),
267
+ subscriptionPrice: money(subscriptionPrice, currency),
268
+ discountRate: formatted_discount || money(regularPrice - subscriptionPrice, currency)
269
+ };
270
+ }
271
+ )
272
+ );
273
+
274
+ const validIncentiveStandards = [INCENTIVE_STANDARD_TYPES.PROGRAM_WIDE, INCENTIVE_STANDARD_TYPES.PSI];
275
+
276
+ function findRelevantIncentive(incentive: Incentive) {
277
+ return (
278
+ incentive.object === 'item' &&
279
+ (incentive.type === 'Discount Percent' || incentive.type === 'Discount Amount') &&
280
+ // only attempt to determine a discount if the incentive is standardized, i.e. we have a criteria object
281
+ incentive.criteria &&
282
+ // note: the API should return either a PSI or a program-wide, not both
283
+ incentive.criteria.node_type === 'PREMISE' &&
284
+ validIncentiveStandards.includes(incentive.criteria.standard)
285
+ );
286
+ }
287
+
233
288
  /**
234
289
  * Convert a string from camel case to kebab case.
235
290
  */
@@ -246,3 +301,13 @@ export const getFallbackValue = (element: HTMLElement & { offer: OfferElement },
246
301
  * Returns a list of opted in products id from the state
247
302
  */
248
303
  export const templatesSelector = (state: State) => ({ templates: state.templates || [] });
304
+
305
+ /**
306
+ * Returns true if no selling plan has price adjustments (except for prepaid, which still use price adjustments). This means that we are calculating the subscription discount using a Shopify Discount Function instead of that information being stored in the selling plan.
307
+ * Generally, the Shopify Discount Function is used when the merchant is using standard flex incentives, i.e. the offer profile is standardized
308
+ */
309
+ export const isShopifyDiscountFunctionInUseSelector = (state: State) => {
310
+ const plans = Object.values(state.productPlans).flat();
311
+
312
+ return plans.length > 0 && plans.every(plan => plan.hasPriceAdjustments === false || plan.prepaidShipments);
313
+ };
@@ -32,6 +32,19 @@ export type Incentive = {
32
32
  value: number;
33
33
  };
34
34
 
35
+ export type StandardIncentive = {
36
+ incentive_target: string;
37
+ incentive_type: string;
38
+ incentive_value: string;
39
+ threshold_field: string | null;
40
+ threshold_value: string | null;
41
+ criteria?: {
42
+ node_type: string;
43
+ premise_value: unknown;
44
+ standard: string;
45
+ };
46
+ };
47
+
35
48
  type ExperimentVariant = {
36
49
  public_id: string;
37
50
  parameters: any;
@@ -67,5 +80,10 @@ export type OfferResponse = {
67
80
  initial: string[];
68
81
  }
69
82
  >;
70
- incentives_display: Record<string, Incentive>;
83
+ incentives_display: IncentiveDisplay;
84
+ // only present for standardized offer profiles
85
+ incentives_display_enhanced?: IncentivesDisplayEnhanced;
71
86
  };
87
+
88
+ export type IncentiveDisplay = Record<string, Incentive>;
89
+ export type IncentivesDisplayEnhanced = Record<string, StandardIncentive>;
@@ -2,6 +2,7 @@ import { Order, Incentive as ApiIncentive, MerchantSettings, OfferResponse } fro
2
2
  import { type Offer } from '../../components/Offer';
3
3
  import { ShopifyCart, ShopifyProductEntity } from '../../shopify/types/shopify';
4
4
  import reducer from '../reducer';
5
+ import { ProductPlanEntity } from '../../shopify/types/productPlan';
5
6
 
6
7
  export type State = ReturnType<typeof reducer>;
7
8
 
@@ -16,8 +17,16 @@ export type NextUpcomingOrderState = Partial<
16
17
 
17
18
  export type IncentivesState = Record<string, IncentiveObject>;
18
19
 
19
- type Incentive = ApiIncentive & {
20
+ export type Incentive = ApiIncentive & {
20
21
  id: string;
22
+ /**
23
+ * undefined when the offer profile is not standardized
24
+ */
25
+ criteria?: {
26
+ node_type: string;
27
+ premise_value: unknown;
28
+ standard: string;
29
+ };
21
30
  };
22
31
 
23
32
  export type IncentiveObject = {
@@ -68,6 +77,10 @@ export type OptedOutState = { id: string }[];
68
77
 
69
78
  export type AutoshipByDefaultState = Record<string, boolean>;
70
79
 
80
+ export type PriceState = { [productId: string]: { value: number } };
81
+
82
+ export type ProductPlansState = { [productId: string]: ProductPlanEntity[] };
83
+
71
84
  // payload types
72
85
 
73
86
  export type ReceiveOfferPayload = OfferResponse & {
@@ -308,7 +308,7 @@ describe('Shopify productPlan Reducer', () => {
308
308
 
309
309
  const productPlanCreated = mapSellingPlanToDiscount(allocation, [], 'USD');
310
310
 
311
- expect(productPlanCreated).toEqual(expectedProductPlan);
311
+ expect(productPlanCreated).toEqual(jasmine.objectContaining(expectedProductPlan));
312
312
  });
313
313
 
314
314
  it('should create prepaid product plan', () => {
@@ -371,7 +371,7 @@ describe('Shopify productPlan Reducer', () => {
371
371
 
372
372
  const productPlanCreated = mapSellingPlanToDiscount(allocation, sellingPlans, 'USD');
373
373
 
374
- expect(productPlanCreated).toEqual(expectedProductPlan);
374
+ expect(productPlanCreated).toEqual(jasmine.objectContaining(expectedProductPlan));
375
375
  });
376
376
 
377
377
  it('should create prepaid product plan that rounds subscriptionPrice to nearest decimal', () => {
@@ -512,7 +512,7 @@ describe('Shopify productPlan Reducer', () => {
512
512
 
513
513
  const productPlanCreated = mapSellingPlanToDiscount(allocation, sellingPlans, 'USD');
514
514
 
515
- expect(productPlanCreated).toEqual(expectedProductPlan);
515
+ expect(productPlanCreated).toEqual(jasmine.objectContaining(expectedProductPlan));
516
516
  });
517
517
  });
518
518
 
@@ -7,7 +7,12 @@ import {
7
7
  PRODUCT_CHANGE_PREPAID_SHIPMENTS,
8
8
  SETUP_CART
9
9
  } from '../../core/constants';
10
- import { synchronizeCartOptin, getTrackingEvent, guessProductHandle } from '../shopifyMiddleware';
10
+ import {
11
+ synchronizeCartOptin,
12
+ getTrackingEvent,
13
+ guessProductHandle,
14
+ synchronizeSellingPlan
15
+ } from '../shopifyMiddleware';
11
16
  import { getOrCreateHidden } from '../../core/utils';
12
17
 
13
18
  function makeForm(addInput = true) {
@@ -92,7 +97,7 @@ describe('getTrackingEvent', () => {
92
97
  });
93
98
 
94
99
  describe('synchronizeCartOptin', () => {
95
- let store, offer, frequency, product;
100
+ let store, offer, frequency, product, defaultState;
96
101
  beforeEach(() => {
97
102
  store = {
98
103
  dispatch: jasmine.createSpy('dispatch'),
@@ -106,6 +111,23 @@ describe('synchronizeCartOptin', () => {
106
111
  frequency = '1234';
107
112
  product = { id: '38995975209111:original-hash' };
108
113
 
114
+ defaultState = {
115
+ optedin: [],
116
+ offerId: 'offer-id-1234',
117
+ productPlans: {
118
+ 38995975209111: [
119
+ {
120
+ frequency: '688815178030'
121
+ },
122
+ {
123
+ frequency: '1234'
124
+ }
125
+ ]
126
+ }
127
+ };
128
+
129
+ store.getState.and.returnValue(defaultState);
130
+
109
131
  fetchMock.route(
110
132
  '/cart.js',
111
133
  {
@@ -134,12 +156,64 @@ describe('synchronizeCartOptin', () => {
134
156
  { method: 'POST', repeat: 1 }
135
157
  );
136
158
 
159
+ fetchMock.route(
160
+ '/cart/update.js',
161
+ {
162
+ attributes: {}
163
+ },
164
+ { method: 'POST', repeat: 1 }
165
+ );
166
+
137
167
  fetchMock.mockGlobal();
168
+
169
+ // mock the date so that the tracking key is predictable
170
+ jasmine.clock().install();
171
+ jasmine.clock().mockDate(new Date('2025-01-01T00:00:00.000Z'));
138
172
  });
139
173
 
140
174
  afterEach(() => {
141
175
  fetchMock.removeRoutes().unmockGlobal();
142
176
  offer.remove();
177
+
178
+ jasmine.clock().uninstall();
179
+ });
180
+
181
+ it('should update cart attributes', async () => {
182
+ await synchronizeCartOptin({ type: OPTIN_PRODUCT, payload: { offer, frequency, product } }, store);
183
+
184
+ const lastUpdateCall = fetchMock.callHistory.calls('/cart/update.js').at(-1);
185
+ const body = JSON.parse(lastUpdateCall.options.body);
186
+
187
+ expect(body).toEqual({
188
+ attributes: {
189
+ og__1735689600: '38995975209111:original-hash,optin_product,,1234,'
190
+ }
191
+ });
192
+ });
193
+
194
+ it('should update cart attributes when Shopify Discount Function in use', async () => {
195
+ store.getState.and.returnValue({
196
+ ...defaultState,
197
+ productPlans: {
198
+ 38995975209111: [
199
+ {
200
+ frequency: '688815178030',
201
+ hasPriceAdjustments: false
202
+ }
203
+ ]
204
+ }
205
+ });
206
+ await synchronizeCartOptin({ type: OPTIN_PRODUCT, payload: { offer, frequency, product } }, store);
207
+
208
+ const lastUpdateCall = fetchMock.callHistory.calls('/cart/update.js').at(-1);
209
+ const body = JSON.parse(lastUpdateCall.options.body);
210
+
211
+ expect(body).toEqual({
212
+ attributes: {
213
+ og__1735689600: '38995975209111:original-hash,optin_product,,1234,',
214
+ __ordergroove_offer_id: 'offer-id-1234'
215
+ }
216
+ });
143
217
  });
144
218
 
145
219
  it('should set new has product id', async () => {
@@ -173,7 +247,9 @@ describe('synchronizeCartOptin', () => {
173
247
  document.body.appendChild(sectionDiv);
174
248
 
175
249
  await synchronizeCartOptin({ type: OPTIN_PRODUCT, payload: { offer, frequency, product } }, store);
176
- expect(fetchMock.callHistory.lastCall().options.body).toContain('"sections":["123456789__cart-items"]');
250
+ expect(fetchMock.callHistory.calls('/cart/change.js').at(-1).options.body).toContain(
251
+ '"sections":["123456789__cart-items"]'
252
+ );
177
253
  sectionDiv.remove();
178
254
  });
179
255
 
@@ -185,12 +261,15 @@ describe('synchronizeCartOptin', () => {
185
261
  document.body.appendChild(sectionDiv);
186
262
 
187
263
  await synchronizeCartOptin({ type: OPTIN_PRODUCT, payload: { offer, frequency, product } }, store);
188
- expect(fetchMock.callHistory.lastCall().options.body).toContain('"sections":["123456789__cart-footer"]');
264
+ expect(fetchMock.callHistory.calls('/cart/change.js').at(-1).options.body).toContain(
265
+ '"sections":["123456789__cart-footer"]'
266
+ );
189
267
  sectionDiv.remove();
190
268
  });
191
269
 
192
270
  it('should get the subscribed frequency when no frequency is provided in the action payload', async () => {
193
271
  store.getState.and.returnValue({
272
+ ...defaultState,
194
273
  optedin: [
195
274
  {
196
275
  id: '38995975209111:original-hash',
@@ -223,11 +302,14 @@ describe('synchronizeCartOptin', () => {
223
302
  }
224
303
  });
225
304
 
226
- expect(fetchMock.callHistory.lastCall().options.body).toContain('"selling_plan":"688815178030"');
305
+ expect(fetchMock.callHistory.calls('/cart/change.js').at(-1).options.body).toContain(
306
+ '"selling_plan":"688815178030"'
307
+ );
227
308
  });
228
309
 
229
310
  it('should have no selling plan when product is opted out', async () => {
230
311
  store.getState.and.returnValue({
312
+ ...defaultState,
231
313
  optedin: []
232
314
  });
233
315
 
@@ -254,7 +336,7 @@ describe('synchronizeCartOptin', () => {
254
336
  }
255
337
  });
256
338
 
257
- expect(fetchMock.callHistory.lastCall().options.body).toContain('"selling_plan":null');
339
+ expect(fetchMock.callHistory.calls('/cart/change.js').at(-1).options.body).toContain('"selling_plan":null');
258
340
  });
259
341
  });
260
342
 
@@ -291,3 +373,142 @@ describe('guessProductHandle', () => {
291
373
  expect(guessProductHandle()).toEqual('some-meta-json');
292
374
  });
293
375
  });
376
+
377
+ describe('synchronizeSellingPlan', () => {
378
+ const testProduct = '123456';
379
+ const testSellingPlan = '688815178030';
380
+ const testSessionId = 'session-123';
381
+ const testOfferId = 'offer-id-1234';
382
+
383
+ function makeCartAddForm(productId = testProduct) {
384
+ const element = document.createElement('form');
385
+ element.action = '/cart/add';
386
+ element.innerHTML = `
387
+ <input value="${productId}" name="id" type="hidden">
388
+ `;
389
+ document.body.appendChild(element);
390
+ return element;
391
+ }
392
+
393
+ function getFormData(formElement) {
394
+ const data = Object.fromEntries(new FormData(formElement));
395
+ delete data.id;
396
+ return data;
397
+ }
398
+
399
+ let store;
400
+ let offerElement = {
401
+ isCart: false,
402
+ shouldEnableOffer: true
403
+ };
404
+
405
+ beforeEach(() => {
406
+ document.body.innerHTML = '';
407
+ store = {
408
+ getState: jasmine.createSpy('getState')
409
+ };
410
+ store.getState.and.returnValue({
411
+ sessionId: testSessionId,
412
+ optedin: [{ id: testProduct, frequency: testSellingPlan }],
413
+ productPlans: {},
414
+ offerId: testOfferId
415
+ });
416
+ });
417
+
418
+ afterEach(() => {
419
+ document.body.innerHTML = '';
420
+ });
421
+
422
+ it('should add attributes when product is opted in', () => {
423
+ const formElement = makeCartAddForm();
424
+ expect(getFormData(formElement)).toEqual({});
425
+
426
+ synchronizeSellingPlan(store, offerElement);
427
+
428
+ expect(getFormData(formElement)).toEqual({
429
+ selling_plan: testSellingPlan,
430
+ 'attributes[og__session]': testSessionId
431
+ });
432
+ });
433
+
434
+ it('should add attributes when product is opted in and Shopify Discount Function is in use', () => {
435
+ store.getState.and.returnValue({
436
+ ...store.getState(),
437
+ productPlans: {
438
+ [testProduct]: [
439
+ {
440
+ frequency: testSellingPlan,
441
+ hasPriceAdjustments: false
442
+ }
443
+ ]
444
+ }
445
+ });
446
+
447
+ const formElement = makeCartAddForm();
448
+ expect(getFormData(formElement)).toEqual({});
449
+
450
+ synchronizeSellingPlan(store, offerElement);
451
+
452
+ expect(getFormData(formElement)).toEqual({
453
+ selling_plan: testSellingPlan,
454
+ 'attributes[og__session]': testSessionId,
455
+ 'attributes[__ordergroove_offer_id]': testOfferId
456
+ });
457
+ });
458
+
459
+ it('should handle multiple forms on the page', () => {
460
+ const formElement1 = makeCartAddForm(testProduct);
461
+ const formElement2 = makeCartAddForm(testProduct);
462
+
463
+ synchronizeSellingPlan(store, offerElement);
464
+
465
+ expect(getFormData(formElement1)).toEqual({
466
+ selling_plan: testSellingPlan,
467
+ 'attributes[og__session]': testSessionId
468
+ });
469
+ expect(getFormData(formElement2)).toEqual({
470
+ selling_plan: testSellingPlan,
471
+ 'attributes[og__session]': testSessionId
472
+ });
473
+ });
474
+
475
+ it('should remove selling_plan input when product is not opted in', () => {
476
+ store.getState.and.returnValue({
477
+ ...store.getState(),
478
+ sessionId: testSessionId,
479
+ optedin: []
480
+ });
481
+
482
+ const formElement = makeCartAddForm();
483
+ // Add an existing selling_plan input
484
+ const existingInput = document.createElement('input');
485
+ existingInput.name = 'selling_plan';
486
+ existingInput.value = testSellingPlan;
487
+ existingInput.type = 'hidden';
488
+ formElement.appendChild(existingInput);
489
+
490
+ expect(getFormData(formElement).selling_plan).toEqual(testSellingPlan);
491
+
492
+ synchronizeSellingPlan(store, offerElement);
493
+
494
+ expect(getFormData(formElement).selling_plan).toEqual(undefined);
495
+ });
496
+
497
+ it('should not run when offerElement.isCart is true', () => {
498
+ const formElement = makeCartAddForm();
499
+ const offerElement = { isCart: true, shouldEnableOffer: true };
500
+
501
+ synchronizeSellingPlan(store, offerElement);
502
+
503
+ expect(getFormData(formElement)).toEqual({});
504
+ });
505
+
506
+ it('should not run when offerElement.shouldEnableOffer is false', () => {
507
+ const formElement = makeCartAddForm();
508
+ const offerElement = { isCart: false, shouldEnableOffer: false };
509
+
510
+ synchronizeSellingPlan(store, offerElement);
511
+
512
+ expect(getFormData(formElement)).toEqual({});
513
+ });
514
+ });