diff --git a/packages/template-retail-react-app/app/assets/svg/paypal.svg b/packages/template-retail-react-app/app/assets/svg/paypal-icon.svg similarity index 100% rename from packages/template-retail-react-app/app/assets/svg/paypal.svg rename to packages/template-retail-react-app/app/assets/svg/paypal-icon.svg diff --git a/packages/template-retail-react-app/app/components/icons/index.jsx b/packages/template-retail-react-app/app/components/icons/index.jsx index 2c2711af5e..5edb36e14d 100644 --- a/packages/template-retail-react-app/app/components/icons/index.jsx +++ b/packages/template-retail-react-app/app/components/icons/index.jsx @@ -68,7 +68,7 @@ import CVVSymbol from '@salesforce/retail-react-app/app/assets/svg/cc-cvv.svg' import DiscoverSymbol from '@salesforce/retail-react-app/app/assets/svg/cc-discover.svg' import LocationSymbol from '@salesforce/retail-react-app/app/assets/svg/location.svg' import MastercardSymbol from '@salesforce/retail-react-app/app/assets/svg/cc-mastercard.svg' -import PaypalSymbol from '@salesforce/retail-react-app/app/assets/svg/paypal.svg' +import PaypalSymbol from '@salesforce/retail-react-app/app/assets/svg/paypal-icon.svg' import SocialPinterestSymbol from '@salesforce/retail-react-app/app/assets/svg/social-pinterest.svg' import VisaSymbol from '@salesforce/retail-react-app/app/assets/svg/cc-visa.svg' @@ -186,7 +186,7 @@ export const LockIcon = icon( } ) export const LocationIcon = icon('location') -export const PaypalIcon = icon('paypal', {viewBox: PaypalSymbol.viewBox}) +export const PaypalIcon = icon('paypal-icon', {viewBox: PaypalSymbol.viewBox}) export const PlugIcon = icon('plug') export const PlusIcon = icon('plus') export const MastercardIcon = icon('cc-mastercard', {viewBox: MastercardSymbol.viewBox}) diff --git a/packages/template-retail-react-app/app/components/product-view/index.jsx b/packages/template-retail-react-app/app/components/product-view/index.jsx index 3032e0d87b..fe6c329b56 100644 --- a/packages/template-retail-react-app/app/components/product-view/index.jsx +++ b/packages/template-retail-react-app/app/components/product-view/index.jsx @@ -35,7 +35,7 @@ import {useAddToCartModalContext} from '@salesforce/retail-react-app/app/hooks/u import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react' -import {useSalesforcePayments} from '@salesforce/retail-react-app/app/hooks/use-salesforce-payments' +import {useShopperConfiguration} from '@salesforce/retail-react-app/app/hooks/use-shopper-configuration' // project components import ImageGallery from '@salesforce/retail-react-app/app/components/image-gallery' @@ -199,7 +199,7 @@ const ProductView = forwardRef( const [pickupEnabled, setPickupEnabled] = useState(false) const storeName = selectedStore?.name const inventoryId = selectedStore?.inventoryId - const sfPaymentsEnabled = useSalesforcePayments() + const sfPaymentsEnabled = useShopperConfiguration('SalesforcePaymentsAllowed') === true const {disableButton, customInventoryMessage} = useMemo(() => { let shouldDisableButton = showInventoryMessage diff --git a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js index 0433b50fe1..5ebb9310e0 100644 --- a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js @@ -38,7 +38,7 @@ import {EINSTEIN_RECOMMENDERS} from '@salesforce/retail-react-app/app/constants' import DisplayPrice from '@salesforce/retail-react-app/app/components/display-price' import SFPaymentsExpress from '@salesforce/retail-react-app/app/components/sf-payments-express' import SelectBonusProductsCard from '@salesforce/retail-react-app/app/pages/cart/partials/select-bonus-products-card' -import {useSalesforcePayments} from '@salesforce/retail-react-app/app/hooks/use-salesforce-payments' +import {useShopperConfiguration} from '@salesforce/retail-react-app/app/hooks/use-shopper-configuration' import { getRemainingAvailableBonusProductsForProduct, @@ -85,7 +85,7 @@ export const AddToCartModal = () => { : Array.isArray(itemsAdded) ? itemsAdded.reduce((acc, {quantity}) => acc + quantity, 0) : 0 - const sfPaymentsEnabled = useSalesforcePayments() + const sfPaymentsEnabled = useShopperConfiguration('SalesforcePaymentsAllowed') === true // Bonus product logic const {data: productsWithPromotions} = useBasketProductsWithPromotions(basket) diff --git a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js index 158f6f98f2..fe2b72a780 100644 --- a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js @@ -67,11 +67,14 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({ // Mock getConfig for SF Payments configuration const mockGetConfig = jest.fn() -// Mock useSalesforcePayments to respond to config -jest.mock('@salesforce/retail-react-app/app/hooks/use-salesforce-payments', () => ({ - useSalesforcePayments: jest.fn(() => { +// Mock useShopperConfiguration to respond to config +jest.mock('@salesforce/retail-react-app/app/hooks/use-shopper-configuration', () => ({ + useShopperConfiguration: jest.fn((configId) => { const config = mockGetConfig() - return config?.app?.sfPayments?.enabled === true + if (configId === 'SalesforcePaymentsAllowed') { + return config?.app?.sfPayments?.enabled + } + return undefined }) })) jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => { diff --git a/packages/template-retail-react-app/app/hooks/use-salesforce-payments.js b/packages/template-retail-react-app/app/hooks/use-salesforce-payments.js deleted file mode 100644 index 04051c8a1b..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-salesforce-payments.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2025, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import {useConfigurations} from '@salesforce/commerce-sdk-react' - -/** - * Hook to check if Salesforce Payments is enabled. - * @returns {boolean} True if Salesforce Payments is enabled, false otherwise - */ -export const useSalesforcePayments = () => { - const {data: configurations} = useConfigurations() - return ( - configurations?.configurations?.find( - (configuration) => configuration.id === 'sfPaymentsEnabled' - )?.value === 'true' - ) -} diff --git a/packages/template-retail-react-app/app/hooks/use-shopper-configuration.js b/packages/template-retail-react-app/app/hooks/use-shopper-configuration.js new file mode 100644 index 0000000000..e5df530857 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-shopper-configuration.js @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {useConfigurations} from '@salesforce/commerce-sdk-react' + +/** + * Hook to get a shopper configuration value. + * @param {string} configurationId - The ID of the configuration to retrieve + * @returns {*} The configuration value, or undefined if not found + */ +export const useShopperConfiguration = (configurationId) => { + const {data: configurations} = useConfigurations() + const config = configurations?.configurations?.find( + (configuration) => configuration.id === configurationId + ) + return config?.value +} diff --git a/packages/template-retail-react-app/app/hooks/use-shopper-configuration.test.js b/packages/template-retail-react-app/app/hooks/use-shopper-configuration.test.js new file mode 100644 index 0000000000..10b0c062e5 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-shopper-configuration.test.js @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {renderHook} from '@testing-library/react' +import {useShopperConfiguration} from '@salesforce/retail-react-app/app/hooks/use-shopper-configuration' +import {useConfigurations} from '@salesforce/commerce-sdk-react' + +// Mock the commerce-sdk-react hook +jest.mock('@salesforce/commerce-sdk-react', () => ({ + useConfigurations: jest.fn() +})) + +describe('useShopperConfiguration', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + test('returns the configuration value when configuration exists with boolean true', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [ + {id: 'SalesforcePaymentsAllowed', value: true}, + {id: 'AnotherConfig', value: false} + ] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('SalesforcePaymentsAllowed')) + + expect(result.current).toBe(true) + }) + + test('returns the configuration value when configuration exists with boolean false', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'SomeConfig', value: false}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBe(false) + }) + + test('returns the configuration value when configuration exists with string value', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'StringConfig', value: 'some-string-value'}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('StringConfig')) + + expect(result.current).toBe('some-string-value') + }) + + test('returns the configuration value when configuration exists with numeric value', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'NumericConfig', value: 42}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('NumericConfig')) + + expect(result.current).toBe(42) + }) + + test('returns the configuration value when configuration exists with object value', () => { + const objectValue = {key: 'value', nested: {prop: 'test'}} + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'ObjectConfig', value: objectValue}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('ObjectConfig')) + + expect(result.current).toEqual(objectValue) + }) + + test('returns undefined when configuration does not exist', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [ + {id: 'Config1', value: true}, + {id: 'Config2', value: false} + ] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('NonExistentConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns undefined when configurations data is undefined', () => { + useConfigurations.mockReturnValue({ + data: undefined + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns undefined when configurations data is null', () => { + useConfigurations.mockReturnValue({ + data: null + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns undefined when configurations array is undefined', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: undefined + } + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns undefined when configurations array is empty', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns the correct configuration when multiple configurations exist', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [ + {id: 'Config1', value: 'value1'}, + {id: 'Config2', value: 'value2'}, + {id: 'Config3', value: 'value3'} + ] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('Config2')) + + expect(result.current).toBe('value2') + }) + + test('returns undefined when configuration exists but has no value property', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'ConfigWithoutValue'}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('ConfigWithoutValue')) + + expect(result.current).toBeUndefined() + }) + + test('returns null when configuration value is explicitly null', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'NullConfig', value: null}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('NullConfig')) + + expect(result.current).toBeNull() + }) + + test('returns 0 when configuration value is 0', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'ZeroConfig', value: 0}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('ZeroConfig')) + + expect(result.current).toBe(0) + }) + + test('returns empty string when configuration value is empty string', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'EmptyStringConfig', value: ''}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('EmptyStringConfig')) + + expect(result.current).toBe('') + }) + + test('is case-sensitive when matching configuration IDs', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [ + {id: 'SalesforcePaymentsAllowed', value: true}, + {id: 'salesforcepaymentsallowed', value: false} + ] + } + }) + + const {result: result1} = renderHook(() => + useShopperConfiguration('SalesforcePaymentsAllowed') + ) + const {result: result2} = renderHook(() => + useShopperConfiguration('salesforcepaymentsallowed') + ) + + expect(result1.current).toBe(true) + expect(result2.current).toBe(false) + }) +}) diff --git a/packages/template-retail-react-app/app/pages/cart/partials/cart-cta.jsx b/packages/template-retail-react-app/app/pages/cart/partials/cart-cta.jsx index c75cd8d952..0dca4ea49f 100644 --- a/packages/template-retail-react-app/app/pages/cart/partials/cart-cta.jsx +++ b/packages/template-retail-react-app/app/pages/cart/partials/cart-cta.jsx @@ -16,10 +16,10 @@ import { } from '@salesforce/retail-react-app/app/components/icons' import Link from '@salesforce/retail-react-app/app/components/link' import SFPaymentsExpress from '@salesforce/retail-react-app/app/components/sf-payments-express' -import {useSalesforcePayments} from '@salesforce/retail-react-app/app/hooks/use-salesforce-payments' +import {useShopperConfiguration} from '@salesforce/retail-react-app/app/hooks/use-shopper-configuration' const CartCta = () => { - const sfPaymentsEnabled = useSalesforcePayments() + const sfPaymentsEnabled = useShopperConfiguration('SalesforcePaymentsAllowed') === true return ( diff --git a/packages/template-retail-react-app/app/pages/checkout/index.jsx b/packages/template-retail-react-app/app/pages/checkout/index.jsx index 7ceb9646a5..a4a3c1c40a 100644 --- a/packages/template-retail-react-app/app/pages/checkout/index.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/index.jsx @@ -43,7 +43,7 @@ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useMultiship} from '@salesforce/retail-react-app/app/hooks/use-multiship' -import {useSalesforcePayments} from '@salesforce/retail-react-app/app/hooks/use-salesforce-payments' +import {useShopperConfiguration} from '@salesforce/retail-react-app/app/hooks/use-shopper-configuration' const Checkout = () => { const {formatMessage} = useIntl() @@ -59,10 +59,11 @@ const Checkout = () => { const isPasswordlessEnabled = !!passwordless?.enabled const {removeEmptyShipments} = useMultiship(basket) const multishipEnabled = getConfig()?.app?.multishipEnabled ?? true - const sfPaymentsEnabled = useSalesforcePayments() + const sfPaymentsEnabled = useShopperConfiguration('SalesforcePaymentsAllowed') === true const placeOrderCheckoutStep = sfPaymentsEnabled ? 4 : 5 const sfPaymentsSheetRef = useRef(null) const [expressPaymentMethodsRendered, setExpressPaymentMethodsRendered] = useState(false) + const [shouldHidePlaceOrderButton, setShouldHidePlaceOrderButton] = useState(false) // cart has both pickup and delivery orders const isDeliveryAndPickupOrder = @@ -90,6 +91,11 @@ const Checkout = () => { } }, [basket?.basketId]) + // Callback to handle when payment method requires its own pay button + const handleRequiresPayButtonChange = (requiresPayButton) => { + setShouldHidePlaceOrderButton(requiresPayButton === false) + } + const submitOrder = async () => { const doCreateOrder = async () => { return await createOrder({ @@ -184,12 +190,15 @@ const Checkout = () => { )} {sfPaymentsEnabled ? ( - + ) : ( )} - {step === placeOrderCheckoutStep && ( + {step === placeOrderCheckoutStep && !shouldHidePlaceOrderButton && (