@ordergroove/offers 2.40.4-alpha-PR-1095-4.10 → 2.41.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ordergroove/offers",
3
- "version": "2.40.4-alpha-PR-1095-4.10+cab51c63",
3
+ "version": "2.41.0",
4
4
  "description": "offer state component",
5
5
  "author": "Eugenio Lattanzio <eugenio63@gmail.com>",
6
6
  "homepage": "https://github.com/ordergroove/plush-toys#readme",
@@ -49,5 +49,5 @@
49
49
  "@ordergroove/offers-templates": "^0.9.6",
50
50
  "@types/lodash.memoize": "^4.1.9"
51
51
  },
52
- "gitHead": "cab51c63b058f168847b0bbf9d0b5de7a8c2abe7"
52
+ "gitHead": "24e315047abbe0e265409f1f5265e3d9fd462672"
53
53
  }
@@ -1,5 +1,6 @@
1
1
  import * as offersAll from '../index';
2
2
  import { api } from '../core/api';
3
+ import { REQUEST_OFFER } from '../core/constants';
3
4
 
4
5
  const offers = offersAll.offers;
5
6
 
@@ -71,16 +72,13 @@ describe('Offers', () => {
71
72
  });
72
73
 
73
74
  describe('offers.resolveSettings', () => {
74
- it('should fetch offer for single product in pdp', () => {
75
+ it('should fetch offer for single product in pdp', async () => {
75
76
  offers.resolveSettings('0e5de2bedc5e11e3a2e4bc764e106cf4', 'staging', { product: '123' }, mockStore);
76
- expect(fetchOfferSpy).toHaveBeenCalledWith(
77
- 'https://staging.offers.ordergroove.com',
78
- '0e5de2bedc5e11e3a2e4bc764e106cf4',
79
- 'xyz',
80
- '123',
81
- 'pdp',
82
- theState
83
- );
77
+ expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
78
+ expect(mockStore.dispatch).toHaveBeenCalledWith({
79
+ type: REQUEST_OFFER,
80
+ payload: { product: '123', module: 'pdp', offer: undefined }
81
+ });
84
82
  });
85
83
 
86
84
  it('should fetch offer for each product in cart', () => {
@@ -90,23 +88,14 @@ describe('Offers', () => {
90
88
  { cart: { products: ['123', '456'] } },
91
89
  mockStore
92
90
  );
93
- expect(fetchOfferSpy).toHaveBeenCalledTimes(2);
94
91
 
95
- expect(fetchOfferSpy.calls.argsFor(0)).toEqual([
96
- 'https://staging.offers.ordergroove.com',
97
- '0e5de2bedc5e11e3a2e4bc764e106cf4',
98
- 'xyz',
99
- '123',
100
- 'pdp',
101
- theState
92
+ expect(mockStore.dispatch).toHaveBeenCalledTimes(2);
93
+
94
+ expect(mockStore.dispatch.calls.argsFor(0)).toEqual([
95
+ { type: REQUEST_OFFER, payload: { product: '123', module: 'pdp', offer: undefined } }
102
96
  ]);
103
- expect(fetchOfferSpy.calls.argsFor(1)).toEqual([
104
- 'https://staging.offers.ordergroove.com',
105
- '0e5de2bedc5e11e3a2e4bc764e106cf4',
106
- 'xyz',
107
- '456',
108
- 'pdp',
109
- theState
97
+ expect(mockStore.dispatch.calls.argsFor(1)).toEqual([
98
+ { type: REQUEST_OFFER, payload: { product: '456', module: 'pdp', offer: undefined } }
110
99
  ]);
111
100
  });
112
101
 
@@ -157,49 +157,7 @@ describe('redux actions', function () {
157
157
  });
158
158
 
159
159
  it('fetchOffer should return a function', () => {
160
- expect(fetchOffer()).toEqual(jasmine.any(Function));
161
- });
162
-
163
- it('should call api.fetchOffer with environment.apiUrl from state as first param ', async () => {
164
- const state = {
165
- merchantId: 'the merchantId',
166
- sessionId: 'the sessionId',
167
- environment: { apiUrl: 'the environment.apiUrl' }
168
- };
169
- const getState = jasmine.createSpy('getState').and.returnValue(state);
170
- const fetchOfferSpy = spyOn(api, 'fetchOffer').and.resolveTo({ hey: 'ho' });
171
- await fetchOffer('the product')(this.dispatch, getState);
172
- expect(fetchOfferSpy).toHaveBeenCalledWith(
173
- 'the environment.apiUrl',
174
- 'the merchantId',
175
- 'the sessionId',
176
- 'the product',
177
- 'pdp',
178
- state
179
- );
180
- });
181
-
182
- it('should dispatch the receiveOffer if api returns', async () => {
183
- const fetchOfferSpy = spyOn(api, 'fetchOffer').and.resolveTo({ hey: 'ho' });
184
-
185
- await fetchOffer('yum product')(this.dispatch, this.getState);
186
-
187
- expect(this.dispatch.calls.count()).toEqual(3);
188
- expect(this.dispatch.calls.argsFor(0)[0]).toEqual(requestOffer('yum product', 'pdp'));
189
- expect(this.dispatch.calls.argsFor(1)[0]).toEqual(receiveOffer({ hey: 'ho' }));
190
- expect(this.getState).toHaveBeenCalled();
191
- expect(fetchOfferSpy).toHaveBeenCalledWith('some-apiUrl', 'foo', 'bar', 'yum product', 'pdp', theState);
192
- });
193
-
194
- it('should dispatch fetchResponseError if api fails', async () => {
195
- const fetchOfferSpy = spyOn(api, 'fetchOffer').and.rejectWith(Error({ hey: 'ho' }));
196
- await fetchOffer('yum product')(this.dispatch, this.getState);
197
-
198
- expect(this.dispatch.calls.count()).toEqual(3);
199
- expect(this.dispatch.calls.argsFor(0)[0]).toEqual(requestOffer('yum product', 'pdp'));
200
- expect(this.dispatch.calls.argsFor(1)[0]).toEqual(fetchResponseError(Error({ hey: 'ho' })));
201
- expect(fetchOfferSpy).toHaveBeenCalledWith('some-apiUrl', 'foo', 'bar', 'yum product', 'pdp', theState);
202
- expect(this.getState).toHaveBeenCalled();
160
+ expect(fetchOffer()).toEqual(requestOffer());
203
161
  });
204
162
  });
205
163
 
@@ -1,5 +1,5 @@
1
1
  import { experimentsMiddleware, experimentsReducer, getAssignedExperimentVariant } from '../experiments';
2
- import { CREATED_SESSION_ID, RECEIVE_MERCHANT_SETTINGS } from '../constants';
2
+ import { CREATED_SESSION_ID, READY, RECEIVE_MERCHANT_SETTINGS } from '../constants';
3
3
  import { createSessionId } from '../actions';
4
4
  import { createStore, combineReducers, applyMiddleware } from 'redux';
5
5
  import { sessionId } from '../reducer';
@@ -34,7 +34,9 @@ describe('experiments', () => {
34
34
 
35
35
  store.dispatch({ type: CREATED_SESSION_ID, payload: 'abc' });
36
36
  store.dispatch({ type: RECEIVE_MERCHANT_SETTINGS, payload: { experiments } });
37
+ store.dispatch({ type: READY });
37
38
 
39
+ await new Promise(r => setTimeout(r, 10));
38
40
  const {
39
41
  experiments: { currentVariant }
40
42
  } = store.getState();
@@ -56,7 +58,9 @@ describe('experiments', () => {
56
58
 
57
59
  store.dispatch({ type: CREATED_SESSION_ID, payload: 'cda' });
58
60
  store.dispatch({ type: RECEIVE_MERCHANT_SETTINGS, payload: { experiments } });
61
+ store.dispatch({ type: READY });
59
62
 
63
+ await new Promise(r => setTimeout(r, 10));
60
64
  const {
61
65
  experiments: { currentVariant }
62
66
  } = store.getState();
@@ -0,0 +1,78 @@
1
+ import { offerRequestMiddleware } from '../offerRequest';
2
+
3
+ import { environment, merchantId, offer, sessionId } from '../reducer';
4
+ import { requestOffer } from '../actions';
5
+ import { createStore, combineReducers, applyMiddleware } from 'redux';
6
+ import { FETCH_RESPONSE_ERROR } from '../constants';
7
+
8
+ const mockOfferResponse = (productId, inStock = true, autoship = true, defaultFrequency) => {
9
+ return Promise.resolve({
10
+ json() {
11
+ return Promise.resolve({
12
+ in_stock: { [productId]: inStock },
13
+ eligibility_groups: { [productId]: ['subscription', 'upsell'] },
14
+ result: 'success',
15
+ autoship: { [productId]: autoship },
16
+ autoship_by_default: { [productId]: false },
17
+ modifiers: {},
18
+ module_view: { regular: '096135e6650111e9a444bc764e106cf4' },
19
+ incentives_display: {},
20
+ incentives: {
21
+ [productId]: { initial: [], ongoing: [] }
22
+ },
23
+ ...(defaultFrequency && {
24
+ default_frequencies: {
25
+ [productId]: defaultFrequency
26
+ }
27
+ })
28
+ });
29
+ }
30
+ });
31
+ };
32
+
33
+ describe('offerRequest', () => {
34
+ let fetch, store;
35
+ const lastError = (acc = null, cur) => (cur.type == FETCH_RESPONSE_ERROR ? cur : acc);
36
+ beforeEach(() => {
37
+ fetch = spyOn(window, 'fetch');
38
+
39
+ store = createStore(
40
+ combineReducers({ offer, environment, merchantId, sessionId, lastError }),
41
+ {
42
+ environment: { apiUrl: 'http://localhost' },
43
+ merchantId: 'abc-merchant',
44
+ sessionId: 'x.y.z',
45
+ lastError: null
46
+ },
47
+ applyMiddleware(offerRequestMiddleware)
48
+ );
49
+ });
50
+
51
+ it('should call fetch offer with environment apiUrl from state as first param', async () => {
52
+ fetch.and.returnValue(mockOfferResponse('123', true, true));
53
+ store.dispatch(requestOffer('123'));
54
+ expect(fetch).toHaveBeenCalledTimes(1);
55
+ expect(fetch).toHaveBeenCalledWith(
56
+ 'http://localhost/offer/abc-merchant/pdp?session_id=x.y.z&page_type=1&p=%5B%22123%22%5D&module_view=%5B%22regular%22%5D',
57
+
58
+ {}
59
+ );
60
+
61
+ await new Promise(r => setTimeout(r, 10));
62
+ expect(store.getState().offer.offerId).toEqual('096135e6650111e9a444bc764e106cf4');
63
+ });
64
+
65
+ it('should dispatch fetchResponseError if api fails', async () => {
66
+ fetch.and.rejectWith({ error: true });
67
+ store.dispatch(requestOffer('1234'));
68
+ await new Promise(r => setTimeout(r, 100));
69
+ expect(fetch).toHaveBeenCalledTimes(1);
70
+ expect(fetch).toHaveBeenCalledWith(
71
+ 'http://localhost/offer/abc-merchant/pdp?session_id=x.y.z&page_type=1&p=%5B%221234%22%5D&module_view=%5B%22regular%22%5D',
72
+
73
+ {}
74
+ );
75
+
76
+ expect(store.getState().lastError).toEqual({ type: FETCH_RESPONSE_ERROR, payload: { error: true } });
77
+ });
78
+ });
@@ -198,27 +198,7 @@ export const requestOffer = (product, module = constants.DEFAULT_OFFER_MODULE, o
198
198
  payload: { product, module, offer }
199
199
  });
200
200
 
201
- export const fetchOffer = (product, module = constants.DEFAULT_OFFER_MODULE, offerElement) =>
202
- function fetchOfferThunk(dispatch, getState) {
203
- const state = getState();
204
- const {
205
- merchantId,
206
- sessionId,
207
- environment: { apiUrl }
208
- } = state;
209
- const requestAction = requestOffer(product, module, offerElement);
210
- dispatch(requestAction);
211
-
212
- const productId = safeProductId(product);
213
- if (!productId) return null;
214
- return api
215
- .fetchOffer(apiUrl, merchantId, sessionId, productId, module, state)
216
- .then(
217
- response => dispatch(receiveOffer(response, offerElement)),
218
- err => dispatch(fetchResponseError(err))
219
- )
220
- .finally(() => dispatch(fetchDone(requestAction)));
221
- };
201
+ export const fetchOffer = requestOffer;
222
202
 
223
203
  export const checkout = () => ({
224
204
  type: constants.CHECKOUT
package/src/core/api.js CHANGED
@@ -66,7 +66,7 @@ export const toProductId = product =>
66
66
 
67
67
  export const fetchOffer = memoize(
68
68
  withFetchJson(
69
- withHost((merchantId, sessionId, product, module = 'pdp', state = {}) => {
69
+ withHost((merchantId, sessionId, product, module = 'pdp', searchParams = {}) => {
70
70
  if (!merchantId) throw Error('merchantId required');
71
71
  if (!sessionId) throw Error('sessionId required');
72
72
  if (!product) throw Error('product required');
@@ -75,18 +75,10 @@ export const fetchOffer = memoize(
75
75
  ['session_id', sessionId],
76
76
  ['page_type', 1],
77
77
  ['p', toProductId(product)],
78
- ['module_view', JSON.stringify(['regular'])]
78
+ ['module_view', JSON.stringify(['regular'])],
79
+ ...Object.entries(searchParams)
79
80
  ];
80
81
 
81
- // NOTE: This block is only relevant for merchants actively running an AB test.
82
- // For all other merchants, experiments.variants will be null.
83
- const { experiments } = state;
84
- const variant = experiments?.variants?.at(experiments.currentVariant);
85
-
86
- if (variant) {
87
- query.push(['variant', variant.public_id]);
88
- }
89
-
90
82
  return [`/offer/${merchantId}/${module}?${toQuery(query)}`];
91
83
  })
92
84
  ),
@@ -1,5 +1,6 @@
1
- import { RECEIVE_MERCHANT_SETTINGS, SET_EXPERIMENT_VARIANT } from './constants';
1
+ import { READY, RECEIVE_MERCHANT_SETTINGS, REQUEST_OFFER, SET_EXPERIMENT_VARIANT } from './constants';
2
2
  import murmur from 'murmurhash-js';
3
+ import { waitFor } from './waitUntilOffersReady';
3
4
 
4
5
  /**
5
6
  * Returns the index of a variant based on the provided key and variants.
@@ -82,22 +83,37 @@ export function getAssignedExperimentVariant(experimentSettings, sessionId) {
82
83
  * @return {function} A function that takes the next middleware function and an action as arguments.
83
84
  */
84
85
  export function experimentsMiddleware(store) {
85
- return next => action => {
86
- const response = next(action);
87
- if (action.type !== RECEIVE_MERCHANT_SETTINGS) {
88
- return response;
89
- }
86
+ const [waitForReady, resolveReady] = waitFor();
87
+
88
+ let variant, experimentSettings;
89
+
90
+ return next => async action => {
91
+ if (action.type === READY) {
92
+ resolveReady();
93
+ } else if (action.type === RECEIVE_MERCHANT_SETTINGS) {
94
+ await waitForReady;
95
+ experimentSettings = action.payload.experiments;
96
+
97
+ const { sessionId } = store.getState();
90
98
 
91
- const { experiments: experimentSettings, sessionId } = store.getState();
92
- const variant = getAssignedExperimentVariant(experimentSettings, sessionId);
99
+ variant = getAssignedExperimentVariant(experimentSettings, sessionId);
93
100
 
94
- if (variant) {
95
- store.dispatch({
96
- type: SET_EXPERIMENT_VARIANT,
97
- payload: variant
98
- });
101
+ if (variant) {
102
+ store.dispatch({
103
+ type: SET_EXPERIMENT_VARIANT,
104
+ payload: variant
105
+ });
106
+ }
107
+ } else if (action.type === REQUEST_OFFER) {
108
+ await waitForReady;
109
+ if (variant) {
110
+ action.payload.searchParams = {
111
+ ...action.payload.searchParams,
112
+ variant: variant.public_id
113
+ };
114
+ }
99
115
  }
100
116
 
101
- return response;
117
+ return next(action);
102
118
  };
103
119
  }
@@ -0,0 +1,34 @@
1
+ import { fetchDone, fetchResponseError, receiveOffer } from './actions';
2
+ import api from './api';
3
+ import { DEFAULT_OFFER_MODULE, REQUEST_OFFER } from './constants';
4
+ import { safeProductId } from './utils';
5
+
6
+ export function offerRequestMiddleware(store) {
7
+ return next => action => {
8
+ if (action.type === REQUEST_OFFER) {
9
+ const {
10
+ merchantId,
11
+ sessionId,
12
+ environment: { apiUrl }
13
+ } = store.getState();
14
+
15
+ const productId = safeProductId(action.payload.product);
16
+ if (productId)
17
+ api
18
+ .fetchOffer(
19
+ apiUrl,
20
+ merchantId,
21
+ sessionId,
22
+ productId,
23
+ action.payload.module || DEFAULT_OFFER_MODULE,
24
+ action.payload.searchParams
25
+ )
26
+ .then(
27
+ response => store.dispatch(receiveOffer(response, action.payload.offer)),
28
+ err => store.dispatch(fetchResponseError(err))
29
+ )
30
+ .finally(() => store.dispatch(fetchDone(action)));
31
+ }
32
+ return next(action);
33
+ };
34
+ }
@@ -271,7 +271,7 @@ export const productOffer = (state = {}, action) => {
271
271
  ...state,
272
272
  ...Object.entries(action.payload.autoship)
273
273
  .map(([key]) => ({ [key]: Object.keys(action.payload.modifiers) }))
274
- .reduce((acc, object) => ({ ...acc, ...object }))
274
+ .reduce((acc, object) => ({ ...acc, ...object }), {})
275
275
  };
276
276
  case constants.CHECKOUT:
277
277
  return {};
package/src/core/store.js CHANGED
@@ -4,6 +4,8 @@ import thunk from 'redux-thunk';
4
4
  import { loadState } from './localStorage';
5
5
  import { dispatchMiddleware, localStorageMiddleware, offerEvents } from './middleware';
6
6
  import { waitUntilOffersReady } from './waitUntilOffersReady';
7
+ import { offerRequestMiddleware } from './offerRequest';
8
+ import { experimentsMiddleware } from './experiments';
7
9
 
8
10
  export function makeStore(reducer, ...extraMiddlewares) {
9
11
  if (window.og && window.og.store) return window.og.store;
@@ -17,7 +19,14 @@ export function makeStore(reducer, ...extraMiddlewares) {
17
19
  })
18
20
  : compose;
19
21
 
20
- const middlewares = [waitUntilOffersReady, thunk, dispatchMiddleware, offerEvents];
22
+ const middlewares = [
23
+ waitUntilOffersReady,
24
+ thunk,
25
+ experimentsMiddleware,
26
+ offerRequestMiddleware,
27
+ dispatchMiddleware,
28
+ offerEvents
29
+ ];
21
30
 
22
31
  let initial = {};
23
32
 
package/src/index.js CHANGED
@@ -7,12 +7,10 @@ import platform from './platform';
7
7
  import { autoInitializeOffers, onReady } from './core/utils';
8
8
  import { authorizeShopifyCustomer } from './shopify/shopifyBootstrap';
9
9
  import shopifyTrackingMiddleware from './shopify/shopifyTrackingMiddleware';
10
- import { experimentsMiddleware } from './core/experiments';
11
10
 
12
11
  export const store = makeStore(
13
12
  ...(platform?.shopify_selling_plans ? [shopifyReducer, shopifyMiddleware] : [defaultReducer]),
14
- platform.shopify && shopifyTrackingMiddleware,
15
- experimentsMiddleware
13
+ platform.shopify && shopifyTrackingMiddleware
16
14
  );
17
15
 
18
16
  export const offers = makeApi(store);
package/src/make-api.js CHANGED
@@ -146,15 +146,11 @@ export default function makeApi(store) {
146
146
  } else if (settings.cart && Array.isArray(settings.cart.products)) {
147
147
  products = products.concat(settings.cart.products);
148
148
  }
149
- const { apiUrl } = environment({}, actions.setEnvironment(env));
150
149
 
151
150
  const state = storeInstance.getState();
152
151
  const { sessionId } = state;
153
152
  if (sessionId) {
154
- products.forEach(product => {
155
- const id = safeProductId(product);
156
- api.fetchOffer(apiUrl, merchantId, sessionId, `${id}`, DEFAULT_OFFER_MODULE, state);
157
- });
153
+ products.forEach(product => storeInstance.dispatch(actions.requestOffer(product)));
158
154
  }
159
155
 
160
156
  if (settings.product_discounts && typeof settings.product_discounts === 'object') {
@@ -345,8 +345,8 @@ export function getSubscribedFrequency(productId, store) {
345
345
  * @param store
346
346
  */
347
347
  function synchronizeSellingPlan(store: any, offerElement?: HTMLElement) {
348
- if (offerElement.isCart) return; // hidden inputs are used when product page, not cart.
349
- if (!offerElement.shouldEnableOffer) return; // do not set a selling plan if we're hiding the offer
348
+ if (offerElement?.isCart) return; // hidden inputs are used when product page, not cart.
349
+ if (!offerElement?.shouldEnableOffer) return; // do not set a selling plan if we're hiding the offer
350
350
 
351
351
  [...document.querySelectorAll('form[action$="/cart/add"] [name=id]')].forEach((productIdInput: HTMLInputElement) => {
352
352
  const productId = productIdInput.value;
@@ -85,7 +85,7 @@ export const mapExistingOptinsFromOfferResponse = (state, offerEl) =>
85
85
  mapFrequencyToSellingPlan(
86
86
  offerEl?.config?.frequencies,
87
87
  offerEl?.config?.frequenciesEveryPeriod,
88
- offerEl.defaultFrequency
88
+ offerEl?.defaultFrequency
89
89
  ) ||
90
90
  getFirstSellingPlan(offerEl?.config?.frequencies)
91
91
  : it.frequency
@@ -103,7 +103,7 @@ export const reduceNewOptinsFromOfferResponse = (
103
103
  Object.keys(autoship).reduce((acc, id) => {
104
104
  if (!existingOptins.some(it => it.id === id)) {
105
105
  if (!(autoship[id] && autoship_by_default[id] && in_stock[id])) return acc;
106
- const { config: { frequencies: sellingPlans, frequenciesEveryPeriod } = {}, defaultFrequency } = offerEl;
106
+ const { config: { frequencies: sellingPlans, frequenciesEveryPeriod } = {}, defaultFrequency } = offerEl || {};
107
107
  const psdf = default_frequencies[id];
108
108
  let frequency;
109
109
 
@@ -314,7 +314,7 @@ export const config = (
314
314
  defaultFrequency,
315
315
  config: { frequencies: sellingPlans, frequenciesEveryPeriod, prepaidSellingPlans = {} } = {},
316
316
  product
317
- } = offerEl;
317
+ } = offerEl || {};
318
318
 
319
319
  // We don't want to be setting the default frequency to a prepaid selling plan
320
320
  if (prepaidSellingPlans[product?.id]?.some(({ sellingPlan }) => sellingPlan === defaultFrequency)) {
@@ -381,10 +381,10 @@ export const offer = (state = {}, _action) => state;
381
381
  function getFrequencyForPrepaidShipments({ prepaidShipments, offer: offerEl, product }) {
382
382
  if (prepaidShipments) {
383
383
  const productId = safeProductId(product.id);
384
- const plan = offerEl.config.prepaidSellingPlans[productId]?.find(p => p.numberShipments === prepaidShipments);
384
+ const plan = offerEl?.config.prepaidSellingPlans[productId]?.find(p => p.numberShipments === prepaidShipments);
385
385
  return plan ? plan.sellingPlan : null;
386
386
  }
387
- return offerEl.config.frequencies[0];
387
+ return offerEl?.config.frequencies[0];
388
388
  }
389
389
 
390
390
  function getOptedInItem(cartItem) {