@ordergroove/offers 2.26.2 → 2.26.3-alpha-PR-593-20.20

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.
Files changed (48) hide show
  1. package/build.js +3 -1
  2. package/dist/bundle-report.html +170 -104
  3. package/dist/offers.js +64 -75
  4. package/dist/offers.js.map +3 -3
  5. package/examples/cart.js +105 -0
  6. package/examples/index.html +2 -2
  7. package/examples/products/cheap-watch.js +183 -0
  8. package/examples/shopify-cart.html +26 -0
  9. package/examples/shopify-pdp.html +34 -0
  10. package/karma.conf.js +2 -1
  11. package/package.json +4 -4
  12. package/src/__tests__/offers.spec.js +35 -10
  13. package/src/components/FrequencyStatus.js +14 -11
  14. package/src/components/Offer.js +11 -7
  15. package/src/components/OptinButton.js +1 -1
  16. package/src/components/OptinSelect.js +2 -2
  17. package/src/components/OptinToggle.js +2 -2
  18. package/src/components/OptoutButton.js +1 -1
  19. package/src/components/Price.js +8 -4
  20. package/src/components/Select.js +3 -13
  21. package/src/components/SelectFrequency.js +24 -6
  22. package/src/components/TestWizard.js +1 -1
  23. package/src/components/__tests__/OG.fspec.js +24 -0
  24. package/src/components/__tests__/Offer.spec.js +4 -4
  25. package/src/components/__tests__/OptinButton.spec.js +2 -2
  26. package/src/components/__tests__/OptinToggle.spec.js +2 -2
  27. package/src/components/__tests__/OptoutButton.spec.js +1 -1
  28. package/src/components/__tests__/SelectFrequency.fspec.js +1 -0
  29. package/src/components/__tests__/SelectFrequency.spec.js +1 -1
  30. package/src/components/__tests__/TestWizard.spec.js +2 -2
  31. package/src/components/__tests__/Text.spec.js +3 -0
  32. package/src/core/__tests__/actions.spec.js +6 -6
  33. package/src/core/actions.js +12 -10
  34. package/src/core/constants.js +3 -0
  35. package/src/core/reducer.js +14 -14
  36. package/src/core/resolveProperties.js +2 -7
  37. package/src/core/selectors.js +1 -1
  38. package/src/core/store.js +6 -5
  39. package/src/index.js +57 -202
  40. package/src/make-api.js +190 -0
  41. package/src/platform.ts +5 -0
  42. package/src/shopify/__tests__/shopifyReducer.spec.js +477 -0
  43. package/src/shopify/shopifyMiddleware.ts +202 -0
  44. package/src/shopify/shopifyReducer.js +214 -0
  45. package/tsconfig.json +35 -0
  46. package/examples/5starnutrition-main.js +0 -3
  47. package/examples/single-offer.html +0 -9
  48. package/src/init-test.js +0 -3
@@ -0,0 +1,477 @@
1
+ import * as constants from '../../core/constants';
2
+ import * as coreReducers from '../../core/reducer';
3
+ import {
4
+ autoshipEligible,
5
+ config,
6
+ inStock,
7
+ offer,
8
+ offerId,
9
+ optedin,
10
+ productOffer,
11
+ productPlans
12
+ } from '../shopifyReducer';
13
+
14
+ describe('autoshipEligible', () => {
15
+ it('should return true for each id given action RECEIVE_PRODUCT_PLANS', () => {
16
+ const actual = autoshipEligible(
17
+ {},
18
+ {
19
+ type: constants.RECEIVE_PRODUCT_PLANS,
20
+ payload: {
21
+ 'yum product id 1': {},
22
+ 'yum product id 2': {}
23
+ }
24
+ }
25
+ );
26
+ expect(actual).toEqual({ 'yum product id 1': true, 'yum product id 2': true });
27
+ });
28
+
29
+ it('should return true for each key in items given action SETUP_CART', () => {
30
+ const actual = autoshipEligible(
31
+ {},
32
+ {
33
+ type: constants.SETUP_CART,
34
+ payload: {
35
+ items: [
36
+ {
37
+ key: 'yum key 1'
38
+ },
39
+ {
40
+ key: 'yum key 2'
41
+ }
42
+ ]
43
+ }
44
+ }
45
+ );
46
+ expect(actual).toEqual({ 'yum key 1': true, 'yum key 2': true });
47
+ });
48
+
49
+ it('should return true for each product and variant given action SETUP_PRODUCT and products have selling plan allocations', () => {
50
+ const actual = autoshipEligible(
51
+ {},
52
+ {
53
+ type: constants.SETUP_PRODUCT,
54
+ payload: {
55
+ id: 'yum product id',
56
+ selling_plan_allocations: [{ 'yum key': 'yum value' }],
57
+ variants: [
58
+ {
59
+ id: 'yum variant id',
60
+ selling_plan_allocations: [{ 'yum key': 'yum value' }]
61
+ }
62
+ ]
63
+ }
64
+ }
65
+ );
66
+
67
+ expect(actual).toEqual({ 'yum product id': true, 'yum variant id': true });
68
+ });
69
+
70
+ it('should return false for each product and variant given action SETUP_PRODUCT and products have no selling plan allocations', () => {
71
+ const actual = autoshipEligible(
72
+ {},
73
+ {
74
+ type: constants.SETUP_PRODUCT,
75
+ payload: {
76
+ id: 'yum product id',
77
+ selling_plan_allocations: [],
78
+ variants: [
79
+ {
80
+ id: 'yum variant id',
81
+ selling_plan_allocations: []
82
+ }
83
+ ]
84
+ }
85
+ }
86
+ );
87
+
88
+ expect(actual).toEqual({ 'yum product id': false, 'yum variant id': false });
89
+ });
90
+
91
+ it('should return unmodified state given unsupported action', () => {
92
+ const actual = autoshipEligible(
93
+ { 'yum existing key': 'yum existing value' },
94
+ {
95
+ type: 'yum unsupported action',
96
+ payload: {}
97
+ }
98
+ );
99
+
100
+ expect(actual).toEqual({ 'yum existing key': 'yum existing value' });
101
+ });
102
+ });
103
+
104
+ describe('config', () => {
105
+ it('should return unique frequencies given action RECEIVE_PRODUCT_PLANS', () => {
106
+ const actual = config(
107
+ {},
108
+ {
109
+ type: constants.RECEIVE_PRODUCT_PLANS,
110
+ payload: {
111
+ 'product plan id': { '1_1': {}, '3_1': {} }
112
+ }
113
+ }
114
+ );
115
+
116
+ expect(actual).toEqual(
117
+ jasmine.objectContaining({
118
+ frequencies: ['1_1', '3_1']
119
+ })
120
+ );
121
+ });
122
+
123
+ it('should return first selling plan id as default frequency given action SETUP_PRODUCT', () => {
124
+ const actual = config(
125
+ {},
126
+ {
127
+ type: constants.SETUP_PRODUCT,
128
+ payload: {
129
+ selling_plan_groups: [
130
+ {
131
+ selling_plans: [
132
+ {
133
+ id: 'yum selling plan id 1'
134
+ },
135
+ {
136
+ id: 'yum selling plan id 2'
137
+ }
138
+ ]
139
+ }
140
+ ]
141
+ }
142
+ }
143
+ );
144
+
145
+ expect(actual).toEqual(
146
+ jasmine.objectContaining({
147
+ defaultFrequency: 'yum selling plan id 1'
148
+ })
149
+ );
150
+ });
151
+
152
+ it('should return selling plan ids as frequencies given action SETUP_PRODUCT', () => {
153
+ const actual = config(
154
+ {},
155
+ {
156
+ type: constants.SETUP_PRODUCT,
157
+ payload: {
158
+ selling_plan_groups: [
159
+ {
160
+ selling_plans: [
161
+ {
162
+ id: 'yum selling plan id 1'
163
+ },
164
+ {
165
+ id: 'yum selling plan id 2'
166
+ }
167
+ ]
168
+ }
169
+ ]
170
+ }
171
+ }
172
+ );
173
+
174
+ expect(actual).toEqual(
175
+ jasmine.objectContaining({
176
+ frequencies: ['yum selling plan id 1', 'yum selling plan id 2']
177
+ })
178
+ );
179
+ });
180
+
181
+ it('should return values of first selling plan group as frequencies text given action SETUP_PRODUCT', () => {
182
+ const actual = config(
183
+ {},
184
+ {
185
+ type: constants.SETUP_PRODUCT,
186
+ payload: {
187
+ selling_plan_groups: [
188
+ {
189
+ options: [{ values: 'yum values' }],
190
+ selling_plans: [
191
+ {
192
+ id: 'yum selling plan id'
193
+ }
194
+ ]
195
+ }
196
+ ]
197
+ }
198
+ }
199
+ );
200
+
201
+ expect(actual).toEqual(
202
+ jasmine.objectContaining({
203
+ frequenciesText: 'yum values'
204
+ })
205
+ );
206
+ });
207
+
208
+ it('should return unmodified state given unsupported action', () => {
209
+ const actual = config(
210
+ { 'yum existing key': 'yum existing value' },
211
+ {
212
+ type: 'yum unsupported action',
213
+ payload: {}
214
+ }
215
+ );
216
+
217
+ expect(actual).toEqual({ 'yum existing key': 'yum existing value' });
218
+ });
219
+ });
220
+
221
+ describe('inStock', () => {
222
+ it('should return true for each id given action RECEIVE_PRODUCT_PLANS', () => {
223
+ const actual = inStock(
224
+ {},
225
+ {
226
+ type: constants.RECEIVE_PRODUCT_PLANS,
227
+ payload: {
228
+ 'yum product id 1': {},
229
+ 'yum product id 2': {}
230
+ }
231
+ }
232
+ );
233
+ expect(actual).toEqual({ 'yum product id 1': true, 'yum product id 2': true });
234
+ });
235
+
236
+ it('should return true item key given action SETUP_CART', () => {
237
+ const actual = inStock(
238
+ {},
239
+ {
240
+ type: constants.SETUP_CART,
241
+ payload: {
242
+ items: [{ key: 'yum item key 1' }, { key: 'yum item key 2' }]
243
+ }
244
+ }
245
+ );
246
+ expect(actual).toEqual({ 'yum item key 1': true, 'yum item key 2': true });
247
+ });
248
+
249
+ it('should return true for each available product and variant given action SETUP_PRODUCT', () => {
250
+ const actual = inStock(
251
+ {},
252
+ {
253
+ type: constants.SETUP_PRODUCT,
254
+ payload: {
255
+ id: 'yum product id',
256
+ available: true,
257
+ variants: [
258
+ {
259
+ id: 'yum variant id',
260
+ available: true
261
+ }
262
+ ]
263
+ }
264
+ }
265
+ );
266
+
267
+ expect(actual).toEqual({
268
+ 'yum product id': true,
269
+ 'yum variant id': true
270
+ });
271
+ });
272
+
273
+ it('should return false for each unavailable product and variant given action SETUP_PRODUCT', () => {
274
+ const actual = inStock(
275
+ {},
276
+ {
277
+ type: constants.SETUP_PRODUCT,
278
+ payload: {
279
+ id: 'yum product id',
280
+ available: false,
281
+ variants: [
282
+ {
283
+ id: 'yum variant id',
284
+ available: false
285
+ }
286
+ ]
287
+ }
288
+ }
289
+ );
290
+
291
+ expect(actual).toEqual({
292
+ 'yum product id': false,
293
+ 'yum variant id': false
294
+ });
295
+ });
296
+
297
+ it('should return unmodified state given unsupported action', () => {
298
+ const actual = inStock(
299
+ { 'yum existing key': 'yum existing value' },
300
+ {
301
+ type: 'yum unsupported action',
302
+ payload: {}
303
+ }
304
+ );
305
+
306
+ expect(actual).toEqual({ 'yum existing key': 'yum existing value' });
307
+ });
308
+ });
309
+
310
+ describe('offer', () => {
311
+ it('should return unmodified state', () => {
312
+ const actual = offer({ 'yum existing key': 'yum existing value' });
313
+
314
+ expect(actual).toEqual({ 'yum existing key': 'yum existing value' });
315
+ });
316
+ });
317
+
318
+ describe('offerId', () => {
319
+ it('should return shopify constant', () => {
320
+ const actual = offerId();
321
+
322
+ expect(actual).toEqual('native-shopify-offer');
323
+ });
324
+ });
325
+
326
+ describe('optedin', () => {
327
+ it('should return optins given action SETUP_CART', () => {
328
+ const actual = optedin(
329
+ {},
330
+ {
331
+ type: constants.SETUP_CART,
332
+ payload: {
333
+ items: [
334
+ {
335
+ key: 'yum item key 1',
336
+ selling_plan_allocation: {
337
+ selling_plan: {
338
+ id: 'yum selling plan id 1'
339
+ }
340
+ }
341
+ },
342
+ {
343
+ key: 'yum item key 2',
344
+ selling_plan_allocation: {
345
+ selling_plan: {
346
+ id: 'yum selling plan id 2'
347
+ }
348
+ }
349
+ }
350
+ ]
351
+ }
352
+ }
353
+ );
354
+
355
+ expect(actual).toEqual([
356
+ {
357
+ id: 'yum item key 1',
358
+ frequency: 'yum selling plan id 1'
359
+ },
360
+ {
361
+ id: 'yum item key 2',
362
+ frequency: 'yum selling plan id 2'
363
+ }
364
+ ]);
365
+ });
366
+
367
+ it('should return unmodified state given unsupported action', () => {
368
+ const actual = optedin(
369
+ { 'yum existing key': 'yum existing value' },
370
+ {
371
+ type: 'yum unsupported action',
372
+ payload: {}
373
+ }
374
+ );
375
+
376
+ expect(actual).toEqual({ 'yum existing key': 'yum existing value' });
377
+ });
378
+ });
379
+
380
+ describe('productOffer', () => {
381
+ it('should return unmodified state', () => {
382
+ const actual = productOffer({ 'yum existing key': 'yum existing value' });
383
+
384
+ expect(actual).toEqual({ 'yum existing key': 'yum existing value' });
385
+ });
386
+ });
387
+
388
+ describe('productPlans', () => {
389
+ it('should product plans with formatted discounts given action SETUP_PRODUCT', () => {
390
+ const actual = productPlans(
391
+ {},
392
+ {
393
+ type: constants.SETUP_PRODUCT,
394
+ payload: {
395
+ id: 'yum product id',
396
+ selling_plan_allocations: [
397
+ {
398
+ selling_plan_id: 'yum selling plan id 1',
399
+ compare_at_price: 100,
400
+ price: 50,
401
+ price_adjustments: [
402
+ {
403
+ value: 50,
404
+ value_type: 'percentage'
405
+ }
406
+ ]
407
+ }
408
+ ],
409
+ variants: [
410
+ {
411
+ id: 'yum variant id 1',
412
+ selling_plan_allocations: [
413
+ {
414
+ selling_plan_id: 'yum selling plan id 2',
415
+ compare_at_price: 50,
416
+ price: 25,
417
+ price_adjustments: [
418
+ {
419
+ value: 25
420
+ }
421
+ ]
422
+ }
423
+ ]
424
+ },
425
+ {
426
+ id: 'yum variant id 2',
427
+ selling_plan_allocations: [
428
+ {
429
+ selling_plan_id: 'yum selling plan id 3',
430
+ compare_at_price: 10,
431
+ price: 8,
432
+ price_adjustments: []
433
+ }
434
+ ]
435
+ }
436
+ ]
437
+ }
438
+ }
439
+ );
440
+
441
+ expect(actual).toEqual({
442
+ 'yum product id': { 'yum selling plan id 1': ['$1.00', '50%', '$.50'] },
443
+ 'yum variant id 1': {
444
+ 'yum selling plan id 2': ['$.50', '$.25', '$.25']
445
+ },
446
+ 'yum variant id 2': { 'yum selling plan id 3': ['$.10', '%', '$8'] }
447
+ });
448
+ });
449
+
450
+ it('should return payload given action RECEIVE_PRODUCT_PLANS', () => {
451
+ const actual = productPlans(
452
+ {},
453
+ {
454
+ type: constants.RECEIVE_PRODUCT_PLANS,
455
+ payload: {
456
+ 'yum key': 'yum value'
457
+ }
458
+ }
459
+ );
460
+
461
+ expect(actual).toEqual({
462
+ 'yum key': 'yum value'
463
+ });
464
+ });
465
+
466
+ it('should return unmodified state given unsupported action', () => {
467
+ const actual = productPlans(
468
+ { 'yum existing key': 'yum existing value' },
469
+ {
470
+ type: 'yum unsupported action',
471
+ payload: {}
472
+ }
473
+ );
474
+
475
+ expect(actual).toEqual({ 'yum existing key': 'yum existing value' });
476
+ });
477
+ });
@@ -0,0 +1,202 @@
1
+ import memoize from 'lodash.memoize';
2
+ import {
3
+ OPTIN_PRODUCT,
4
+ OPTOUT_PRODUCT,
5
+ PRODUCT_CHANGE_FREQUENCY,
6
+ REQUEST_OFFER,
7
+ SETUP_CART,
8
+ SETUP_PRODUCT
9
+ } from '../core/constants';
10
+
11
+ import { makeSubscribedSelector } from '../core/selectors';
12
+
13
+ declare global {
14
+ interface Window {
15
+ og: {
16
+ previewMode: boolean;
17
+ };
18
+ Shopify: { routes?: { root: String } };
19
+ }
20
+ }
21
+
22
+ const SHOPIFY_ROOT = window.Shopify?.routes.root || '/';
23
+ const CART_PAGE_URL = `${SHOPIFY_ROOT}cart`;
24
+ const CART_CHANGE_JS_URL = `${SHOPIFY_ROOT}cart/change.js`;
25
+ const CART_JS_URL = `${SHOPIFY_ROOT}cart.js`;
26
+ const PRODUCTS_URL = `${SHOPIFY_ROOT}products/`;
27
+
28
+ async function setupPdp(store) {
29
+ const handle = guessProductHandle();
30
+ if (handle) {
31
+ try {
32
+ store.dispatch({ type: SETUP_PRODUCT, payload: await getProduct(handle) });
33
+ } catch (err) {
34
+ console.warn('OG: Unable to fetch product details for PDP', err);
35
+ }
36
+ }
37
+ }
38
+
39
+ const getCart = async () => await (await fetch(CART_JS_URL)).json();
40
+
41
+ /**
42
+ * Attemps to guess the product handle o
43
+ * @returns
44
+ */
45
+ function guessProductHandle(): String {
46
+ return (
47
+ [
48
+ () =>
49
+ // Use the oembed to get the product handle
50
+ (document
51
+ .querySelector('[href$=".oembed"]')
52
+ ?.getAttribute('href')
53
+ ?.match(/\/([^\/]+)\.oembed$/) || [])[1],
54
+
55
+ () =>
56
+ // Use the open graph og:type==product and og:url to get the product handle
57
+ ((document.querySelector('meta[property="og:type"][content="product"]') &&
58
+ document
59
+ .querySelector('meta[property="og:url"][content]')
60
+ ?.getAttribute('content')
61
+ ?.match(/\/([^\/]+)$/)) ||
62
+ [])[1],
63
+
64
+ () =>
65
+ // use any json in the markup
66
+ [...document.querySelectorAll('[type$=json]')]
67
+ .map(it => JSON.parse(it.textContent))
68
+ .find(it => it.handle && it.price)?.handle
69
+ ]
70
+ // returns the first truthy and prevent call next functions
71
+ .reduce((acc, cur) => acc || cur(), '')
72
+ );
73
+ }
74
+
75
+ const getProduct = memoize(async handle => (await fetch(`${PRODUCTS_URL}${handle}.js`)).json());
76
+
77
+ async function setupCart(store) {
78
+ const cart = await getCart();
79
+ store.dispatch({ type: SETUP_CART, payload: cart });
80
+ const { items } = cart;
81
+ const products = await Promise.all(Array.from(new Set(items.map(({ handle }) => handle))).map(getProduct));
82
+ products.forEach(product => store.dispatch({ type: SETUP_PRODUCT, payload: product }));
83
+ }
84
+
85
+ /**
86
+ * Synchronizes the optins/optouts using shopify cart ajax api
87
+ *
88
+ * @param action
89
+ * @param store
90
+ */
91
+ async function synchronizeCartOptin(action: any, store: any) {
92
+ const offerElement = action.payload.offer;
93
+ const selling_plan = action.payload.frequency || null;
94
+
95
+ if (!offerElement?.isCart) return;
96
+
97
+ const closestSection = offerElement.closest('.shopify-section');
98
+ const closestSectionId = (closestSection?.id.match(/^shopify-section-(.+)/) || [])[1];
99
+
100
+ const key = action.payload.product.id; // shopify cart.item.key
101
+ const cart = await getCart();
102
+ const item = cart.items.find(it => it.key === key); // cart.items[offerIx];
103
+
104
+ const res = await fetch(CART_CHANGE_JS_URL, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({
108
+ id: key,
109
+ quantity: item.quantity,
110
+ properties: item.properties,
111
+ selling_plan: selling_plan || null,
112
+ sections: closestSectionId ? [closestSectionId] : undefined
113
+ })
114
+ });
115
+
116
+ if (res.status !== 200) throw new Error('Cart not updated');
117
+
118
+ const sections = (await res.json())?.sections;
119
+
120
+ if (sections && closestSectionId in sections) {
121
+ const sectionRawHtml = sections[closestSectionId];
122
+
123
+ const el = new DOMParser()
124
+ .parseFromString(sectionRawHtml.toString() || '', 'text/html')
125
+ .getElementById('shopify-section-' + closestSectionId);
126
+
127
+ closestSection.innerHTML = el.innerHTML;
128
+
129
+ } else if (window.location.href.endsWith(CART_PAGE_URL)) {
130
+ // only do if we are on the cart page
131
+ window.location.reload();
132
+ }
133
+ }
134
+
135
+ function getOrCreateHidden(parent, name, value) {
136
+ let input = parent.querySelector(`[name="${name}"]`);
137
+ if (input && !value) {
138
+ input.remove();
139
+ return;
140
+ }
141
+ if (!input) {
142
+ input = document.createElement('input');
143
+ input.type = 'hidden';
144
+ input.name = name;
145
+ parent.appendChild(input);
146
+ }
147
+ input.value = value;
148
+ }
149
+ /**
150
+ * // update <input type="hidden" name="selling_plan"/> if available
151
+ *
152
+ * @param store
153
+ */
154
+ function synchronizeSellingPlan(store: any, offerElement?: HTMLElement) {
155
+ [...document.querySelectorAll('[name=id]')].forEach((productIdInput: HTMLInputElement) => {
156
+ const productId = productIdInput.value;
157
+
158
+ const subscribedSelector = makeSubscribedSelector({ id: productId });
159
+ const sellingPlanId = subscribedSelector(store.getState())?.frequency;
160
+
161
+ getOrCreateHidden(productIdInput.form, 'selling_plan', sellingPlanId);
162
+ if (offerElement) {
163
+ // use this to update the product attributes in future
164
+ }
165
+ });
166
+ }
167
+
168
+ export default function shopifyMiddleware(store) {
169
+ return next => action => {
170
+ /**
171
+ * This redux middleware will perform Shopify specific side-effects such as change
172
+ * the product selling plan when offer is cart
173
+ */
174
+ switch (action.type) {
175
+ case OPTIN_PRODUCT:
176
+ case OPTOUT_PRODUCT:
177
+ case PRODUCT_CHANGE_FREQUENCY:
178
+ break;
179
+ case REQUEST_OFFER:
180
+ if (action.payload.offer?.isCart) {
181
+ setupCart(store);
182
+ } else {
183
+ setupPdp(store);
184
+ }
185
+ default:
186
+ }
187
+
188
+ next(action);
189
+
190
+ switch (action.type) {
191
+ case OPTIN_PRODUCT:
192
+ case OPTOUT_PRODUCT:
193
+ case PRODUCT_CHANGE_FREQUENCY:
194
+ synchronizeCartOptin(action, store);
195
+ case REQUEST_OFFER:
196
+ case SETUP_PRODUCT:
197
+ synchronizeSellingPlan(store, action.payload.offer);
198
+ break;
199
+ default:
200
+ }
201
+ };
202
+ }