@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ordergroove/offers",
3
- "version": "2.46.0",
3
+ "version": "2.46.1-alpha-PR-1280-4.55+fb9521c5",
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.10.0",
50
50
  "@types/lodash.memoize": "^4.1.9"
51
51
  },
52
- "gitHead": "29fe0f44f5d9dc13b9c8a150b894b5a99c31cd8d"
52
+ "gitHead": "fb9521c55b1918562fb538d5f658c5ba499ebe67"
53
53
  }
@@ -76,7 +76,9 @@ export class Offer extends TemplateElement {
76
76
  productFrequency: { type: String },
77
77
  isCart: { type: Boolean, attribute: 'cart' },
78
78
  optedin: { type: Object },
79
- variationId: { type: String }
79
+ variationId: { type: String },
80
+ /** Attribute to force reading prices from the Offer response instead of the selling plan. Only used for testing. */
81
+ overrideSellingPlanPrice: { type: Boolean, attribute: 'dev-override-selling-plan-price' }
80
82
  };
81
83
  }
82
84
 
@@ -3,7 +3,11 @@ import { connect } from '../core/connect';
3
3
 
4
4
  import { withProduct } from '../core/resolveProperties';
5
5
  import { TemplateElement } from '../core/base';
6
- import { makeProductDefaultFrequencySelector, makeProductFrequencyOptedInSelector } from '../core/selectors';
6
+ import {
7
+ makeDiscountedProductPriceSelector,
8
+ makeProductDefaultFrequencySelector,
9
+ makeProductFrequencyOptedInSelector
10
+ } from '../core/selectors';
7
11
  import { safeProductId } from '../core/utils';
8
12
 
9
13
  export class Price extends withProduct(TemplateElement) {
@@ -13,9 +17,13 @@ export class Price extends withProduct(TemplateElement) {
13
17
  regular: { type: Boolean, reflect: true },
14
18
  subscription: { type: Boolean, reflect: true },
15
19
  discount: { type: Boolean, reflect: true },
20
+ /** Force displaying the pay-as-you-go price. This is relevant when there is a prepaid plan that the user has opted into, and you still want to display the pay-as-you-go price for comparison */
16
21
  payAsYouGo: { type: Boolean, reflect: true, attribute: 'pay-as-you-go' },
17
22
  frequency: { type: Object },
18
- productPlans: { type: Object }
23
+ /** If Shopify, this is derived from the selling plans attached to the product */
24
+ productPlans: { type: Object },
25
+ /** The discounted price, as calculated from the Offers API response */
26
+ discountedProductPriceFromOffers: { type: Object }
19
27
  };
20
28
  }
21
29
 
@@ -46,17 +54,28 @@ export class Price extends withProduct(TemplateElement) {
46
54
  const frequency = this.frequency || this.configDefaultFrequency || this.offer?.defaultFrequency;
47
55
  const plans = this.productPlans[realProductId] || [];
48
56
 
49
- if (this.payAsYouGo) {
50
- const payAsYouGoPlan = plans.find(plan => plan.prepaidShipments === null || plan.prepaidShipments === undefined);
51
- if (!payAsYouGoPlan) return '';
52
- return payAsYouGoPlan.subscriptionPrice;
53
- }
57
+ let currentPlan = this.payAsYouGo
58
+ ? plans.find(plan => plan.prepaidShipments === null || plan.prepaidShipments === undefined)
59
+ : plans.find(plan => plan.frequency === frequency);
54
60
 
55
- const currentPlan = plans.find(plan => plan.frequency === frequency);
56
61
  if (!currentPlan) return '';
57
- const { regularPrice, discountRate, subscriptionPrice } = currentPlan;
58
62
 
59
- if (subscriptionPrice === regularPrice) return '';
63
+ // default to pulling from the selling plan
64
+ let { regularPrice, discountRate, subscriptionPrice } = currentPlan;
65
+ // if the selling plan has no price adjustments, then use the offer response to determine the discounted price
66
+ // this will be true for merchants on standardized offer profiles
67
+ // we still rely on the selling plan for prepaid subscriptions, for simplicity
68
+ if (
69
+ // overrideSellingPlanPrice is a dev flag to force using the offer price for testing purposes
70
+ (currentPlan.hasPriceAdjustments === false || this.offer?.overrideSellingPlanPrice) &&
71
+ !currentPlan.prepaidShipments
72
+ ) {
73
+ ({ regularPrice, discountRate, subscriptionPrice } = this.discountedProductPriceFromOffers);
74
+ }
75
+
76
+ // if payAsYouGo, always show the price even if no discount
77
+ // it's unclear if this was the original intention, but preserving existing behavior
78
+ if (subscriptionPrice === regularPrice && !this.payAsYouGo) return '';
60
79
 
61
80
  if (this.regular) {
62
81
  return regularPrice;
@@ -82,7 +101,8 @@ export class Price extends withProduct(TemplateElement) {
82
101
  const mapStateToProps = (state, ownProps) => ({
83
102
  productPlans: state.productPlans,
84
103
  configDefaultFrequency: makeProductDefaultFrequencySelector(ownProps.product?.id)(state),
85
- frequency: makeProductFrequencyOptedInSelector(ownProps.product)(state)
104
+ frequency: makeProductFrequencyOptedInSelector(ownProps.product)(state),
105
+ discountedProductPriceFromOffers: makeDiscountedProductPriceSelector(ownProps.product?.id)(state)
86
106
  });
87
107
 
88
108
  export default connect(mapStateToProps)(Price);
@@ -18,7 +18,8 @@ async function renderPriceTemplate(
18
18
  subscriptionPrice: '$0.90'
19
19
  }
20
20
  ]
21
- }
21
+ },
22
+ discountedProductPriceFromOffers: {}
22
23
  }
23
24
  ) {
24
25
  // make sure the element was cleaned up
@@ -27,6 +28,7 @@ async function renderPriceTemplate(
27
28
  const element = document.querySelector(TAG_NAME_UNDER_TEST);
28
29
  element.frequency = properties.frequency;
29
30
  element.productPlans = properties.productPlans;
31
+ element.discountedProductPriceFromOffers = properties.discountedProductPriceFromOffers;
30
32
  await element.updateComplete;
31
33
  return element;
32
34
  }
@@ -141,6 +143,28 @@ describe('Price', () => {
141
143
  expect(ogPriceNullPrepaid).toContain('$4.50');
142
144
  });
143
145
 
146
+ // note: it's unclear whether this behavior was intentional when first implemented, but codifying it with a test in case any merchant is relying on it
147
+ it('should render payAsYouGo price even if no discount', async () => {
148
+ const template = html`
149
+ <og-some-price pay-as-you-go product="yum id"></og-some-price>
150
+ `;
151
+
152
+ const priceDiv = await renderPriceTemplate(template, {
153
+ frequency: '1_3',
154
+ productPlans: {
155
+ 'yum id': [
156
+ {
157
+ frequency: '1_3',
158
+ regularPrice: '$5.00',
159
+ discountRate: '$0.00',
160
+ subscriptionPrice: '$5.00'
161
+ }
162
+ ]
163
+ }
164
+ });
165
+ expect(priceDiv.shadowRoot.innerHTML).toContain('$5.00');
166
+ });
167
+
144
168
  it('should render empty price when subscription is equal to regular', async () => {
145
169
  const template = html`
146
170
  <og-some-price discount product="yum id"></og-some-price>
@@ -162,4 +186,53 @@ describe('Price', () => {
162
186
  expect(ogPrice).not.toContain('10%');
163
187
  expect(ogPrice).not.toContain('$1.00');
164
188
  });
189
+
190
+ describe('discountedProductPriceFromOffers', () => {
191
+ async function setupDiscountedProductPriceTest(hasPriceAdjustments, extraPlanProps = {}) {
192
+ const template = html`
193
+ <og-some-price product="yum id"></og-some-price>
194
+ `;
195
+ return renderPriceTemplate(template, {
196
+ frequency: '1_3',
197
+ productPlans: {
198
+ 'yum id': [
199
+ {
200
+ frequency: '1_3',
201
+ regularPrice: '$1.00',
202
+ discountRate: '10%',
203
+ subscriptionPrice: '$PLAN_PRICE',
204
+ hasPriceAdjustments: hasPriceAdjustments,
205
+ ...extraPlanProps
206
+ }
207
+ ]
208
+ },
209
+ discountedProductPriceFromOffers: {
210
+ regularPrice: '$1.00',
211
+ discountRate: '20%',
212
+ subscriptionPrice: '$OFFER_PRICE'
213
+ }
214
+ });
215
+ }
216
+
217
+ it('should render discountedProductPriceFromOffers when selling plan has no price adjustments', async () => {
218
+ const priceDiv = await setupDiscountedProductPriceTest(false);
219
+
220
+ const insideText = priceDiv.shadowRoot.textContent.trim();
221
+ expect(insideText).toBe('$OFFER_PRICE');
222
+ });
223
+
224
+ it('should not use discountedProductPriceFromOffers when selling plan has price adjustments', async () => {
225
+ const priceDiv = await setupDiscountedProductPriceTest(true);
226
+
227
+ const insideText = priceDiv.shadowRoot.textContent.trim();
228
+ expect(insideText).toBe('$PLAN_PRICE');
229
+ });
230
+
231
+ it('should use discountedProductPriceFromOffers for prepaid subscriptions even when plan does not have price adjustments', async () => {
232
+ const priceDiv = await setupDiscountedProductPriceTest(false, { prepaidShipments: 3 });
233
+
234
+ const insideText = priceDiv.shadowRoot.textContent.trim();
235
+ expect(insideText).toBe('$PLAN_PRICE');
236
+ });
237
+ });
165
238
  });
@@ -281,6 +281,19 @@ describe('experiments.shopify', () => {
281
281
 
282
282
  const session_A = 'cda';
283
283
  const session_B = 'abc';
284
+
285
+ function expectPlanFrequenciesToMatch(actualProductPlans, expectedProductPlans) {
286
+ for (const productId in expectedProductPlans) {
287
+ const actualPlans = actualProductPlans[productId];
288
+ const expectedPlans = expectedProductPlans[productId];
289
+
290
+ const actualFrequencies = actualPlans.map(plan => plan.frequency).sort();
291
+ const expectedFrequencies = expectedPlans.map(plan => plan.frequency).sort();
292
+
293
+ expect(actualFrequencies).toEqual(expectedFrequencies);
294
+ }
295
+ }
296
+
284
297
  it('should not change selling plan groups if no experiment matches', async () => {
285
298
  const experiments = {
286
299
  public_id: '123',
@@ -302,7 +315,7 @@ describe('experiments.shopify', () => {
302
315
 
303
316
  await new Promise(r => setTimeout(r, 10));
304
317
 
305
- expect(store.getState().productPlans).toEqual(expectedNoModifiedProductPlans);
318
+ expectPlanFrequenciesToMatch(store.getState().productPlans, expectedNoModifiedProductPlans);
306
319
  });
307
320
 
308
321
  it('should change selling plan groups if experiment matches variant A', async () => {
@@ -360,7 +373,7 @@ describe('experiments.shopify', () => {
360
373
  }
361
374
  ]
362
375
  };
363
- expect(store.getState().productPlans).toEqual(expectedVariantAProductPlans);
376
+ expectPlanFrequenciesToMatch(store.getState().productPlans, expectedVariantAProductPlans);
364
377
  });
365
378
 
366
379
  it('should change selling plan groups if experiment matches variant B', async () => {
@@ -413,6 +426,6 @@ describe('experiments.shopify', () => {
413
426
  ]
414
427
  };
415
428
 
416
- expect(store.getState().productPlans).toEqual(expectedVariantBProductPlans);
429
+ expectPlanFrequenciesToMatch(store.getState().productPlans, expectedVariantBProductPlans);
417
430
  });
418
431
  });
@@ -11,7 +11,8 @@ import {
11
11
  eligibilityGroups,
12
12
  defaultFrequencies,
13
13
  productPlans,
14
- prepaidShipmentsSelected
14
+ prepaidShipmentsSelected,
15
+ incentives
15
16
  } from '../reducer';
16
17
  import * as constants from '../constants';
17
18
  import { getObjectStructuredProductPlans } from '../adapters';
@@ -1003,4 +1004,154 @@ describe('reducers', () => {
1003
1004
  });
1004
1005
  });
1005
1006
  });
1007
+
1008
+ describe('incentives', () => {
1009
+ const offerResponse = {
1010
+ result: 'success',
1011
+ module_view: { regular: 'f5aa0b83653c4fae956bc0440415dfcb' },
1012
+ incentives: {
1013
+ 44198332104980: {
1014
+ ongoing: ['8285f16f826e4cf29b49f073e45e32ab', '10205e185a5c4ccf83de75459241623a'],
1015
+ initial: ['0f98a321d29e47d2a17fe3a8a7522d26']
1016
+ }
1017
+ },
1018
+ incentives_display: {
1019
+ '8285f16f826e4cf29b49f073e45e32ab': {
1020
+ object: 'order',
1021
+ field: 'sub_total',
1022
+ type: 'Discount Percent',
1023
+ value: 10.0
1024
+ },
1025
+ '10205e185a5c4ccf83de75459241623a': {
1026
+ object: 'item',
1027
+ field: 'total_price',
1028
+ type: 'Discount Amount',
1029
+ value: 5.0
1030
+ },
1031
+ '0f98a321d29e47d2a17fe3a8a7522d26': {
1032
+ object: 'item',
1033
+ field: 'total_price',
1034
+ type: 'Discount Percent',
1035
+ value: 11.0
1036
+ }
1037
+ }
1038
+ };
1039
+
1040
+ it('should map incentives correctly', () => {
1041
+ expect(incentives({}, { type: constants.RECEIVE_OFFER, payload: offerResponse })).toEqual({
1042
+ 44198332104980: {
1043
+ ongoing: [
1044
+ {
1045
+ object: 'order',
1046
+ field: 'sub_total',
1047
+ type: 'Discount Percent',
1048
+ value: 10.0,
1049
+ id: '8285f16f826e4cf29b49f073e45e32ab'
1050
+ },
1051
+ {
1052
+ object: 'item',
1053
+ field: 'total_price',
1054
+ type: 'Discount Amount',
1055
+ value: 5.0,
1056
+ id: '10205e185a5c4ccf83de75459241623a'
1057
+ }
1058
+ ],
1059
+ initial: [
1060
+ {
1061
+ object: 'item',
1062
+ field: 'total_price',
1063
+ type: 'Discount Percent',
1064
+ value: 11.0,
1065
+ id: '0f98a321d29e47d2a17fe3a8a7522d26'
1066
+ }
1067
+ ]
1068
+ }
1069
+ });
1070
+ });
1071
+
1072
+ it('should consider incentives_display_enhanced if present', () => {
1073
+ const enhancedOfferResponse = {
1074
+ ...offerResponse,
1075
+ incentives_display_enhanced: {
1076
+ '8285f16f826e4cf29b49f073e45e32ab': {
1077
+ incentive_target: 'order',
1078
+ incentive_type: 'discount_percent',
1079
+ incentive_value: '10.00',
1080
+ // no criteria, since this is program-wide
1081
+ threshold_field: null,
1082
+ threshold_value: null
1083
+ },
1084
+ '10205e185a5c4ccf83de75459241623a': {
1085
+ incentive_target: 'item',
1086
+ incentive_type: 'discount_percent',
1087
+ incentive_value: '5.00',
1088
+ threshold_field: null,
1089
+ threshold_value: null,
1090
+ criteria: {
1091
+ node_type: 'PREMISE',
1092
+ standard: 'PREPAID_ORDERS_PER_BILLING',
1093
+ premise_value: 3
1094
+ }
1095
+ },
1096
+ '0f98a321d29e47d2a17fe3a8a7522d26': {
1097
+ incentive_target: 'item',
1098
+ incentive_type: 'discount_percent',
1099
+ incentive_value: '11.00',
1100
+ threshold_field: null,
1101
+ threshold_value: null,
1102
+ criteria: {
1103
+ node_type: 'PREMISE',
1104
+ standard: 'PSI',
1105
+ premise_value: ['Product Group A']
1106
+ }
1107
+ }
1108
+ }
1109
+ };
1110
+
1111
+ expect(incentives({}, { type: constants.RECEIVE_OFFER, payload: enhancedOfferResponse })).toEqual({
1112
+ 44198332104980: {
1113
+ ongoing: [
1114
+ {
1115
+ object: 'order',
1116
+ field: 'sub_total',
1117
+ type: 'Discount Percent',
1118
+ value: 10.0,
1119
+ id: '8285f16f826e4cf29b49f073e45e32ab',
1120
+ criteria: {
1121
+ node_type: 'PREMISE',
1122
+ standard: 'PROGRAM_WIDE',
1123
+ premise_value: null
1124
+ }
1125
+ },
1126
+ {
1127
+ object: 'item',
1128
+ field: 'total_price',
1129
+ type: 'Discount Amount',
1130
+ value: 5.0,
1131
+ id: '10205e185a5c4ccf83de75459241623a',
1132
+ criteria: {
1133
+ node_type: 'PREMISE',
1134
+ standard: 'PREPAID_ORDERS_PER_BILLING',
1135
+ premise_value: 3
1136
+ }
1137
+ }
1138
+ ],
1139
+ initial: [
1140
+ {
1141
+ object: 'item',
1142
+ field: 'total_price',
1143
+ type: 'Discount Percent',
1144
+ value: 11.0,
1145
+ id: '0f98a321d29e47d2a17fe3a8a7522d26',
1146
+ criteria: {
1147
+ node_type: 'PREMISE',
1148
+ standard: 'PSI',
1149
+ premise_value: ['Product Group A']
1150
+ }
1151
+ }
1152
+ ]
1153
+ }
1154
+ });
1155
+ });
1156
+ });
1006
1157
  });