diff --git a/packages/extension-chakra-store-locator/README.md b/packages/extension-chakra-store-locator/README.md index 008f4d9c7d..5b7d39cd9c 100644 --- a/packages/extension-chakra-store-locator/README.md +++ b/packages/extension-chakra-store-locator/README.md @@ -35,7 +35,7 @@ If you want to use this without having to install `@chakra-ui` in your project, ### `@salesforce/commerce-sdk-react` Provider -This extension uses the `@salesforce/commerce-sdk-react` package to fetch the store locator data from SCAPI. Your application must use the `CommerceApiProvider` in the React component tree. +This extension uses the `@salesforce/commerce-sdk-react` package to fetch the store locator data from SCAPI. If you provide a `commerceApi` configuration in the extension config, the `CommerceApiProvider` will be added to the React component tree as the default provider. If you already have a `CommerceApiProvider` in your application, do not include the `commerceApi` configuration in the extension config. ## Configurations @@ -66,7 +66,16 @@ The Store Locator extension is configured via the `mobify.app.extensions` proper "countryCode": "DE", "countryName": "Germany" } - ] + ], + "commerceApi": { + "proxyPath": "/mobify/proxy/api", + "parameters": { + "shortCode": "8o7m175y", + "clientId": "c9c45bfd-0ed3-4aa2-9971-40f88962b836", + "organizationId": "f_ecom_zzrf_001", + "siteId": "RefArchGlobal" + } + } } ] ] diff --git a/packages/extension-chakra-store-locator/src/components/with-optional-commerce-sdk-react-provider.test.jsx b/packages/extension-chakra-store-locator/src/components/with-optional-commerce-sdk-react-provider.test.jsx new file mode 100644 index 0000000000..f6d1e7e796 --- /dev/null +++ b/packages/extension-chakra-store-locator/src/components/with-optional-commerce-sdk-react-provider.test.jsx @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025, Salesforce, 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 React from 'react' +import {render, screen} from '@testing-library/react' +import {withOptionalCommerceSdkReactProvider} from './with-optional-commerce-sdk-react-provider' +import PropTypes from 'prop-types' + +jest.mock('@salesforce/commerce-sdk-react', () => ({ + useCommerceApi: jest.fn(), + // eslint-disable-next-line react/prop-types + CommerceApiProvider: ({children}) => { + return
{children}
+ } +})) + +jest.mock('@salesforce/pwa-kit-react-sdk/utils/url', () => ({ + getAppOrigin: jest.fn(() => 'https://example.com') +})) + +describe('withOptionalCommerceSdkReactProvider', () => { + const TestComponent = () =>
Test Component
+ const mockConfig = { + commerceApi: { + parameters: { + shortCode: 'test', + clientId: 'test-client', + organizationId: 'test-org', + siteId: 'test-site', + locale: 'en-US', + currency: 'USD' + }, + proxyPath: '/api' + } + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('wraps component with CommerceApiProvider when no provider exists', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {useCommerceApi} = require('@salesforce/commerce-sdk-react') + useCommerceApi.mockImplementation(() => { + throw new Error('No provider') + }) + + const WrappedComponent = withOptionalCommerceSdkReactProvider(TestComponent, mockConfig) + const {container} = render() + + expect(screen.getByTestId('commerce-provider')).toBeTruthy() + expect(screen.getByText('Test Component')).toBeTruthy() + }) + + it('does not wrap component when provider already exists', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {useCommerceApi} = require('@salesforce/commerce-sdk-react') + useCommerceApi.mockReturnValue({ShopperProducts: {}, ShopperBaskets: {}}) + + const WrappedComponent = withOptionalCommerceSdkReactProvider(TestComponent, mockConfig) + const {container} = render() + + expect(container.querySelector('[data-testid="commerce-provider"]')).toBeNull() + expect(screen.getByText('Test Component')).toBeTruthy() + }) + + it('passes props to wrapped component', () => { + const TestComponentWithProps = ({testProp}) =>
{testProp}
+ TestComponentWithProps.propTypes = { + testProp: PropTypes.string + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {useCommerceApi} = require('@salesforce/commerce-sdk-react') + useCommerceApi.mockImplementation(() => { + throw new Error('No provider') + }) + + const WrappedComponent = withOptionalCommerceSdkReactProvider( + TestComponentWithProps, + mockConfig + ) + render() + + expect(screen.getByText('test value')).toBeTruthy() + }) + + it('renders wrapped component without provider when config is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {useCommerceApi} = require('@salesforce/commerce-sdk-react') + useCommerceApi.mockImplementation(() => { + throw new Error('No provider') + }) + + const invalidConfig = {} + const WrappedComponent = withOptionalCommerceSdkReactProvider(TestComponent, invalidConfig) + const {container} = render() + + expect(container.querySelector('[data-testid="commerce-provider"]')).toBeNull() + expect(screen.getByText('Test Component')).toBeTruthy() + }) +}) diff --git a/packages/extension-chakra-store-locator/src/components/with-optional-commerce-sdk-react-provider.tsx b/packages/extension-chakra-store-locator/src/components/with-optional-commerce-sdk-react-provider.tsx new file mode 100644 index 0000000000..d8734f8c4d --- /dev/null +++ b/packages/extension-chakra-store-locator/src/components/with-optional-commerce-sdk-react-provider.tsx @@ -0,0 +1,77 @@ +/* + * 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 React from 'react' +import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' +import {CommerceApiProvider, useCommerceApi} from '@salesforce/commerce-sdk-react' +import {UserConfig} from '../types/config' +import {logger} from '../logger' + +/** + * Checks if the CommerceApiProvider is already installed in the component tree. + * @returns boolean, true if the CommerceApiProvider is installed, false otherwise. + */ +const useHasCommerceApiProvider = () => { + let hasProvider = false + + try { + const api = useCommerceApi() + + // the api object is an object with a bunch of api clients like ShopperProduct, ShopperOrder, etc. + // if the object is empty, then the CommerceApiProvider is not installed + if (Object.keys(api).length > 0) { + hasProvider = true + } + } catch (_) { + hasProvider = false + } + + return hasProvider +} + +type WithOptionalCommerceSdkReactProvider = React.ComponentPropsWithoutRef + +/** + * Higher-order component that conditionally installs the CommerceApiProvider if the config is provided. + * + * @param WrappedComponent - The component to be optionally wrapped with CommerceApiProvider. + * @param config - The configuration object for the CommerceApiProvider. + * @returns A component that wraps the given component with CommerceApiProvider if it is not already present in the component tree. + */ +export const withOptionalCommerceSdkReactProvider =

( + WrappedComponent: React.ComponentType

, + config: UserConfig +) => { + const HOC: React.FC

= (props: WithOptionalCommerceSdkReactProvider) => { + if (useHasCommerceApiProvider()) { + return + } + if (!config.commerceApi || !config.commerceApi?.parameters) { + logger.error( + 'CommerceApiProvider is not installed and no commerceApi config is provided, this extension may not work as expected.' + ) + return + } + const appOrigin = getAppOrigin() + return ( + + + + ) + } + + return HOC +} diff --git a/packages/extension-chakra-store-locator/src/logger.ts b/packages/extension-chakra-store-locator/src/logger.ts new file mode 100644 index 0000000000..8f30ca05ab --- /dev/null +++ b/packages/extension-chakra-store-locator/src/logger.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2025, Salesforce, 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 createLogger from '@salesforce/pwa-kit-runtime/utils/logger-factory' + +export const logger = createLogger({packageName: 'extension-chakra-store-locator'}) diff --git a/packages/extension-chakra-store-locator/src/setup-app.ts b/packages/extension-chakra-store-locator/src/setup-app.ts index f1fb30ed79..34e07c582d 100644 --- a/packages/extension-chakra-store-locator/src/setup-app.ts +++ b/packages/extension-chakra-store-locator/src/setup-app.ts @@ -11,13 +11,16 @@ import {RouteProps} from 'react-router-dom' // Platform Imports import {ApplicationExtension} from '@salesforce/pwa-kit-extension-sdk/react' +import {applyHOCs} from '@salesforce/pwa-kit-extension-sdk/react/utils' // Local Imports import {withOptionalChakra} from './components/with-optional-chakra-provider' +import {withOptionalCommerceSdkReactProvider} from './components/with-optional-commerce-sdk-react-provider' import {withStoreLocator} from './components/with-store-locator' import {Config} from './types' import StoreLocatorPage from './pages/store-locator' +import {logger} from './logger' import extensionMeta from '../extension-meta.json' class StoreLocatorExtension extends ApplicationExtension { @@ -29,13 +32,19 @@ class StoreLocatorExtension extends ApplicationExtension { const config = this.getConfig() if (!config.supportedCountries || config.supportedCountries.length === 0) { - // TODO: use our logger - console.warn( + logger.error( '[extension-chakra-store-locator] Missing supportedCountries, this extension will not work.' ) } - return withStoreLocator(withOptionalChakra(App), config) + const HOCs = [ + (component: React.ComponentType) => withStoreLocator(component, config), + (component: React.ComponentType) => + withOptionalCommerceSdkReactProvider(component, config), + (component: React.ComponentType) => withOptionalChakra(component) + ] + + return applyHOCs(App, HOCs) } extendRoutes(routes: RouteProps[]): RouteProps[] { diff --git a/packages/extension-chakra-store-locator/src/types/config.ts b/packages/extension-chakra-store-locator/src/types/config.ts index 192cf1e9ed..68f5e41af3 100644 --- a/packages/extension-chakra-store-locator/src/types/config.ts +++ b/packages/extension-chakra-store-locator/src/types/config.ts @@ -21,6 +21,17 @@ export interface UserConfig extends ApplicationExtensionConfig { countryCode: string countryName: string }> + commerceApi?: { + proxyPath: string + parameters: { + shortCode: string + clientId: string + organizationId: string + siteId: string + locale: string + currency: string + } + } } /** diff --git a/packages/template-typescript-minimal/package.json b/packages/template-typescript-minimal/package.json index 7708e8dde5..cf67e317ed 100644 --- a/packages/template-typescript-minimal/package.json +++ b/packages/template-typescript-minimal/package.json @@ -22,6 +22,7 @@ "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@loadable/component": "^5.15.3", + "@salesforce/extension-chakra-store-locator": "^0.1.0-extensibility-preview.3", "@salesforce/pwa-kit-dev": "4.0.0-extensibility-preview.3", "@salesforce/pwa-kit-extension-sdk": "4.0.0-extensibility-preview.3", "@salesforce/pwa-kit-react-sdk": "4.0.0-extensibility-preview.3", @@ -44,6 +45,44 @@ "npm": "^8.0.0 || ^9.0.0 || ^10.0.0" }, "mobify": { + "app": { + "extensions": [ + [ + "@salesforce/extension-chakra-store-locator", + { + "enabled": true, + "path": "/store-locator", + "radius": 100, + "radiusUnit": "km", + "defaultPageSize": 10, + "defaultPostalCode": "10178", + "defaultCountry": "Germany", + "defaultCountryCode": "DE", + "supportedCountries": [ + { + "countryCode": "US", + "countryName": "United States" + }, + { + "countryCode": "DE", + "countryName": "Germany" + } + ], + "commerceApi": { + "proxyPath": "/mobify/proxy/api", + "parameters": { + "shortCode": "8o7m175y", + "clientId": "c9c45bfd-0ed3-4aa2-9971-40f88962b836", + "organizationId": "f_ecom_zzrf_001", + "siteId": "RefArchGlobal", + "locale": "en-GB", + "currency": "USD" + } + } + } + ] + ] + }, "ssrEnabled": true, "ssrOnly": [ "ssr.js",