diff --git a/.changeset/shiny-paws-allow.md b/.changeset/shiny-paws-allow.md new file mode 100644 index 00000000..ceacb6e1 --- /dev/null +++ b/.changeset/shiny-paws-allow.md @@ -0,0 +1,5 @@ +--- +"@paypal/react-paypal-js": patch +--- + +Created CardFieldsProvider and context for creating and providing Card Fields sessions diff --git a/packages/react-paypal-js/src/v6/components/CardFieldsProvider.ssr.test.tsx b/packages/react-paypal-js/src/v6/components/CardFieldsProvider.ssr.test.tsx new file mode 100644 index 00000000..89061277 --- /dev/null +++ b/packages/react-paypal-js/src/v6/components/CardFieldsProvider.ssr.test.tsx @@ -0,0 +1,292 @@ +/** + * @jest-environment node + */ + +import React from "react"; +import { renderToString } from "react-dom/server"; + +import { usePayPal } from "../hooks/usePayPal"; +import { useCardFields, useCardFieldsSession } from "../hooks/useCardFields"; +import { INSTANCE_LOADING_STATE } from "../types"; +import { isServer } from "../utils"; +import { CardFieldsProvider } from "./CardFieldsProvider"; + +import type { + CardFieldsSessionState, + CardFieldsStatusState, +} from "../context/CardFieldsProviderContext"; +import type { CardFieldsSessionType } from "./CardFieldsProvider"; + +jest.mock("../hooks/usePayPal"); + +jest.mock("../utils", () => ({ + ...jest.requireActual("../utils"), + isServer: () => true, +})); + +const mockUsePayPal = usePayPal as jest.MockedFunction; + +const oneTimePaymentSessionType: CardFieldsSessionType = "one-time-payment"; +const savePaymentSessionType: CardFieldsSessionType = "save-payment"; + +// Test utilites +function expectInitialStatusState(state: CardFieldsStatusState): void { + expect(state.cardFieldsError).toBe(null); +} + +function expectInitialSessionState(state: CardFieldsSessionState): void { + expect(state.cardFieldsSession).toBe(null); +} + +// Render helpers +function renderSSRCardFieldsProvider({ + sessionType, + children, +}: { + sessionType: CardFieldsSessionType; + children?: React.ReactNode; +}) { + const { cardFieldsSessionState, cardFieldsStatusState, TestComponent } = + setupSSRTestComponent(); + + const html = renderToString( + + {children} + , + ); + + return { html, cardFieldsSessionState, cardFieldsStatusState }; +} + +function setupSSRTestComponent() { + const cardFieldsStatusState: CardFieldsStatusState = { + cardFieldsError: null, + }; + + const cardFieldsSessionState: CardFieldsSessionState = { + cardFieldsSession: null, + }; + + function TestComponent({ + children = null, + }: { + children?: React.ReactNode; + }) { + const newCardFieldsStatusState = useCardFields(); + const newCardFieldsSessionState = useCardFieldsSession(); + + Object.assign(cardFieldsStatusState, newCardFieldsStatusState); + Object.assign(cardFieldsSessionState, newCardFieldsSessionState); + + return <>{children}; + } + + return { cardFieldsStatusState, cardFieldsSessionState, TestComponent }; +} + +describe("CardFieldsProvider SSR", () => { + beforeEach(() => { + mockUsePayPal.mockReturnValue({ + sdkInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.PENDING, + eligiblePaymentMethods: null, + error: null, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Server-Side Rendering", () => { + test("should verify isServer mock is working", () => { + expect(isServer()).toBe(true); + }); + + test("should not attempt DOM access during server rendering", () => { + // In Node environment, document should be undefined + expect(typeof document).toBe("undefined"); + + // Will throw error if any DOM access is attempted during render + expect(() => + renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }), + ).not.toThrow(); + }); + + test("should work correctly in SSR context", () => { + const { cardFieldsSessionState, cardFieldsStatusState } = + renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expectInitialStatusState(cardFieldsStatusState); + expectInitialSessionState(cardFieldsSessionState); + }); + + test("should render without warnings or errors", () => { + // This sdkInstance state would fail client side + mockUsePayPal.mockReturnValue({ + sdkInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.REJECTED, + eligiblePaymentMethods: null, + error: null, + }); + + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(); + + const { html } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + children:
SSR Content
, + }); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(html).toBeTruthy(); + expect(typeof html).toBe("string"); + expect(html).toContain("SSR Content"); + + consoleSpy.mockRestore(); + }); + }); + + describe("Hydration Preparation", () => { + test("should prepare serializable state for client hydration", () => { + const { cardFieldsSessionState, cardFieldsStatusState } = + renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + // Server state should be safe for serialization + const serializedSessionState = JSON.stringify( + cardFieldsSessionState, + ); + const serializedStatusState = JSON.stringify(cardFieldsStatusState); + + expect(() => JSON.parse(serializedSessionState)).not.toThrow(); + expect(() => JSON.parse(serializedStatusState)).not.toThrow(); + + const parsedSessionState = JSON.parse(serializedSessionState); + const parsedStatusState = JSON.parse(serializedStatusState); + + expectInitialSessionState(parsedSessionState); + expectInitialStatusState(parsedStatusState); + }); + + test("should maintain consistent state with different options", () => { + const { + cardFieldsStatusState: cardFieldsStatusState1, + cardFieldsSessionState: cardFieldsSessionState1, + } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + const { + cardFieldsStatusState: cardFieldsStatusState2, + cardFieldsSessionState: cardFieldsSessionState2, + } = renderSSRCardFieldsProvider({ + sessionType: savePaymentSessionType, + }); + + // Both should have consistent initial state regardless of options + expectInitialStatusState(cardFieldsStatusState1); + expectInitialSessionState(cardFieldsSessionState1); + + expectInitialStatusState(cardFieldsStatusState2); + expectInitialSessionState(cardFieldsSessionState2); + }); + }); + + describe("Multiple renders", () => { + test("should maintain state consistency across multiple server renders", () => { + const { + html: html1, + cardFieldsStatusState: cardFieldsStatusState1, + cardFieldsSessionState: cardFieldsSessionState1, + } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + const { + html: html2, + cardFieldsStatusState: cardFieldsStatusState2, + cardFieldsSessionState: cardFieldsSessionState2, + } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expectInitialStatusState(cardFieldsStatusState1); + expectInitialSessionState(cardFieldsSessionState1); + expectInitialStatusState(cardFieldsStatusState2); + expectInitialSessionState(cardFieldsSessionState2); + expect(html1).toBe(html2); + }); + + test("should generate consistent HTML across renders", () => { + const { html: html1 } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + children:
Test Content
, + }); + const { html: html2 } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + children:
Test Content
, + }); + + expect(html1).toBe(html2); + expect(html1).toContain('data-testid="ssr-content"'); + }); + }); + + describe("Context isolation", () => { + const expectedSessionContextKeys = ["cardFieldsSession"] as const; + const expectedStatusContextKeys = ["cardFieldsError"] as const; + + describe("useCardFields", () => { + test("should only return status context values", () => { + const { cardFieldsStatusState } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + const receivedStatusKeys = Object.keys(cardFieldsStatusState); + expect(receivedStatusKeys.sort()).toEqual( + [...expectedStatusContextKeys].sort(), + ); + }); + + test("should not return session context values", () => { + const { cardFieldsStatusState } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + const receivedStatusKeys = Object.keys(cardFieldsStatusState); + expectedSessionContextKeys.forEach((key) => { + expect(receivedStatusKeys).not.toContain(key); + }); + }); + }); + + describe("useCardFieldsSession", () => { + test("should only return session context values", () => { + const { cardFieldsSessionState } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + const receivedSessionKeys = Object.keys(cardFieldsSessionState); + expect(receivedSessionKeys.sort()).toEqual( + [...expectedSessionContextKeys].sort(), + ); + }); + + test("should not return status context values", () => { + const { cardFieldsSessionState } = renderSSRCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + const receivedSessionKeys = Object.keys(cardFieldsSessionState); + expectedStatusContextKeys.forEach((key) => { + expect(receivedSessionKeys).not.toContain(key); + }); + }); + }); + }); +}); diff --git a/packages/react-paypal-js/src/v6/components/CardFieldsProvider.test.tsx b/packages/react-paypal-js/src/v6/components/CardFieldsProvider.test.tsx new file mode 100644 index 00000000..e6bc2a0d --- /dev/null +++ b/packages/react-paypal-js/src/v6/components/CardFieldsProvider.test.tsx @@ -0,0 +1,387 @@ +import React from "react"; +import { renderHook } from "@testing-library/react-hooks"; + +import { usePayPal } from "../hooks/usePayPal"; +import { INSTANCE_LOADING_STATE } from "../types"; +import { expectCurrentErrorValue } from "../hooks/useErrorTestUtil"; +import { CardFieldsProvider } from "./CardFieldsProvider"; +import { useCardFields, useCardFieldsSession } from "../hooks/useCardFields"; +import { toError } from "../utils"; + +import type { + CardFieldsOneTimePaymentSession, + CardFieldsSavePaymentSession, +} from "../types"; +import type { CardFieldsSessionType } from "./CardFieldsProvider"; + +jest.mock("../hooks/usePayPal"); + +jest.mock("../utils", () => ({ + ...jest.requireActual("../utils"), + isServer: () => false, +})); + +const mockUsePayPal = usePayPal as jest.MockedFunction; + +const oneTimePaymentSessionType: CardFieldsSessionType = "one-time-payment"; +const savePaymentSessionType: CardFieldsSessionType = "save-payment"; + +// Mock Factories +const createMockOneTimePaymentSession = + (): CardFieldsOneTimePaymentSession => ({ + createCardFieldsComponent: jest.fn(), + on: jest.fn(), + submit: jest.fn(), + update: jest.fn(), + }); + +const createMockSavePaymentSession = (): CardFieldsSavePaymentSession => ({ + createCardFieldsComponent: jest.fn(), + on: jest.fn(), + submit: jest.fn(), + update: jest.fn(), +}); + +const createMockSdkInstance = ({ + cardFieldsOneTimePaymentSession = createMockOneTimePaymentSession(), + cardFieldsSavePaymentSession = createMockSavePaymentSession(), +}: { + cardFieldsOneTimePaymentSession?: CardFieldsOneTimePaymentSession; + cardFieldsSavePaymentSession?: CardFieldsSavePaymentSession; +} = {}) => ({ + createCardFieldsOneTimePaymentSession: jest + .fn() + .mockReturnValue(cardFieldsOneTimePaymentSession), + createCardFieldsSavePaymentSession: jest + .fn() + .mockReturnValue(cardFieldsSavePaymentSession), +}); + +// Render helper +function renderCardFieldsProvider({ + sessionType, +}: { + sessionType: CardFieldsSessionType; +}) { + return renderHook( + () => ({ + status: useCardFields(), + session: useCardFieldsSession(), + }), + { + initialProps: { sessionType }, + wrapper: ({ children, sessionType }) => ( + + {children} + + ), + }, + ); +} + +describe("CardFieldsProvider", () => { + let mockCardFieldsOneTimePaymentSession: CardFieldsOneTimePaymentSession; + let mockCardFieldsSavePaymentSession: CardFieldsSavePaymentSession; + let mockSdkInstance: ReturnType; + + beforeEach(() => { + mockCardFieldsOneTimePaymentSession = createMockOneTimePaymentSession(); + mockCardFieldsSavePaymentSession = createMockSavePaymentSession(); + mockSdkInstance = createMockSdkInstance({ + cardFieldsOneTimePaymentSession: + mockCardFieldsOneTimePaymentSession, + cardFieldsSavePaymentSession: mockCardFieldsSavePaymentSession, + }); + + mockUsePayPal.mockReturnValue({ + // @ts-expect-error mocking sdk instance + sdkInstance: mockSdkInstance, + loadingStatus: INSTANCE_LOADING_STATE.RESOLVED, + eligiblePaymentMethods: null, + error: null, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("initialization", () => { + test("should not error if there is no sdkInstance but loading is still pending", () => { + mockUsePayPal.mockReturnValue({ + sdkInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.PENDING, + eligiblePaymentMethods: null, + error: null, + }); + + const { result } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expect(result.current.session.cardFieldsSession).toBeNull(); + expect(result.current.status.cardFieldsError).toBeNull(); + expectCurrentErrorValue(null); + }); + + test("should error if there is no sdkInstance and loading is rejected", () => { + mockUsePayPal.mockReturnValue({ + sdkInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.REJECTED, + eligiblePaymentMethods: null, + error: null, + }); + + const { result } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expect(result.current.session.cardFieldsSession).toBeNull(); + expect(result.current.status.cardFieldsError).toEqual( + toError("no sdk instance available"), + ); + expectCurrentErrorValue(result.current.status.cardFieldsError); + }); + + test("should clear any sdkInstance related errors if the sdkInstance becomes available", () => { + // First render: no sdkInstance and not in PENDING state, should error + mockUsePayPal.mockReturnValue({ + sdkInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.REJECTED, + eligiblePaymentMethods: null, + error: null, + }); + + const { result, rerender } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expect(result.current.session.cardFieldsSession).toBeNull(); + expect(result.current.status.cardFieldsError).toEqual( + toError("no sdk instance available"), + ); + expectCurrentErrorValue(result.current.status.cardFieldsError); + + // Second render: sdkInstance becomes available, error should clear + mockUsePayPal.mockReturnValue({ + // @ts-expect-error mocking sdk instance + sdkInstance: mockSdkInstance, + loadingStatus: INSTANCE_LOADING_STATE.RESOLVED, + eligiblePaymentMethods: null, + error: null, + }); + + rerender(); + expect(result.current.session.cardFieldsSession).toBe( + mockCardFieldsOneTimePaymentSession, + ); + expect(result.current.status.cardFieldsError).toBeNull(); + expectCurrentErrorValue(null); + }); + + test("should handle errors when creating the session", () => { + const errorMessage = "Failed to create session"; + + mockSdkInstance.createCardFieldsOneTimePaymentSession.mockImplementationOnce( + () => { + throw new Error(errorMessage); + }, + ); + + const { result } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expect(result.current.session.cardFieldsSession).toBeNull(); + expect(result.current.status.cardFieldsError).toEqual( + toError(errorMessage), + ); + expectCurrentErrorValue(result.current.status.cardFieldsError); + }); + }); + + describe("sessionType", () => { + test.each([ + { + sessionType: oneTimePaymentSessionType, + expectedMethod: + "createCardFieldsOneTimePaymentSession" as const, + unexpectedMethod: "createCardFieldsSavePaymentSession" as const, + }, + { + sessionType: savePaymentSessionType, + expectedMethod: "createCardFieldsSavePaymentSession" as const, + unexpectedMethod: + "createCardFieldsOneTimePaymentSession" as const, + }, + ])( + "should call $expectedMethod method when sessionType is $sessionType", + ({ sessionType, expectedMethod, unexpectedMethod }) => { + renderCardFieldsProvider({ + sessionType, + }); + + // Check that the correct create method was called + expect(mockSdkInstance[expectedMethod]).toHaveBeenCalledTimes( + 1, + ); + expect( + mockSdkInstance[unexpectedMethod], + ).not.toHaveBeenCalled(); + }, + ); + + test("should be correct session instance when sessionType is 'one-time-payment'", () => { + const { result } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expect(result.current.session.cardFieldsSession).toBe( + mockCardFieldsOneTimePaymentSession, + ); + expect(result.current.session.cardFieldsSession).not.toBe( + mockCardFieldsSavePaymentSession, + ); + }); + + test("should be correct session instance when sessionType is 'save-payment'", () => { + const { result } = renderCardFieldsProvider({ + sessionType: savePaymentSessionType, + }); + + expect(result.current.session.cardFieldsSession).toBe( + mockCardFieldsSavePaymentSession, + ); + expect(result.current.session.cardFieldsSession).not.toBe( + mockCardFieldsOneTimePaymentSession, + ); + }); + }); + + describe("session lifecycle", () => { + test("should update the session when the provider re-runs with a new sdkInstance", () => { + // Initial render with first mockSdkInstance + const { result, rerender } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expect(result.current.session.cardFieldsSession).toBe( + mockCardFieldsOneTimePaymentSession, + ); + + // Create a new mockSdkInstance with different session instances + jest.clearAllMocks(); + const newMockCardFieldsOneTimePaymentSession = + createMockOneTimePaymentSession(); + const newMockCardFieldsSavePaymentSession = + createMockSavePaymentSession(); + const newMockSdkInstance = createMockSdkInstance({ + cardFieldsOneTimePaymentSession: + newMockCardFieldsOneTimePaymentSession, + cardFieldsSavePaymentSession: + newMockCardFieldsSavePaymentSession, + }); + + mockUsePayPal.mockReturnValue({ + // @ts-expect-error mocking sdk instance + sdkInstance: newMockSdkInstance, + loadingStatus: INSTANCE_LOADING_STATE.RESOLVED, + eligiblePaymentMethods: null, + error: null, + }); + + rerender(); + expect( + newMockSdkInstance.createCardFieldsOneTimePaymentSession, + ).toHaveBeenCalledTimes(1); + expect( + newMockSdkInstance.createCardFieldsSavePaymentSession, + ).not.toHaveBeenCalled(); + expect(result.current.session.cardFieldsSession).toBe( + newMockCardFieldsOneTimePaymentSession, + ); + expect(result.current.session.cardFieldsSession).not.toBe( + mockCardFieldsOneTimePaymentSession, + ); + }); + + test("should update the session when the provider re-runs with a new sessionType", () => { + // Initial render with one-time-payment + const { result, rerender } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + expect(result.current.session.cardFieldsSession).toBe( + mockCardFieldsOneTimePaymentSession, + ); + expect(result.current.session.cardFieldsSession).not.toBe( + mockCardFieldsSavePaymentSession, + ); + + // Rerender with save-payment + rerender({ sessionType: savePaymentSessionType }); + + expect(result.current.session.cardFieldsSession).toBe( + mockCardFieldsSavePaymentSession, + ); + expect(result.current.session.cardFieldsSession).not.toBe( + mockCardFieldsOneTimePaymentSession, + ); + }); + }); + + describe("Context isolation", () => { + const expectedSessionContextKeys = ["cardFieldsSession"] as const; + const expectedStatusContextKeys = ["cardFieldsError"] as const; + + describe("useCardFields", () => { + test("should only return status context values", () => { + const { result } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + const receivedStatusKeys = Object.keys(result.current.status); + expect(receivedStatusKeys.sort()).toEqual( + [...expectedStatusContextKeys].sort(), + ); + }); + + test("should not return session context values", () => { + const { result } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + const receivedStatusKeys = Object.keys(result.current.status); + expectedSessionContextKeys.forEach((key) => { + expect(receivedStatusKeys).not.toContain(key); + }); + }); + }); + + describe("useCardFieldsSession", () => { + test("should only return session context values", () => { + const { result } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + const receivedSessionKeys = Object.keys(result.current.session); + expect(receivedSessionKeys.sort()).toEqual( + [...expectedSessionContextKeys].sort(), + ); + }); + + test("should not return status context values", () => { + const { result } = renderCardFieldsProvider({ + sessionType: oneTimePaymentSessionType, + }); + + const receivedSesssionKeys = Object.keys( + result.current.session, + ); + expectedStatusContextKeys.forEach((key) => { + expect(receivedSesssionKeys).not.toContain(key); + }); + }); + }); + }); +}); diff --git a/packages/react-paypal-js/src/v6/components/CardFieldsProvider.tsx b/packages/react-paypal-js/src/v6/components/CardFieldsProvider.tsx new file mode 100644 index 00000000..20665313 --- /dev/null +++ b/packages/react-paypal-js/src/v6/components/CardFieldsProvider.tsx @@ -0,0 +1,126 @@ +import React, { + JSX, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +import { usePayPal } from "../hooks/usePayPal"; +import { + CardFieldsSessionContext, + CardFieldsStatusContext, +} from "../context/CardFieldsProviderContext"; +import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderEnums"; +import { useError } from "../hooks/useError"; +import { toError } from "../utils"; + +import type { + CardFieldsOneTimePaymentSession, + CardFieldsSavePaymentSession, +} from "../types"; +import type { + CardFieldsSessionState, + CardFieldsStatusState, +} from "../context/CardFieldsProviderContext"; + +export type CardFieldsSession = + | CardFieldsOneTimePaymentSession + | CardFieldsSavePaymentSession; + +export type CardFieldsSessionType = "one-time-payment" | "save-payment"; + +type CardFieldsProviderProps = { + children: ReactNode; + sessionType: CardFieldsSessionType; +}; + +/** + * {@link CardFieldsProvider} creates the appropriate Card Fields session based on the `sessionType` prop value, and then provides it to child components that require it. + * + * @example + * + * + * + * + * + */ +export const CardFieldsProvider = ({ + children, + sessionType, +}: CardFieldsProviderProps): JSX.Element => { + const { sdkInstance, loadingStatus } = usePayPal(); + const [cardFieldsSession, setCardFieldsSession] = + useState(null); + const [cardFieldsError, setCardFieldsError] = useState(null); + // Using the error hook here so it can participate in side-effects provided by the hook. + // The actual error instance is stored in the provider's state. + const [, setError] = useError(); + + const handleError = useCallback( + (error: Error | null) => { + setError(error); + setCardFieldsError(error); + }, + [setError], + ); + + useEffect(() => { + // Early return: Still loading, wait for sdkInstance + if (loadingStatus === INSTANCE_LOADING_STATE.PENDING) { + return; + } + + // Error case: Loading finished but no sdkInstance + if (!sdkInstance) { + handleError(toError("no sdk instance available")); + return; + } + + // Clear previous sdkInstance loading errors + handleError(null); + + // Create Card Fields session based on sessionType + try { + const newCardFieldsSession = + sessionType === "one-time-payment" + ? sdkInstance.createCardFieldsOneTimePaymentSession() + : sdkInstance.createCardFieldsSavePaymentSession(); + + setCardFieldsSession(newCardFieldsSession); + } catch (error) { + handleError(toError(error)); + } + + return () => { + setCardFieldsSession(null); + }; + }, [sdkInstance, loadingStatus, sessionType, handleError]); + + const sessionContextValue: CardFieldsSessionState = useMemo( + () => ({ + cardFieldsSession, + }), + [cardFieldsSession], + ); + + const statusContextValue: CardFieldsStatusState = useMemo( + () => ({ + cardFieldsError, + }), + [cardFieldsError], + ); + + return ( + + + {children} + + + ); +}; diff --git a/packages/react-paypal-js/src/v6/context/CardFieldsProviderContext.tsx b/packages/react-paypal-js/src/v6/context/CardFieldsProviderContext.tsx new file mode 100644 index 00000000..998d41c6 --- /dev/null +++ b/packages/react-paypal-js/src/v6/context/CardFieldsProviderContext.tsx @@ -0,0 +1,17 @@ +import { createContext } from "react"; + +import type { CardFieldsSession } from "../components/CardFieldsProvider"; + +export interface CardFieldsSessionState { + cardFieldsSession: CardFieldsSession | null; +} + +export const CardFieldsSessionContext = + createContext(null); + +export interface CardFieldsStatusState { + cardFieldsError: Error | null; +} + +export const CardFieldsStatusContext = + createContext(null); diff --git a/packages/react-paypal-js/src/v6/hooks/useCardFields.test.ts b/packages/react-paypal-js/src/v6/hooks/useCardFields.test.ts new file mode 100644 index 00000000..ee87259f --- /dev/null +++ b/packages/react-paypal-js/src/v6/hooks/useCardFields.test.ts @@ -0,0 +1,25 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { useCardFields, useCardFieldsSession } from "./useCardFields"; + +describe("useCardFields", () => { + test("should throw an error when used without CardFieldsProvider", () => { + const { result } = renderHook(() => useCardFields()); + + expect(result.error).toEqual( + new Error("useCardFields must be used within a CardFieldsProvider"), + ); + }); +}); + +describe("useCardFieldsSession", () => { + test("should throw an error when used without CardFieldsProvider", () => { + const { result } = renderHook(() => useCardFieldsSession()); + + expect(result.error).toEqual( + new Error( + "useCardFieldsSession must be used within a CardFieldsProvider", + ), + ); + }); +}); diff --git a/packages/react-paypal-js/src/v6/hooks/useCardFields.ts b/packages/react-paypal-js/src/v6/hooks/useCardFields.ts new file mode 100644 index 00000000..41edc2f7 --- /dev/null +++ b/packages/react-paypal-js/src/v6/hooks/useCardFields.ts @@ -0,0 +1,46 @@ +import { useContext } from "react"; + +import { + CardFieldsSessionContext, + CardFieldsStatusContext, +} from "../context/CardFieldsProviderContext"; + +import type { CardFieldsProvider } from "../components/CardFieldsProvider"; +import type { + CardFieldsSessionState, + CardFieldsStatusState, +} from "../context/CardFieldsProviderContext"; + +/** + * Returns {@link CardFieldsStatusState} provided by a parent {@link CardFieldsProvider} + * + * @returns {CardFieldsStatusState} + */ +export function useCardFields(): CardFieldsStatusState { + const context = useContext(CardFieldsStatusContext); + + if (context === null) { + throw new Error( + "useCardFields must be used within a CardFieldsProvider", + ); + } + + return context; +} + +/** + * Returns {@link CardFieldsSessionState} provided by a parent {@link CardFieldsProvider} + * + * @returns {CardFieldsSessionState} + */ +export function useCardFieldsSession(): CardFieldsSessionState { + const context = useContext(CardFieldsSessionContext); + + if (context === null) { + throw new Error( + "useCardFieldsSession must be used within a CardFieldsProvider", + ); + } + + return context; +} diff --git a/packages/react-paypal-js/src/v6/index.ts b/packages/react-paypal-js/src/v6/index.ts index fc2ccdd9..a32927d2 100644 --- a/packages/react-paypal-js/src/v6/index.ts +++ b/packages/react-paypal-js/src/v6/index.ts @@ -1,6 +1,11 @@ export * from "./types"; +export { + CardFieldsProvider, + type CardFieldsSessionType, +} from "./components/CardFieldsProvider"; export { PayPalOneTimePaymentButton } from "./components/PayPalOneTimePaymentButton"; export { PayPalProvider } from "./components/PayPalProvider"; +export { useCardFields } from "./hooks/useCardFields"; export { usePayPal } from "./hooks/usePayPal"; export { usePayLaterOneTimePaymentSession } from "./hooks/usePayLaterOneTimePaymentSession"; export { usePayPalOneTimePaymentSession } from "./hooks/usePayPalOneTimePaymentSession";