@ordergroove/offers 2.45.6 → 2.46.1-alpha-PR-1285-2.53
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/CHANGELOG.md +11 -0
- package/dist/bundle-report.html +58 -54
- package/dist/examples.js +5 -5
- package/dist/examples.js.map +2 -2
- package/dist/offers.js +104 -77
- package/dist/offers.js.map +4 -4
- package/package.json +3 -3
- package/src/components/Offer.js +3 -1
- package/src/components/Price.js +31 -11
- package/src/components/Tooltip.js +127 -15
- package/src/components/__tests__/Price.spec.js +74 -1
- package/src/components/__tests__/Tooltip.spec.js +214 -3
- 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 +227 -6
- package/src/shopify/__tests__/shopifyReducer.spec.js +90 -17
- package/src/shopify/reducers/productPlans.ts +2 -1
- package/src/shopify/shopifyMiddleware.ts +45 -7
- package/src/shopify/shopifyReducer.ts +21 -0
- package/src/shopify/types/productPlan.ts +1 -0
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>;
|
|
@@ -2,6 +2,7 @@ import { Order, Incentive as ApiIncentive, MerchantSettings, OfferResponse } fro
|
|
|
2
2
|
import { type Offer } from '../../components/Offer';
|
|
3
3
|
import { ShopifyCart, ShopifyProductEntity } from '../../shopify/types/shopify';
|
|
4
4
|
import reducer from '../reducer';
|
|
5
|
+
import { ProductPlanEntity } from '../../shopify/types/productPlan';
|
|
5
6
|
|
|
6
7
|
export type State = ReturnType<typeof reducer>;
|
|
7
8
|
|
|
@@ -16,8 +17,16 @@ export type NextUpcomingOrderState = Partial<
|
|
|
16
17
|
|
|
17
18
|
export type IncentivesState = Record<string, IncentiveObject>;
|
|
18
19
|
|
|
19
|
-
type Incentive = ApiIncentive & {
|
|
20
|
+
export type Incentive = ApiIncentive & {
|
|
20
21
|
id: string;
|
|
22
|
+
/**
|
|
23
|
+
* undefined when the offer profile is not standardized
|
|
24
|
+
*/
|
|
25
|
+
criteria?: {
|
|
26
|
+
node_type: string;
|
|
27
|
+
premise_value: unknown;
|
|
28
|
+
standard: string;
|
|
29
|
+
};
|
|
21
30
|
};
|
|
22
31
|
|
|
23
32
|
export type IncentiveObject = {
|
|
@@ -68,6 +77,10 @@ export type OptedOutState = { id: string }[];
|
|
|
68
77
|
|
|
69
78
|
export type AutoshipByDefaultState = Record<string, boolean>;
|
|
70
79
|
|
|
80
|
+
export type PriceState = { [productId: string]: { value: number } };
|
|
81
|
+
|
|
82
|
+
export type ProductPlansState = { [productId: string]: ProductPlanEntity[] };
|
|
83
|
+
|
|
71
84
|
// payload types
|
|
72
85
|
|
|
73
86
|
export type ReceiveOfferPayload = OfferResponse & {
|
|
@@ -308,7 +308,7 @@ describe('Shopify productPlan Reducer', () => {
|
|
|
308
308
|
|
|
309
309
|
const productPlanCreated = mapSellingPlanToDiscount(allocation, [], 'USD');
|
|
310
310
|
|
|
311
|
-
expect(productPlanCreated).toEqual(expectedProductPlan);
|
|
311
|
+
expect(productPlanCreated).toEqual(jasmine.objectContaining(expectedProductPlan));
|
|
312
312
|
});
|
|
313
313
|
|
|
314
314
|
it('should create prepaid product plan', () => {
|
|
@@ -371,7 +371,7 @@ describe('Shopify productPlan Reducer', () => {
|
|
|
371
371
|
|
|
372
372
|
const productPlanCreated = mapSellingPlanToDiscount(allocation, sellingPlans, 'USD');
|
|
373
373
|
|
|
374
|
-
expect(productPlanCreated).toEqual(expectedProductPlan);
|
|
374
|
+
expect(productPlanCreated).toEqual(jasmine.objectContaining(expectedProductPlan));
|
|
375
375
|
});
|
|
376
376
|
|
|
377
377
|
it('should create prepaid product plan that rounds subscriptionPrice to nearest decimal', () => {
|
|
@@ -512,7 +512,7 @@ describe('Shopify productPlan Reducer', () => {
|
|
|
512
512
|
|
|
513
513
|
const productPlanCreated = mapSellingPlanToDiscount(allocation, sellingPlans, 'USD');
|
|
514
514
|
|
|
515
|
-
expect(productPlanCreated).toEqual(expectedProductPlan);
|
|
515
|
+
expect(productPlanCreated).toEqual(jasmine.objectContaining(expectedProductPlan));
|
|
516
516
|
});
|
|
517
517
|
});
|
|
518
518
|
|
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
PRODUCT_CHANGE_PREPAID_SHIPMENTS,
|
|
8
8
|
SETUP_CART
|
|
9
9
|
} from '../../core/constants';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
synchronizeCartOptin,
|
|
12
|
+
getTrackingEvent,
|
|
13
|
+
guessProductHandle,
|
|
14
|
+
synchronizeSellingPlan
|
|
15
|
+
} from '../shopifyMiddleware';
|
|
11
16
|
import { getOrCreateHidden } from '../../core/utils';
|
|
12
17
|
|
|
13
18
|
function makeForm(addInput = true) {
|
|
@@ -92,7 +97,7 @@ describe('getTrackingEvent', () => {
|
|
|
92
97
|
});
|
|
93
98
|
|
|
94
99
|
describe('synchronizeCartOptin', () => {
|
|
95
|
-
let store, offer, frequency, product;
|
|
100
|
+
let store, offer, frequency, product, defaultState;
|
|
96
101
|
beforeEach(() => {
|
|
97
102
|
store = {
|
|
98
103
|
dispatch: jasmine.createSpy('dispatch'),
|
|
@@ -106,6 +111,23 @@ describe('synchronizeCartOptin', () => {
|
|
|
106
111
|
frequency = '1234';
|
|
107
112
|
product = { id: '38995975209111:original-hash' };
|
|
108
113
|
|
|
114
|
+
defaultState = {
|
|
115
|
+
optedin: [],
|
|
116
|
+
offerId: 'offer-id-1234',
|
|
117
|
+
productPlans: {
|
|
118
|
+
38995975209111: [
|
|
119
|
+
{
|
|
120
|
+
frequency: '688815178030'
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
frequency: '1234'
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
store.getState.and.returnValue(defaultState);
|
|
130
|
+
|
|
109
131
|
fetchMock.route(
|
|
110
132
|
'/cart.js',
|
|
111
133
|
{
|
|
@@ -134,12 +156,64 @@ describe('synchronizeCartOptin', () => {
|
|
|
134
156
|
{ method: 'POST', repeat: 1 }
|
|
135
157
|
);
|
|
136
158
|
|
|
159
|
+
fetchMock.route(
|
|
160
|
+
'/cart/update.js',
|
|
161
|
+
{
|
|
162
|
+
attributes: {}
|
|
163
|
+
},
|
|
164
|
+
{ method: 'POST', repeat: 1 }
|
|
165
|
+
);
|
|
166
|
+
|
|
137
167
|
fetchMock.mockGlobal();
|
|
168
|
+
|
|
169
|
+
// mock the date so that the tracking key is predictable
|
|
170
|
+
jasmine.clock().install();
|
|
171
|
+
jasmine.clock().mockDate(new Date('2025-01-01T00:00:00.000Z'));
|
|
138
172
|
});
|
|
139
173
|
|
|
140
174
|
afterEach(() => {
|
|
141
175
|
fetchMock.removeRoutes().unmockGlobal();
|
|
142
176
|
offer.remove();
|
|
177
|
+
|
|
178
|
+
jasmine.clock().uninstall();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should update cart attributes', async () => {
|
|
182
|
+
await synchronizeCartOptin({ type: OPTIN_PRODUCT, payload: { offer, frequency, product } }, store);
|
|
183
|
+
|
|
184
|
+
const lastUpdateCall = fetchMock.callHistory.calls('/cart/update.js').at(-1);
|
|
185
|
+
const body = JSON.parse(lastUpdateCall.options.body);
|
|
186
|
+
|
|
187
|
+
expect(body).toEqual({
|
|
188
|
+
attributes: {
|
|
189
|
+
og__1735689600: '38995975209111:original-hash,optin_product,,1234,'
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should update cart attributes when Shopify Discount Function in use', async () => {
|
|
195
|
+
store.getState.and.returnValue({
|
|
196
|
+
...defaultState,
|
|
197
|
+
productPlans: {
|
|
198
|
+
38995975209111: [
|
|
199
|
+
{
|
|
200
|
+
frequency: '688815178030',
|
|
201
|
+
hasPriceAdjustments: false
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
await synchronizeCartOptin({ type: OPTIN_PRODUCT, payload: { offer, frequency, product } }, store);
|
|
207
|
+
|
|
208
|
+
const lastUpdateCall = fetchMock.callHistory.calls('/cart/update.js').at(-1);
|
|
209
|
+
const body = JSON.parse(lastUpdateCall.options.body);
|
|
210
|
+
|
|
211
|
+
expect(body).toEqual({
|
|
212
|
+
attributes: {
|
|
213
|
+
og__1735689600: '38995975209111:original-hash,optin_product,,1234,',
|
|
214
|
+
__ordergroove_offer_id: 'offer-id-1234'
|
|
215
|
+
}
|
|
216
|
+
});
|
|
143
217
|
});
|
|
144
218
|
|
|
145
219
|
it('should set new has product id', async () => {
|
|
@@ -173,7 +247,9 @@ describe('synchronizeCartOptin', () => {
|
|
|
173
247
|
document.body.appendChild(sectionDiv);
|
|
174
248
|
|
|
175
249
|
await synchronizeCartOptin({ type: OPTIN_PRODUCT, payload: { offer, frequency, product } }, store);
|
|
176
|
-
expect(fetchMock.callHistory.
|
|
250
|
+
expect(fetchMock.callHistory.calls('/cart/change.js').at(-1).options.body).toContain(
|
|
251
|
+
'"sections":["123456789__cart-items"]'
|
|
252
|
+
);
|
|
177
253
|
sectionDiv.remove();
|
|
178
254
|
});
|
|
179
255
|
|
|
@@ -185,12 +261,15 @@ describe('synchronizeCartOptin', () => {
|
|
|
185
261
|
document.body.appendChild(sectionDiv);
|
|
186
262
|
|
|
187
263
|
await synchronizeCartOptin({ type: OPTIN_PRODUCT, payload: { offer, frequency, product } }, store);
|
|
188
|
-
expect(fetchMock.callHistory.
|
|
264
|
+
expect(fetchMock.callHistory.calls('/cart/change.js').at(-1).options.body).toContain(
|
|
265
|
+
'"sections":["123456789__cart-footer"]'
|
|
266
|
+
);
|
|
189
267
|
sectionDiv.remove();
|
|
190
268
|
});
|
|
191
269
|
|
|
192
270
|
it('should get the subscribed frequency when no frequency is provided in the action payload', async () => {
|
|
193
271
|
store.getState.and.returnValue({
|
|
272
|
+
...defaultState,
|
|
194
273
|
optedin: [
|
|
195
274
|
{
|
|
196
275
|
id: '38995975209111:original-hash',
|
|
@@ -223,11 +302,14 @@ describe('synchronizeCartOptin', () => {
|
|
|
223
302
|
}
|
|
224
303
|
});
|
|
225
304
|
|
|
226
|
-
expect(fetchMock.callHistory.
|
|
305
|
+
expect(fetchMock.callHistory.calls('/cart/change.js').at(-1).options.body).toContain(
|
|
306
|
+
'"selling_plan":"688815178030"'
|
|
307
|
+
);
|
|
227
308
|
});
|
|
228
309
|
|
|
229
310
|
it('should have no selling plan when product is opted out', async () => {
|
|
230
311
|
store.getState.and.returnValue({
|
|
312
|
+
...defaultState,
|
|
231
313
|
optedin: []
|
|
232
314
|
});
|
|
233
315
|
|
|
@@ -254,7 +336,7 @@ describe('synchronizeCartOptin', () => {
|
|
|
254
336
|
}
|
|
255
337
|
});
|
|
256
338
|
|
|
257
|
-
expect(fetchMock.callHistory.
|
|
339
|
+
expect(fetchMock.callHistory.calls('/cart/change.js').at(-1).options.body).toContain('"selling_plan":null');
|
|
258
340
|
});
|
|
259
341
|
});
|
|
260
342
|
|
|
@@ -291,3 +373,142 @@ describe('guessProductHandle', () => {
|
|
|
291
373
|
expect(guessProductHandle()).toEqual('some-meta-json');
|
|
292
374
|
});
|
|
293
375
|
});
|
|
376
|
+
|
|
377
|
+
describe('synchronizeSellingPlan', () => {
|
|
378
|
+
const testProduct = '123456';
|
|
379
|
+
const testSellingPlan = '688815178030';
|
|
380
|
+
const testSessionId = 'session-123';
|
|
381
|
+
const testOfferId = 'offer-id-1234';
|
|
382
|
+
|
|
383
|
+
function makeCartAddForm(productId = testProduct) {
|
|
384
|
+
const element = document.createElement('form');
|
|
385
|
+
element.action = '/cart/add';
|
|
386
|
+
element.innerHTML = `
|
|
387
|
+
<input value="${productId}" name="id" type="hidden">
|
|
388
|
+
`;
|
|
389
|
+
document.body.appendChild(element);
|
|
390
|
+
return element;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function getFormData(formElement) {
|
|
394
|
+
const data = Object.fromEntries(new FormData(formElement));
|
|
395
|
+
delete data.id;
|
|
396
|
+
return data;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let store;
|
|
400
|
+
let offerElement = {
|
|
401
|
+
isCart: false,
|
|
402
|
+
shouldEnableOffer: true
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
beforeEach(() => {
|
|
406
|
+
document.body.innerHTML = '';
|
|
407
|
+
store = {
|
|
408
|
+
getState: jasmine.createSpy('getState')
|
|
409
|
+
};
|
|
410
|
+
store.getState.and.returnValue({
|
|
411
|
+
sessionId: testSessionId,
|
|
412
|
+
optedin: [{ id: testProduct, frequency: testSellingPlan }],
|
|
413
|
+
productPlans: {},
|
|
414
|
+
offerId: testOfferId
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
afterEach(() => {
|
|
419
|
+
document.body.innerHTML = '';
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('should add attributes when product is opted in', () => {
|
|
423
|
+
const formElement = makeCartAddForm();
|
|
424
|
+
expect(getFormData(formElement)).toEqual({});
|
|
425
|
+
|
|
426
|
+
synchronizeSellingPlan(store, offerElement);
|
|
427
|
+
|
|
428
|
+
expect(getFormData(formElement)).toEqual({
|
|
429
|
+
selling_plan: testSellingPlan,
|
|
430
|
+
'attributes[og__session]': testSessionId
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('should add attributes when product is opted in and Shopify Discount Function is in use', () => {
|
|
435
|
+
store.getState.and.returnValue({
|
|
436
|
+
...store.getState(),
|
|
437
|
+
productPlans: {
|
|
438
|
+
[testProduct]: [
|
|
439
|
+
{
|
|
440
|
+
frequency: testSellingPlan,
|
|
441
|
+
hasPriceAdjustments: false
|
|
442
|
+
}
|
|
443
|
+
]
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const formElement = makeCartAddForm();
|
|
448
|
+
expect(getFormData(formElement)).toEqual({});
|
|
449
|
+
|
|
450
|
+
synchronizeSellingPlan(store, offerElement);
|
|
451
|
+
|
|
452
|
+
expect(getFormData(formElement)).toEqual({
|
|
453
|
+
selling_plan: testSellingPlan,
|
|
454
|
+
'attributes[og__session]': testSessionId,
|
|
455
|
+
'attributes[__ordergroove_offer_id]': testOfferId
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should handle multiple forms on the page', () => {
|
|
460
|
+
const formElement1 = makeCartAddForm(testProduct);
|
|
461
|
+
const formElement2 = makeCartAddForm(testProduct);
|
|
462
|
+
|
|
463
|
+
synchronizeSellingPlan(store, offerElement);
|
|
464
|
+
|
|
465
|
+
expect(getFormData(formElement1)).toEqual({
|
|
466
|
+
selling_plan: testSellingPlan,
|
|
467
|
+
'attributes[og__session]': testSessionId
|
|
468
|
+
});
|
|
469
|
+
expect(getFormData(formElement2)).toEqual({
|
|
470
|
+
selling_plan: testSellingPlan,
|
|
471
|
+
'attributes[og__session]': testSessionId
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should remove selling_plan input when product is not opted in', () => {
|
|
476
|
+
store.getState.and.returnValue({
|
|
477
|
+
...store.getState(),
|
|
478
|
+
sessionId: testSessionId,
|
|
479
|
+
optedin: []
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const formElement = makeCartAddForm();
|
|
483
|
+
// Add an existing selling_plan input
|
|
484
|
+
const existingInput = document.createElement('input');
|
|
485
|
+
existingInput.name = 'selling_plan';
|
|
486
|
+
existingInput.value = testSellingPlan;
|
|
487
|
+
existingInput.type = 'hidden';
|
|
488
|
+
formElement.appendChild(existingInput);
|
|
489
|
+
|
|
490
|
+
expect(getFormData(formElement).selling_plan).toEqual(testSellingPlan);
|
|
491
|
+
|
|
492
|
+
synchronizeSellingPlan(store, offerElement);
|
|
493
|
+
|
|
494
|
+
expect(getFormData(formElement).selling_plan).toEqual(undefined);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should not run when offerElement.isCart is true', () => {
|
|
498
|
+
const formElement = makeCartAddForm();
|
|
499
|
+
const offerElement = { isCart: true, shouldEnableOffer: true };
|
|
500
|
+
|
|
501
|
+
synchronizeSellingPlan(store, offerElement);
|
|
502
|
+
|
|
503
|
+
expect(getFormData(formElement)).toEqual({});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should not run when offerElement.shouldEnableOffer is false', () => {
|
|
507
|
+
const formElement = makeCartAddForm();
|
|
508
|
+
const offerElement = { isCart: false, shouldEnableOffer: false };
|
|
509
|
+
|
|
510
|
+
synchronizeSellingPlan(store, offerElement);
|
|
511
|
+
|
|
512
|
+
expect(getFormData(formElement)).toEqual({});
|
|
513
|
+
});
|
|
514
|
+
});
|