From c025b9334d4fb9366bdc9a28d7164d5c68707a79 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:02:14 -0600 Subject: [PATCH 01/10] update paypal js to export messaging component --- .../types/v6/components/paypal-messages.d.ts | 252 ++++++++++++++++++ packages/paypal-js/types/v6/index.d.ts | 4 + .../src/v6/hooks/usePayPalMessages.ts | 43 +++ 3 files changed, 299 insertions(+) create mode 100644 packages/paypal-js/types/v6/components/paypal-messages.d.ts create mode 100644 packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts diff --git a/packages/paypal-js/types/v6/components/paypal-messages.d.ts b/packages/paypal-js/types/v6/components/paypal-messages.d.ts new file mode 100644 index 00000000..0f661cef --- /dev/null +++ b/packages/paypal-js/types/v6/components/paypal-messages.d.ts @@ -0,0 +1,252 @@ +// ============================================================================ +// Content & Messaging Types +// ============================================================================ + +type ContentType = "TEXT" | "IMAGE" | "LINK" | "TEXT_VARIABLE"; + +type ContentBlock = { + alternativeText?: string; + altTextPath?: string; + clickUrl?: string; + text?: string; + textPath?: string; + srcUrl?: string; + type: ContentType; + name?: string; + embeddable?: boolean; +}; + +type MessageItems = { + actionItems: ContentBlock[]; + disclaimerItems?: ContentBlock[]; + mainItems: ContentBlock[]; +}; + +// ============================================================================ +// Offer Types +// ============================================================================ + +type OfferType = + | "PAYPAL_BALANCE" + | "PAYPAL_CASHBACK_MASTERCARD" + | "PAYPAL_CREDIT_NO_INTEREST" + | "PAYPAL_DEBIT_CARD" + | "PAY_LATER_LONG_TERM" + | "PAY_LATER_PAY_IN_1" + | "PAY_LATER_SHORT_TERM"; + +type OfferTypes = OfferType[]; + +export type UserOfferTypes = OfferTypes | string; + +// ============================================================================ +// Base Configuration Types +// ============================================================================ + +/** + * Base options for PayPal Messages configuration. + */ +export type PayPalMessagesOptions = { + buyerCountry?: string; + currencyCode?: string; + shopperSessionId?: string; +}; + +// ============================================================================ +// Caching Types +// ============================================================================ + +type Cache = { + local: boolean; + origin: boolean; +}; + +type UserCache = boolean | Partial; + +// ============================================================================ +// Fetch Content Types +// ============================================================================ + +type RequestType = "MOCK" | "SAMPLE"; + +type LogoPosition = "INLINE" | "LEFT" | "RIGHT" | "TOP"; + +type LogoType = "MONOGRAM" | "NONE" | "TEXT" | "WORDMARK"; + +type TextColor = "BLACK" | "MONOCHROME" | "WHITE"; + +type OnReady = (messageContent: MessageContent) => void; + +/** + * Internal options for fetching message content. + */ +type FetchContentOptions = PayPalMessagesOptions & { + amount?: string; + cache: Cache; + logoPosition: LogoPosition; + logoType: LogoType; + offerTypes?: OfferTypes; + onContentReady?: OnReady; + onTemplateReady?: OnReady; + requestType?: RequestType; // Used in lower environments to receive request from mock (client-tier) or samples (mid-tier) + textColor?: TextColor; +}; + +/** + * User-facing options for fetching message content. + */ +type UserFetchContentOptions = Omit< + Partial, + "offerTypes" | "cache" +> & { + cache?: UserCache; + offerTypes?: UserOfferTypes; + onReady?: OnReady; +}; + +/** + * Message content returned from fetch operations. + */ +export type MessageContent = { + messageItems: MessageItems; + config: FetchContentOptions; + messageOfferType?: OfferType; + update: ( + fetchContentOptions: UserFetchContentOptions, + ) => Promise; + impressionUrl?: string; + clickUrl?: string; + embeddable?: boolean; + partnerAttributionId?: string; + buyerCountryCode?: string; + // values needed for analytics captured at time of fetch + renderDuration?: number; + pageType?: string; + attributes?: string[]; + // Flag to indicate if content is template content or final content + isTemplateContent: boolean; +}; + +// ============================================================================ +// Learn More Types +// ============================================================================ + +/** + * Options for configuring Learn More presentations. + */ +type LearnMoreOptions = PayPalMessagesOptions & { + amount?: string; + offerType?: OfferType; + presentationMode: "AUTO" | "MODAL" | "POPUP" | "REDIRECT"; + stageTag?: string; + clickUrl?: URL; + embeddable?: boolean; + onShow?: (data?: object) => void; + onClose?: (data?: object) => void; + onApply?: (data?: object) => void; + onCalculate?: (data?: object) => void; +}; + +/** + * User-facing options for Learn More presentations. + */ +export type UserLearnMoreOptions = Omit< + Partial, + "clickUrl" +> & { + clickUrl?: string | URL; +}; + +/** + * Base type for all LearnMore implementations. + * Represents the common public API shared by all LearnMore variants. + */ +export type LearnMoreBase = { + /** + * Indicates whether the Learn More presentation is currently open. + */ + isOpen: boolean; + + /** + * Opens the Learn More presentation. + * @param userLearnMoreOptions - Optional configuration to update before opening. + * @returns Promise that resolves when the presentation is opened and configured. + */ + open: (userLearnMoreOptions?: UserLearnMoreOptions) => Promise; + + /** + * Closes the Learn More presentation. + * @param trigger - Optional trigger identifier (e.g., "viaCustomButton"). + */ + close: (trigger?: string) => void; + + /** + * Updates the Learn More presentation with new options. + * @param userLearnMoreOptions - New configuration options. + * @returns Promise that resolves when the update is complete. + */ + update: (userLearnMoreOptions?: UserLearnMoreOptions) => Promise; + + /** + * Shows the Learn More presentation (internal display method). + */ + show: () => void; + + /** + * Sets up the PostMessenger for communication with the Learn More content. + */ + setupPostMessenger: () => void; +}; + +/** + * Type representing a LearnMorePopup instance. + * Opens Learn More content in a browser popup window. + */ +export type LearnMorePopupType = LearnMoreBase; + +/** + * Type representing a LearnMoreModal instance. + * Opens Learn More content in an iframe modal overlay with custom accessibility features. + */ +export type LearnMoreModalType = LearnMoreBase & { + /** + * Sets up a custom close button with tab trap functionality. + * Modal-specific method for accessibility enhancements. + */ + setupCustomCloseButton: () => void; +}; + +/** + * Type representing a LearnMoreRedirect instance. + * Opens Learn More content in a new browser tab/window via window.open(). + */ +export type LearnMoreRedirectType = LearnMoreBase; + +/** + * Union type for all Learn More presentation variants. + */ +export type LearnMore = + | LearnMorePopupType + | LearnMoreModalType + | LearnMoreRedirectType; + +// ============================================================================ +// Public API Types +// ============================================================================ + +/** + * Session object for managing PayPal Messages. + */ +export type PayPalMessagesSession = { + fetchContent: () => MessageContent; + createLearnMore: (options: LearnMoreOptions) => LearnMore; +}; + +/** + * Main PayPal Messages instance interface. + */ +export interface PayPalMessagesInstance { + createPayPalMessages: ( + messagesOptions?: PayPalMessagesOptions, + ) => PayPalMessagesSession; +} diff --git a/packages/paypal-js/types/v6/index.d.ts b/packages/paypal-js/types/v6/index.d.ts index cc4d32d4..04bcacfc 100644 --- a/packages/paypal-js/types/v6/index.d.ts +++ b/packages/paypal-js/types/v6/index.d.ts @@ -6,6 +6,7 @@ import { EligiblePaymentMethodsOutput, FindEligibleMethodsOptions, } from "./components/find-eligible-methods"; +import { PayPalMessagesInstance } from "./components/paypal-messages"; export interface PayPalV6Namespace { /** @@ -37,6 +38,7 @@ export interface PayPalV6Namespace { export type Components = | "paypal-payments" | "paypal-guest-payments" + | "paypal-messages" | "venmo-payments" | "paypal-legacy-billing-agreements"; @@ -124,6 +126,7 @@ export type SdkInstance = BaseInstance & ("paypal-guest-payments" extends T[number] ? PayPalGuestPaymentsInstance : unknown) & + ("paypal-messages" extends T[number] ? PayPalMessagesInstance : unknown) & ("venmo-payments" extends T[number] ? VenmoPaymentsInstance : unknown) & ("paypal-legacy-billing-agreements" extends T[number] ? PayPalLegacyBillingInstance @@ -193,6 +196,7 @@ export * from "./components/paypal-guest-payments"; export * from "./components/paypal-legacy-billing-agreements"; export * from "./components/venmo-payments"; export * from "./components/find-eligible-methods"; +export * from "./components/paypal-messages"; // export a subset of types from base-component export { diff --git a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts new file mode 100644 index 00000000..3585e8ec --- /dev/null +++ b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react"; + +import { usePayPal } from "./usePayPal"; +import { useError } from "./useError"; + +import type { PayPalMessagesOptions, PayPalMessagesSession } from "../types"; + +type PayPalMessagesReturn = { + error: Error | null; +}; + +export const usePayPalMessages = ( + options: PayPalMessagesOptions, +): PayPalMessagesReturn => { + const { sdkInstance } = usePayPal(); + const sessionRef = useRef(null); + const [error, setError] = useError(); + const { buyerCountry, currencyCode, shopperSessionId } = options; + + useEffect(() => { + if (!sdkInstance) { + setError(new Error("no sdk instance available")); + } + }, [sdkInstance, setError]); + + useEffect(() => { + if (!sdkInstance) { + return; + } + + const newSession = sdkInstance.createPayPalMessages({ + buyerCountry, + currencyCode, + shopperSessionId, + }); + + sessionRef.current = newSession; + }, [buyerCountry, currencyCode, sdkInstance, shopperSessionId]); + + return { + error, + }; +}; From fc8fcdf99258b0f26a1e4616d0b218da829702a7 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:48:14 -0600 Subject: [PATCH 02/10] revert paypal-js changes --- .../types/v6/components/paypal-messages.d.ts | 252 ------------------ packages/paypal-js/types/v6/index.d.ts | 3 - 2 files changed, 255 deletions(-) delete mode 100644 packages/paypal-js/types/v6/components/paypal-messages.d.ts diff --git a/packages/paypal-js/types/v6/components/paypal-messages.d.ts b/packages/paypal-js/types/v6/components/paypal-messages.d.ts deleted file mode 100644 index 0f661cef..00000000 --- a/packages/paypal-js/types/v6/components/paypal-messages.d.ts +++ /dev/null @@ -1,252 +0,0 @@ -// ============================================================================ -// Content & Messaging Types -// ============================================================================ - -type ContentType = "TEXT" | "IMAGE" | "LINK" | "TEXT_VARIABLE"; - -type ContentBlock = { - alternativeText?: string; - altTextPath?: string; - clickUrl?: string; - text?: string; - textPath?: string; - srcUrl?: string; - type: ContentType; - name?: string; - embeddable?: boolean; -}; - -type MessageItems = { - actionItems: ContentBlock[]; - disclaimerItems?: ContentBlock[]; - mainItems: ContentBlock[]; -}; - -// ============================================================================ -// Offer Types -// ============================================================================ - -type OfferType = - | "PAYPAL_BALANCE" - | "PAYPAL_CASHBACK_MASTERCARD" - | "PAYPAL_CREDIT_NO_INTEREST" - | "PAYPAL_DEBIT_CARD" - | "PAY_LATER_LONG_TERM" - | "PAY_LATER_PAY_IN_1" - | "PAY_LATER_SHORT_TERM"; - -type OfferTypes = OfferType[]; - -export type UserOfferTypes = OfferTypes | string; - -// ============================================================================ -// Base Configuration Types -// ============================================================================ - -/** - * Base options for PayPal Messages configuration. - */ -export type PayPalMessagesOptions = { - buyerCountry?: string; - currencyCode?: string; - shopperSessionId?: string; -}; - -// ============================================================================ -// Caching Types -// ============================================================================ - -type Cache = { - local: boolean; - origin: boolean; -}; - -type UserCache = boolean | Partial; - -// ============================================================================ -// Fetch Content Types -// ============================================================================ - -type RequestType = "MOCK" | "SAMPLE"; - -type LogoPosition = "INLINE" | "LEFT" | "RIGHT" | "TOP"; - -type LogoType = "MONOGRAM" | "NONE" | "TEXT" | "WORDMARK"; - -type TextColor = "BLACK" | "MONOCHROME" | "WHITE"; - -type OnReady = (messageContent: MessageContent) => void; - -/** - * Internal options for fetching message content. - */ -type FetchContentOptions = PayPalMessagesOptions & { - amount?: string; - cache: Cache; - logoPosition: LogoPosition; - logoType: LogoType; - offerTypes?: OfferTypes; - onContentReady?: OnReady; - onTemplateReady?: OnReady; - requestType?: RequestType; // Used in lower environments to receive request from mock (client-tier) or samples (mid-tier) - textColor?: TextColor; -}; - -/** - * User-facing options for fetching message content. - */ -type UserFetchContentOptions = Omit< - Partial, - "offerTypes" | "cache" -> & { - cache?: UserCache; - offerTypes?: UserOfferTypes; - onReady?: OnReady; -}; - -/** - * Message content returned from fetch operations. - */ -export type MessageContent = { - messageItems: MessageItems; - config: FetchContentOptions; - messageOfferType?: OfferType; - update: ( - fetchContentOptions: UserFetchContentOptions, - ) => Promise; - impressionUrl?: string; - clickUrl?: string; - embeddable?: boolean; - partnerAttributionId?: string; - buyerCountryCode?: string; - // values needed for analytics captured at time of fetch - renderDuration?: number; - pageType?: string; - attributes?: string[]; - // Flag to indicate if content is template content or final content - isTemplateContent: boolean; -}; - -// ============================================================================ -// Learn More Types -// ============================================================================ - -/** - * Options for configuring Learn More presentations. - */ -type LearnMoreOptions = PayPalMessagesOptions & { - amount?: string; - offerType?: OfferType; - presentationMode: "AUTO" | "MODAL" | "POPUP" | "REDIRECT"; - stageTag?: string; - clickUrl?: URL; - embeddable?: boolean; - onShow?: (data?: object) => void; - onClose?: (data?: object) => void; - onApply?: (data?: object) => void; - onCalculate?: (data?: object) => void; -}; - -/** - * User-facing options for Learn More presentations. - */ -export type UserLearnMoreOptions = Omit< - Partial, - "clickUrl" -> & { - clickUrl?: string | URL; -}; - -/** - * Base type for all LearnMore implementations. - * Represents the common public API shared by all LearnMore variants. - */ -export type LearnMoreBase = { - /** - * Indicates whether the Learn More presentation is currently open. - */ - isOpen: boolean; - - /** - * Opens the Learn More presentation. - * @param userLearnMoreOptions - Optional configuration to update before opening. - * @returns Promise that resolves when the presentation is opened and configured. - */ - open: (userLearnMoreOptions?: UserLearnMoreOptions) => Promise; - - /** - * Closes the Learn More presentation. - * @param trigger - Optional trigger identifier (e.g., "viaCustomButton"). - */ - close: (trigger?: string) => void; - - /** - * Updates the Learn More presentation with new options. - * @param userLearnMoreOptions - New configuration options. - * @returns Promise that resolves when the update is complete. - */ - update: (userLearnMoreOptions?: UserLearnMoreOptions) => Promise; - - /** - * Shows the Learn More presentation (internal display method). - */ - show: () => void; - - /** - * Sets up the PostMessenger for communication with the Learn More content. - */ - setupPostMessenger: () => void; -}; - -/** - * Type representing a LearnMorePopup instance. - * Opens Learn More content in a browser popup window. - */ -export type LearnMorePopupType = LearnMoreBase; - -/** - * Type representing a LearnMoreModal instance. - * Opens Learn More content in an iframe modal overlay with custom accessibility features. - */ -export type LearnMoreModalType = LearnMoreBase & { - /** - * Sets up a custom close button with tab trap functionality. - * Modal-specific method for accessibility enhancements. - */ - setupCustomCloseButton: () => void; -}; - -/** - * Type representing a LearnMoreRedirect instance. - * Opens Learn More content in a new browser tab/window via window.open(). - */ -export type LearnMoreRedirectType = LearnMoreBase; - -/** - * Union type for all Learn More presentation variants. - */ -export type LearnMore = - | LearnMorePopupType - | LearnMoreModalType - | LearnMoreRedirectType; - -// ============================================================================ -// Public API Types -// ============================================================================ - -/** - * Session object for managing PayPal Messages. - */ -export type PayPalMessagesSession = { - fetchContent: () => MessageContent; - createLearnMore: (options: LearnMoreOptions) => LearnMore; -}; - -/** - * Main PayPal Messages instance interface. - */ -export interface PayPalMessagesInstance { - createPayPalMessages: ( - messagesOptions?: PayPalMessagesOptions, - ) => PayPalMessagesSession; -} diff --git a/packages/paypal-js/types/v6/index.d.ts b/packages/paypal-js/types/v6/index.d.ts index 04bcacfc..4190be30 100644 --- a/packages/paypal-js/types/v6/index.d.ts +++ b/packages/paypal-js/types/v6/index.d.ts @@ -6,7 +6,6 @@ import { EligiblePaymentMethodsOutput, FindEligibleMethodsOptions, } from "./components/find-eligible-methods"; -import { PayPalMessagesInstance } from "./components/paypal-messages"; export interface PayPalV6Namespace { /** @@ -126,7 +125,6 @@ export type SdkInstance = BaseInstance & ("paypal-guest-payments" extends T[number] ? PayPalGuestPaymentsInstance : unknown) & - ("paypal-messages" extends T[number] ? PayPalMessagesInstance : unknown) & ("venmo-payments" extends T[number] ? VenmoPaymentsInstance : unknown) & ("paypal-legacy-billing-agreements" extends T[number] ? PayPalLegacyBillingInstance @@ -196,7 +194,6 @@ export * from "./components/paypal-guest-payments"; export * from "./components/paypal-legacy-billing-agreements"; export * from "./components/venmo-payments"; export * from "./components/find-eligible-methods"; -export * from "./components/paypal-messages"; // export a subset of types from base-component export { From a29b7f7a267638abf389387fbf4ac6f01d94d566 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:22:50 -0600 Subject: [PATCH 03/10] export paypal messages hook --- packages/react-paypal-js/src/v6/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-paypal-js/src/v6/index.ts b/packages/react-paypal-js/src/v6/index.ts index 66243977..af069d01 100644 --- a/packages/react-paypal-js/src/v6/index.ts +++ b/packages/react-paypal-js/src/v6/index.ts @@ -7,3 +7,4 @@ export { usePayPalOneTimePaymentSession } from "./hooks/usePayPalOneTimePaymentS export { usePayPalSavePaymentSession } from "./hooks/usePayPalSavePaymentSession"; export { useVenmoOneTimePaymentSession } from "./hooks/useVenmoOneTimePaymentSession"; export * from "./hooks/useEligibleMethods"; +export { usePayPalMessages } from "./hooks/usePayPalMessages"; From 5509fac8ff2aab7e6a77aa2f1ad114bc9f9a4d04 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:00:49 -0600 Subject: [PATCH 04/10] refactor session hook arguments --- .../react-paypal-js/src/v6/hooks/usePayPalMessages.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts index 3585e8ec..a3f15c28 100644 --- a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts +++ b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts @@ -9,13 +9,14 @@ type PayPalMessagesReturn = { error: Error | null; }; -export const usePayPalMessages = ( - options: PayPalMessagesOptions, -): PayPalMessagesReturn => { +export function usePayPalMessages({ + buyerCountry, + currencyCode, + shopperSessionId, +}: PayPalMessagesOptions): PayPalMessagesReturn { const { sdkInstance } = usePayPal(); const sessionRef = useRef(null); const [error, setError] = useError(); - const { buyerCountry, currencyCode, shopperSessionId } = options; useEffect(() => { if (!sdkInstance) { @@ -40,4 +41,4 @@ export const usePayPalMessages = ( return { error, }; -}; +} From 3c2f72bc09ebe19b0aa49dbfabf10be16d80dbc1 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:29:11 -0600 Subject: [PATCH 05/10] add handleFetchContent helper --- .../src/v6/hooks/usePayPalMessages.ts | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts index a3f15c28..a00c4906 100644 --- a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts +++ b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts @@ -1,12 +1,21 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { usePayPal } from "./usePayPal"; import { useError } from "./useError"; +import { useIsMountedRef } from "./useIsMounted"; -import type { PayPalMessagesOptions, PayPalMessagesSession } from "../types"; +import type { + FetchContentOptions, + MessageContent, + PayPalMessagesOptions, + PayPalMessagesSession, +} from "../types"; type PayPalMessagesReturn = { error: Error | null; + handleFetchContent: ( + options: FetchContentOptions, + ) => Promise; }; export function usePayPalMessages({ @@ -15,6 +24,7 @@ export function usePayPalMessages({ shopperSessionId, }: PayPalMessagesOptions): PayPalMessagesReturn { const { sdkInstance } = usePayPal(); + const isMountedRef = useIsMountedRef(); const sessionRef = useRef(null); const [error, setError] = useError(); @@ -38,7 +48,39 @@ export function usePayPalMessages({ sessionRef.current = newSession; }, [buyerCountry, currencyCode, sdkInstance, shopperSessionId]); + const handleFetchContent = useCallback( + async (options: FetchContentOptions) => { + if (!isMountedRef.current) { + return; + } + + if (!sessionRef.current) { + setError(new Error("PayPal session not available")); + return; + } + + try { + const result = await sessionRef.current.fetchContent(options); + + // fetchContent will return null in the case of an API erro + if (result === null) { + setError( + new Error("Failed to fetch PayPal Messages content"), + ); + return; + } + + return result; + } catch (err) { + setError(err as Error); + return; + } + }, + [isMountedRef, setError], + ); + return { error, + handleFetchContent, }; } From e23176bb1ef0c7058b9d959e71676bd1ef854c20 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:41:24 -0600 Subject: [PATCH 06/10] add handle create learn more helper --- .../types/v6/components/paypal-messages.d.ts | 2 +- .../src/v6/hooks/usePayPalMessages.ts | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/paypal-js/types/v6/components/paypal-messages.d.ts b/packages/paypal-js/types/v6/components/paypal-messages.d.ts index 61186df5..7ab03eae 100644 --- a/packages/paypal-js/types/v6/components/paypal-messages.d.ts +++ b/packages/paypal-js/types/v6/components/paypal-messages.d.ts @@ -47,7 +47,7 @@ export type FetchContentOptions = PayPalMessagesOptions & { /** * Options for configuring Learn More presentations. */ -type LearnMoreOptions = PayPalMessagesOptions & { +export type LearnMoreOptions = PayPalMessagesOptions & { amount?: string; presentationMode: "AUTO" | "MODAL" | "POPUP" | "REDIRECT"; onShow?: (data?: object) => void; diff --git a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts index a00c4906..385aa533 100644 --- a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts +++ b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts @@ -9,10 +9,15 @@ import type { MessageContent, PayPalMessagesOptions, PayPalMessagesSession, + LearnMoreOptions, + LearnMore, } from "../types"; type PayPalMessagesReturn = { error: Error | null; + handleCreateLearnMore: ( + options?: LearnMoreOptions, + ) => LearnMore | undefined; handleFetchContent: ( options: FetchContentOptions, ) => Promise; @@ -55,7 +60,7 @@ export function usePayPalMessages({ } if (!sessionRef.current) { - setError(new Error("PayPal session not available")); + setError(new Error("PayPal Messages session not available")); return; } @@ -79,8 +84,25 @@ export function usePayPalMessages({ [isMountedRef, setError], ); + const handleCreateLearnMore = useCallback( + (options?: LearnMoreOptions) => { + if (!isMountedRef.current) { + return; + } + + if (!sessionRef.current) { + setError(new Error("PayPal Messages session not available")); + return; + } + + return sessionRef.current.createLearnMore(options); + }, + [isMountedRef, setError], + ); + return { error, + handleCreateLearnMore, handleFetchContent, }; } From 28529e1abed08bbdda4a3bdac0a489db3d97da16 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:53:32 -0600 Subject: [PATCH 07/10] chore: add changeset --- .changeset/great-pianos-divide.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/great-pianos-divide.md diff --git a/.changeset/great-pianos-divide.md b/.changeset/great-pianos-divide.md new file mode 100644 index 00000000..ec49da91 --- /dev/null +++ b/.changeset/great-pianos-divide.md @@ -0,0 +1,6 @@ +--- +"@paypal/react-paypal-js": patch +"@paypal/paypal-js": patch +--- + +Add v6 paypal-messages hook and types. From f6c0d4e823a06157ac54f9ba3e3e2b8bffefb7d7 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:55:13 -0600 Subject: [PATCH 08/10] fix comment --- packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts index 385aa533..05f9872e 100644 --- a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts +++ b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.ts @@ -67,7 +67,7 @@ export function usePayPalMessages({ try { const result = await sessionRef.current.fetchContent(options); - // fetchContent will return null in the case of an API erro + // fetchContent will return null in the case of an API error if (result === null) { setError( new Error("Failed to fetch PayPal Messages content"), From 79f43bce40c4a17c6b78ce86519e662dd6d98785 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:45:15 -0600 Subject: [PATCH 09/10] add tests --- .../src/v6/hooks/usePayPalMessages.test.ts | 465 ++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 packages/react-paypal-js/src/v6/hooks/usePayPalMessages.test.ts diff --git a/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.test.ts b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.test.ts new file mode 100644 index 00000000..8e3102ba --- /dev/null +++ b/packages/react-paypal-js/src/v6/hooks/usePayPalMessages.test.ts @@ -0,0 +1,465 @@ +import { renderHook, act } from "@testing-library/react-hooks"; + +import { expectCurrentErrorValue } from "./useErrorTestUtil"; +import { usePayPalMessages } from "./usePayPalMessages"; +import { usePayPal } from "./usePayPal"; +import { INSTANCE_LOADING_STATE } from "../types"; + +import type { + PayPalMessagesSession, + LearnMore, + FetchContentOptions, + LearnMoreOptions, + PayPalMessagesOptions, +} from "../types"; + +jest.mock("./usePayPal"); + +const mockUsePayPal = usePayPal as jest.MockedFunction; + +const createMockPayPalMessagesSession = (): PayPalMessagesSession => ({ + fetchContent: jest.fn().mockResolvedValue({ + content: "
Mock PayPal Messages content
", + meta: { + trackingPayload: "mock-tracking-payload", + }, + }), + createLearnMore: jest.fn().mockReturnValue({ + isOpen: false, + open: jest.fn(), + close: jest.fn(), + update: jest.fn(), + show: jest.fn(), + setupPostMessenger: jest.fn(), + } as LearnMore), +}); + +const createMockSdkInstance = ( + messagesSession = createMockPayPalMessagesSession(), +) => ({ + createPayPalMessages: jest.fn().mockReturnValue(messagesSession), +}); + +describe("usePayPalMessages", () => { + let mockMessagesSession: PayPalMessagesSession; + let mockSdkInstance: ReturnType; + + beforeEach(() => { + mockMessagesSession = createMockPayPalMessagesSession(); + mockSdkInstance = createMockSdkInstance(mockMessagesSession); + + 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 error if there is no sdkInstance when called", () => { + mockUsePayPal.mockReturnValue({ + sdkInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.PENDING, + eligiblePaymentMethods: null, + error: null, + }); + + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { + result: { + current: { error }, + }, + } = renderHook(() => usePayPalMessages(props)); + + expectCurrentErrorValue(error); + + expect(error).toEqual(new Error("no sdk instance available")); + }); + + test("should create a PayPal Messages session with all options", () => { + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + shopperSessionId: "test-session-id", + }; + + renderHook(() => usePayPalMessages(props)); + + expect(mockSdkInstance.createPayPalMessages).toHaveBeenCalledWith({ + buyerCountry: "US", + currencyCode: "USD", + shopperSessionId: "test-session-id", + }); + }); + + test("should create a PayPal Messages session without optional shopperSessionId", () => { + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + renderHook(() => usePayPalMessages(props)); + + expect(mockSdkInstance.createPayPalMessages).toHaveBeenCalledWith({ + buyerCountry: "US", + currencyCode: "USD", + shopperSessionId: undefined, + }); + }); + }); + + describe("session lifecycle", () => { + test("should create new session when buyerCountry changes", () => { + const { rerender } = renderHook( + ({ buyerCountry }) => + usePayPalMessages({ + buyerCountry, + currencyCode: "USD", + }), + { initialProps: { buyerCountry: "US" } }, + ); + + jest.clearAllMocks(); + + rerender({ buyerCountry: "GB" }); + + expect(mockSdkInstance.createPayPalMessages).toHaveBeenCalledWith({ + buyerCountry: "GB", + currencyCode: "USD", + shopperSessionId: undefined, + }); + }); + + test("should create new session when currencyCode changes", () => { + const { rerender } = renderHook( + ({ currencyCode }) => + usePayPalMessages({ + buyerCountry: "US", + currencyCode, + }), + { initialProps: { currencyCode: "USD" } }, + ); + + jest.clearAllMocks(); + + rerender({ currencyCode: "EUR" }); + + expect(mockSdkInstance.createPayPalMessages).toHaveBeenCalledWith({ + buyerCountry: "US", + currencyCode: "EUR", + shopperSessionId: undefined, + }); + }); + + test("should create new session when shopperSessionId changes", () => { + const { rerender } = renderHook( + ({ shopperSessionId }) => + usePayPalMessages({ + buyerCountry: "US", + currencyCode: "USD", + shopperSessionId, + }), + { initialProps: { shopperSessionId: "session-1" } }, + ); + + jest.clearAllMocks(); + + rerender({ shopperSessionId: "session-2" }); + + expect(mockSdkInstance.createPayPalMessages).toHaveBeenCalledWith({ + buyerCountry: "US", + currencyCode: "USD", + shopperSessionId: "session-2", + }); + }); + + test("should create new session when sdkInstance changes", () => { + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { rerender } = renderHook(() => usePayPalMessages(props)); + + jest.clearAllMocks(); + + const newMockSession = createMockPayPalMessagesSession(); + const newMockSdkInstance = createMockSdkInstance(newMockSession); + + mockUsePayPal.mockReturnValue({ + // @ts-expect-error mocking sdk instance + sdkInstance: newMockSdkInstance, + loadingStatus: INSTANCE_LOADING_STATE.RESOLVED, + eligiblePaymentMethods: null, + error: null, + }); + + rerender(); + + expect(newMockSdkInstance.createPayPalMessages).toHaveBeenCalled(); + }); + }); + + describe("handleFetchContent", () => { + test("should successfully fetch content with valid options", async () => { + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result } = renderHook(() => usePayPalMessages(props)); + + const fetchOptions: FetchContentOptions = { + amount: "100", + logoPosition: "INLINE", + logoType: "MONOGRAM", + }; + + let content: Record | null | void = undefined; + + await act(async () => { + content = await result.current.handleFetchContent(fetchOptions); + }); + + expect(mockMessagesSession.fetchContent).toHaveBeenCalledWith( + fetchOptions, + ); + expect(content).toEqual({ + content: "
Mock PayPal Messages content
", + meta: { + trackingPayload: "mock-tracking-payload", + }, + }); + }); + + test("should return undefined when component is unmounted", async () => { + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result, unmount } = renderHook(() => + usePayPalMessages(props), + ); + + unmount(); + + let content: Record | null | void = undefined; + + await act(async () => { + content = await result.current.handleFetchContent({ + amount: "100", + logoPosition: "INLINE", + logoType: "MONOGRAM", + }); + }); + + expect(content).toBeUndefined(); + expect(mockMessagesSession.fetchContent).not.toHaveBeenCalled(); + }); + + test("should set error when session is not available", async () => { + mockUsePayPal.mockReturnValue({ + sdkInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.PENDING, + eligiblePaymentMethods: null, + error: null, + }); + + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result } = renderHook(() => usePayPalMessages(props)); + + await act(async () => { + await result.current.handleFetchContent({ + amount: "100", + logoPosition: "INLINE", + logoType: "MONOGRAM", + }); + }); + + const { error } = result.current; + + expectCurrentErrorValue(error); + + expect(error).toEqual( + new Error("PayPal Messages session not available"), + ); + }); + + test("should set error when fetchContent returns null", async () => { + (mockMessagesSession.fetchContent as jest.Mock).mockResolvedValue( + null, + ); + + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result } = renderHook(() => usePayPalMessages(props)); + + await act(async () => { + await result.current.handleFetchContent({ + amount: "100", + logoPosition: "INLINE", + logoType: "MONOGRAM", + }); + }); + + const { error } = result.current; + + expectCurrentErrorValue(error); + + expect(error).toEqual( + new Error("Failed to fetch PayPal Messages content"), + ); + }); + + test("should catch and set error when fetchContent throws", async () => { + const testError = new Error("Network error"); + (mockMessagesSession.fetchContent as jest.Mock).mockRejectedValue( + testError, + ); + + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result } = renderHook(() => usePayPalMessages(props)); + + await act(async () => { + await result.current.handleFetchContent({ + amount: "100", + logoPosition: "INLINE", + logoType: "MONOGRAM", + }); + }); + + const { error } = result.current; + + expectCurrentErrorValue(error); + + expect(error).toBe(testError); + }); + }); + + describe("handleCreateLearnMore", () => { + test("should successfully create learn more link with options", () => { + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result } = renderHook(() => usePayPalMessages(props)); + + const learnMoreOptions: LearnMoreOptions = { + amount: "100", + presentationMode: "MODAL", + }; + + let learnMore: LearnMore | undefined; + + act(() => { + learnMore = + result.current.handleCreateLearnMore(learnMoreOptions); + }); + + expect(mockMessagesSession.createLearnMore).toHaveBeenCalledWith( + learnMoreOptions, + ); + expect(learnMore).toEqual({ + isOpen: false, + open: expect.any(Function), + close: expect.any(Function), + update: expect.any(Function), + show: expect.any(Function), + setupPostMessenger: expect.any(Function), + }); + }); + + test("should successfully create learn more link without options", () => { + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result } = renderHook(() => usePayPalMessages(props)); + + let learnMore: LearnMore | undefined; + + act(() => { + learnMore = result.current.handleCreateLearnMore(); + }); + + expect(mockMessagesSession.createLearnMore).toHaveBeenCalledWith( + undefined, + ); + expect(learnMore).toBeDefined(); + }); + + test("should return undefined when component is unmounted", () => { + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result, unmount } = renderHook(() => + usePayPalMessages(props), + ); + + unmount(); + + let learnMore: LearnMore | undefined; + + act(() => { + learnMore = result.current.handleCreateLearnMore(); + }); + + expect(learnMore).toBeUndefined(); + expect(mockMessagesSession.createLearnMore).not.toHaveBeenCalled(); + }); + + test("should set error when session is not available", () => { + mockUsePayPal.mockReturnValue({ + sdkInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.PENDING, + eligiblePaymentMethods: null, + error: null, + }); + + const props: PayPalMessagesOptions = { + buyerCountry: "US", + currencyCode: "USD", + }; + + const { result } = renderHook(() => usePayPalMessages(props)); + + act(() => { + result.current.handleCreateLearnMore(); + }); + + const { error } = result.current; + + expectCurrentErrorValue(error); + + expect(error).toEqual( + new Error("PayPal Messages session not available"), + ); + }); + }); +}); From 1941983d54d45985f1e92d21d15b4d45cccbf2c7 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:01:28 -0600 Subject: [PATCH 10/10] update changeset --- .changeset/great-pianos-divide.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/great-pianos-divide.md b/.changeset/great-pianos-divide.md index ec49da91..979568f5 100644 --- a/.changeset/great-pianos-divide.md +++ b/.changeset/great-pianos-divide.md @@ -1,6 +1,5 @@ --- "@paypal/react-paypal-js": patch -"@paypal/paypal-js": patch --- Add v6 paypal-messages hook and types.