@paypal/checkout-components 5.0.405 → 5.0.406

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": "@paypal/checkout-components",
3
- "version": "5.0.405",
3
+ "version": "5.0.406",
4
4
  "description": "PayPal Checkout components, for integrating checkout products.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -73,6 +73,7 @@ export type LabelOptions = {|
73
73
  tagline: ?boolean,
74
74
  content: ?ContentType,
75
75
  experiment?: Experiment,
76
+ shouldApplyPayNowOrLaterLabel?: boolean,
76
77
  |};
77
78
 
78
79
  export type WalletLabelOptions = {|
@@ -23,6 +23,7 @@ export type ContentMap = {
23
23
  Donate?: ({|
24
24
  logo: ChildType,
25
25
  |}) => ChildType /** Not available in `tr` language **/,
26
+ PayNowOrLater?: ({| logo: ChildType |}) => ChildType,
26
27
  |},
27
28
  };
28
29
 
@@ -379,6 +380,14 @@ export const componentContent: ContentMap = {
379
380
  </Text>
380
381
  </Fragment>
381
382
  ),
383
+ PayNowOrLater: ({ logo }) => (
384
+ <Fragment>
385
+ {logo}
386
+ <Text animate optional>
387
+ Pay Now or Later
388
+ </Text>
389
+ </Fragment>
390
+ ),
382
391
  },
383
392
  es: {
384
393
  Checkout: ({ logo }) => (
@@ -230,9 +230,25 @@ function ButtonPersonalization(opts: LabelOptions): ?ChildType {
230
230
  }
231
231
 
232
232
  export function Label(opts: LabelOptions): ChildType {
233
+ const {
234
+ logo,
235
+ locale: { lang },
236
+ shouldApplyPayNowOrLaterLabel,
237
+ } = opts;
238
+
239
+ let buttonLabel = <BasicLabel {...opts} />;
240
+
241
+ if (!__WEB__) {
242
+ const { PayNowOrLater } = componentContent[lang];
243
+
244
+ if (shouldApplyPayNowOrLaterLabel && PayNowOrLater) {
245
+ buttonLabel = <PayNowOrLater logo={logo} />;
246
+ }
247
+ }
248
+
233
249
  return (
234
250
  <Fragment>
235
- <BasicLabel {...opts} />
251
+ {buttonLabel}
236
252
  <ButtonPersonalization {...opts} />
237
253
  </Fragment>
238
254
  );
package/src/types.js CHANGED
@@ -68,6 +68,8 @@ export type Experiment = {|
68
68
  venmoEnableWebOnNonNativeBrowser?: boolean,
69
69
  spbEagerOrderCreation?: boolean,
70
70
  paypalCreditButtonCreateVaultSetupTokenExists?: boolean,
71
+ isPaylaterCobrandedLabelEnabled?: boolean,
72
+ isPaylaterCobrandedLabelRandomizationEnabled?: boolean,
71
73
  |};
72
74
 
73
75
  export type Requires = {|
@@ -109,7 +109,13 @@ export function Button({
109
109
  const colors = fundingConfig.colors;
110
110
  const secondaryColors = fundingConfig.secondaryColors || {};
111
111
 
112
- let { color, period, label, shouldApplyRebrandedStyles } = style;
112
+ let {
113
+ color,
114
+ period,
115
+ label,
116
+ shouldApplyRebrandedStyles,
117
+ shouldApplyPayNowOrLaterLabel,
118
+ } = style;
113
119
 
114
120
  // if no color option is passed in via style props
115
121
  if (color === "" || typeof color === "undefined") {
@@ -189,6 +195,10 @@ export function Button({
189
195
  })
190
196
  : fundingConfig.labelText || fundingSource;
191
197
 
198
+ if (shouldApplyPayNowOrLaterLabel) {
199
+ labelText = "PayPal Pay Now or Later";
200
+ }
201
+
192
202
  if (!showPayLabel && instrument?.vendor && instrument.label) {
193
203
  labelText = instrument.secondaryInstruments
194
204
  ? `${instrument.secondaryInstruments[0].type} & ${instrument.vendor} ${instrument.label}`
@@ -234,6 +244,7 @@ export function Button({
234
244
  tagline={tagline}
235
245
  content={content}
236
246
  experiment={experiment}
247
+ shouldApplyPayNowOrLaterLabel={shouldApplyPayNowOrLaterLabel}
237
248
  />
238
249
  );
239
250
 
@@ -0,0 +1,297 @@
1
+ /* @flow */
2
+ import { describe, expect, it, beforeEach, vi } from "vitest";
3
+ import { FUNDING } from "@paypal/sdk-constants";
4
+
5
+ import { BUTTON_FLOW } from "../../constants";
6
+
7
+ import {
8
+ getCobrandedBNPLLabelFlags,
9
+ getBNPLLabelABTestFromStorage,
10
+ determineRandomBNPLLabel,
11
+ getBNPLLabelForABTest,
12
+ } from "./props";
13
+
14
+ describe("getBNPLLabelABTestFromStorage", () => {
15
+ it("should return null when storage state has no bnplLabelABTest value", () => {
16
+ const storageState = {
17
+ get: vi.fn().mockReturnValue(null),
18
+ set: vi.fn(),
19
+ };
20
+
21
+ const result = getBNPLLabelABTestFromStorage(storageState);
22
+
23
+ expect(result).toBeNull();
24
+ expect(storageState.get).toHaveBeenCalledWith("bnplLabelABTest");
25
+ });
26
+
27
+ it("should return null when storage state has bnplLabelABTest but no value property", () => {
28
+ const storageState = {
29
+ get: vi.fn().mockReturnValue({ someOtherProperty: "test" }),
30
+ set: vi.fn(),
31
+ };
32
+
33
+ const result = getBNPLLabelABTestFromStorage(storageState);
34
+
35
+ expect(result).toBeNull();
36
+ });
37
+
38
+ it("should return value when storage state has bnplLabelABTest with value property", () => {
39
+ const mockStoredValue = {
40
+ shouldApplyPayNowOrLaterLabel: true,
41
+ sessionID: "test-session",
42
+ };
43
+
44
+ const storageState = {
45
+ get: vi.fn().mockReturnValue({ value: mockStoredValue }),
46
+ set: vi.fn(),
47
+ };
48
+
49
+ const result = getBNPLLabelABTestFromStorage(storageState);
50
+
51
+ expect(result).toEqual(mockStoredValue);
52
+ });
53
+ });
54
+
55
+ describe("determineRandomBNPLLabel", () => {
56
+ let mathRandomSpy;
57
+
58
+ beforeEach(() => {
59
+ mathRandomSpy = vi.spyOn(Math, "random");
60
+ });
61
+
62
+ afterEach(() => {
63
+ mathRandomSpy.mockRestore();
64
+ });
65
+
66
+ it("should return true when random value is less than 0.5", () => {
67
+ mathRandomSpy.mockReturnValue(0.3);
68
+ expect(determineRandomBNPLLabel()).toBe(true);
69
+ });
70
+
71
+ it("should return false when random value is greater than or equal to 0.5", () => {
72
+ mathRandomSpy.mockReturnValue(0.7);
73
+ expect(determineRandomBNPLLabel()).toBe(false);
74
+ });
75
+
76
+ it("should return false when random value is exactly 0.5", () => {
77
+ mathRandomSpy.mockReturnValue(0.5);
78
+ expect(determineRandomBNPLLabel()).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe("getBNPLLabelForABTest", () => {
83
+ it("should return cached value when sessionID matches", () => {
84
+ const mockSessionID = "test-session-123";
85
+ const storageState = {
86
+ get: vi.fn().mockReturnValue({
87
+ value: {
88
+ shouldApplyPayNowOrLaterLabel: true,
89
+ sessionID: mockSessionID,
90
+ },
91
+ }),
92
+ set: vi.fn(),
93
+ };
94
+
95
+ const result = getBNPLLabelForABTest({
96
+ storageState,
97
+ sessionID: mockSessionID,
98
+ });
99
+
100
+ expect(result).toBe(true);
101
+ expect(storageState.set).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("should randomize and store when sessionID does not match", () => {
105
+ const storageState = {
106
+ get: vi.fn().mockReturnValue({
107
+ value: {
108
+ shouldApplyPayNowOrLaterLabel: true,
109
+ sessionID: "old-session",
110
+ },
111
+ }),
112
+ set: vi.fn(),
113
+ };
114
+
115
+ const result = getBNPLLabelForABTest({
116
+ storageState,
117
+ sessionID: "new-session",
118
+ });
119
+
120
+ expect(typeof result).toBe("boolean");
121
+ expect(storageState.set).toHaveBeenCalledWith("bnplLabelABTest", {
122
+ shouldApplyPayNowOrLaterLabel: result,
123
+ sessionID: "new-session",
124
+ });
125
+ });
126
+
127
+ it("should randomize and store when storage is empty", () => {
128
+ const storageState = {
129
+ get: vi.fn().mockReturnValue(null),
130
+ set: vi.fn(),
131
+ };
132
+
133
+ const result = getBNPLLabelForABTest({
134
+ storageState,
135
+ sessionID: "fresh-session",
136
+ });
137
+
138
+ expect(typeof result).toBe("boolean");
139
+ expect(storageState.set).toHaveBeenCalledWith("bnplLabelABTest", {
140
+ shouldApplyPayNowOrLaterLabel: result,
141
+ sessionID: "fresh-session",
142
+ });
143
+ });
144
+ });
145
+
146
+ describe("getCobrandedBNPLLabelFlags", () => {
147
+ const mockStorageState = {
148
+ get: vi.fn().mockReturnValue(null),
149
+ set: vi.fn(),
150
+ };
151
+
152
+ beforeEach(() => {
153
+ mockStorageState.get.mockReturnValue(null);
154
+ mockStorageState.set.mockClear();
155
+ });
156
+
157
+ // $FlowFixMe - test object intentionally omits non-relevant ButtonPropsInputs fields
158
+ const eligibleProps = {
159
+ fundingSource: FUNDING.PAYPAL,
160
+ fundingEligibility: {
161
+ paylater: { eligible: true },
162
+ },
163
+ experiment: { isPaylaterCobrandedLabelEnabled: true },
164
+ locale: { lang: "en", country: "US" },
165
+ style: {},
166
+ flow: BUTTON_FLOW.PURCHASE,
167
+ storageState: mockStorageState,
168
+ sessionID: "test-session-id",
169
+ };
170
+
171
+ it("should return eligible true and use randomization when all conditions are met", () => {
172
+ const { isPayNowOrLaterLabelEligible, shouldApplyPayNowOrLaterLabel } =
173
+ // $FlowFixMe
174
+ getCobrandedBNPLLabelFlags(eligibleProps);
175
+
176
+ expect(isPayNowOrLaterLabelEligible).toBe(true);
177
+ expect(typeof shouldApplyPayNowOrLaterLabel).toBe("boolean");
178
+ expect(mockStorageState.set).toHaveBeenCalledWith(
179
+ "bnplLabelABTest",
180
+ expect.objectContaining({
181
+ shouldApplyPayNowOrLaterLabel,
182
+ sessionID: "test-session-id",
183
+ })
184
+ );
185
+ });
186
+
187
+ it("should return false when experiment flag is disabled", () => {
188
+ const { isPayNowOrLaterLabelEligible } =
189
+ // $FlowFixMe
190
+ getCobrandedBNPLLabelFlags({
191
+ ...eligibleProps,
192
+ experiment: { isPaylaterCobrandedLabelEnabled: false },
193
+ });
194
+
195
+ expect(isPayNowOrLaterLabelEligible).toBe(false);
196
+ });
197
+
198
+ it("should return false when paylater is not eligible", () => {
199
+ const { isPayNowOrLaterLabelEligible } =
200
+ // $FlowFixMe
201
+ getCobrandedBNPLLabelFlags({
202
+ ...eligibleProps,
203
+ fundingEligibility: { paylater: { eligible: false } },
204
+ });
205
+
206
+ expect(isPayNowOrLaterLabelEligible).toBe(false);
207
+ });
208
+
209
+ it("should return false when fundingSource is not PAYPAL or undefined", () => {
210
+ const { isPayNowOrLaterLabelEligible } =
211
+ // $FlowFixMe
212
+ getCobrandedBNPLLabelFlags({
213
+ ...eligibleProps,
214
+ fundingSource: FUNDING.VENMO,
215
+ });
216
+
217
+ expect(isPayNowOrLaterLabelEligible).toBe(false);
218
+ });
219
+
220
+ it("should return false when a non-paypal label is set", () => {
221
+ const { isPayNowOrLaterLabelEligible } =
222
+ // $FlowFixMe
223
+ getCobrandedBNPLLabelFlags({
224
+ ...eligibleProps,
225
+ style: { label: "checkout" },
226
+ });
227
+
228
+ expect(isPayNowOrLaterLabelEligible).toBe(false);
229
+ });
230
+
231
+ it("should return true when label is explicitly set to paypal", () => {
232
+ const { isPayNowOrLaterLabelEligible } =
233
+ // $FlowFixMe
234
+ getCobrandedBNPLLabelFlags({
235
+ ...eligibleProps,
236
+ style: { label: "paypal" },
237
+ });
238
+
239
+ expect(isPayNowOrLaterLabelEligible).toBe(true);
240
+ });
241
+
242
+ it("should return false when locale does not have PayNowOrLater content", () => {
243
+ const { isPayNowOrLaterLabelEligible } =
244
+ // $FlowFixMe
245
+ getCobrandedBNPLLabelFlags({
246
+ ...eligibleProps,
247
+ locale: { lang: "fr", country: "FR" },
248
+ });
249
+
250
+ expect(isPayNowOrLaterLabelEligible).toBe(false);
251
+ });
252
+
253
+ it("should return false when props is null", () => {
254
+ const { isPayNowOrLaterLabelEligible } = getCobrandedBNPLLabelFlags(null);
255
+
256
+ expect(isPayNowOrLaterLabelEligible).toBe(false);
257
+ });
258
+
259
+ it("should return shouldApplyPayNowOrLaterLabel true when randomization is disabled", () => {
260
+ const { isPayNowOrLaterLabelEligible, shouldApplyPayNowOrLaterLabel } =
261
+ // $FlowFixMe
262
+ getCobrandedBNPLLabelFlags({
263
+ ...eligibleProps,
264
+ experiment: {
265
+ isPaylaterCobrandedLabelEnabled: true,
266
+ isPaylaterCobrandedLabelRandomizationEnabled: false,
267
+ },
268
+ });
269
+
270
+ expect(isPayNowOrLaterLabelEligible).toBe(true);
271
+ expect(shouldApplyPayNowOrLaterLabel).toBe(true);
272
+ });
273
+
274
+ it("should return shouldApplyPayNowOrLaterLabel true when eligible but no storageState", () => {
275
+ const { isPayNowOrLaterLabelEligible, shouldApplyPayNowOrLaterLabel } =
276
+ // $FlowFixMe
277
+ getCobrandedBNPLLabelFlags({
278
+ ...eligibleProps,
279
+ storageState: undefined,
280
+ });
281
+
282
+ expect(isPayNowOrLaterLabelEligible).toBe(true);
283
+ expect(shouldApplyPayNowOrLaterLabel).toBe(true);
284
+ });
285
+
286
+ it("should return shouldApplyPayNowOrLaterLabel false when not eligible regardless of randomization", () => {
287
+ const { isPayNowOrLaterLabelEligible, shouldApplyPayNowOrLaterLabel } =
288
+ // $FlowFixMe
289
+ getCobrandedBNPLLabelFlags({
290
+ ...eligibleProps,
291
+ fundingSource: FUNDING.VENMO,
292
+ });
293
+
294
+ expect(isPayNowOrLaterLabelEligible).toBe(false);
295
+ expect(shouldApplyPayNowOrLaterLabel).toBe(false);
296
+ });
297
+ });
@@ -48,6 +48,7 @@ import {
48
48
  MESSAGE_ALIGN,
49
49
  } from "../../constants";
50
50
  import { getFundingConfig, isFundingEligible } from "../../funding";
51
+ import { componentContent } from "../../funding/content";
51
52
  import type { StateGetSet } from "../../lib/session";
52
53
 
53
54
  import { BUTTON_SIZE_STYLE } from "./config";
@@ -331,6 +332,8 @@ export type ButtonStyle = {|
331
332
  borderRadius?: number,
332
333
  shouldApplyRebrandedStyles: boolean,
333
334
  isButtonColorABTestMerchant: boolean,
335
+ isPayNowOrLaterLabelEligible: boolean,
336
+ shouldApplyPayNowOrLaterLabel: boolean,
334
337
  |};
335
338
 
336
339
  export type ButtonStyleInputs = {|
@@ -344,6 +347,7 @@ export type ButtonStyleInputs = {|
344
347
  disableMaxWidth?: boolean | void,
345
348
  disableMaxHeight?: boolean | void,
346
349
  borderRadius?: number | void,
350
+ shouldApplyPayNowOrLaterLabel?: boolean | void,
347
351
  |};
348
352
 
349
353
  type PersonalizationComponentProps = {|
@@ -529,6 +533,11 @@ type ColorABTestStorage = {|
529
533
  sessionID: string,
530
534
  |};
531
535
 
536
+ type BNPLLabelABTestStorage = {|
537
+ shouldApplyPayNowOrLaterLabel: boolean,
538
+ sessionID: string,
539
+ |};
540
+
532
541
  type GetButtonColorArgs = {|
533
542
  experiment: Experiment,
534
543
  fundingSource: ?$Values<typeof FUNDING>,
@@ -695,6 +704,7 @@ export type ButtonPropsInputs = {
695
704
  messageMarkup?: string | void,
696
705
  renderedButtons: $ReadOnlyArray<$Values<typeof FUNDING>>,
697
706
  buttonColor: ButtonColor,
707
+ storageState?: StateGetSet,
698
708
  userAgent: string,
699
709
  };
700
710
 
@@ -728,6 +738,48 @@ export function getColorABTestFromStorage(
728
738
  return null;
729
739
  }
730
740
 
741
+ export function getBNPLLabelABTestFromStorage(
742
+ storageState: StateGetSet
743
+ ): ?BNPLLabelABTestStorage {
744
+ const sessionState = storageState.get("bnplLabelABTest");
745
+
746
+ if (sessionState && sessionState.value) {
747
+ return sessionState.value;
748
+ }
749
+
750
+ return null;
751
+ }
752
+
753
+ export function determineRandomBNPLLabel(): boolean {
754
+ return Math.random() < 0.5;
755
+ }
756
+
757
+ export function getBNPLLabelForABTest({
758
+ storageState,
759
+ sessionID,
760
+ }: {|
761
+ storageState: StateGetSet,
762
+ sessionID: ?string,
763
+ |}): boolean {
764
+ const bnplLabelFromStorage = getBNPLLabelABTestFromStorage(storageState);
765
+
766
+ if (bnplLabelFromStorage) {
767
+ const { sessionID: storedSessionID, shouldApplyPayNowOrLaterLabel } =
768
+ bnplLabelFromStorage;
769
+
770
+ if (storedSessionID && sessionID === storedSessionID) {
771
+ return shouldApplyPayNowOrLaterLabel;
772
+ }
773
+ }
774
+
775
+ const shouldApplyPayNowOrLaterLabel = determineRandomBNPLLabel();
776
+ storageState.set("bnplLabelABTest", {
777
+ shouldApplyPayNowOrLaterLabel,
778
+ sessionID,
779
+ });
780
+ return shouldApplyPayNowOrLaterLabel;
781
+ }
782
+
731
783
  export function determineRandomButtonColor({
732
784
  buttonColorInput,
733
785
  }: {|
@@ -970,6 +1022,70 @@ export function getButtonColor({
970
1022
  }
971
1023
  }
972
1024
 
1025
+ export function getCobrandedBNPLLabelFlags(props: ?ButtonPropsInputs): {|
1026
+ isPayNowOrLaterLabelEligible: boolean,
1027
+ shouldApplyPayNowOrLaterLabel: boolean,
1028
+ |} {
1029
+ const label = props?.style?.label;
1030
+ const lang = props?.locale?.lang;
1031
+ const isPurchaseFlow = props?.flow === BUTTON_FLOW.PURCHASE;
1032
+ const isEnLang = Boolean(lang && componentContent[lang]?.PayNowOrLater);
1033
+ const isCobrandedEligibleFundingSource =
1034
+ props?.fundingSource === FUNDING.PAYPAL ||
1035
+ props?.fundingSource === undefined;
1036
+ const isPaylaterEligible =
1037
+ props?.fundingEligibility?.paylater?.eligible || false;
1038
+ const isLabelEligible = label === undefined || label === BUTTON_LABEL.PAYPAL;
1039
+
1040
+ const isPaylaterCobrandedLabelEnabled =
1041
+ props?.experiment?.isPaylaterCobrandedLabelEnabled || false;
1042
+
1043
+ const isPayNowOrLaterLabelEligible = Boolean(
1044
+ isPaylaterCobrandedLabelEnabled &&
1045
+ isCobrandedEligibleFundingSource &&
1046
+ isPaylaterEligible &&
1047
+ isLabelEligible &&
1048
+ isEnLang &&
1049
+ isPurchaseFlow
1050
+ );
1051
+
1052
+ const isPaylaterCobrandedLabelRandomizationEnabled =
1053
+ props?.experiment?.isPaylaterCobrandedLabelRandomizationEnabled ?? true;
1054
+ const hasStorageState = Boolean(props?.storageState);
1055
+ const hasSessionID = Boolean(props?.sessionID);
1056
+ const shouldRunABTestRandomization =
1057
+ isPaylaterCobrandedLabelRandomizationEnabled &&
1058
+ hasStorageState &&
1059
+ hasSessionID;
1060
+
1061
+ // SSR path: the client already computed values
1062
+ const precomputedLabel = props?.style?.shouldApplyPayNowOrLaterLabel;
1063
+
1064
+ if (precomputedLabel === true || precomputedLabel === false) {
1065
+ return {
1066
+ isPayNowOrLaterLabelEligible,
1067
+ shouldApplyPayNowOrLaterLabel: precomputedLabel,
1068
+ };
1069
+ }
1070
+
1071
+ // Client path: compute shouldApplyPayNowOrLaterLabel from scratch
1072
+ let shouldApplyPayNowOrLaterLabel = false;
1073
+
1074
+ if (isPayNowOrLaterLabelEligible) {
1075
+ if (shouldRunABTestRandomization && props && props.storageState) {
1076
+ shouldApplyPayNowOrLaterLabel = getBNPLLabelForABTest({
1077
+ storageState: props.storageState,
1078
+ sessionID: props.sessionID,
1079
+ });
1080
+ } else {
1081
+ // Randomization disabled or storageState unavailable → 100% treatment
1082
+ shouldApplyPayNowOrLaterLabel = true;
1083
+ }
1084
+ }
1085
+
1086
+ return { isPayNowOrLaterLabelEligible, shouldApplyPayNowOrLaterLabel };
1087
+ }
1088
+
973
1089
  const getDefaultButtonPropsInput = (): ButtonPropsInputs => {
974
1090
  return {};
975
1091
  };
@@ -1119,6 +1235,9 @@ export function normalizeButtonStyle(
1119
1235
  }
1120
1236
  }
1121
1237
 
1238
+ const { isPayNowOrLaterLabelEligible, shouldApplyPayNowOrLaterLabel } =
1239
+ getCobrandedBNPLLabelFlags(props);
1240
+
1122
1241
  return {
1123
1242
  label,
1124
1243
  layout,
@@ -1133,6 +1252,8 @@ export function normalizeButtonStyle(
1133
1252
  borderRadius,
1134
1253
  shouldApplyRebrandedStyles,
1135
1254
  isButtonColorABTestMerchant,
1255
+ isPayNowOrLaterLabelEligible,
1256
+ shouldApplyPayNowOrLaterLabel,
1136
1257
  };
1137
1258
  }
1138
1259
 
@@ -235,6 +235,7 @@ export const getButtonsComponent: () => ButtonsComponent = memoize(() => {
235
235
  allowpaymentrequest: "allowpaymentrequest",
236
236
  scrolling: "no",
237
237
  title: `${FUNDING_BRAND_LABEL.PAYPAL}${fundingSource}`,
238
+ role: "presentation",
238
239
  },
239
240
  };
240
241
  },