diff --git a/package-lock.json b/package-lock.json index 6d0c083b6e..81ce7a607d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10260,4 +10260,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 9564e5cc4f..770c4177ec 100644 --- a/package.json +++ b/package.json @@ -38,4 +38,4 @@ "dependencies": { "node-fetch": "^2.6.9" } -} +} \ No newline at end of file diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index d4ba664990..f95c345ce7 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -1,6 +1,7 @@ ## v3.9.0-dev (Oct 29, 2024) - Support Node 22 [#2218](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2218) - Update `default.js` template to include new login configurations [#2079] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2079) +- Add Data Cloud API configuration to `default.js`. [#2229] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2229) ## v3.8.0 (Oct 28, 2024) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index 74d2eaf3bb..4d2cc8d7fd 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -44,6 +44,11 @@ module.exports = { // By setting this to true, the Einstein activities generated by the environment will appear // in production environment reports isProduction: false + }, + // Datacloud api config + dataCloudAPI: { + appSourceId: '{{answers.project.dataCloud.appSourceId}}', + tenantId: '{{answers.project.dataCloud.tenantId}}' } }, // This list contains server-side only libraries that you don't want to be compiled by webpack diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index aab2da5b26..75e028ebde 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -68,7 +68,8 @@ const {handler} = runtime.createHandler(options, (app) => { ], 'connect-src': [ // Connect to Einstein APIs - 'api.cquotient.com' + 'api.cquotient.com', + '*.c360a.salesforce.com' ] } } diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index aab2da5b26..75e028ebde 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -68,7 +68,8 @@ const {handler} = runtime.createHandler(options, (app) => { ], 'connect-src': [ // Connect to Einstein APIs - 'api.cquotient.com' + 'api.cquotient.com', + '*.c360a.salesforce.com' ] } } diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 44ebc400bf..251a4ce041 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -73,6 +73,11 @@ module.exports = { // By setting this to true, the Einstein activities generated by the environment will appear // in production environment reports isProduction: false + }, + // Datacloud api config + dataCloudAPI: { + appSourceId: '{{answers.project.dataCloud.appSourceId}}', + tenantId: '{{answers.project.dataCloud.tenantId}}' } }, // This list contains server-side only libraries that you don't want to be compiled by webpack diff --git a/packages/pwa-kit-create-app/scripts/create-mobify-app.js b/packages/pwa-kit-create-app/scripts/create-mobify-app.js index 7742b00c67..c13854e7fb 100755 --- a/packages/pwa-kit-create-app/scripts/create-mobify-app.js +++ b/packages/pwa-kit-create-app/scripts/create-mobify-app.js @@ -219,7 +219,7 @@ const RETAIL_REACT_APP_QUESTIONS = [ } ] -// Project dictionary describing details and how the gerator should ask questions etc. +// Project dictionary describing details and how the generator should ask questions etc. const PRESETS = [ { id: 'retail-react-app', @@ -265,7 +265,9 @@ const PRESETS = [ ['project.commerce.shortCode']: 'kv7kzm78', ['project.commerce.isSlasPrivate']: false, ['project.einstein.clientId']: '1ea06c6e-c936-4324-bcf0-fada93f83bb1', - ['project.einstein.siteId']: 'aaij-MobileFirst' + ['project.einstein.siteId']: 'aaij-MobileFirst', + ['project.dataCloud.appSourceId']: '10a761d0-5d19-41fa-8681-0591d5884e27', + ['project.dataCloud.tenantId']: 'g43g8zrvh1ytcztdmmzg8m3dh1' }, assets: ['translations'], private: false @@ -290,7 +292,9 @@ const PRESETS = [ ['project.commerce.shortCode']: 'kv7kzm78', ['project.commerce.isSlasPrivate']: false, ['project.einstein.clientId']: '1ea06c6e-c936-4324-bcf0-fada93f83bb1', - ['project.einstein.siteId']: 'aaij-MobileFirst' + ['project.einstein.siteId']: 'aaij-MobileFirst', + ['project.dataCloud.appSourceId']: '10a761d0-5d19-41fa-8681-0591d5884e27', + ['project.dataCloud.tenantId']: 'g43g8zrvh1ytcztdmmzg8m3dh1' }, assets: ['translations'], private: true @@ -315,7 +319,9 @@ const PRESETS = [ ['project.commerce.shortCode']: 'kv7kzm78', ['project.commerce.isSlasPrivate']: true, ['project.einstein.clientId']: '1ea06c6e-c936-4324-bcf0-fada93f83bb1', - ['project.einstein.siteId']: 'aaij-MobileFirst' + ['project.einstein.siteId']: 'aaij-MobileFirst', + ['project.dataCloud.appSourceId']: '10a761d0-5d19-41fa-8681-0591d5884e27', + ['project.dataCloud.tenantId']: 'g43g8zrvh1ytcztdmmzg8m3dh1' }, assets: ['translations'], private: true @@ -340,7 +346,9 @@ const PRESETS = [ ['project.commerce.shortCode']: 'xitgmcd3', ['project.einstein.clientId']: '1ea06c6e-c936-4324-bcf0-fada93f83bb1', ['project.einstein.siteId']: 'aaij-MobileFirst', - ['project.commerce.isSlasPrivate']: true + ['project.commerce.isSlasPrivate']: true, + ['project.dataCloud.appSourceId']: '10a761d0-5d19-41fa-8681-0591d5884e27', + ['project.dataCloud.tenantId']: 'g43g8zrvh1ytcztdmmzg8m3dh1' }, assets: ['translations'], private: true @@ -365,7 +373,9 @@ const PRESETS = [ ['project.commerce.shortCode']: 'performance-001', ['project.einstein.clientId']: '1ea06c6e-c936-4324-bcf0-fada93f83bb1', ['project.einstein.siteId']: 'aaij-MobileFirst', - ['project.commerce.isSlasPrivate']: false + ['project.commerce.isSlasPrivate']: false, + ['project.dataCloud.appSourceId']: '10a761d0-5d19-41fa-8681-0591d5884e27', + ['project.dataCloud.tenantId']: 'g43g8zrvh1ytcztdmmzg8m3dh1' }, assets: ['translations'], private: true diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 0f66096e82..802657af59 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,3 +1,6 @@ +## v6.1.0-dev +- Send PWA Kit events to Data Cloud [#2229] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2229) + ## v6.0.0 - DNT Consent Banner: [#2203](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2203) - Implemented opt-in Social & Passwordless Login features and fixed the Reset Password flow which now leverages SLAS APIs [#2079] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2079) diff --git a/packages/template-retail-react-app/app/components/recommended-products/index.jsx b/packages/template-retail-react-app/app/components/recommended-products/index.jsx index e3d2064e69..d88fe34529 100644 --- a/packages/template-retail-react-app/app/components/recommended-products/index.jsx +++ b/packages/template-retail-react-app/app/components/recommended-products/index.jsx @@ -11,6 +11,7 @@ import {useIntl} from 'react-intl' import {Button} from '@salesforce/retail-react-app/app/components/shared/ui' import ProductScroller from '@salesforce/retail-react-app/app/components/product-scroller' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useDatacloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import useIntersectionObserver from '@salesforce/retail-react-app/app/hooks/use-intersection-observer' import {useWishList} from '@salesforce/retail-react-app/app/hooks/use-wish-list' @@ -41,6 +42,7 @@ const RecommendedProducts = ({zone, recommender, products, title, shouldFetch, . const {data: customer} = useCurrentCustomer() const {customerId} = customer const {data: wishlist} = useWishList() + const datacloud = useDatacloud() const createCustomerProductListItem = useShopperCustomersMutation( 'createCustomerProductListItem' @@ -98,6 +100,13 @@ const RecommendedProducts = ({zone, recommender, products, title, shouldFetch, . }, recommendations.recs.map((rec) => ({id: rec.id})) ) + datacloud.sendViewRecommendations( + { + recommenderName: recommendations.recommenderName, + __recoUUID: recommendations.recoUUID + }, + recommendations.recs.map((rec) => ({id: rec.id})) + ) } }, [isOnScreen, recommendations]) diff --git a/packages/template-retail-react-app/app/hooks/use-datacloud.js b/packages/template-retail-react-app/app/hooks/use-datacloud.js new file mode 100644 index 0000000000..007d16cfd4 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-datacloud.js @@ -0,0 +1,481 @@ +/* + * 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 {useMemo} from 'react' +import Cookies from 'js-cookie' +import logger from '@salesforce/retail-react-app/app/utils/logger-instance' +import {initDataCloudSdk} from '@salesforce/cc-datacloud-typescript' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {useUsid, useCustomerType, useDNT} from '@salesforce/commerce-sdk-react' +import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' +import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' + +export class DataCloudApi { + constructor({siteId, appSourceId, tenantId, dnt}) { + this.siteId = siteId + + // Return early if Data Cloud API configuration is not available + if (!appSourceId || !tenantId) { + console.error('DataCloud API Configuration is missing.') + return + } + this.sdk = initDataCloudSdk(tenantId, appSourceId) + this.dnt = dnt + } + + /** + * Constructs the base event object with the necessary data required + * for every event sent to Data Cloud. + * + * @param {object} args - The arguments containing event-specific details + * @returns {object} - The base event object + */ + _constructBaseEvent(args) { + return { + guestId: args.guestId, + siteId: this.siteId, + sessionId: args.sessionId, + deviceId: args.deviceId, + dateTime: new Date().toISOString(), + ...(args.customerId && {customerId: args.customerId}), // Can remove the conditionality after the hook -> Promise is changed in future PWA release + ...(args.customerNo && {customerNo: args.customerNo}) + } + } + + _constructUserDetails(args) { + return { + isAnonymous: args.isGuest, + firstName: args.firstName, + lastName: args.lastName + } + } + + /** + * Generates the event details object required for sending an + * event to Data Cloud. + * + * @param {string} eventType - The type of event being recorded (e.g + * "identity", "userEngagement", "contactPointEmail") + * @param {string} category - The category of the event, representing + * its broader grouping (e.g. "Profile", "Engagement") + * @returns {object} - The event details object + */ + _generateEventDetails(eventType, category, email = '') { + return { + eventId: crypto.randomUUID(), + eventType: eventType, + category: category, + ...(eventType === 'contactPointEmail' && {email}) + } + } + + /** + * Constructs an object containing the product Id. + * + * This method extracts and returns the appropriate product Id based on + * the product type. + * + * @param {object} product - The product object + * @returns {object} - An object containing the resolved product Id + */ + _constructDatacloudProduct(product) { + // Return the product SKU in the following priority order: + // 1. id if available - SKU of the Variant Product + // 2. productId if available - SKU of product hits within a category + // 3. masterId - SKU of the Master Product + return { + id: product?.id ?? product?.productId ?? product?.master?.masterId + } + } + /** + * Constructs the base search result object with relevant search + * metadata. + * + * @param {object} searchParams - The searchParams object + * @returns {object} - The base search result object + */ + _constructBaseSearchResult(searchParams) { + return { + searchResultTitle: searchParams.q, + searchResultPosition: searchParams.offset, + searchResultPageNumber: + searchParams.limit != 0 ? searchParams.offset / searchParams.limit + 1 : 1 + } + } + + _concatenateEvents = (...events) => ({...events.reduce((acc, obj) => ({...acc, ...obj}), {})}) + + /** + * Sends a `page-view` event to Data Cloud. + * + * This method records an `userEnagement` event type to track which page the shopper has viewed. + * + * @param {string} path - The URL path of the page that was viewed + * @param {object} args - Additional metadata for the event + */ + async sendViewPage(path, args) { + const baseEvent = this._constructBaseEvent(args) + const userDetails = this._constructUserDetails(args) + + // If DNT, we do not send the identity Profile event + const identityProfile = this.dnt + ? {} + : this._concatenateEvents( + baseEvent, + this._generateEventDetails('identity', 'Profile'), + userDetails, + { + sourceUrl: path + } + ) + + const userEngagement = this._concatenateEvents( + baseEvent, + this._generateEventDetails('userEngagement', 'Engagement'), + { + interactionName: 'page-view', + sourceUrl: path + } + ) + + const interaction = { + events: [...(!this.dnt ? [identityProfile] : []), userEngagement] + } + + try { + this.sdk.webEventsAppSourceIdPost(interaction) + } catch (err) { + logger.error('Error sending DataCloud event', err) + } + } + + /** + * Sends a `catalog-object-view-start` event to Data Cloud. + * + * This method records a `catalog` event type to track when a shopper + * views the details of a product (e.g. a Product Detail Page). + * + * @param {object} product - The product being viewed + * @param {object} args - Additional metadata for the event + */ + async sendViewProduct(product, args) { + const baseEvent = this._constructBaseEvent(args) + const baseProduct = this._constructDatacloudProduct(product) + const userDetails = this._constructUserDetails(args) + + const identityProfile = this.dnt + ? {} + : this._concatenateEvents( + baseEvent, + this._generateEventDetails('identity', 'Profile'), + userDetails + ) + + let contactPointEmail = null + if (args.email) { + contactPointEmail = this._concatenateEvents( + baseEvent, + this._generateEventDetails('contactPointEmail', 'Profile', args.email) + ) + } + + const catalog = this._concatenateEvents( + baseEvent, + this._generateEventDetails('catalog', 'Engagement'), + baseProduct, + { + type: 'Product', + webStoreId: 'pwa', + interactionName: 'catalog-object-view-start' + } + ) + + const interaction = { + events: [ + ...(!this.dnt ? [identityProfile] : []), + ...(contactPointEmail ? [contactPointEmail] : []), + catalog + ] + } + + try { + this.sdk.webEventsAppSourceIdPost(interaction) + } catch (err) { + logger.error('Error sending DataCloud event', err) + } + } + + /** + * Sends a `catalog-object-impression` event to Data Cloud. + * + * This method records a `catalog` event type and represents a single + * page of product impressions (e.g. a Product List Page). + * + * One event is sent for each product on the page. + * + * @param {object} searchParams - The searchParams object + * @param {object} category - The category object + * @param {object} searchResults - The searchResults object + * @param {object} args - Additional metadata for the event + */ + async sendViewCategory(searchParams, category, searchResults, args) { + const baseEvent = this._constructBaseEvent(args) + const userDetails = this._constructUserDetails(args) + + const products = searchResults?.hits?.map((product) => + this._constructDatacloudProduct(product) + ) + + const catalogObjects = products.map((product) => { + return this._concatenateEvents( + baseEvent, + this._generateEventDetails('catalog', 'Engagement'), + this._constructBaseSearchResult(searchParams), + { + id: product.id, + type: 'Product', + webStoreId: 'pwa', + categoryId: category.id, + interactionName: 'catalog-object-impression' + } + ) + }) + + const identityProfile = this.dnt + ? null + : this._concatenateEvents( + baseEvent, + this._generateEventDetails('identity', 'Profile'), + userDetails + ) + + let contactPointEmail = null + if (args.email) { + contactPointEmail = this._concatenateEvents( + baseEvent, + this._generateEventDetails('contactPointEmail', 'Profile', args.email) + ) + } + + const interaction = { + events: [ + ...(!this.dnt ? [identityProfile] : []), + ...(contactPointEmail ? [contactPointEmail] : []), + ...catalogObjects + ] + } + + try { + this.sdk.webEventsAppSourceIdPost(interaction) + } catch (err) { + logger.error('Error sending DataCloud event', err) + } + } + + /** + * Sends a `catalog-object-impression` event to Data Cloud with + * additional search result data. + * + * This method records a `catalog` event type when a shopper completes a + * search, logging an impression of the products displayed in the search + * results. + * + * @param {object} searchParams - The searchParams object + * @param {object} searchResults - The searchResults object containing an + * array of product impressions + * @param {object} args - Additional metadata for the event + */ + async sendViewSearchResults(searchParams, searchResults, args) { + const baseEvent = this._constructBaseEvent(args) + const userDetails = this._constructUserDetails(args) + + const products = searchResults?.hits?.map((product) => + this._constructDatacloudProduct(product) + ) + + const catalogObjects = products.map((product) => { + return this._concatenateEvents( + baseEvent, + this._generateEventDetails('catalog', 'Engagement'), + this._constructBaseSearchResult(searchParams), + { + searchResultId: crypto.randomUUID(), + id: product.id, + type: 'Product', + webStoreId: 'pwa', + interactionName: 'catalog-object-impression' + } + ) + }) + + const identityProfile = this.dnt + ? {} + : this._concatenateEvents( + baseEvent, + this._generateEventDetails('identity', 'Profile'), + userDetails + ) + + let contactPointEmail = null + if (args.email) { + contactPointEmail = this._concatenateEvents( + baseEvent, + this._generateEventDetails('contactPointEmail', 'Profile', args.email) + ) + } + + const interaction = { + events: [ + ...(!this.dnt ? [identityProfile] : []), + ...(contactPointEmail ? [contactPointEmail] : []), + ...catalogObjects + ] + } + + try { + this.sdk.webEventsAppSourceIdPost(interaction) + } catch (err) { + logger.error('Error sending DataCloud event', err) + } + } + + /** + * Sends a `catalog-object-impression` event to Data Cloud with + * additional recommendation data. + * + * This method records a `catalog` event type when a shopper views a recommendation, + * logging an impression of the products displayed in the recommendation. + * + * @param {object} recommenderDetails - Metadata about the recommendation source + * @param {array} products - List of recommended products + * @param {object} args - Additional metadata for the event + */ + async sendViewRecommendations(recommenderDetails, products, args) { + const baseEvent = this._constructBaseEvent(args) + const userDetails = this._constructUserDetails(args) + + const catalogObjects = products.map((product) => { + return this._concatenateEvents( + baseEvent, + this._generateEventDetails('catalog', 'Engagement'), + { + id: product.id, + type: 'Product', + webStoreId: 'pwa', + interactionName: 'catalog-object-impression', + personalizationId: recommenderDetails.recommenderName, //* The identifier of the personalization (e.g., recommendation), provided by the personalization service provider, that led to the event. + personalizationContextId: recommenderDetails.__recoUUID //* The identifier, provided by the personalization service provider, of the specific content (e.g., product) associated with this event. + } + ) + }) + + const identityProfile = this.dnt + ? {} + : this._concatenateEvents( + baseEvent, + this._generateEventDetails('identity', 'Profile'), + userDetails + ) + + let contactPointEmail = null + if (args.email) { + contactPointEmail = this._concatenateEvents( + baseEvent, + this._generateEventDetails('contactPointEmail', 'Profile', args.email) + ) + } + + const interaction = { + events: [ + ...(!this.dnt ? [identityProfile] : []), + ...(contactPointEmail ? [contactPointEmail] : []), + ...catalogObjects + ] + } + + try { + this.sdk.webEventsAppSourceIdPost(interaction) + } catch (err) { + logger.error('Error sending DataCloud event', err) + } + } +} + +/** + * Custom hook for sending PWA Kit events to Data Cloud. + * + * This hook provides methods to track various user interactions, such as + * page views, product views, category views, search impressions, and recommendations. + * + * @returns {object} An object containing methods for sending different Data Cloud events + */ +const useDataCloud = () => { + const {getUsidWhenReady} = useUsid() + const {isRegistered} = useCustomerType() + const {data: customer} = useCurrentCustomer() + const {site} = useMultiSite() + const {effectiveDnt} = useDNT() + const sessionId = Cookies.get('sid') + + // If Do Not Track is enabled, then the following fields are replaced with '__DNT__' + const getEventUserParameters = async () => { + const usid = await getUsidWhenReady() + return { + isGuest: isRegistered ? 0 : 1, + customerId: effectiveDnt ? '__DNT__' : customer?.customerId, + customerNo: effectiveDnt ? '__DNT__' : customer?.customerNo, + guestId: effectiveDnt ? '__DNT__' : usid, + deviceId: effectiveDnt ? '__DNT__' : usid, + sessionId: effectiveDnt ? '__DNT__' : sessionId, + firstName: customer?.firstName, + lastName: customer?.lastName, + email: !effectiveDnt ? customer?.email : undefined + } + } + + // Grab Data Cloud configuration values and intialize the sdk + const { + app: {dataCloudAPI: config} + } = getConfig() + + const {appSourceId, tenantId} = config + + const dataCloud = useMemo( + () => + new DataCloudApi({ + siteId: site.id, + appSourceId: appSourceId, + tenantId: tenantId, + dnt: effectiveDnt + }), + [site] + ) + + return { + async sendViewPage(...args) { + const userParameters = await getEventUserParameters() + return dataCloud.sendViewPage(...args.concat(userParameters)) + }, + async sendViewProduct(...args) { + const userParameters = await getEventUserParameters() + return dataCloud.sendViewProduct(...args.concat(userParameters)) + }, + async sendViewCategory(...args) { + const userParameters = await getEventUserParameters() + return dataCloud.sendViewCategory(...args.concat(userParameters)) + }, + async sendViewSearchResults(...args) { + const userParameters = await getEventUserParameters() + return dataCloud.sendViewSearchResults(...args.concat(userParameters)) + }, + async sendViewRecommendations(...args) { + const userParameters = await getEventUserParameters() + return dataCloud.sendViewRecommendations(...args.concat(userParameters)) + } + } +} + +export default useDataCloud diff --git a/packages/template-retail-react-app/app/hooks/use-datacloud.test.js b/packages/template-retail-react-app/app/hooks/use-datacloud.test.js new file mode 100644 index 0000000000..8d8427f5eb --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-datacloud.test.js @@ -0,0 +1,164 @@ +/* + * 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 {renderHook, waitFor} from '@testing-library/react' +import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' +import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' +import {useDNT} from '@salesforce/commerce-sdk-react' +import { + mockLoginViewPageEvent, + mockViewProductEvent, + mockViewCategoryEvent, + mockViewSearchResultsEvent, + mockViewRecommendationsEvent, + mockSearchParam, + mockGloveSearchResult, + mockCategorySearchParams, + mockRecommendationIds, + mockLoginViewPageEventDNT +} from '@salesforce/retail-react-app/app/mocks/datacloud-mock-data' +import { + mockProduct, + mockCategory, + mockSearchResults, + mockRecommenderDetails +} from '@salesforce/retail-react-app/app/hooks/einstein-mock-data' + +const dataCloudConfig = { + app: { + dataCloudAPI: { + appSourceId: '6ebc532a-2247-48e9-8300-d8c2b84eb463', + tenantId: 'mvst0mlfmrsd8zbwg8zgmytbg1' + }, + defaultSite: 'test-site' + } +} + +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => { + return { + getConfig: jest.fn(() => dataCloudConfig) + } +}) + +jest.mock('@salesforce/commerce-sdk-react', () => { + const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...originalModule, + useUsid: () => { + return { + getUsidWhenReady: jest.fn(() => { + return 'guest-usid' + }) + } + }, + useCustomerType: jest.fn(() => { + return {isRegistered: true} + }), + useDNT: jest.fn(() => { + return {effectiveDnt: false} + }) + } +}) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({ + useCurrentCustomer: jest.fn(() => { + return { + data: { + customerId: 1234567890, + firstName: 'John', + lastName: 'Smith', + email: 'johnsmith@salesforce.com' + } + } + }) +})) +jest.mock('js-cookie', () => ({ + get: jest.fn(() => 'mockCookieValue') +})) +const mockWebEventsAppSourceIdPost = jest.fn() +jest.mock('@salesforce/cc-datacloud-typescript', () => { + return { + initDataCloudSdk: () => { + return { + webEventsAppSourceIdPost: mockWebEventsAppSourceIdPost + } + } + } +}) + +const mockUseContext = jest.fn().mockImplementation(() => ({site: {id: 'RefArch'}})) +React.useContext = mockUseContext +describe('useDataCloud', function () { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('sendViewPage', async () => { + const {result} = renderHook(() => useDataCloud()) + expect(result.current).toBeDefined() + result.current.sendViewPage('/login') + await waitFor(() => { + expect(mockWebEventsAppSourceIdPost).toHaveBeenCalledWith(mockLoginViewPageEvent) + }) + }) + + test('sendViewPage does not send Profile event when DNT is enabled', async () => { + useDNT.mockReturnValueOnce({ + effectiveDnt: true + }) + const {result} = renderHook(() => useDataCloud()) + expect(result.current).toBeDefined() + result.current.sendViewPage('/login') + await waitFor(() => { + expect(mockWebEventsAppSourceIdPost).toHaveBeenCalledWith(mockLoginViewPageEventDNT) + }) + }) + + test('sendViewProduct', async () => { + const {result} = renderHook(() => useDataCloud()) + expect(result.current).toBeDefined() + result.current.sendViewProduct(mockProduct) + await waitFor(() => { + expect(mockWebEventsAppSourceIdPost).toHaveBeenCalledWith(mockViewProductEvent) + }) + }) + + test('sendViewCategory with no email', async () => { + useCurrentCustomer.mockReturnValue({ + data: { + customerId: 1234567890, + firstName: 'John', + lastName: 'Smith' + } + }) + const {result} = renderHook(() => useDataCloud()) + expect(result.current).toBeDefined() + result.current.sendViewCategory(mockCategorySearchParams, mockCategory, mockSearchResults) + await waitFor(() => { + expect(mockWebEventsAppSourceIdPost).toHaveBeenCalledWith(mockViewCategoryEvent) + }) + }) + + test('sendViewSearchResults with no email', async () => { + const {result} = renderHook(() => useDataCloud()) + expect(result.current).toBeDefined() + result.current.sendViewSearchResults(mockSearchParam, mockGloveSearchResult) + await waitFor(() => { + expect(mockWebEventsAppSourceIdPost).toHaveBeenCalledWith(mockViewSearchResultsEvent) + }) + }) + + test('sendViewRecommendations with non email', async () => { + const {result} = renderHook(() => useDataCloud()) + expect(result.current).toBeDefined() + result.current.sendViewRecommendations(mockRecommenderDetails, mockRecommendationIds) + await waitFor(() => { + expect(mockWebEventsAppSourceIdPost).toHaveBeenCalledWith(mockViewRecommendationsEvent) + }) + }) +}) diff --git a/packages/template-retail-react-app/app/mocks/datacloud-mock-data.js b/packages/template-retail-react-app/app/mocks/datacloud-mock-data.js new file mode 100644 index 0000000000..d03622bc4e --- /dev/null +++ b/packages/template-retail-react-app/app/mocks/datacloud-mock-data.js @@ -0,0 +1,404 @@ +/* + * 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 {expect} from '@jest/globals' + +export const mockLoginViewPageEvent = { + events: [ + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'identity', + category: 'Profile', + isAnonymous: 0, + firstName: 'John', + lastName: 'Smith', + sourceUrl: '/login' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'userEngagement', + category: 'Engagement', + interactionName: 'page-view', + sourceUrl: '/login' + }) + ] +} + +export const mockLoginViewPageEventDNT = { + events: [ + expect.objectContaining({ + guestId: '__DNT__', + siteId: 'RefArch', + sessionId: '__DNT__', + deviceId: '__DNT__', + dateTime: expect.any(String), + customerId: '__DNT__', + customerNo: '__DNT__', + eventId: expect.any(String), + eventType: 'userEngagement', + category: 'Engagement', + interactionName: 'page-view', + sourceUrl: '/login' + }) + ] +} + +export const mockViewProductEvent = { + events: [ + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'identity', + category: 'Profile', + isAnonymous: 0, + firstName: 'John', + lastName: 'Smith' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'contactPointEmail', + category: 'Profile', + email: 'johnsmith@salesforce.com' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'catalog', + category: 'Engagement', + id: '56736828M', + type: 'Product', + webStoreId: 'pwa', + interactionName: 'catalog-object-view-start' + }) + ] +} + +export const mockCategorySearchParams = { + limit: 25, + offset: 0, + sort: 'best-matches' +} + +export const mockViewCategoryEvent = { + events: [ + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'identity', + category: 'Profile', + isAnonymous: 0, + firstName: 'John', + lastName: 'Smith' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'catalog', + category: 'Engagement', + searchResultTitle: undefined, + searchResultPosition: 0, + searchResultPageNumber: 1, + id: '25752986M', + type: 'Product', + webStoreId: 'pwa', + categoryId: 'mens-accessories-ties', + interactionName: 'catalog-object-impression' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'catalog', + category: 'Engagement', + searchResultTitle: undefined, + searchResultPosition: 0, + searchResultPageNumber: 1, + id: '25752235M', + type: 'Product', + webStoreId: 'pwa', + categoryId: 'mens-accessories-ties', + interactionName: 'catalog-object-impression' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'catalog', + category: 'Engagement', + searchResultTitle: undefined, + searchResultPosition: 0, + searchResultPageNumber: 1, + id: '25752218M', + type: 'Product', + webStoreId: 'pwa', + categoryId: 'mens-accessories-ties', + interactionName: 'catalog-object-impression' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'catalog', + category: 'Engagement', + searchResultTitle: undefined, + searchResultPosition: 0, + searchResultPageNumber: 1, + id: '25752981M', + type: 'Product', + webStoreId: 'pwa', + categoryId: 'mens-accessories-ties', + interactionName: 'catalog-object-impression' + }) + ] +} + +export const mockSearchParam = { + limit: 25, + offset: 0, + q: 'oxford glove', + sort: 'best-matches' +} + +export const mockGloveSearchResult = { + limit: 1, + hits: [ + { + currency: 'GBP', + hitType: 'master', + image: { + alt: "Men's Oxford Gloves, , large", + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dwb69853b8/images/large/TG250_206.jpg', + link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dwb69853b8/images/large/TG250_206.jpg', + title: "Men's Oxford Gloves, " + }, + price: 63.99, + pricePerUnit: 63.99, + priceRanges: [ + { + maxPrice: 63.99, + minPrice: 63.99, + pricebook: 'gbp-m-list-prices' + } + ], + productId: 'TG250M', + productName: "Men's Oxford Gloves", + productType: { + master: true + }, + c_productUrl: 'https://pwa-kit.mobify-storefront.com/global/en-GB/product/TG250M' + } + ], + query: 'oxford glove', + refinements: [ + { + attributeId: 'cgid', + label: 'Category', + values: [ + { + hitCount: 1, + label: 'Mens', + value: 'mens', + values: [ + { + hitCount: 1, + label: 'Accessories', + value: 'mens-accessories', + values: [ + { + hitCount: 1, + label: 'Gloves', + value: 'mens-accessories-gloves' + } + ] + } + ] + } + ] + }, + { + attributeId: 'c_refinementColor', + label: 'Colour', + values: [ + { + hitCount: 0, + label: 'Beige', + presentationId: 'beige', + value: 'Beige' + }, + { + hitCount: 0, + label: 'Black', + presentationId: 'black', + value: 'Black' + } + ] + }, + { + attributeId: 'price', + label: 'Price', + values: [ + { + hitCount: 1, + label: '£50 - £99.99', + value: '(50..100)' + } + ] + } + ], + selectedSortingOption: 'best-matches', + sortingOptions: [ + { + id: 'best-matches', + label: 'Best Matches' + } + ], + offset: 0, + total: 1 +} + +export const mockViewSearchResultsEvent = { + events: [ + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'identity', + category: 'Profile', + isAnonymous: 0, + firstName: 'John', + lastName: 'Smith' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'catalog', + category: 'Engagement', + searchResultTitle: 'oxford glove', + searchResultPosition: 0, + searchResultPageNumber: 1, + searchResultId: expect.any(String), + id: 'TG250M', + type: 'Product', + webStoreId: 'pwa', + interactionName: 'catalog-object-impression' + }) + ] +} + +export const mockRecommendationIds = [{id: '11111111'}, {id: '22222222'}] + +export const mockViewRecommendationsEvent = { + events: [ + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'identity', + category: 'Profile', + isAnonymous: 0, + firstName: 'John', + lastName: 'Smith' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'catalog', + category: 'Engagement', + id: '11111111', + type: 'Product', + webStoreId: 'pwa', + interactionName: 'catalog-object-impression', + personalizationId: 'testRecommender', + personalizationContextId: '883360544021M' + }), + expect.objectContaining({ + guestId: 'guest-usid', + siteId: 'RefArch', + sessionId: expect.any(String), + deviceId: expect.any(String), + dateTime: expect.any(String), + customerId: 1234567890, + eventId: expect.any(String), + eventType: 'catalog', + category: 'Engagement', + id: '22222222', + type: 'Product', + webStoreId: 'pwa', + interactionName: 'catalog-object-impression', + personalizationId: 'testRecommender', + personalizationContextId: '883360544021M' + }) + ] +} diff --git a/packages/template-retail-react-app/app/pages/account/index.jsx b/packages/template-retail-react-app/app/pages/account/index.jsx index 9ece4047fb..494b472e66 100644 --- a/packages/template-retail-react-app/app/pages/account/index.jsx +++ b/packages/template-retail-react-app/app/pages/account/index.jsx @@ -41,6 +41,7 @@ import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useDataCloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import {useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {isHydrated} from '@salesforce/retail-react-app/app/utils/utils' @@ -94,11 +95,13 @@ const Account = () => { const [showLoading, setShowLoading] = useState(false) const einstein = useEinstein() + const dataCloud = useDataCloud() const {buildUrl} = useMultiSite() /**************** Einstein ****************/ useEffect(() => { einstein.sendViewPage(location.pathname) + dataCloud.sendViewPage(location.pathname) }, [location]) const onSignoutClick = async () => { diff --git a/packages/template-retail-react-app/app/pages/home/index.jsx b/packages/template-retail-react-app/app/pages/home/index.jsx index 4da313a256..3295a49c6b 100644 --- a/packages/template-retail-react-app/app/pages/home/index.jsx +++ b/packages/template-retail-react-app/app/pages/home/index.jsx @@ -35,6 +35,7 @@ import {heroFeatures, features} from '@salesforce/retail-react-app/app/pages/hom //Hooks import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useDatacloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' // Constants import { @@ -55,6 +56,7 @@ import {useProductSearch} from '@salesforce/commerce-sdk-react' const Home = () => { const intl = useIntl() const einstein = useEinstein() + const datacloud = useDatacloud() const {pathname} = useLocation() const {res} = useServerContext() @@ -79,6 +81,7 @@ const Home = () => { /**************** Einstein ****************/ useEffect(() => { einstein.sendViewPage(pathname) + datacloud.sendViewPage(pathname) }, []) return ( diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index e2c3b4a103..2a931a5704 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -23,6 +23,7 @@ import {useForm} from 'react-hook-form' import {useRouteMatch} from 'react-router' import {useLocation} from 'react-router-dom' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useDatacloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import LoginForm from '@salesforce/retail-react-app/app/components/login' import PasswordlessEmailConfirmation from '@salesforce/retail-react-app/app/components/email-confirmation/index' import { @@ -56,6 +57,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const queryParams = new URLSearchParams(location.search) const {path} = useRouteMatch() const einstein = useEinstein() + const datacloud = useDatacloud() const {isRegistered, customerType} = useCustomerType() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser) @@ -184,6 +186,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { /**************** Einstein ****************/ useEffect(() => { einstein.sendViewPage(location.pathname) + datacloud.sendViewPage(location.pathname) }, []) return ( diff --git a/packages/template-retail-react-app/app/pages/product-detail/index.jsx b/packages/template-retail-react-app/app/pages/product-detail/index.jsx index 51533402dc..71764c0e2e 100644 --- a/packages/template-retail-react-app/app/pages/product-detail/index.jsx +++ b/packages/template-retail-react-app/app/pages/product-detail/index.jsx @@ -31,6 +31,7 @@ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-curre import {useVariant} from '@salesforce/retail-react-app/app/hooks' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useDatacloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import useActiveData from '@salesforce/retail-react-app/app/hooks/use-active-data' import {useServerContext} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks' // Project Components @@ -61,6 +62,7 @@ const ProductDetail = () => { const history = useHistory() const location = useLocation() const einstein = useEinstein() + const datacloud = useDatacloud() const activeData = useActiveData() const toast = useToast() const navigate = useNavigation() @@ -423,6 +425,7 @@ const ProductDetail = () => { useEffect(() => { if (product && product.type.set) { einstein.sendViewProduct(product) + datacloud.sendViewProduct(product) const childrenProducts = product.setProducts childrenProducts.map((child) => { try { @@ -434,6 +437,7 @@ const ProductDetail = () => { }) } activeData.sendViewProduct(category, child, 'detail') + datacloud.sendViewProduct(child) }) } else if (product) { try { @@ -445,6 +449,7 @@ const ProductDetail = () => { }) } activeData.sendViewProduct(category, product, 'detail') + datacloud.sendViewProduct(product) } }, [product]) diff --git a/packages/template-retail-react-app/app/pages/product-list/index.jsx b/packages/template-retail-react-app/app/pages/product-list/index.jsx index 52640fcb96..a5af052766 100644 --- a/packages/template-retail-react-app/app/pages/product-list/index.jsx +++ b/packages/template-retail-react-app/app/pages/product-list/index.jsx @@ -72,6 +72,7 @@ import { } from '@salesforce/retail-react-app/app/hooks' import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useDatacloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import useActiveData from '@salesforce/retail-react-app/app/hooks/use-active-data' // Others @@ -117,6 +118,7 @@ const ProductList = (props) => { const location = useLocation() const toast = useToast() const einstein = useEinstein() + const datacloud = useDatacloud() const activeData = useActiveData() const {res} = useServerContext() const customerId = useCustomerId() @@ -391,6 +393,7 @@ const ProductList = (props) => { additionalProperties: {error: err, searchQuery} }) } + datacloud.sendViewSearchResults(searchParams, productSearchResult) activeData.sendViewSearch(searchParams, productSearchResult) } else { try { @@ -401,6 +404,7 @@ const ProductList = (props) => { additionalProperties: {error: err, category} }) } + datacloud.sendViewCategory(searchParams, category, productSearchResult) activeData.sendViewCategory(searchParams, category, productSearchResult) } } diff --git a/packages/template-retail-react-app/app/pages/registration/index.jsx b/packages/template-retail-react-app/app/pages/registration/index.jsx index 018c3601fb..8cc87ed687 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.jsx @@ -16,6 +16,7 @@ import Seo from '@salesforce/retail-react-app/app/components/seo' import RegisterForm from '@salesforce/retail-react-app/app/components/register' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useDatacloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' const Registration = () => { @@ -24,6 +25,7 @@ const Registration = () => { const {isRegistered} = useCustomerType() const form = useForm() const einstein = useEinstein() + const datacloud = useDatacloud() const {pathname} = useLocation() const register = useAuthHelper(AuthHelpers.Register) @@ -54,6 +56,7 @@ const Registration = () => { /**************** Einstein ****************/ useEffect(() => { einstein.sendViewPage(pathname) + datacloud.sendViewPage(pathname) }, []) return ( diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.jsx index 7d0d1d7205..1d26812369 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.jsx @@ -15,6 +15,7 @@ import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset import ResetPasswordLanding from '@salesforce/retail-react-app/app/pages/reset-password/reset-password-landing' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' +import useDatacloud from '@salesforce/retail-react-app/app/hooks/use-datacloud' import {useLocation} from 'react-router-dom' import {useRouteMatch} from 'react-router' import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset' @@ -29,6 +30,7 @@ const ResetPassword = () => { const form = useForm() const navigate = useNavigation() const einstein = useEinstein() + const datacloud = useDatacloud() const {pathname} = useLocation() const {path} = useRouteMatch() const {getPasswordResetToken} = usePasswordReset() @@ -48,6 +50,7 @@ const ResetPassword = () => { /**************** Einstein ****************/ useEffect(() => { einstein.sendViewPage(pathname) + datacloud.sendViewPage(pathname) }, []) return ( diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 2625ffa96b..6cda5946b2 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -130,7 +130,9 @@ const {handler} = runtime.createHandler(options, (app) => { ], 'connect-src': [ // Connect to Einstein APIs - 'api.cquotient.com' + 'api.cquotient.com', + // Connect to DataCloud APIs + '*.c360a.salesforce.com' ] } } diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 40cbac42a9..fa5b6f4666 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -51,6 +51,10 @@ module.exports = { // This differs from the siteId in commerceAPIConfig for testing purposes siteId: 'aaij-MobileFirst', isProduction: false + }, + dataCloudAPI: { + appSourceId: 'fb81edab-24c6-4b40-8684-b67334dfdf32', + tenantId: 'mmyw8zrxhfsg09lfmzrd1zjqmg' } }, externals: [], diff --git a/packages/template-retail-react-app/config/mocks/default.js b/packages/template-retail-react-app/config/mocks/default.js index c157e2449b..3a8e7af91e 100644 --- a/packages/template-retail-react-app/config/mocks/default.js +++ b/packages/template-retail-react-app/config/mocks/default.js @@ -95,6 +95,10 @@ module.exports = { // This is temporary and is meant as a placeholder until there is a mechanism for reading // the is_production property from an MRT target isProduction: false + }, + dataCloudAPI: { + appSourceId: '23df7335-2e9d-4fbc-bc34-7e93649e69b7', + tenantId: '5zqheixqu9vji7spdkzxwh4hpz' } }, // This list contains server-side only libraries that you don't want to be compiled by webpack diff --git a/packages/template-retail-react-app/jest-setup.js b/packages/template-retail-react-app/jest-setup.js index 67f78691ec..309bf9da35 100644 --- a/packages/template-retail-react-app/jest-setup.js +++ b/packages/template-retail-react-app/jest-setup.js @@ -86,6 +86,10 @@ export const setupMockServer = () => { }), rest.post('*/v3/personalization/recs/EinsteinTestSite/*', (req, res, ctx) => { return res(ctx.delay(0), ctx.status(200), ctx.json({})) + }), + // Mock Data Cloud API + rest.post('*.pc-rnd.c360a.salesforce.com/web/events/*', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(204), ctx.json({})) }) ) } diff --git a/packages/template-retail-react-app/jest.config.js b/packages/template-retail-react-app/jest.config.js index 4f4aa9834e..a7333f1681 100644 --- a/packages/template-retail-react-app/jest.config.js +++ b/packages/template-retail-react-app/jest.config.js @@ -18,8 +18,12 @@ module.exports = { '^@tanstack/react-query$': '/node_modules/@tanstack/react-query/build/lib/index.js', '^is-what$': '/node_modules/is-what/dist/cjs/index.cjs', - '^copy-anything$': '/node_modules/copy-anything/dist/cjs/index.cjs' + '^copy-anything$': '/node_modules/copy-anything/dist/cjs/index.cjs', + "^@salesforce/cc-datacloud-typescript$": "/node_modules/@salesforce/cc-datacloud-typescript/dist/index.js" }, + transformIgnorePatterns: [ + "/node_modules/(?!@salesforce/cc-datacloud-typescript)" + ], setupFilesAfterEnv: [path.join(__dirname, 'jest-setup.js')], collectCoverageFrom: [ 'app/**/*.{js,jsx}', diff --git a/packages/template-retail-react-app/package-lock.json b/packages/template-retail-react-app/package-lock.json index efcc360c3a..5aebb5780c 100644 --- a/packages/template-retail-react-app/package-lock.json +++ b/packages/template-retail-react-app/package-lock.json @@ -19,6 +19,7 @@ "@lhci/cli": "^0.11.0", "@loadable/component": "^5.15.3", "@peculiar/webcrypto": "^1.4.2", + "@salesforce/cc-datacloud-typescript": "^1.0.10", "@tanstack/react-query": "^4.28.0", "@tanstack/react-query-devtools": "^4.29.1", "@testing-library/dom": "^9.0.1", @@ -1984,6 +1985,25 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@salesforce/cc-datacloud-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@salesforce/cc-datacloud-typescript/-/cc-datacloud-typescript-1.0.10.tgz", + "integrity": "sha512-TvVjnr11ld7UBMIVV134famyeSrUXzRer/A9iZ7De4ABSvU89N43mgCamdXPlmELFxzVikJbR53zSFHNLt7jjQ==", + "dependencies": { + "headers-polyfill": "^4.0.2", + "openapi-fetch": "^0.8.2", + "rollup": "^2.79.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@salesforce/cc-datacloud-typescript/node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, "node_modules/@sentry/core": { "version": "6.19.7", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.19.7.tgz", @@ -6809,6 +6829,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-fetch": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.8.2.tgz", + "integrity": "sha512-4g+NLK8FmQ51RW6zLcCBOVy/lwYmFJiiT+ckYZxJWxUxH4XFhsNcX2eeqVMfVOi+mDNFja6qDXIZAz2c5J/RVw==", + "dependencies": { + "openapi-typescript-helpers": "^0.0.5" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.5.tgz", + "integrity": "sha512-MRffg93t0hgGZbYTxg60hkRIK2sRuEOHEtCUgMuLgbCC33TMQ68AmxskzUlauzZYD47+ENeGV/ElI7qnWqrAxA==" + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -7903,6 +7936,20 @@ "node": ">=10.0.0" } }, + "node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index bedde38718..bd19c03cf5 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -45,6 +45,7 @@ "@lhci/cli": "^0.11.0", "@loadable/component": "^5.15.3", "@peculiar/webcrypto": "^1.4.2", + "@salesforce/cc-datacloud-typescript": "^1.0.10", "@salesforce/commerce-sdk-react": "3.2.0-dev", "@salesforce/pwa-kit-dev": "3.9.0-dev", "@salesforce/pwa-kit-react-sdk": "3.9.0-dev", @@ -98,7 +99,7 @@ }, { "path": "build/vendor.js", - "maxSize": "325 kB" + "maxSize": "328 kB" } ] }