@paypal/checkout-components 5.0.316 → 5.0.317

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.
@@ -2,12 +2,17 @@
2
2
 
3
3
  import { request, memoize } from "@krakenjs/belter/src";
4
4
  import {
5
+ getLogger,
5
6
  buildDPoPHeaders,
6
7
  getSDKHost,
7
8
  getClientID,
8
9
  getMerchantID as getSDKMerchantID,
9
10
  } from "@paypal/sdk-client/src";
10
11
  import { FUNDING } from "@paypal/sdk-constants/src";
12
+ import { SUPPORTED_FUNDING_SOURCES } from "@paypal/funding-components/src";
13
+ import { ZalgoPromise } from "@krakenjs/zalgo-promise/src";
14
+
15
+ import { getButtonsComponent, type ButtonsComponent } from "../zoid/buttons";
11
16
 
12
17
  import type {
13
18
  ButtonVariables,
@@ -19,9 +24,15 @@ import type {
19
24
  RenderForm,
20
25
  GetFlexDirectionArgs,
21
26
  GetFlexDirection,
22
- BuildButtonContainerArgs,
23
27
  Color,
24
28
  FundingSources,
29
+ ApplyButtonStylesProps,
30
+ HostedButtonPreferences,
31
+ NcpResponsePreferences,
32
+ ButtonPreferences,
33
+ GetButtonsProps,
34
+ RenderStandaloneButtonProps,
35
+ RenderDefaultButtonProps,
25
36
  } from "./types";
26
37
 
27
38
  const entryPoint = "SDK";
@@ -41,6 +52,7 @@ export const getMerchantID = (): string | void => {
41
52
  // https://developer.paypal.com/docs/multiparty/checkout/multiseller-payments/
42
53
  const merchantIds = getSDKMerchantID();
43
54
  if (merchantIds.length > 1) {
55
+ getLogger().error("ncps_multiple_merchant_ids", { merchantIds });
44
56
  throw new Error("Multiple merchant-ids are not supported.");
45
57
  }
46
58
  return merchantIds[0];
@@ -77,6 +89,41 @@ export const createAccessToken: CreateAccessToken = memoize<CreateAccessToken>(
77
89
  }
78
90
  );
79
91
 
92
+ export const getButtonPreferences = ({
93
+ button_preferences: buttonPreferences,
94
+ eligible_funding_methods: eligibleFundingMethods,
95
+ }: NcpResponsePreferences): HostedButtonPreferences => {
96
+ if (!buttonPreferences?.length || !eligibleFundingMethods?.length) {
97
+ const preferences = {
98
+ buttonPreferences,
99
+ eligibleFundingMethods,
100
+ };
101
+
102
+ getLogger().error("ncps_missing_preferences", { preferences });
103
+
104
+ throw new Error(
105
+ `Expected preferences to be populated, received: ${JSON.stringify({
106
+ preferences,
107
+ })}`
108
+ );
109
+ }
110
+
111
+ return {
112
+ // Remove any buttons that are not included in eligibleFundingMethods.
113
+ // If the funding method is "default", we want to keep it in the preferences, and decide which
114
+ // button should be rendered in its place in renderStandaloneButton()
115
+ buttonPreferences: buttonPreferences.filter(
116
+ (fundingMethod) =>
117
+ eligibleFundingMethods.includes(fundingMethod) ||
118
+ fundingMethod === "default"
119
+ ),
120
+ // Sort the eligible funding methods returned from /ncp/api/form-fields in the order that they would appear in the smart stack.
121
+ eligibleFundingMethods: SUPPORTED_FUNDING_SOURCES.filter((fundingMethod) =>
122
+ eligibleFundingMethods.includes(fundingMethod)
123
+ ),
124
+ };
125
+ };
126
+
80
127
  const getButtonVariable = (variables: ButtonVariables, key: string): string =>
81
128
  variables?.find((variable) => variable.name === key)?.value ?? "";
82
129
 
@@ -90,7 +137,13 @@ export const getHostedButtonDetails: HostedButtonDetailsParams = async ({
90
137
 
91
138
  // $FlowIssue request returns ZalgoPromise
92
139
  const { body } = response;
93
- const { link_variables: variables, preferences } = body.button_details;
140
+ const {
141
+ link_variables: variables,
142
+ js_sdk_container_id: buttonContainerId,
143
+ preferences,
144
+ } = body.button_details;
145
+
146
+ const shouldIncludePreferences = preferences && body.version === "2";
94
147
 
95
148
  return {
96
149
  style: {
@@ -102,14 +155,11 @@ export const getHostedButtonDetails: HostedButtonDetailsParams = async ({
102
155
  height: parseInt(getButtonVariable(variables, "height"), 10) || undefined,
103
156
  },
104
157
  version: body.version,
105
- buttonContainerId: body.button_container_id,
158
+ buttonContainerId: buttonContainerId || "spb-container",
106
159
  html: body.html,
107
160
  htmlScript: body.html_script,
108
- ...(preferences && {
109
- preferences: {
110
- buttonPreferences: preferences.button_preferences,
111
- eligibleFundingMethods: preferences.eligible_funding_methods,
112
- },
161
+ ...(shouldIncludePreferences && {
162
+ preferences: getButtonPreferences(preferences),
113
163
  }),
114
164
  };
115
165
  };
@@ -250,16 +300,16 @@ export const buildHostedButtonOnApprove = ({
250
300
  };
251
301
  };
252
302
 
253
- export function getFlexDirection({
303
+ export const getFlexDirection = ({
254
304
  layout,
255
- }: GetFlexDirectionArgs): GetFlexDirection {
256
- return { flexDirection: layout === "horizontal" ? "row" : "column" };
257
- }
305
+ }: GetFlexDirectionArgs): GetFlexDirection => ({
306
+ flexDirection: layout === "horizontal" ? "row" : "column",
307
+ });
258
308
 
259
- export function getButtonColor(
309
+ export const getButtonColor = (
260
310
  color: Color,
261
311
  fundingSource: FundingSources
262
- ): Color {
312
+ ): Color => {
263
313
  const colorMap = {
264
314
  gold: {
265
315
  paypal: "gold",
@@ -289,41 +339,110 @@ export function getButtonColor(
289
339
  };
290
340
 
291
341
  return colorMap[color][fundingSource];
292
- }
293
-
294
- export function shouldRenderSDKButtons(
295
- fundingSources: $ReadOnlyArray<FundingSources>
296
- ): boolean {
297
- return Boolean(fundingSources.length);
298
- }
342
+ };
299
343
 
300
- export function appendButtonContainer({
344
+ export const applyContainerStyles = ({
301
345
  flexDirection,
302
- selector,
303
- }: BuildButtonContainerArgs) {
304
- const elm = getElementFromSelector(selector);
346
+ buttonContainerId,
347
+ }: ApplyButtonStylesProps): void => {
348
+ const buttonContainer = document.querySelector(`#${buttonContainerId}`);
305
349
 
306
- if (!elm) {
307
- throw new Error("PayPal button container selector was not found");
350
+ if (!buttonContainer) {
351
+ getLogger().error("ncps_button_container_missing", {
352
+ buttonContainerId,
353
+ });
354
+
355
+ throw new Error(`Element with id ${buttonContainerId} not found.`);
308
356
  }
309
357
 
310
- const buttonContainer = document.createElement("div");
358
+ buttonContainer.style.flexDirection = flexDirection;
359
+ };
311
360
 
312
- buttonContainer.setAttribute(
313
- "style",
314
- `display: flex; flex-wrap: nowrap; gap: 16px; max-width: 750px; flex-direction: ${flexDirection}`
361
+ /**
362
+ * Filters out all eligible funding methods that are already specified in button preferences
363
+ */
364
+ export const getDefaultButtonOptions = ({
365
+ buttonPreferences,
366
+ eligibleFundingMethods,
367
+ }: HostedButtonPreferences): ButtonPreferences => {
368
+ return eligibleFundingMethods.filter(
369
+ (fundingSource: string) => !buttonPreferences.includes(fundingSource)
315
370
  );
371
+ };
316
372
 
317
- const primaryButton = document.createElement("div");
318
- primaryButton.setAttribute("id", `ncp-primary-button`);
319
- primaryButton.setAttribute("style", "flex-grow: 1");
373
+ /**
374
+ * Gets buttons component instance.
375
+ */
376
+ export const getButtons = ({
377
+ fundingSource,
378
+ buttonOptions,
379
+ }: GetButtonsProps): ButtonsComponent => {
380
+ const Buttons = getButtonsComponent();
320
381
 
321
- const secondaryButton = document.createElement("div");
322
- secondaryButton.setAttribute("id", `ncp-secondary-button`);
323
- secondaryButton.setAttribute("style", "flex-grow: 1");
382
+ const { style } = buttonOptions;
324
383
 
325
- buttonContainer.appendChild(primaryButton);
326
- buttonContainer.appendChild(secondaryButton);
384
+ // $FlowFixMe
385
+ return Buttons({
386
+ ...buttonOptions,
387
+ fundingSource,
388
+ style: {
389
+ ...style,
390
+ // $FlowFixMe
391
+ color: getButtonColor(style.color, fundingSource),
392
+ },
393
+ });
394
+ };
327
395
 
328
- elm?.appendChild(buttonContainer);
329
- }
396
+ /**
397
+ * Handles logic for each specified button preference.
398
+ */
399
+ export const renderStandaloneButton = ({
400
+ fundingSource,
401
+ buttonContainerId,
402
+ buttonOptions,
403
+ }: RenderStandaloneButtonProps): ZalgoPromise<void> | void => {
404
+ const standaloneButton = getButtons({
405
+ fundingSource,
406
+ buttonOptions,
407
+ });
408
+
409
+ // $FlowFixMe
410
+ if (standaloneButton.isEligible()) {
411
+ // $FlowFixMe
412
+ return standaloneButton.render(`#${buttonContainerId}`);
413
+ }
414
+
415
+ getLogger().error(`ncps_standalone_${fundingSource}_ineligible`);
416
+ };
417
+
418
+ /**
419
+ * Handles logic for "default" button preference.
420
+ */
421
+ export const renderDefaultButton = ({
422
+ eligibleDefaultButtons,
423
+ buttonContainerId,
424
+ buttonOptions,
425
+ }: RenderDefaultButtonProps): void => {
426
+ const eligibleButtons = [...eligibleDefaultButtons];
427
+
428
+ // If we exhaust all default options, we don't render any button.
429
+ while (eligibleButtons.length) {
430
+ const fundingSource = eligibleButtons[0];
431
+
432
+ const standaloneButton = getButtons({
433
+ fundingSource,
434
+ buttonOptions,
435
+ });
436
+
437
+ // If the funding source is eligible, render button & return to end loop.
438
+ // $FlowFixMe
439
+ if (standaloneButton.isEligible()) {
440
+ // $FlowFixMe
441
+ return standaloneButton.render(`#${buttonContainerId}`);
442
+ }
443
+
444
+ // If funding source is ineligible, log error and move to next funding option.
445
+ getLogger().error(`ncps_standalone_${fundingSource}_ineligible`);
446
+ eligibleButtons.shift();
447
+ }
448
+ };
@@ -2,6 +2,9 @@
2
2
  /* eslint-disable no-restricted-globals, promise/no-native */
3
3
  import { test, expect, vi } from "vitest";
4
4
  import { request } from "@krakenjs/belter/src";
5
+ import { getLogger } from "@paypal/sdk-client/src";
6
+
7
+ import { getButtonsComponent } from "../zoid/buttons";
5
8
 
6
9
  import {
7
10
  buildHostedButtonCreateOrder,
@@ -10,9 +13,11 @@ import {
10
13
  getHostedButtonDetails,
11
14
  getFlexDirection,
12
15
  getButtonColor,
13
- shouldRenderSDKButtons,
14
- appendButtonContainer,
15
16
  getElementFromSelector,
17
+ getButtonPreferences,
18
+ renderStandaloneButton,
19
+ applyContainerStyles,
20
+ renderDefaultButton,
16
21
  } from "./utils";
17
22
 
18
23
  vi.mock("@krakenjs/belter/src", async () => {
@@ -28,6 +33,16 @@ vi.mock("@paypal/sdk-client/src", async () => {
28
33
  getSDKHost: () => "example.com",
29
34
  getClientID: () => "client_id_123",
30
35
  getMerchantID: () => ["merchant_id_123"],
36
+ getLogger: vi.fn(() => ({
37
+ error: vi.fn(),
38
+ })),
39
+ };
40
+ });
41
+
42
+ vi.mock("../zoid/buttons", async () => {
43
+ return {
44
+ ...(await vi.importActual("../zoid/buttons")),
45
+ getButtonsComponent: vi.fn(),
31
46
  };
32
47
  });
33
48
 
@@ -97,6 +112,7 @@ describe("getHostedButtonDetails", () => {
97
112
  button_preferences: ["paypal", "paylater"],
98
113
  eligible_funding_methods: ["paypal", "venmo", "paylater"],
99
114
  },
115
+ js_sdk_container_id: "spb-container",
100
116
  },
101
117
  version: "2",
102
118
  },
@@ -556,38 +572,8 @@ test("getButtonColor", () => {
556
572
  });
557
573
  });
558
574
 
559
- test("shouldRenderSDKButtons", () => {
560
- expect(shouldRenderSDKButtons([])).toBe(false);
561
- expect(shouldRenderSDKButtons(["paypal"])).toBe(true);
562
- expect(shouldRenderSDKButtons(["paypal", "venmo"])).toBe(true);
563
- });
564
-
565
- test("buildButtonContainer", () => {
566
- const containerId = "#container-id";
567
- const selector = document.createElement("div");
568
-
569
- selector.setAttribute("id", containerId.slice(1));
570
-
571
- vi.spyOn(document, "querySelector").mockReturnValueOnce(selector);
572
-
573
- expect(() =>
574
- appendButtonContainer({ flexDirection: "row", selector: containerId })
575
- ).not.toThrow();
576
-
577
- expect(() =>
578
- appendButtonContainer({ flexDirection: "row", selector })
579
- ).not.toThrow();
580
-
581
- expect(() =>
582
- appendButtonContainer({
583
- flexDirection: "row",
584
- selector: `${containerId}-not-found`,
585
- })
586
- ).toThrow("PayPal button container selector was not found");
587
- });
588
-
589
575
  test("getElementFromSelector", () => {
590
- const containerId = "#container-id";
576
+ const containerId = "container-id";
591
577
  const selector = document.createElement("div");
592
578
 
593
579
  selector.setAttribute("id", containerId.slice(1));
@@ -602,4 +588,234 @@ test("getElementFromSelector", () => {
602
588
  expect(mockQuerySelector).toHaveBeenCalledWith(containerId);
603
589
  });
604
590
 
591
+ describe("getButtonPreferences", () => {
592
+ test("returns all button preferences if all are eligible", () => {
593
+ const params = {
594
+ button_preferences: ["paypal", "venmo"],
595
+ eligible_funding_methods: ["paypal", "venmo", "paylater"],
596
+ };
597
+
598
+ const preferences = getButtonPreferences(params);
599
+
600
+ expect(preferences.buttonPreferences).toEqual(["paypal", "venmo"]);
601
+ });
602
+
603
+ test("removes any button preferences not in the eligible funding methods", () => {
604
+ const params = {
605
+ button_preferences: ["paypal", "venmo"],
606
+ eligible_funding_methods: ["paypal", "paylater"],
607
+ };
608
+
609
+ const preferences = getButtonPreferences(params);
610
+
611
+ expect(preferences.buttonPreferences).toEqual(["paypal"]);
612
+ });
613
+ test("sorts eligible funding methods according to SUPPORTED_FUNDING_SOURCES", () => {
614
+ const params = {
615
+ button_preferences: ["paypal", "venmo"],
616
+ eligible_funding_methods: ["paylater", "venmo", "paypal"],
617
+ };
618
+
619
+ const preferences = getButtonPreferences(params);
620
+
621
+ expect(preferences.eligibleFundingMethods).toEqual([
622
+ "paypal",
623
+ "venmo",
624
+ "paylater",
625
+ ]);
626
+ });
627
+
628
+ test("doesn't filter out 'default' in button preferences", () => {
629
+ const params = {
630
+ button_preferences: ["paypal", "default"],
631
+ eligible_funding_methods: ["paylater", "venmo", "paypal"],
632
+ };
633
+
634
+ const preferences = getButtonPreferences(params);
635
+
636
+ expect(preferences.buttonPreferences).toEqual(["paypal", "default"]);
637
+ });
638
+
639
+ test("logs & throws error if the input is bad", () => {
640
+ const errorMock = vi.fn();
641
+
642
+ // $FlowIssue
643
+ getLogger.mockImplementation(() => ({ error: errorMock }));
644
+
645
+ const params = {
646
+ button_preferences: [],
647
+ eligible_funding_methods: [],
648
+ };
649
+
650
+ const shouldThrowError = () => getButtonPreferences(params);
651
+
652
+ expect(shouldThrowError).toThrowError();
653
+ expect(errorMock).toBeCalledTimes(1);
654
+ });
655
+ });
656
+
657
+ describe("applyContainerStyles", () => {
658
+ const buttonContainerId = "button-container";
659
+ const params = { flexDirection: "vertical", buttonContainerId };
660
+
661
+ test("successfully applies styles to container", () => {
662
+ const buttonContainer = document.createElement("div");
663
+ buttonContainer.id = buttonContainerId;
664
+ vi.spyOn(document, "querySelector").mockReturnValueOnce(buttonContainer);
665
+
666
+ applyContainerStyles(params);
667
+
668
+ expect(buttonContainer?.style.length).toBeTruthy();
669
+ });
670
+
671
+ test("throws error if button container cannot be found", () => {
672
+ // Intentionally not setting up the button container to throw the error
673
+ const shouldThrowError = () => applyContainerStyles(params);
674
+ expect(shouldThrowError).toThrowError(
675
+ `Element with id ${buttonContainerId} not found.`
676
+ );
677
+ });
678
+ });
679
+
680
+ describe("render buttons", () => {
681
+ const containerId = "container-id";
682
+ const expectedContainerId = `#${containerId}`;
683
+ const renderMock = vi.fn();
684
+ const errorMock = vi.fn();
685
+ const baseParams = {
686
+ buttonContainerId: containerId,
687
+ buttonOptions: {
688
+ createOrder: vi.fn(),
689
+ onApprove: vi.fn(),
690
+ onClick: vi.fn(),
691
+ onInit: vi.fn(),
692
+ style: {
693
+ color: "gold",
694
+ layout: "",
695
+ shape: "",
696
+ height: 40,
697
+ label: "",
698
+ tagline: true,
699
+ },
700
+ hostedButtonId: "",
701
+ },
702
+ };
703
+
704
+ beforeEach(() => {
705
+ vi.resetAllMocks();
706
+ // $FlowIssue
707
+ getLogger.mockImplementation(() => ({ error: errorMock }));
708
+ });
709
+
710
+ describe("renderStandaloneButton", () => {
711
+ test("renders button if eligible", () => {
712
+ const Buttons = vi.fn(() => ({
713
+ render: renderMock,
714
+ isEligible: vi.fn(() => true),
715
+ }));
716
+
717
+ // $FlowIssue
718
+ getButtonsComponent.mockImplementationOnce(() => Buttons);
719
+
720
+ renderStandaloneButton({
721
+ ...baseParams,
722
+ fundingSource: "paypal",
723
+ });
724
+
725
+ expect(renderMock).toHaveBeenCalledTimes(1);
726
+ expect(renderMock).toHaveBeenCalledWith(expectedContainerId);
727
+ expect(Buttons).toHaveBeenCalledWith(
728
+ expect.objectContaining({
729
+ fundingSource: "paypal",
730
+ })
731
+ );
732
+ });
733
+
734
+ test("does not render button if button is ineligible", () => {
735
+ const Buttons = vi.fn(() => ({
736
+ render: renderMock,
737
+ isEligible: vi.fn(() => false),
738
+ }));
739
+
740
+ // $FlowIssue
741
+ getButtonsComponent.mockImplementationOnce(() => Buttons);
742
+
743
+ renderStandaloneButton({
744
+ ...baseParams,
745
+ fundingSource: "venmo",
746
+ });
747
+
748
+ expect(renderMock).toHaveBeenCalledTimes(0);
749
+ expect(errorMock).toHaveBeenCalledWith(
750
+ "ncps_standalone_venmo_ineligible"
751
+ );
752
+ });
753
+ });
754
+
755
+ describe("renderDefaultButton", () => {
756
+ test("renders the first eligible button", () => {
757
+ const Buttons = vi.fn(() => ({
758
+ render: renderMock,
759
+ isEligible: vi.fn(() => true),
760
+ }));
761
+
762
+ // $FlowIssue
763
+ getButtonsComponent.mockImplementation(() => Buttons);
764
+
765
+ renderDefaultButton({
766
+ ...baseParams,
767
+ eligibleDefaultButtons: ["venmo", "paylater"],
768
+ });
769
+
770
+ expect(renderMock).toHaveBeenCalledWith(expectedContainerId);
771
+ expect(errorMock).toHaveBeenCalledTimes(0);
772
+ });
773
+
774
+ test("renders the next eligible button if button fails Buttons().isEligible() check", () => {
775
+ const Buttons = vi.fn(({ fundingSource }) => ({
776
+ render: renderMock,
777
+ isEligible: vi.fn(() => fundingSource === "paylater"),
778
+ }));
779
+
780
+ // $FlowIssue
781
+ getButtonsComponent.mockImplementation(() => Buttons);
782
+
783
+ renderDefaultButton({
784
+ ...baseParams,
785
+ eligibleDefaultButtons: ["venmo", "paylater"],
786
+ });
787
+
788
+ expect(errorMock).toHaveBeenCalledTimes(1);
789
+ expect(errorMock).toHaveBeenCalledWith(
790
+ "ncps_standalone_venmo_ineligible"
791
+ );
792
+ expect(renderMock).toHaveBeenCalledWith(expectedContainerId);
793
+ });
794
+
795
+ test("does not render any button if all fail Buttons().isEligible()", () => {
796
+ const Buttons = vi.fn(() => ({
797
+ render: renderMock,
798
+ isEligible: vi.fn(() => false),
799
+ }));
800
+
801
+ // $FlowIssue
802
+ getButtonsComponent.mockImplementation(() => Buttons);
803
+
804
+ renderDefaultButton({
805
+ ...baseParams,
806
+ eligibleDefaultButtons: ["venmo", "paylater"],
807
+ });
808
+
809
+ expect(errorMock).toHaveBeenCalledTimes(2);
810
+ expect(errorMock).toHaveBeenCalledWith(
811
+ "ncps_standalone_venmo_ineligible"
812
+ );
813
+ expect(errorMock).toHaveBeenCalledWith(
814
+ "ncps_standalone_paylater_ineligible"
815
+ );
816
+ expect(renderMock).toHaveBeenCalledTimes(0);
817
+ });
818
+ });
819
+ });
820
+
605
821
  /* eslint-enable no-restricted-globals, promise/no-native */