From c5db22c419e3970b5be2ea2e9a76ed9eb77c13f0 Mon Sep 17 00:00:00 2001 From: vcua-mobify <47404250+vcua-mobify@users.noreply.github.com> Date: Wed, 21 Sep 2022 17:43:27 -0700 Subject: [PATCH] Add new activities to Einstein API (#714) * Add new endpoints to Einstein API * Update pages to send viewPage activity * Update checkout to send beginCheckout activity * Update PDP to send viewSearch and viewCategory activities * Address duplicate activity calls * Update PLP to send clickSearch and clickCategory activities * Include realm. Also include path location on viewPage * Begin fixing tests * Move useEffect to before the redirect to fix an error. * Mock Einstein in product listing test * Fix lint * Add tests for new activities * Add a guard for setting realm and remove correlationId for now. * Simplify activity logic in PLP * Fix tests * Fix lint * Remove pathname from home page viewPage * Re-add pathname to home page. * Refactor: use constants to represent checkout steps * Add checkoutStep activity to Einstein API Co-authored-by: Ben Chypak --- .../app/commerce-api/__mocks__/einstein.js | 28 + .../app/commerce-api/einstein.js | 161 ++++++ .../app/commerce-api/einstein.test.js | 133 ++++- .../app/commerce-api/hooks/useEinstein.js | 21 + .../commerce-api/mocks/einstein-mock-data.js | 494 ++++++++++++++++++ .../app/pages/account/index.jsx | 10 +- .../app/pages/account/index.test.js | 2 + .../app/pages/checkout/index.test.js | 2 + .../pages/checkout/partials/contact-info.jsx | 5 +- .../checkout/partials/contact-info.test.js | 1 + .../app/pages/checkout/partials/payment.jsx | 5 +- .../checkout/partials/shipping-address.jsx | 5 +- .../checkout/partials/shipping-options.jsx | 7 +- .../pages/checkout/util/checkout-context.js | 44 +- .../app/pages/home/index.jsx | 13 +- .../app/pages/login/index.jsx | 7 + .../app/pages/login/index.test.js | 2 + .../app/pages/product-list/index.jsx | 24 + .../app/pages/product-list/index.test.js | 2 + .../app/pages/registration/index.jsx | 9 + .../app/pages/registration/index.test.jsx | 2 + .../app/pages/reset-password/index.jsx | 11 +- .../app/pages/reset-password/index.test.jsx | 2 + 23 files changed, 966 insertions(+), 24 deletions(-) diff --git a/packages/template-retail-react-app/app/commerce-api/__mocks__/einstein.js b/packages/template-retail-react-app/app/commerce-api/__mocks__/einstein.js index 8b4b94c929..a21aa806c1 100644 --- a/packages/template-retail-react-app/app/commerce-api/__mocks__/einstein.js +++ b/packages/template-retail-react-app/app/commerce-api/__mocks__/einstein.js @@ -21,6 +21,34 @@ class EinsteinAPI { return {requestId: 'test-req-id', uuid: 'test-uuid'} } + async sendViewSearch() { + return {requestId: 'test-req-id', uuid: 'test-uuid'} + } + + async sendClickSearch() { + return {requestId: 'test-req-id', uuid: 'test-uuid'} + } + + async sendViewCategory() { + return {requestId: 'test-req-id', uuid: 'test-uuid'} + } + + async sendClickCategory() { + return {requestId: 'test-req-id', uuid: 'test-uuid'} + } + + async sendViewPage() { + return {requestId: 'test-req-id', uuid: 'test-uuid'} + } + + async sendBeginCheckout() { + return {requestId: 'test-req-id', uuid: 'test-uuid'} + } + + async sendCheckoutStep() { + return {requestId: 'test-req-id', uuid: 'test-uuid'} + } + async sendViewReco() { return {requestId: 'test-req-id', uuid: 'test-uuid'} } diff --git a/packages/template-retail-react-app/app/commerce-api/einstein.js b/packages/template-retail-react-app/app/commerce-api/einstein.js index cd867a711a..84efcaca60 100644 --- a/packages/template-retail-react-app/app/commerce-api/einstein.js +++ b/packages/template-retail-react-app/app/commerce-api/einstein.js @@ -37,6 +37,11 @@ class EinsteinAPI { console.warn('Missing `cookieId`. For optimal results this value must be defined.') } + // The first part of the siteId is the realm + if (this.config.siteId) { + body.realm = this.config.siteId.split('-')[0] + } + return body } @@ -90,6 +95,105 @@ class EinsteinAPI { return this.einsteinFetch(endpoint, method, body) } + /** + * Tells the Einstein engine when a user views search results. + **/ + async sendViewSearch(searchText, searchResults, args) { + const endpoint = `/activities/${this.config.siteId}/viewSearch` + const method = 'POST' + + const products = searchResults.hits.map((product) => { + const {productId, sku = '', altId = '', altIdType = ''} = product + return { + id: productId, + sku, + altId, + altIdType + } + }) + + const body = { + searchText, + products, + ...args + } + + return this.einsteinFetch(endpoint, method, body) + } + + /** + * Tells the Einstein engine when a user clicks on a search result. + **/ + async sendClickSearch(searchText, product, args) { + const endpoint = `/activities/${this.config.siteId}/clickSearch` + const method = 'POST' + const {productId, sku = '', altId = '', altIdType = ''} = product + const body = { + searchText, + product: { + id: productId, + sku, + altId, + altIdType + }, + ...args + } + + return this.einsteinFetch(endpoint, method, body) + } + + /** + * Tells the Einstein engine when a user views a category. + **/ + async sendViewCategory(category, searchResults, args) { + const endpoint = `/activities/${this.config.siteId}/viewCategory` + const method = 'POST' + + const products = searchResults.hits.map((product) => { + const {productId, sku = '', altId = '', altIdType = ''} = product + return { + id: productId, + sku, + altId, + altIdType + } + }) + + const body = { + category: { + id: category.id + }, + products, + ...args + } + + return this.einsteinFetch(endpoint, method, body) + } + + /** + * Tells the Einstein engine when a user clicks a product from the category page. + * Not meant to be used when the user clicks a category from the nav bar. + **/ + async sendClickCategory(category, product, args) { + const endpoint = `/activities/${this.config.siteId}/clickCategory` + const method = 'POST' + const {productId, sku = '', altId = '', altIdType = ''} = product + const body = { + category: { + id: category.id + }, + product: { + id: productId, + sku, + altId, + altIdType + }, + ...args + } + + return this.einsteinFetch(endpoint, method, body) + } + /** * Tells the Einstein engine when a user views a set of recommendations * https://developer.salesforce.com/docs/commerce/einstein-api/references#einstein-recommendations:Summary @@ -132,6 +236,63 @@ class EinsteinAPI { return this.einsteinFetch(endpoint, method, body) } + /** + * Tells the Einstein engine when a user views a page. + * Use this only for pages where another activity does not fit. (ie. on the PDP, use viewProduct rather than this) + **/ + async sendViewPage(path, args) { + const endpoint = `/activities/${this.config.siteId}/viewPage` + const method = 'POST' + const body = { + currentLocation: path, + ...args + } + + return this.einsteinFetch(endpoint, method, body) + } + + /** + * Tells the Einstein engine when a user starts the checkout process. + **/ + async sendBeginCheckout(basket, args) { + const endpoint = `/activities/${this.config.siteId}/beginCheckout` + const method = 'POST' + const products = basket.productItems.map((product) => { + const {productId, sku = '', price = '', quantity = ''} = product + return { + id: productId, + sku, + price, + quantity + } + }) + const subTotal = basket.productSubTotal + const body = { + products: products, + amount: subTotal, + ...args + } + + return this.einsteinFetch(endpoint, method, body) + } + + /** + * Tells the Einstein engine when a user reaches the given step during checkout. + * https://developer.salesforce.com/docs/commerce/einstein-api/references#einstein-recommendations:Summary + **/ + async sendCheckoutStep(stepName, stepNumber, basket, args) { + const endpoint = `/activities/${this.config.siteId}/checkoutStep` + const method = 'POST' + const body = { + stepName, + stepNumber, + basketId: basket.basketId, + ...args + } + + return this.einsteinFetch(endpoint, method, body) + } + /** * Tells the Einstein engine when a user adds an item to their cart. * https://developer.salesforce.com/docs/commerce/einstein-api/references#einstein-recommendations:Summary diff --git a/packages/template-retail-react-app/app/commerce-api/einstein.test.js b/packages/template-retail-react-app/app/commerce-api/einstein.test.js index 80b54daaa8..d101b76bda 100644 --- a/packages/template-retail-react-app/app/commerce-api/einstein.test.js +++ b/packages/template-retail-react-app/app/commerce-api/einstein.test.js @@ -10,6 +10,9 @@ import { mockAddToCartProduct, mockGetZoneRecommendationsResponse, mockProduct, + mockCategory, + mockSearchResults, + mockBasket, mockRecommendationsResponse, mockRecommenderDetails } from './mocks/einstein-mock-data' @@ -55,7 +58,125 @@ describe('EinsteinAPI', () => { 'x-cq-client-id': 'test-id' }, body: - '{"product":{"id":"56736828M","sku":"","altId":"","altIdType":""},"cookieId":"test-usid"}' + '{"product":{"id":"56736828M","sku":"","altId":"","altIdType":""},"cookieId":"test-usid","realm":"test"}' + } + ) + }) + + test('viewSearch sends expected api request', async () => { + const searchTerm = 'tie' + await einsteinApi.sendViewSearch(searchTerm, mockSearchResults) + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/test-path/v3/activities/test-site-id/viewSearch', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-cq-client-id': 'test-id' + }, + body: + '{"searchText":"tie","products":[{"id":"25752986M","sku":"","altId":"","altIdType":""},{"id":"25752235M","sku":"","altId":"","altIdType":""},{"id":"25752218M","sku":"","altId":"","altIdType":""},{"id":"25752981M","sku":"","altId":"","altIdType":""}],"cookieId":"test-usid","realm":"test"}' + } + ) + }) + + test('viewCategory sends expected api request', async () => { + await einsteinApi.sendViewCategory(mockCategory, mockSearchResults) + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/test-path/v3/activities/test-site-id/viewCategory', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-cq-client-id': 'test-id' + }, + body: + '{"category":{"id":"mens-accessories-ties"},"products":[{"id":"25752986M","sku":"","altId":"","altIdType":""},{"id":"25752235M","sku":"","altId":"","altIdType":""},{"id":"25752218M","sku":"","altId":"","altIdType":""},{"id":"25752981M","sku":"","altId":"","altIdType":""}],"cookieId":"test-usid","realm":"test"}' + } + ) + }) + + test('clickSearch sends expected api request', async () => { + const searchTerm = 'tie' + const clickedProduct = mockSearchResults.hits[0] + await einsteinApi.sendClickSearch(searchTerm, clickedProduct) + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/test-path/v3/activities/test-site-id/clickSearch', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-cq-client-id': 'test-id' + }, + body: + '{"searchText":"tie","product":{"id":"25752986M","sku":"","altId":"","altIdType":""},"cookieId":"test-usid","realm":"test"}' + } + ) + }) + + test('clickCategory sends expected api request', async () => { + const clickedProduct = mockSearchResults.hits[0] + await einsteinApi.sendClickCategory(mockCategory, clickedProduct) + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/test-path/v3/activities/test-site-id/clickCategory', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-cq-client-id': 'test-id' + }, + body: + '{"category":{"id":"mens-accessories-ties"},"product":{"id":"25752986M","sku":"","altId":"","altIdType":""},"cookieId":"test-usid","realm":"test"}' + } + ) + }) + + test('viewPage sends expected api request', async () => { + const path = '/' + await einsteinApi.sendViewPage(path) + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/test-path/v3/activities/test-site-id/viewPage', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-cq-client-id': 'test-id' + }, + body: '{"currentLocation":"/","cookieId":"test-usid","realm":"test"}' + } + ) + }) + + test('beginCheckout sends expected api request', async () => { + await einsteinApi.sendBeginCheckout(mockBasket) + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/test-path/v3/activities/test-site-id/beginCheckout', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-cq-client-id': 'test-id' + }, + body: + '{"products":[{"id":"682875719029M","sku":"","price":29.99,"quantity":1}],"amount":29.99,"cookieId":"test-usid","realm":"test"}' + } + ) + }) + + test('checkouStep sends expected api request', async () => { + const checkoutStepName = 'CheckoutStep' + const checkoutStep = 0 + await einsteinApi.sendCheckoutStep(checkoutStepName, checkoutStep, mockBasket) + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/test-path/v3/activities/test-site-id/checkoutStep', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-cq-client-id': 'test-id' + }, + body: + '{"stepName":"CheckoutStep","stepNumber":0,"basketId":"f6bbeee30fb93c2f94213f60f8","cookieId":"test-usid","realm":"test"}' } ) }) @@ -71,7 +192,7 @@ describe('EinsteinAPI', () => { 'x-cq-client-id': 'test-id' }, body: - '{"products":[{"id":"883360544021M","sku":"","price":155,"quantity":1}],"cookieId":"test-usid"}' + '{"products":[{"id":"883360544021M","sku":"","price":155,"quantity":1}],"cookieId":"test-usid","realm":"test"}' } ) }) @@ -87,7 +208,7 @@ describe('EinsteinAPI', () => { 'x-cq-client-id': 'test-id' }, body: - '{"recommenderName":"testRecommender","__recoUUID":"883360544021M","product":{"id":"56736828M","sku":"","altId":"","altIdType":""},"cookieId":"test-usid"}' + '{"recommenderName":"testRecommender","__recoUUID":"883360544021M","product":{"id":"56736828M","sku":"","altId":"","altIdType":""},"cookieId":"test-usid","realm":"test"}' } ) }) @@ -103,7 +224,7 @@ describe('EinsteinAPI', () => { 'x-cq-client-id': 'test-id' }, body: - '{"recommenderName":"testRecommender","__recoUUID":"883360544021M","products":{"id":"test-reco"},"cookieId":"test-usid"}' + '{"recommenderName":"testRecommender","__recoUUID":"883360544021M","products":{"id":"test-reco"},"cookieId":"test-usid","realm":"test"}' } ) }) @@ -136,7 +257,7 @@ describe('EinsteinAPI', () => { 'Content-Type': 'application/json', 'x-cq-client-id': 'test-id' }, - body: '{"cookieId":"test-usid"}' + body: '{"cookieId":"test-usid","realm":"test"}' } ) @@ -189,7 +310,7 @@ describe('EinsteinAPI', () => { 'Content-Type': 'application/json', 'x-cq-client-id': 'test-id' }, - body: '{"cookieId":"test-usid"}' + body: '{"cookieId":"test-usid","realm":"test"}' } ) diff --git a/packages/template-retail-react-app/app/commerce-api/hooks/useEinstein.js b/packages/template-retail-react-app/app/commerce-api/hooks/useEinstein.js index 75da17fcb5..1d3cce976b 100644 --- a/packages/template-retail-react-app/app/commerce-api/hooks/useEinstein.js +++ b/packages/template-retail-react-app/app/commerce-api/hooks/useEinstein.js @@ -19,6 +19,27 @@ const useEinstein = () => { async sendViewProduct(...args) { return api.einstein.sendViewProduct(...args) }, + async sendViewSearch(...args) { + return api.einstein.sendViewSearch(...args) + }, + async sendClickSearch(...args) { + return api.einstein.sendClickSearch(...args) + }, + async sendViewCategory(...args) { + return api.einstein.sendViewCategory(...args) + }, + async sendClickCategory(...args) { + return api.einstein.sendClickCategory(...args) + }, + async sendViewPage(...args) { + return api.einstein.sendViewPage(...args) + }, + async sendBeginCheckout(...args) { + return api.einstein.sendBeginCheckout(...args) + }, + async sendCheckoutStep(...args) { + return api.einstein.sendCheckoutStep(...args) + }, async sendViewReco(...args) { return api.einstein.sendViewReco(...args) }, diff --git a/packages/template-retail-react-app/app/commerce-api/mocks/einstein-mock-data.js b/packages/template-retail-react-app/app/commerce-api/mocks/einstein-mock-data.js index 14d0c0afcf..9726e06e39 100644 --- a/packages/template-retail-react-app/app/commerce-api/mocks/einstein-mock-data.js +++ b/packages/template-retail-react-app/app/commerce-api/mocks/einstein-mock-data.js @@ -48,6 +48,500 @@ export const mockGetZoneRecommendationsResponse = { recommenderName: 'recently-viewed-products' } +export const mockCategory = { + id: 'mens-accessories-ties', + image: + 'https://zzrf-001.sandbox.us03.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-storefront-catalog-m-non-en/default/dwd2ff3ec8/images/slot/sub_banners/cat-banner-mens-ties.jpg', + name: 'Ties', + pageDescription: + "Shop Mens's Ties for all occasions including business or casual at Commerce Cloud", + pageTitle: "Men's Casual and Business Ties", + parentCategoryId: 'mens-accessories', + parentCategoryTree: [ + { + id: 'mens', + name: 'Mens' + }, + { + id: 'mens-accessories', + name: 'Accessories' + }, + { + id: 'mens-accessories-ties', + name: 'Ties' + } + ], + c_enableCompare: false, + c_showInMenu: true +} + +export const mockSearchResults = { + limit: 4, + hits: [ + { + currency: 'GBP', + hitType: 'master', + image: { + alt: 'Striped Silk Tie, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw6e365a5e/images/large/PG.949114314S.REDSI.PZ.jpg', + link: + 'https://zzrf-001.sandbox.us03.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw6e365a5e/images/large/PG.949114314S.REDSI.PZ.jpg', + title: 'Striped Silk Tie, ' + }, + orderable: true, + price: 19.19, + productId: '25752986M', + productName: 'Striped Silk Tie', + productType: { + master: true + }, + representedProduct: { + id: '793775370033M' + }, + representedProducts: [ + { + id: '793775370033M' + }, + { + id: '793775362380M' + } + ], + variationAttributes: [ + { + id: 'color', + name: 'Colour', + values: [ + { + name: 'Red', + orderable: true, + value: 'REDSI' + }, + { + name: 'Turquoise', + orderable: true, + value: 'TURQUSI' + } + ] + } + ] + }, + { + currency: 'GBP', + hitType: 'master', + image: { + alt: 'Checked Silk Tie, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dwe64d25bd/images/large/PG.949612424S.COBATSI.PZ.jpg', + link: + 'https://zzrf-001.sandbox.us03.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dwe64d25bd/images/large/PG.949612424S.COBATSI.PZ.jpg', + title: 'Checked Silk Tie, ' + }, + orderable: true, + price: 19.19, + productId: '25752235M', + productName: 'Checked Silk Tie', + productType: { + master: true + }, + representedProduct: { + id: '682875090845M' + }, + representedProducts: [ + { + id: '682875090845M' + }, + { + id: '682875719029M' + }, + { + id: '682875540326M' + } + ], + variationAttributes: [ + { + id: 'color', + name: 'Colour', + values: [ + { + name: 'Cobalt', + orderable: true, + value: 'COBATSI' + }, + { + name: 'Navy', + orderable: true, + value: 'NAVYSI' + }, + { + name: 'Yellow', + orderable: true, + value: 'YELLOSI' + } + ] + } + ] + }, + { + currency: 'GBP', + hitType: 'master', + image: { + alt: 'Solid Silk Tie, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c618527/images/large/PG.949432114S.NAVYSI.PZ.jpg', + link: + 'https://zzrf-001.sandbox.us03.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c618527/images/large/PG.949432114S.NAVYSI.PZ.jpg', + title: 'Solid Silk Tie, ' + }, + orderable: true, + price: 19.19, + productId: '25752218M', + productName: 'Solid Silk Tie', + productType: { + master: true + }, + representedProduct: { + id: '029407331289M' + }, + representedProducts: [ + { + id: '029407331289M' + }, + { + id: '029407331227M' + }, + { + id: '029407331258M' + } + ], + variationAttributes: [ + { + id: 'color', + name: 'Colour', + values: [ + { + name: 'Navy', + orderable: true, + value: 'NAVYSI' + }, + { + name: 'Red', + orderable: true, + value: 'REDSI' + }, + { + name: 'Yellow', + orderable: true, + value: 'YELLOSI' + } + ] + } + ] + }, + { + currency: 'GBP', + hitType: 'master', + image: { + alt: 'Striped Silk Tie, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4982cf11/images/large/PG.949034314S.TAUPESI.PZ.jpg', + link: + 'https://zzrf-001.sandbox.us03.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4982cf11/images/large/PG.949034314S.TAUPESI.PZ.jpg', + title: 'Striped Silk Tie, ' + }, + orderable: true, + price: 19.19, + productId: '25752981M', + productName: 'Striped Silk Tie', + productType: { + master: true + }, + representedProduct: { + id: '793775064963M' + }, + representedProducts: [ + { + id: '793775064963M' + } + ], + variationAttributes: [ + { + id: 'color', + name: 'Colour', + values: [ + { + name: 'Taupe', + orderable: true, + value: 'TAUPESI' + } + ] + } + ] + } + ], + query: '', + refinements: [ + { + attributeId: 'cgid', + label: 'Category', + values: [ + { + hitCount: 4, + label: 'New Arrivals', + value: 'newarrivals' + }, + { + hitCount: 4, + label: 'Mens', + value: 'mens', + values: [ + { + hitCount: 4, + label: 'Accessories', + value: 'mens-accessories', + values: [ + { + hitCount: 4, + label: 'Ties', + value: 'mens-accessories-ties' + } + ] + } + ] + } + ] + }, + { + attributeId: 'c_refinementColor', + label: 'Colour', + values: [ + { + hitCount: 0, + label: 'Beige', + presentationId: 'beige', + value: 'Beige' + }, + { + hitCount: 0, + label: 'Black', + presentationId: 'black', + value: 'Black' + }, + { + hitCount: 1, + label: 'Blue', + presentationId: 'blue', + value: 'Blue' + }, + { + hitCount: 2, + label: 'Navy', + presentationId: 'navy', + value: 'Navy' + }, + { + hitCount: 1, + label: 'Brown', + presentationId: 'brown', + value: 'Brown' + }, + { + hitCount: 1, + label: 'Green', + presentationId: 'green', + value: 'Green' + }, + { + hitCount: 0, + label: 'Grey', + presentationId: 'grey', + value: 'Grey' + }, + { + hitCount: 0, + label: 'Orange', + presentationId: 'orange', + value: 'Orange' + }, + { + hitCount: 0, + label: 'Pink', + presentationId: 'pink', + value: 'Pink' + }, + { + hitCount: 0, + label: 'Purple', + presentationId: 'purple', + value: 'Purple' + }, + { + hitCount: 2, + label: 'Red', + presentationId: 'red', + value: 'Red' + }, + { + hitCount: 0, + label: 'White', + presentationId: 'white', + value: 'White' + }, + { + hitCount: 2, + label: 'Yellow', + presentationId: 'yellow', + value: 'Yellow' + }, + { + hitCount: 0, + label: 'Miscellaneous', + presentationId: 'miscellaneous', + value: 'Miscellaneous' + } + ] + }, + { + attributeId: 'price', + label: 'Price', + values: [ + { + hitCount: 4, + label: '£0 - £19.99', + value: '(0..20)' + } + ] + }, + { + attributeId: 'c_isNew', + label: 'New Arrival' + } + ], + searchPhraseSuggestions: {}, + selectedRefinements: { + cgid: 'mens-accessories-ties', + htype: 'master' + }, + sortingOptions: [ + { + id: 'best-matches', + label: 'Best Matches' + }, + { + id: 'price-low-to-high', + label: 'Price Low To High' + }, + { + id: 'price-high-to-low', + label: 'Price High to Low' + }, + { + id: 'product-name-ascending', + label: 'Product Name A - Z' + }, + { + id: 'product-name-descending', + label: 'Product Name Z - A' + }, + { + id: 'brand', + label: 'Brand' + }, + { + id: 'most-popular', + label: 'Most Popular' + }, + { + id: 'top-sellers', + label: 'Top Sellers' + } + ], + offset: 0, + total: 4 +} + +export const mockBasket = { + adjustedMerchandizeTotalTax: 1.5, + adjustedShippingTotalTax: 0.3, + agentBasket: false, + basketId: 'f6bbeee30fb93c2f94213f60f8', + channelType: 'storefront', + creationDate: '2022-09-15T19:29:10.361Z', + currency: 'USD', + customerInfo: { + customerId: 'bdlrFJmudIlHaRk0oYkbYYlKw3' + }, + lastModified: '2022-09-15T19:31:04.677Z', + merchandizeTotalTax: 1.5, + notes: {}, + orderTotal: 37.78, + productItems: [ + { + adjustedTax: 1.5, + basePrice: 29.99, + bonusProductLineItem: false, + gift: false, + itemId: 'de63c61b3edeca38b2d9a67a67', + itemText: 'Checked Silk Tie', + price: 29.99, + priceAfterItemDiscount: 29.99, + priceAfterOrderDiscount: 29.99, + productId: '682875719029M', + productName: 'Checked Silk Tie', + quantity: 1, + shipmentId: 'me', + tax: 1.5, + taxBasis: 29.99, + taxClassId: 'standard', + taxRate: 0.05 + } + ], + productSubTotal: 29.99, + productTotal: 29.99, + shipments: [ + { + adjustedMerchandizeTotalTax: 1.5, + adjustedShippingTotalTax: 0.3, + gift: false, + merchandizeTotalTax: 1.5, + productSubTotal: 29.99, + productTotal: 29.99, + shipmentId: 'me', + shipmentTotal: 37.78, + shippingMethod: { + description: 'Order received within 7-10 business days', + id: '001', + name: 'Ground', + price: 5.99, + c_estimatedArrivalTime: '7-10 Business Days' + }, + shippingStatus: 'not_shipped', + shippingTotal: 5.99, + shippingTotalTax: 0.3, + taxTotal: 1.8 + } + ], + shippingItems: [ + { + adjustedTax: 0.3, + basePrice: 5.99, + itemId: 'b931764832e5bd90a3c226552f', + itemText: 'Shipping', + price: 5.99, + priceAfterItemDiscount: 5.99, + shipmentId: 'me', + tax: 0.3, + taxBasis: 5.99, + taxClassId: 'standard', + taxRate: 0.05 + } + ], + shippingTotal: 5.99, + shippingTotalTax: 0.3, + taxation: 'net', + taxTotal: 1.8 +} + export const mockProduct = { currency: 'USD', id: '56736828M', 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 a2ef39a2cc..51822789d4 100644 --- a/packages/template-retail-react-app/app/pages/account/index.jsx +++ b/packages/template-retail-react-app/app/pages/account/index.jsx @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useState} from 'react' +import React, {useEffect, useState} from 'react' import PropTypes from 'prop-types' import {FormattedMessage, useIntl} from 'react-intl' import {Route, Switch, useRouteMatch, Redirect} from 'react-router' @@ -38,6 +38,7 @@ import {messages, navLinks} from './constant' import useNavigation from '../../hooks/use-navigation' import LoadingSpinner from '../../components/loading-spinner' import useMultiSite from '../../hooks/use-multi-site' +import useEinstein from '../../commerce-api/hooks/useEinstein' const Account = () => { const {path} = useRouteMatch() @@ -49,8 +50,15 @@ const Account = () => { const [mobileNavIndex, setMobileNavIndex] = useState(-1) const [showLoading, setShowLoading] = useState(false) + const einstein = useEinstein() + const {buildUrl} = useMultiSite() + /**************** Einstein ****************/ + useEffect(() => { + einstein.sendViewPage(location.pathname) + }, [location]) + const onSignoutClick = async () => { setShowLoading(true) await customer.logout() diff --git a/packages/template-retail-react-app/app/pages/account/index.test.js b/packages/template-retail-react-app/app/pages/account/index.test.js index eefb1212ca..058ee40c02 100644 --- a/packages/template-retail-react-app/app/pages/account/index.test.js +++ b/packages/template-retail-react-app/app/pages/account/index.test.js @@ -20,6 +20,8 @@ import useCustomer from '../../commerce-api/hooks/useCustomer' import Account from './index' import mockConfig from '../../../config/mocks/default' +jest.mock('../../commerce-api/einstein') + jest.mock('../../commerce-api/utils', () => { const originalModule = jest.requireActual('../../commerce-api/utils') return { diff --git a/packages/template-retail-react-app/app/pages/checkout/index.test.js b/packages/template-retail-react-app/app/pages/checkout/index.test.js index 37466b03f9..0db8b35a7b 100644 --- a/packages/template-retail-react-app/app/pages/checkout/index.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/index.test.js @@ -27,6 +27,8 @@ import mockConfig from '../../../config/mocks/default' jest.setTimeout(60000) +jest.mock('../../commerce-api/einstein') + // Make sure fetch is defined in test env Object.defineProperty(window, 'fetch', { value: require('cross-fetch') diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index e7823ea34c..fa7d5c6f09 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -42,6 +42,7 @@ const ContactInfo = () => { setIsGuestCheckout, step, login, + checkoutSteps, setCheckoutStep, goToNextStep } = useCheckout() @@ -94,13 +95,13 @@ const ContactInfo = () => { defaultMessage: 'Contact Info', id: 'contact_info.title.contact_info' })} - editing={step === 0} + editing={step === checkoutSteps.Contact_Info} isLoading={form.formState.isSubmitting} onEdit={() => { if (!isGuestCheckout) { setSignOutConfirmDialogIsOpen(true) } else { - setCheckoutStep(0) + setCheckoutStep(checkoutSteps.Contact_Info) } }} editLabel={ diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js index ed0589a787..6616e689ad 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js @@ -20,6 +20,7 @@ jest.mock('../util/checkout-context', () => { setIsGuestCheckout: jest.fn(), step: 0, login: null, + checkoutSteps: {Contact_Info: 0}, setCheckoutStep: null, goToNextStep: null }) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/payment.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/payment.jsx index 187e6a0f41..92757cfde2 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/payment.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/payment.jsx @@ -22,6 +22,7 @@ const Payment = () => { const { step, + checkoutSteps, setCheckoutStep, selectedShippingAddress, selectedBillingAddress, @@ -48,13 +49,13 @@ const Payment = () => { setCheckoutStep(3)} + onEdit={() => setCheckoutStep(checkoutSteps.Payment)} > diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx index 679f2f017b..8237a90434 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx @@ -21,6 +21,7 @@ export default function ShippingAddress() { const { step, + checkoutSteps, selectedShippingAddress, setShippingAddress, setCheckoutStep, @@ -42,10 +43,10 @@ export default function ShippingAddress() { defaultMessage: 'Shipping Address', id: 'shipping_address.title.shipping_address' })} - editing={step === 1} + editing={step === checkoutSteps.Shipping_Address} isLoading={isLoading} disabled={selectedShippingAddress == null} - onEdit={() => setCheckoutStep(1)} + onEdit={() => setCheckoutStep(checkoutSteps.Shipping_Address)} > { - if (step === 2) { + if (step === checkoutSteps.Shipping_Options) { getShippingMethods() } }, [step]) @@ -74,10 +75,10 @@ export default function ShippingOptions() { defaultMessage: 'Shipping & Gift Options', id: 'shipping_options.title.shipping_gift_options' })} - editing={step === 2} + editing={step === checkoutSteps.Shipping_Options} isLoading={form.formState.isSubmitting} disabled={selectedShippingMethod == null || !selectedShippingAddress} - onEdit={() => setCheckoutStep(2)} + onEdit={() => setCheckoutStep(checkoutSteps.Shipping_Options)} >
{ const customer = useCustomer() const basket = useBasket() const {formatMessage} = useIntl() + const einstein = useEinstein() const [state, setState] = useState({ - // @TODO: use contants to represent checkout steps like const CHECKOUT_STEP_2_SHIPPING = 2 step: undefined, isGuestCheckout: false, shippingMethods: undefined, @@ -32,6 +33,18 @@ export const CheckoutProvider = ({children}) => { sectionError: undefined }) + const CheckoutSteps = { + Contact_Info: 0, + Shipping_Address: 1, + Shipping_Options: 2, + Payment: 3, + Review_Order: 4 + } + + const getCheckoutStepName = (step) => { + return Object.keys(CheckoutSteps).find((key) => CheckoutSteps[key] === step) + } + const mergeState = useCallback((data) => { // If we become unmounted during an async call that results in updating state, we // skip the update to avoid React errors about setting state in unmounted components. @@ -65,26 +78,41 @@ export const CheckoutProvider = ({children}) => { // A failed condition sets the current step and returns early (order matters). if (customer.customerId && basket.basketId && state.step == undefined) { if (!basket.customerInfo?.email) { - mergeState({step: 0}) + mergeState({step: CheckoutSteps.Contact_Info}) return } if (basket.shipments && !basket.shipments[0]?.shippingAddress) { - mergeState({step: 1}) + mergeState({step: CheckoutSteps.Shipping_Address}) return } if (basket.shipments && !basket.shipments[0]?.shippingMethod) { - mergeState({step: 2}) + mergeState({step: CheckoutSteps.Shipping_Options}) return } if (!basket.paymentInstruments || !basket.billingAddress) { - mergeState({step: 3}) + mergeState({step: CheckoutSteps.Payment}) return } - mergeState({step: 4}) + mergeState({step: CheckoutSteps.Review_Order}) } }, [customer, basket]) + /**************** Einstein ****************/ + // Run this once when checkout begins + useEffect(() => { + if (basket && basket.productItems) { + einstein.sendBeginCheckout(basket) + } + }, []) + + // Run this every time checkout steps change + useEffect(() => { + if (state.step != undefined) { + einstein.sendCheckoutStep(getCheckoutStepName(state.step), state.step, basket) + } + }, [state.step]) + // We combine our state and actions into a single context object. This is much more // convenient than having to import and bind actions seprately. State updates will // cause this object to be reinitialized, which may lead to unecesary rerenders, @@ -136,6 +164,10 @@ export const CheckoutProvider = ({children}) => { return result }, + get checkoutSteps() { + return CheckoutSteps + }, + // Local state setters // Callbacks/functions for setting local state data // ---------------- 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 068a26e8d6..24ad6ece7f 100644 --- a/packages/template-retail-react-app/app/pages/home/index.jsx +++ b/packages/template-retail-react-app/app/pages/home/index.jsx @@ -5,9 +5,10 @@ * 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 React, {useEffect} from 'react' import PropTypes from 'prop-types' import {useIntl, FormattedMessage} from 'react-intl' +import {useLocation} from 'react-router-dom' // Components import { @@ -33,6 +34,9 @@ import ProductScroller from '../../components/product-scroller' import {getAssetUrl} from 'pwa-kit-react-sdk/ssr/universal/utils' import {heroFeatures, features} from './data' +//Hooks +import useEinstein from '../../commerce-api/hooks/useEinstein' + // Constants import { MAX_CACHE_AGE, @@ -48,6 +52,13 @@ import { */ const Home = ({productSearchResult, isLoading}) => { const intl = useIntl() + const einstein = useEinstein() + const {pathname} = useLocation() + + /**************** Einstein ****************/ + useEffect(() => { + einstein.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 e7c9b093b6..820197b5dd 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -14,6 +14,7 @@ import useNavigation from '../../hooks/use-navigation' import Seo from '../../components/seo' import {useForm} from 'react-hook-form' import {useLocation} from 'react-router-dom' +import useEinstein from '../../commerce-api/hooks/useEinstein' import LoginForm from '../../components/login' @@ -24,6 +25,7 @@ const Login = () => { const customer = useCustomer() const form = useForm() const location = useLocation() + const einstein = useEinstein() const submitForm = async (data) => { try { @@ -50,6 +52,11 @@ const Login = () => { } }, [customer]) + /**************** Einstein ****************/ + useEffect(() => { + einstein.sendViewPage(location.pathname) + }, []) + return ( diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 68f7cdcfc1..4a699fa1e9 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -18,6 +18,8 @@ import mockConfig from '../../../config/mocks/default' jest.setTimeout(60000) +jest.mock('../../commerce-api/einstein') + const mockRegisteredCustomer = { authType: 'registered', customerId: 'registeredCustomerId', 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 79e3611674..b158560e2d 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 @@ -56,6 +56,7 @@ import {useToast} from '../../hooks/use-toast' import useWishlist from '../../hooks/use-wishlist' import {parse as parseSearchParams} from '../../hooks/use-search-params' import {useCategories} from '../../hooks/use-categories' +import useEinstein from '../../commerce-api/hooks/useEinstein' // Others import {HTTPNotFound} from 'pwa-kit-react-sdk/ssr/universal/errors' @@ -101,6 +102,7 @@ const ProductList = (props) => { const params = useParams() const {categories} = useCategories() const toast = useToast() + const einstein = useEinstein() // Get the current category from global state. let category = undefined @@ -178,6 +180,15 @@ const ProductList = (props) => { } } + /**************** Einstein ****************/ + useEffect(() => { + if (searchQuery) { + einstein.sendViewSearch(searchQuery, productSearchResult) + } else { + einstein.sendViewCategory(category, productSearchResult) + } + }, [productSearchResult]) + /**************** Filters ****************/ const [searchParams, {stringify: stringifySearchParams}] = useSearchParams() const [filtersLoading, setFiltersLoading] = useState(false) @@ -388,6 +399,19 @@ const ProductList = (props) => { product={productSearchItem} enableFavourite={true} isFavourite={isInWishlist} + onClick={() => { + if (searchQuery) { + einstein.sendClickSearch( + searchQuery, + productSearchItem + ) + } else if (category) { + einstein.sendClickCategory( + category, + productSearchItem + ) + } + }} onFavouriteToggle={(isFavourite) => { const action = isFavourite ? addItemToWishlist diff --git a/packages/template-retail-react-app/app/pages/product-list/index.test.js b/packages/template-retail-react-app/app/pages/product-list/index.test.js index d2598088ea..4a5c83e8d9 100644 --- a/packages/template-retail-react-app/app/pages/product-list/index.test.js +++ b/packages/template-retail-react-app/app/pages/product-list/index.test.js @@ -27,6 +27,8 @@ let mockCategoriesResponse = mockCategories let mockProductListSearchResponse = mockProductSearch jest.useFakeTimers() +jest.mock('../../commerce-api/einstein') + jest.mock('../../hooks/use-wishlist') jest.mock('../../commerce-api/utils', () => { 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 cc969ae9ad..c31a7c3634 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.jsx @@ -13,11 +13,15 @@ import Seo from '../../components/seo' import {useForm} from 'react-hook-form' import RegisterForm from '../../components/register' import useNavigation from '../../hooks/use-navigation' +import useEinstein from '../../commerce-api/hooks/useEinstein' +import {useLocation} from 'react-router-dom' const Registration = () => { const navigate = useNavigation() const customer = useCustomer() const form = useForm() + const einstein = useEinstein() + const {pathname} = useLocation() const submitForm = async (data) => { try { @@ -34,6 +38,11 @@ const Registration = () => { } }, [customer]) + /**************** Einstein ****************/ + useEffect(() => { + einstein.sendViewPage(pathname) + }, []) + return ( diff --git a/packages/template-retail-react-app/app/pages/registration/index.test.jsx b/packages/template-retail-react-app/app/pages/registration/index.test.jsx index f55f938629..4caec7f0a2 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.test.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.test.jsx @@ -15,6 +15,8 @@ import mockConfig from '../../../config/mocks/default' jest.setTimeout(60000) +jest.mock('../../commerce-api/einstein') + const mockRegisteredCustomer = { authType: 'registered', customerId: 'registeredCustomerId', 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 38d5a1ca91..55bc6f14d9 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 @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useState} from 'react' +import React, {useState, useEffect} from 'react' import PropTypes from 'prop-types' import {FormattedMessage} from 'react-intl' import {Box, Button, Container, Stack, Text} from '@chakra-ui/react' @@ -15,6 +15,8 @@ import {useForm} from 'react-hook-form' import ResetPasswordForm from '../../components/reset-password' import {BrandLogo} from '../../components/icons' import useNavigation from '../../hooks/use-navigation' +import useEinstein from '../../commerce-api/hooks/useEinstein' +import {useLocation} from 'react-router-dom' const ResetPassword = () => { const customer = useCustomer() @@ -22,6 +24,8 @@ const ResetPassword = () => { const navigate = useNavigation() const [submittedEmail, setSubmittedEmail] = useState('') const [showSubmittedSuccess, setShowSubmittedSuccess] = useState(false) + const einstein = useEinstein() + const {pathname} = useLocation() const submitForm = async ({email}) => { try { @@ -33,6 +37,11 @@ const ResetPassword = () => { } } + /**************** Einstein ****************/ + useEffect(() => { + einstein.sendViewPage(pathname) + }, []) + return ( diff --git a/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx b/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx index 297f45eff3..6ba2081878 100644 --- a/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx +++ b/packages/template-retail-react-app/app/pages/reset-password/index.test.jsx @@ -14,6 +14,8 @@ import mockConfig from '../../../config/mocks/default' jest.setTimeout(60000) +jest.mock('../../commerce-api/einstein') + const mockRegisteredCustomer = { authType: 'registered', customerId: 'registeredCustomerId',