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

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 (47) hide show
  1. package/build.js +3 -1
  2. package/dist/bundle-report.html +174 -103
  3. package/dist/offers.js +63 -74
  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 +32 -2
  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 +3 -3
  20. package/src/components/Select.js +3 -13
  21. package/src/components/SelectFrequency.js +23 -5
  22. package/src/components/__tests__/OG.fspec.js +24 -0
  23. package/src/components/__tests__/Offer.spec.js +4 -4
  24. package/src/components/__tests__/OptinButton.spec.js +2 -2
  25. package/src/components/__tests__/OptinToggle.spec.js +2 -2
  26. package/src/components/__tests__/OptoutButton.spec.js +1 -1
  27. package/src/components/__tests__/SelectFrequency.fspec.js +1 -0
  28. package/src/components/__tests__/SelectFrequency.spec.js +1 -1
  29. package/src/components/__tests__/TestWizard.spec.js +2 -2
  30. package/src/core/__tests__/actions.spec.js +6 -6
  31. package/src/core/actions.js +10 -10
  32. package/src/core/constants.js +2 -0
  33. package/src/core/reducer.js +15 -14
  34. package/src/core/resolveProperties.js +2 -7
  35. package/src/core/selectors.js +1 -1
  36. package/src/core/store.js +6 -5
  37. package/src/index.js +44 -206
  38. package/src/make-api.js +187 -0
  39. package/src/shopify/__tests__/shopifyReducer.spec.js +477 -0
  40. package/src/shopify/getProduct.ts +6 -0
  41. package/src/shopify/guessProductHandle.ts +26 -0
  42. package/src/shopify/shopifyMiddleware.ts +137 -0
  43. package/src/shopify/shopifyReducer.js +199 -0
  44. package/tsconfig.json +35 -0
  45. package/examples/5starnutrition-main.js +0 -3
  46. package/examples/single-offer.html +0 -9
  47. 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,6 @@
1
+ import { guessProductHandle } from './guessProductHandle';
2
+
3
+
4
+ export async function getProduct(handle) {
5
+ return (await fetch(`${window.Shopify?.routes.root}products/${handle}.js`)).json();
6
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Attemps to guess the product handle o
3
+ * @returns
4
+ */
5
+
6
+ export function guessProductHandle(): String {
7
+ return (
8
+ [
9
+ () =>
10
+ // Use the oembed to get the product handle
11
+ (document.querySelector('[href$=".oembed"]')?.getAttribute('href')?.match(/\/([^\/]+)\.oembed$/) || [])[1],
12
+
13
+ () =>
14
+ // Use the open graph og:type==product and og:url to get the product handle
15
+ ((document.querySelector('meta[property="og:type"][content="product"]') &&
16
+ document.querySelector('meta[property="og:url"][content]')?.getAttribute('content')?.match(/\/([^\/]+)$/)) ||
17
+ [])[1],
18
+
19
+ () =>
20
+ // use any json in the markup
21
+ [...document.querySelectorAll('[type$=json]')].map(it => JSON.parse(it.textContent)).find(it => it.handle && it.price)?.handle
22
+ ]
23
+ // returns the first truthy and prevent call next functions
24
+ .reduce((acc, cur) => acc || cur(), '')
25
+ );
26
+ }
@@ -0,0 +1,137 @@
1
+ import {
2
+ OPTIN_PRODUCT,
3
+ OPTOUT_PRODUCT,
4
+ PRODUCT_CHANGE_FREQUENCY,
5
+ REQUEST_OFFER,
6
+ SETUP_CART,
7
+ SETUP_PRODUCT
8
+ } from '../core/constants';
9
+ import { guessProductHandle } from './guessProductHandle';
10
+ import { getProduct } from './getProduct';
11
+ import { makeSubscribedSelector } from '../core/selectors';
12
+
13
+ async function setupPdp(store) {
14
+ const handle = guessProductHandle();
15
+ if (handle) {
16
+ try {
17
+ store.dispatch({ type: SETUP_PRODUCT, payload: await getProduct(handle) });
18
+ } catch (err) {
19
+ console.warn('OG: Unable to fetch product details for PDP', err);
20
+ }
21
+ }
22
+ }
23
+
24
+ const getCart = async () => await (await fetch(`${window.Shopify?.routes.root}cart.js`)).json();
25
+
26
+ async function setupCart(store) {
27
+ const cart = await getCart();
28
+ store.dispatch({ type: SETUP_CART, payload: cart });
29
+ const { items } = cart;
30
+ const products = await Promise.all(Array.from(new Set(items.map(({ handle }) => handle))).map(getProduct));
31
+ products.forEach(product => store.dispatch({ type: SETUP_PRODUCT, payload: product }));
32
+ }
33
+
34
+ async function synchronizeCartOptin(action: any, store: any) {
35
+ const offerElement = action.payload.offer;
36
+ const selling_plan = action.payload.frequency || null;
37
+
38
+ if (offerElement?.isCart) {
39
+ const closestSection = offerElement.closest('.shopify-section');
40
+ const closestSectionId = closestSection && (closestSection.id.match(/^shopify-section-(.+)/) || [])[1];
41
+
42
+ const key = action.payload.product.id; // shopify cart.item.key
43
+ const cart = await getCart();
44
+ const item = cart.items.find(it => it.key === key); // cart.items[offerIx];
45
+
46
+ const res = await fetch('/cart/change.js', {
47
+ method: 'POST',
48
+ credentials: 'same-origin',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({
51
+ id: key,
52
+ quantity: item.quantity,
53
+ properties: {
54
+ ...item.properties
55
+ },
56
+ selling_plan: selling_plan || null,
57
+ sections: closestSectionId ? [closestSectionId] : undefined
58
+ })
59
+ });
60
+ if (res.status !== 200) {
61
+ throw new Error('Cart not updated');
62
+ }
63
+ const { sections } = (await res.json()) || {};
64
+
65
+ if (sections?.length) {
66
+ const [section] = Object.values(sections);
67
+
68
+ const el = new DOMParser()
69
+ .parseFromString(section?.toString() || '', 'text/html')
70
+ .getElementById('shopify-section-' + closestSectionId);
71
+ closestSection.innerHTML = el.innerHTML;
72
+ } else {
73
+ window.location.reload();
74
+ }
75
+
76
+ console.log('update cart');
77
+ }
78
+ }
79
+
80
+ /**
81
+ * // update <input type="hidden" name="selling_plan"/> if available
82
+ *
83
+ * @param store
84
+ */
85
+ function synchronizeSellingPlan(store: any) {
86
+ [...document.querySelectorAll('[name=id]')].forEach(productIdInput => {
87
+ const productId = productIdInput.value;
88
+ let sellingPlanInput = productIdInput.form.selling_plan;
89
+ if (!sellingPlanInput) {
90
+ sellingPlanInput = document.createElement('input');
91
+ sellingPlanInput.type = 'hidden';
92
+ sellingPlanInput.name = 'selling_plan';
93
+ productIdInput.form.appendChild(sellingPlanInput);
94
+ }
95
+
96
+ const subscribedSelector = makeSubscribedSelector({ id: productId });
97
+ const sellingPlanId = subscribedSelector(store.getState())?.frequency;
98
+
99
+ sellingPlanInput.value = sellingPlanId;
100
+ });
101
+ }
102
+
103
+ export default function shopifyMiddleware(store) {
104
+ return next => action => {
105
+ /**
106
+ * This redux middleware will perform Shopify specific side-effects such as change
107
+ * the product selling plan when offer is cart
108
+ */
109
+ switch (action.type) {
110
+ case OPTIN_PRODUCT:
111
+ case OPTOUT_PRODUCT:
112
+ case PRODUCT_CHANGE_FREQUENCY:
113
+ break;
114
+ case REQUEST_OFFER:
115
+ if (action.payload.offer?.isCart) {
116
+ setupCart(store);
117
+ } else {
118
+ setupPdp(store);
119
+ }
120
+ default:
121
+ }
122
+
123
+ next(action);
124
+
125
+ switch (action.type) {
126
+ case OPTIN_PRODUCT:
127
+ case OPTOUT_PRODUCT:
128
+ case PRODUCT_CHANGE_FREQUENCY:
129
+ synchronizeCartOptin(action, store);
130
+ case REQUEST_OFFER:
131
+ case SETUP_PRODUCT:
132
+ synchronizeSellingPlan(store);
133
+ break;
134
+ default:
135
+ }
136
+ };
137
+ }