@ordergroove/offers 2.46.0 → 2.46.1-alpha-PR-1280-4.55
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/dist/bundle-report.html +31 -31
- package/dist/offers.js +72 -72
- package/dist/offers.js.map +4 -4
- package/package.json +2 -2
- package/src/components/Offer.js +3 -1
- package/src/components/Price.js +31 -11
- package/src/components/__tests__/Price.spec.js +74 -1
- package/src/core/__tests__/experiments.spec.js +16 -3
- package/src/core/__tests__/reducer.spec.js +152 -1
- package/src/core/__tests__/selectors.spec.js +405 -1
- package/src/core/adapters.js +2 -0
- package/src/core/constants.js +7 -0
- package/src/core/experiments.js +3 -2
- package/src/core/reducer.ts +41 -9
- package/src/core/selectors.ts +66 -1
- package/src/core/types/api.ts +19 -1
- package/src/core/types/reducer.ts +14 -1
- package/src/shopify/__tests__/productPlan.spec.js +3 -3
- package/src/shopify/__tests__/shopifyMiddleware.spec.js +223 -6
- package/src/shopify/__tests__/shopifyReducer.spec.js +90 -17
- package/src/shopify/reducers/productPlans.ts +2 -1
- package/src/shopify/shopifyMiddleware.ts +44 -7
- package/src/shopify/shopifyReducer.ts +21 -0
- package/src/shopify/types/productPlan.ts +1 -0
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
templatesSelector,
|
|
6
6
|
makeProductSpecificDefaultFrequencySelector,
|
|
7
7
|
makeProductPrepaidShipmentOptionsSelector,
|
|
8
|
-
makeProductFrequenciesSelector
|
|
8
|
+
makeProductFrequenciesSelector,
|
|
9
|
+
makeDiscountedProductPriceSelector,
|
|
10
|
+
isShopifyDiscountFunctionInUseSelector
|
|
9
11
|
} from '../selectors';
|
|
10
12
|
import { stringifyFrequency } from '../api';
|
|
11
13
|
|
|
@@ -231,3 +233,405 @@ describe('makeProductFrequenciesSelector', () => {
|
|
|
231
233
|
});
|
|
232
234
|
});
|
|
233
235
|
});
|
|
236
|
+
|
|
237
|
+
describe('makeDiscountedProductPriceSelector', () => {
|
|
238
|
+
/** @returns {import('../types/reducer').State} */
|
|
239
|
+
function makeTestState({
|
|
240
|
+
incentives = [
|
|
241
|
+
{
|
|
242
|
+
object: 'item',
|
|
243
|
+
field: 'total_price',
|
|
244
|
+
type: 'Discount Percent',
|
|
245
|
+
value: 11,
|
|
246
|
+
criteria: {
|
|
247
|
+
node_type: 'PREMISE',
|
|
248
|
+
standard: 'PROGRAM_WIDE'
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
],
|
|
252
|
+
price = 1000,
|
|
253
|
+
currency = 'USD',
|
|
254
|
+
productId = 123
|
|
255
|
+
} = {}) {
|
|
256
|
+
return {
|
|
257
|
+
config: {
|
|
258
|
+
storeCurrency: currency
|
|
259
|
+
},
|
|
260
|
+
price: {
|
|
261
|
+
[productId]: { value: price }
|
|
262
|
+
},
|
|
263
|
+
incentives: {
|
|
264
|
+
[productId]: {
|
|
265
|
+
initial: incentives,
|
|
266
|
+
ongoing: []
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
it('applies Discount Percent incentive (USD)', () => {
|
|
273
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
274
|
+
const result = selector(makeTestState());
|
|
275
|
+
expect(result).toEqual({
|
|
276
|
+
regularPrice: '$10.00',
|
|
277
|
+
discountRate: '11%',
|
|
278
|
+
subscriptionPrice: '$8.90'
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('applies Discount Amount incentive (USD)', () => {
|
|
283
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
284
|
+
const result = selector(
|
|
285
|
+
makeTestState({
|
|
286
|
+
incentives: [
|
|
287
|
+
{
|
|
288
|
+
object: 'item',
|
|
289
|
+
field: 'total_price',
|
|
290
|
+
type: 'Discount Amount',
|
|
291
|
+
value: 2.5, // $2.50 off
|
|
292
|
+
criteria: {
|
|
293
|
+
node_type: 'PREMISE',
|
|
294
|
+
standard: 'PROGRAM_WIDE'
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
})
|
|
299
|
+
);
|
|
300
|
+
expect(result).toEqual({
|
|
301
|
+
regularPrice: '$10.00',
|
|
302
|
+
discountRate: '$2.50',
|
|
303
|
+
subscriptionPrice: '$7.50'
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('handles Discount Amount larger than price', () => {
|
|
308
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
309
|
+
const result = selector(
|
|
310
|
+
makeTestState({
|
|
311
|
+
incentives: [
|
|
312
|
+
{
|
|
313
|
+
object: 'item',
|
|
314
|
+
field: 'total_price',
|
|
315
|
+
type: 'Discount Amount',
|
|
316
|
+
value: 15, // $15.00 off
|
|
317
|
+
criteria: {
|
|
318
|
+
node_type: 'PREMISE',
|
|
319
|
+
standard: 'PROGRAM_WIDE'
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
]
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
expect(result).toEqual({
|
|
326
|
+
regularPrice: '$10.00',
|
|
327
|
+
discountRate: '$10.00',
|
|
328
|
+
subscriptionPrice: '$0.00'
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('does not apply Discount Amount for non-USD currency', () => {
|
|
333
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
334
|
+
const result = selector(
|
|
335
|
+
makeTestState({
|
|
336
|
+
incentives: [
|
|
337
|
+
{
|
|
338
|
+
object: 'item',
|
|
339
|
+
field: 'total_price',
|
|
340
|
+
type: 'Discount Amount',
|
|
341
|
+
value: 2.5,
|
|
342
|
+
criteria: {
|
|
343
|
+
node_type: 'PREMISE',
|
|
344
|
+
standard: 'PROGRAM_WIDE'
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
],
|
|
348
|
+
currency: 'EUR'
|
|
349
|
+
})
|
|
350
|
+
);
|
|
351
|
+
expect(result).toEqual({
|
|
352
|
+
regularPrice: '€10.00',
|
|
353
|
+
discountRate: '€0.00', // no discount applied
|
|
354
|
+
subscriptionPrice: '€10.00'
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('applies Discount Percent for non-USD currency', () => {
|
|
359
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
360
|
+
const result = selector(
|
|
361
|
+
makeTestState({
|
|
362
|
+
incentives: [
|
|
363
|
+
{
|
|
364
|
+
object: 'item',
|
|
365
|
+
field: 'total_price',
|
|
366
|
+
type: 'Discount Percent',
|
|
367
|
+
value: 20,
|
|
368
|
+
criteria: {
|
|
369
|
+
node_type: 'PREMISE',
|
|
370
|
+
standard: 'PROGRAM_WIDE'
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
],
|
|
374
|
+
currency: 'GBP'
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
expect(result).toEqual({
|
|
378
|
+
regularPrice: '£10.00',
|
|
379
|
+
discountRate: '20%',
|
|
380
|
+
subscriptionPrice: '£8.00'
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('handles missing price for product', () => {
|
|
385
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
386
|
+
const state = makeTestState({ productId: 123 });
|
|
387
|
+
delete state.price[123];
|
|
388
|
+
|
|
389
|
+
const result = selector(state);
|
|
390
|
+
expect(result).toEqual({});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('handles empty incentives array', () => {
|
|
394
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
395
|
+
const result = selector(makeTestState({ incentives: [] }));
|
|
396
|
+
expect(result).toEqual({
|
|
397
|
+
regularPrice: '$10.00',
|
|
398
|
+
discountRate: '$0.00',
|
|
399
|
+
subscriptionPrice: '$10.00'
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('applies 100% discount', () => {
|
|
404
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
405
|
+
const result = selector(
|
|
406
|
+
makeTestState({
|
|
407
|
+
incentives: [
|
|
408
|
+
{
|
|
409
|
+
object: 'item',
|
|
410
|
+
field: 'total_price',
|
|
411
|
+
type: 'Discount Percent',
|
|
412
|
+
value: 100,
|
|
413
|
+
criteria: {
|
|
414
|
+
node_type: 'PREMISE',
|
|
415
|
+
standard: 'PROGRAM_WIDE'
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
]
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
expect(result).toEqual({
|
|
422
|
+
regularPrice: '$10.00',
|
|
423
|
+
discountRate: '100%',
|
|
424
|
+
subscriptionPrice: '$0.00'
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('handles zero price', () => {
|
|
429
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
430
|
+
const result = selector(
|
|
431
|
+
makeTestState({
|
|
432
|
+
price: 0
|
|
433
|
+
})
|
|
434
|
+
);
|
|
435
|
+
expect(result).toEqual({
|
|
436
|
+
regularPrice: '$0.00',
|
|
437
|
+
discountRate: '11%',
|
|
438
|
+
subscriptionPrice: '$0.00'
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('ignores unknown incentive types', () => {
|
|
443
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
444
|
+
const result = selector(
|
|
445
|
+
makeTestState({
|
|
446
|
+
incentives: [
|
|
447
|
+
{
|
|
448
|
+
object: 'item',
|
|
449
|
+
field: 'total_price',
|
|
450
|
+
type: 'Unknown Type',
|
|
451
|
+
value: 50,
|
|
452
|
+
criteria: {
|
|
453
|
+
node_type: 'PREMISE',
|
|
454
|
+
standard: 'PROGRAM_WIDE'
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
],
|
|
458
|
+
price: 1000
|
|
459
|
+
})
|
|
460
|
+
);
|
|
461
|
+
expect(result).toEqual({
|
|
462
|
+
regularPrice: '$10.00',
|
|
463
|
+
discountRate: '$0.00',
|
|
464
|
+
subscriptionPrice: '$10.00'
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('filters out irrelevant incentives', () => {
|
|
469
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
470
|
+
const result = selector(
|
|
471
|
+
makeTestState({
|
|
472
|
+
incentives: [
|
|
473
|
+
{
|
|
474
|
+
object: 'order',
|
|
475
|
+
field: 'total_price',
|
|
476
|
+
type: 'Discount Percent',
|
|
477
|
+
value: 50,
|
|
478
|
+
criteria: {
|
|
479
|
+
node_type: 'PREMISE',
|
|
480
|
+
standard: 'PROGRAM_WIDE'
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
object: 'order',
|
|
485
|
+
field: 'shipping_total',
|
|
486
|
+
type: 'Discount Percent',
|
|
487
|
+
value: 25,
|
|
488
|
+
criteria: {
|
|
489
|
+
node_type: 'PREMISE',
|
|
490
|
+
standard: 'PROGRAM_WIDE'
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
{
|
|
495
|
+
object: 'item',
|
|
496
|
+
field: 'total_price',
|
|
497
|
+
type: 'Discount Percent',
|
|
498
|
+
value: 33,
|
|
499
|
+
criteria: {
|
|
500
|
+
node_type: 'PREMISE',
|
|
501
|
+
standard: 'PREPAID_ORDERS_PER_BILLING'
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
object: 'item',
|
|
506
|
+
field: 'total_price',
|
|
507
|
+
type: 'Discount Percent',
|
|
508
|
+
value: 37,
|
|
509
|
+
criteria: {
|
|
510
|
+
node_type: 'AND',
|
|
511
|
+
children: [
|
|
512
|
+
{
|
|
513
|
+
node_type: 'PREMISE',
|
|
514
|
+
standard: 'NTH_ORDER_FOR_SUBSCRIBER',
|
|
515
|
+
premise_value: 3,
|
|
516
|
+
premise_operand: 'GREATER_THAN'
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
node_type: 'PREMISE',
|
|
520
|
+
standard: 'ITEM_HAS_SUBSCRIPTION',
|
|
521
|
+
premise_value: true
|
|
522
|
+
}
|
|
523
|
+
]
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
object: 'item',
|
|
528
|
+
field: 'total_price',
|
|
529
|
+
type: 'Discount Percent',
|
|
530
|
+
value: 10,
|
|
531
|
+
criteria: {
|
|
532
|
+
node_type: 'PREMISE',
|
|
533
|
+
standard: 'PROGRAM_WIDE'
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
]
|
|
537
|
+
})
|
|
538
|
+
);
|
|
539
|
+
expect(result).toEqual({
|
|
540
|
+
regularPrice: '$10.00',
|
|
541
|
+
discountRate: '10%',
|
|
542
|
+
subscriptionPrice: '$9.00'
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('displays PSI incentive when available', () => {
|
|
547
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
548
|
+
const result = selector(
|
|
549
|
+
makeTestState({
|
|
550
|
+
incentives: [
|
|
551
|
+
{
|
|
552
|
+
object: 'item',
|
|
553
|
+
field: 'total_price',
|
|
554
|
+
type: 'Discount Percent',
|
|
555
|
+
value: 15,
|
|
556
|
+
criteria: {
|
|
557
|
+
node_type: 'PREMISE',
|
|
558
|
+
standard: 'PSI'
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
]
|
|
562
|
+
})
|
|
563
|
+
);
|
|
564
|
+
expect(result).toEqual({
|
|
565
|
+
regularPrice: '$10.00',
|
|
566
|
+
discountRate: '15%',
|
|
567
|
+
subscriptionPrice: '$8.50'
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('does not calculate a discount if no standardized incentives', () => {
|
|
572
|
+
const selector = makeDiscountedProductPriceSelector(123);
|
|
573
|
+
const result = selector(
|
|
574
|
+
makeTestState({
|
|
575
|
+
incentives: [
|
|
576
|
+
{
|
|
577
|
+
object: 'item',
|
|
578
|
+
field: 'total_price',
|
|
579
|
+
type: 'Discount Percent',
|
|
580
|
+
value: 50
|
|
581
|
+
}
|
|
582
|
+
]
|
|
583
|
+
})
|
|
584
|
+
);
|
|
585
|
+
expect(result).toEqual({
|
|
586
|
+
regularPrice: '$10.00',
|
|
587
|
+
discountRate: '$0.00',
|
|
588
|
+
subscriptionPrice: '$10.00'
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe('isShopifyDiscountFunctionInUseSelector', () => {
|
|
594
|
+
it('should return false when productPlans is an empty object', () => {
|
|
595
|
+
const state = { productPlans: {} };
|
|
596
|
+
expect(isShopifyDiscountFunctionInUseSelector(state)).toBe(false);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('should return false when productPlans has product with empty array', () => {
|
|
600
|
+
const state = { productPlans: { 123: [] } };
|
|
601
|
+
expect(isShopifyDiscountFunctionInUseSelector(state)).toBe(false);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('should return false when some product plan does not have hasPriceAdjustments property', () => {
|
|
605
|
+
const state = {
|
|
606
|
+
productPlans: {
|
|
607
|
+
123: [{ id: 'plan-1' }],
|
|
608
|
+
456: [{ id: 'plan-2', hasPriceAdjustments: false }]
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
expect(isShopifyDiscountFunctionInUseSelector(state)).toBe(false);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('should return false when some product plan has hasPriceAdjustments = true', () => {
|
|
615
|
+
const state = {
|
|
616
|
+
productPlans: {
|
|
617
|
+
123: [{ id: 'plan-1', hasPriceAdjustments: false }],
|
|
618
|
+
456: [{ id: 'plan-2', hasPriceAdjustments: true }]
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
expect(isShopifyDiscountFunctionInUseSelector(state)).toBe(false);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('should return true when all product plans have hasPriceAdjustments = false or are prepaid', () => {
|
|
625
|
+
const state = {
|
|
626
|
+
productPlans: {
|
|
627
|
+
123: [{ id: 'plan-1', hasPriceAdjustments: false }],
|
|
628
|
+
456: [
|
|
629
|
+
{ id: 'plan-2', hasPriceAdjustments: false },
|
|
630
|
+
{ id: 'plan-3', hasPriceAdjustments: false },
|
|
631
|
+
{ id: 'plan-3', hasPriceAdjustments: true, prepaidShipments: 3 }
|
|
632
|
+
]
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
expect(isShopifyDiscountFunctionInUseSelector(state)).toBe(true);
|
|
636
|
+
});
|
|
637
|
+
});
|
package/src/core/adapters.js
CHANGED
|
@@ -45,6 +45,8 @@ export const getProductsForPurchasePost = (state = {}, productIds = []) =>
|
|
|
45
45
|
* discountRate: '10%'
|
|
46
46
|
* },
|
|
47
47
|
* ]
|
|
48
|
+
*
|
|
49
|
+
* @returns {import('./types/reducer').ProductPlansState}
|
|
48
50
|
*/
|
|
49
51
|
export const getObjectStructuredProductPlans = (productPlans = {}) => {
|
|
50
52
|
const adaptedProductPlans = {};
|
package/src/core/constants.js
CHANGED
|
@@ -54,6 +54,13 @@ export const ENV_PROD = 'prod';
|
|
|
54
54
|
export const STATIC_HOST = 'static.ordergroove.com';
|
|
55
55
|
export const STAGING_STATIC_HOST = 'staging.static.ordergroove.com';
|
|
56
56
|
|
|
57
|
+
export const INCENTIVE_STANDARD_TYPES = {
|
|
58
|
+
PSI: 'PSI',
|
|
59
|
+
// note: this is a "pseudo-standard" that we set in our own code
|
|
60
|
+
// the API actually returns no criteria for program wide incentives
|
|
61
|
+
PROGRAM_WIDE: 'PROGRAM_WIDE'
|
|
62
|
+
};
|
|
63
|
+
|
|
57
64
|
/**
|
|
58
65
|
* @event
|
|
59
66
|
* Events that fires once optin/optout occurs on a cart offer
|
package/src/core/experiments.js
CHANGED
|
@@ -71,8 +71,9 @@ export function experimentsReducer(state = {}, action) {
|
|
|
71
71
|
* object.
|
|
72
72
|
*/
|
|
73
73
|
function resolveShopifySetupProductWhenExperiment(variant, product, experimentSettings) {
|
|
74
|
-
//
|
|
75
|
-
//
|
|
74
|
+
// When an experiment is running, you may have multiple selling plan groups with the app_id "ordergroove-subscribe-and-save-{experiment_variant_id}"
|
|
75
|
+
// We want offers to use only the selling plan group that matches the assigned experiment variant
|
|
76
|
+
// Note: whether or not you have variant-specific selling plan groups depends on if the Shopify Discount Function is being used. If it is, then the selling plan groups do not contain price adjustments and there is only a single selling plan group (all variants use the same group).
|
|
76
77
|
if (!variant) return;
|
|
77
78
|
|
|
78
79
|
if (experimentSettings.variants.length === 0) return;
|
package/src/core/reducer.ts
CHANGED
|
@@ -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 = (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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(
|
|
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
|
});
|
package/src/core/selectors.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/core/types/api.ts
CHANGED
|
@@ -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:
|
|
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>;
|